Files
markitect-main/markitect/core/serializer.py
tegwick 9b12875681 feat(spaces): implement Phase 0-1 of Information Space Service
Phase 0 - Project Organization:
- Create docs/PROJECT_STRUCTURE.md documenting codebase layout
- Create markitect/core/ with parser, serializer, document_manager, workspace
- Create markitect/schema/ consolidating 6 schema_*.py modules
- Create markitect/storage/ with database module
- Maintain backward compatibility via re-exports from original locations
- Add docs/roadmap/information-space-service/ with README and WORKPLAN

Phase 1 - Foundation (Weeks 1-3):
- Week 1: Core domain models (InformationSpace, SpaceDocument, SpaceConfig,
  SpaceMetadata, SpaceVariable, TransclusionReference, SpaceStatus)
- Week 2: Repository layer with interfaces (ISpaceRepository,
  IDocumentAssociationRepository, IVariableRepository, IReferenceRepository)
  and SQLite implementations with foreign key cascade deletes
- Week 3: SpaceService orchestration layer with full CRUD, document,
  variable, and reference tracking operations

Test coverage: 124 tests (25 model + 63 repository + 36 integration)

Capabilities delivered:
- CAP-001: InformationSpace entity with lifecycle management
- CAP-002: SpaceRepository CRUD with SQLite backing
- CAP-003: Document-Space associations with path-based organization
- CAP-004: Space metadata and configuration schemas
- CAP-005: Database schema with migrations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 02:02:46 +01:00

360 lines
12 KiB
Python

"""
AST to Markdown Serialization - Issue #2 Completion
This module provides functionality to serialize markdown-it AST tokens back into
markdown format, enabling roundtrip validation and document manipulation.
Key Features:
- Convert AST tokens back to markdown text
- Preserve front matter during serialization
- Support for content manipulation operations
- Roundtrip integrity validation
"""
from typing import List, Dict, Any, Optional
import yaml
class ASTSerializer:
"""
Serializes markdown-it AST tokens back to markdown format.
Provides roundtrip capability: markdown -> AST -> markdown
Supports front matter preservation and content manipulation.
"""
def __init__(self):
"""Initialize the AST serializer."""
pass
def serialize_to_markdown(self, ast: List[Dict[str, Any]], front_matter: Optional[Dict[str, Any]] = None) -> str:
"""
Convert AST tokens back to markdown format.
Args:
ast: List of markdown-it AST tokens
front_matter: Optional YAML front matter dictionary
Returns:
Markdown text with optional front matter
Example:
serializer = ASTSerializer()
markdown = serializer.serialize_to_markdown(ast, front_matter)
"""
markdown_parts = []
# Add front matter if present
if front_matter and isinstance(front_matter, dict) and front_matter:
yaml_content = yaml.dump(front_matter, default_flow_style=False).strip()
markdown_parts.append(f"---\n{yaml_content}\n---\n\n")
# Process AST tokens
markdown_content = self._process_tokens(ast)
markdown_parts.append(markdown_content)
return ''.join(markdown_parts)
def _process_tokens(self, tokens: List[Dict[str, Any]]) -> str:
"""
Process a list of AST tokens into markdown text.
Args:
tokens: List of markdown-it tokens
Returns:
Markdown text representation
"""
markdown_lines = []
current_line = ""
list_level = 0
for token in tokens:
token_type = token.get('type', '')
content = token.get('content', '')
markup = token.get('markup', '')
tag = token.get('tag', '')
nesting = token.get('nesting', 0)
level = token.get('level', 0)
# Handle different token types
if token_type == 'heading_open':
heading_level = int(tag[1]) if tag.startswith('h') else 1
current_line = '#' * heading_level + ' '
elif token_type == 'heading_close':
if current_line:
markdown_lines.append(current_line.rstrip())
current_line = ""
markdown_lines.append("") # Empty line after heading
elif token_type == 'paragraph_open':
pass # Start of paragraph
elif token_type == 'paragraph_close':
if current_line:
markdown_lines.append(current_line.rstrip())
current_line = ""
markdown_lines.append("") # Empty line after paragraph
elif token_type == 'inline':
# Process inline content and children
if content:
current_line += content
elif 'children' in token:
current_line += self._process_inline_children(token['children'])
elif token_type == 'list_item_open':
# Handle list items
indent = ' ' * (level // 2)
if markup in ('-', '*'):
current_line = indent + '- '
elif markup.isdigit():
current_line = indent + '1. '
elif token_type == 'list_item_close':
if current_line:
markdown_lines.append(current_line.rstrip())
current_line = ""
elif token_type in ('bullet_list_open', 'ordered_list_open'):
list_level += 1
elif token_type in ('bullet_list_close', 'ordered_list_close'):
list_level -= 1
if list_level == 0:
markdown_lines.append("") # Empty line after list
elif token_type == 'blockquote_open':
pass
elif token_type == 'blockquote_close':
markdown_lines.append("")
elif token_type == 'code_block':
markdown_lines.append(f"```{token.get('info', '')}")
markdown_lines.append(content.rstrip())
markdown_lines.append("```")
markdown_lines.append("")
elif token_type == 'fence':
if nesting == 1: # Opening fence
markdown_lines.append(f"```{token.get('info', '')}")
else: # Closing fence
markdown_lines.append("```")
markdown_lines.append("")
elif token_type == 'hr':
markdown_lines.append("---")
markdown_lines.append("")
elif token_type == 'text':
current_line += content
# Add any remaining content
if current_line:
markdown_lines.append(current_line.rstrip())
# Clean up extra empty lines at the end
while markdown_lines and markdown_lines[-1] == "":
markdown_lines.pop()
return '\n'.join(markdown_lines)
def _process_inline_children(self, children: List[Dict[str, Any]]) -> str:
"""
Process inline children tokens (emphasis, strong, links, etc.).
Args:
children: List of inline token children
Returns:
Processed inline markdown text
"""
result = ""
for child in children:
token_type = child.get('type', '')
content = child.get('content', '')
markup = child.get('markup', '')
if token_type == 'text':
result += content
elif token_type == 'code_inline':
result += f"`{content}`"
elif token_type == 'em_open':
result += markup or '*'
elif token_type == 'em_close':
result += markup or '*'
elif token_type == 'strong_open':
result += markup or '**'
elif token_type == 'strong_close':
result += markup or '**'
elif token_type == 'link_open':
# Extract href from attrs
href = ""
if 'attrs' in child and child['attrs']:
for attr in child['attrs']:
if attr[0] == 'href':
href = attr[1]
break
result += "["
elif token_type == 'link_close':
# This is tricky - we need to get the href from the opening token
# For now, we'll use a placeholder approach
result += "](#)"
elif token_type == 'softbreak':
result += '\n'
elif token_type == 'hardbreak':
result += ' \n'
return result
def modify_ast_content(self, ast: List[Dict[str, Any]], modifications: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Modify AST content based on provided modifications.
Args:
ast: Original AST tokens
modifications: Dictionary of modifications to apply
Returns:
Modified AST tokens
Supported modifications:
- add_section: Add a new section with title and content
- update_front_matter: Update front matter values
"""
modified_ast = ast.copy()
# Handle adding sections
if 'add_section' in modifications:
section_data = modifications['add_section']
title = section_data.get('title', 'New Section')
content = section_data.get('content', '')
level = section_data.get('level', 2)
# Create new section tokens
new_tokens = [
{
"type": "heading_open",
"tag": f"h{level}",
"attrs": {},
"map": None,
"nesting": 1,
"level": 0,
"content": "",
"markup": "#" * level,
"info": "",
"meta": {},
"block": True,
"hidden": False
},
{
"type": "inline",
"tag": "",
"attrs": {},
"map": None,
"nesting": 0,
"level": 1,
"children": [
{
"type": "text",
"tag": "",
"attrs": {},
"map": None,
"nesting": 0,
"level": 0,
"content": title,
"markup": "",
"info": "",
"meta": {},
"block": False,
"hidden": False
}
],
"content": title,
"markup": "",
"info": "",
"meta": {},
"block": True,
"hidden": False
},
{
"type": "heading_close",
"tag": f"h{level}",
"attrs": {},
"map": None,
"nesting": -1,
"level": 0,
"content": "",
"markup": "#" * level,
"info": "",
"meta": {},
"block": True,
"hidden": False
}
]
if content:
new_tokens.extend([
{
"type": "paragraph_open",
"tag": "p",
"attrs": {},
"map": None,
"nesting": 1,
"level": 0,
"content": "",
"markup": "",
"info": "",
"meta": {},
"block": True,
"hidden": False
},
{
"type": "inline",
"tag": "",
"attrs": {},
"map": None,
"nesting": 0,
"level": 1,
"children": [
{
"type": "text",
"tag": "",
"attrs": {},
"map": None,
"nesting": 0,
"level": 0,
"content": content,
"markup": "",
"info": "",
"meta": {},
"block": False,
"hidden": False
}
],
"content": content,
"markup": "",
"info": "",
"meta": {},
"block": True,
"hidden": False
},
{
"type": "paragraph_close",
"tag": "p",
"attrs": {},
"map": None,
"nesting": -1,
"level": 0,
"content": "",
"markup": "",
"info": "",
"meta": {},
"block": True,
"hidden": False
}
])
# Add to end of AST
modified_ast.extend(new_tokens)
return modified_ast