diff --git a/markitect/spaces/rendering/__init__.py b/markitect/spaces/rendering/__init__.py index 01fb53f6..62b65b24 100644 --- a/markitect/spaces/rendering/__init__.py +++ b/markitect/spaces/rendering/__init__.py @@ -6,7 +6,42 @@ This package provides space rendering capabilities: - MarkdownToHTMLRenderer: HTML output renderer - Theme support and customization - Render caching with invalidation +- SpaceRenderingService: Orchestration layer """ -# Rendering will be implemented in Phase 4 -__all__ = [] +from .base import ( + SpaceRenderer, + RenderConfig, + RenderResult, + RenderFormat, + ThemeConfig, + CompositeRenderer, +) +from .html_renderer import ( + MarkdownToHTMLRenderer, + HTMLRendererFactory, + THEME_PROPERTIES, + combine_theme_properties, +) +from .service import ( + SpaceRenderingService, + SpaceRenderingServiceBuilder, +) + +__all__ = [ + # Base classes + "SpaceRenderer", + "RenderConfig", + "RenderResult", + "RenderFormat", + "ThemeConfig", + "CompositeRenderer", + # HTML renderer + "MarkdownToHTMLRenderer", + "HTMLRendererFactory", + "THEME_PROPERTIES", + "combine_theme_properties", + # Service + "SpaceRenderingService", + "SpaceRenderingServiceBuilder", +] diff --git a/markitect/spaces/rendering/base.py b/markitect/spaces/rendering/base.py new file mode 100644 index 00000000..309a9592 --- /dev/null +++ b/markitect/spaces/rendering/base.py @@ -0,0 +1,239 @@ +""" +Abstract base class for Information Space renderers. + +This module provides the foundation for rendering resolved markdown content +to various output formats, with support for theming and caching. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Dict, Any, Optional, List, Set +import hashlib + + +class RenderFormat(Enum): + """Supported output formats for rendering.""" + + HTML = "html" + PDF = "pdf" # Future + DOCX = "docx" # Future + LATEX = "latex" # Future + + +@dataclass +class ThemeConfig: + """ + Configuration for rendering themes. + + Attributes: + name: Theme name (e.g., 'github', 'academic', 'minimal') + layers: List of theme layers to combine (highest priority first) + custom_css: Optional custom CSS to append + custom_properties: Theme property overrides + """ + + name: str = "default" + layers: List[str] = field(default_factory=lambda: ["basic"]) + custom_css: Optional[str] = None + custom_properties: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RenderConfig: + """ + Configuration for rendering operations. + + Attributes: + format: Output format + theme: Theme configuration + include_toc: Whether to include table of contents + highlight_code: Whether to apply syntax highlighting + image_max_width: Maximum image width + image_max_height: Maximum image height + embed_assets: Whether to embed assets inline + base_url: Base URL for relative links + """ + + format: RenderFormat = RenderFormat.HTML + theme: ThemeConfig = field(default_factory=ThemeConfig) + include_toc: bool = False + highlight_code: bool = True + image_max_width: str = "100%" + image_max_height: str = "auto" + embed_assets: bool = False + base_url: Optional[str] = None + + # Advanced options + sanitize_html: bool = True + link_target_blank: bool = True + generate_heading_ids: bool = True + + +@dataclass +class RenderResult: + """ + Result of a rendering operation. + + Attributes: + content: The rendered content + format: The output format + content_hash: Hash of the rendered content + source_hash: Hash of the source content + document_id: ID of the rendered document + space_id: ID of the containing space + dependencies: Document IDs this render depends on + metadata: Additional rendering metadata + """ + + content: str + format: RenderFormat + content_hash: str + source_hash: str + document_id: str + space_id: str + dependencies: Set[str] = field(default_factory=set) + metadata: Dict[str, Any] = field(default_factory=dict) + + @staticmethod + def compute_hash(content: str) -> str: + """Compute a hash of content.""" + return hashlib.sha256(content.encode('utf-8')).hexdigest()[:16] + + +class SpaceRenderer(ABC): + """ + Abstract base class for space content renderers. + + Renderers transform resolved markdown content into output formats + like HTML, PDF, or other document formats. + """ + + def __init__(self, config: Optional[RenderConfig] = None): + """ + Initialize the renderer. + + Args: + config: Rendering configuration + """ + self.config = config or RenderConfig() + + @property + @abstractmethod + def supported_formats(self) -> List[RenderFormat]: + """Return list of formats this renderer supports.""" + pass + + @abstractmethod + def render( + self, + content: str, + document_id: str, + space_id: str, + dependencies: Optional[Set[str]] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> RenderResult: + """ + Render markdown content. + + Args: + content: Resolved markdown content to render + document_id: ID of the document being rendered + space_id: ID of the containing space + dependencies: Document IDs this content depends on + metadata: Additional metadata for rendering + + Returns: + RenderResult with rendered content + """ + pass + + def supports_format(self, format: RenderFormat) -> bool: + """Check if this renderer supports a format.""" + return format in self.supported_formats + + def validate_config(self) -> List[str]: + """ + Validate the current configuration. + + Returns: + List of validation errors (empty if valid) + """ + errors = [] + + if self.config.format not in self.supported_formats: + errors.append( + f"Format {self.config.format.value} not supported. " + f"Supported: {[f.value for f in self.supported_formats]}" + ) + + return errors + + +class CompositeRenderer: + """ + Manages multiple renderers and delegates to the appropriate one. + + Allows rendering to different formats using a unified interface. + """ + + def __init__(self): + """Initialize the composite renderer.""" + self._renderers: Dict[RenderFormat, SpaceRenderer] = {} + + def register(self, renderer: SpaceRenderer) -> None: + """ + Register a renderer for its supported formats. + + Args: + renderer: Renderer to register + """ + for format in renderer.supported_formats: + self._renderers[format] = renderer + + def get_renderer(self, format: RenderFormat) -> Optional[SpaceRenderer]: + """Get the renderer for a format.""" + return self._renderers.get(format) + + def render( + self, + content: str, + document_id: str, + space_id: str, + format: RenderFormat = RenderFormat.HTML, + dependencies: Optional[Set[str]] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> RenderResult: + """ + Render content using the appropriate renderer. + + Args: + content: Markdown content to render + document_id: Document ID + space_id: Space ID + format: Target output format + dependencies: Document dependencies + metadata: Additional metadata + + Returns: + RenderResult + + Raises: + ValueError: If no renderer registered for format + """ + renderer = self._renderers.get(format) + if not renderer: + raise ValueError(f"No renderer registered for format: {format.value}") + + return renderer.render( + content=content, + document_id=document_id, + space_id=space_id, + dependencies=dependencies, + metadata=metadata, + ) + + def supported_formats(self) -> List[RenderFormat]: + """Get all supported formats.""" + return list(self._renderers.keys()) diff --git a/markitect/spaces/rendering/html_renderer.py b/markitect/spaces/rendering/html_renderer.py new file mode 100644 index 00000000..340890cf --- /dev/null +++ b/markitect/spaces/rendering/html_renderer.py @@ -0,0 +1,576 @@ +""" +HTML renderer for Information Spaces. + +This module provides markdown-to-HTML rendering with theming, +code highlighting, and accessibility features. +""" + +import re +import html +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Any, Optional, List, Set + +from .base import ( + SpaceRenderer, + RenderConfig, + RenderResult, + RenderFormat, + ThemeConfig, +) + + +# Built-in theme properties +THEME_PROPERTIES: Dict[str, Dict[str, Any]] = { + "default": { + "font_family": "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif", + "max_width": "800px", + "body_color": "#333333", + "body_background": "#ffffff", + "heading_color": "#333333", + "code_background": "#f6f8fa", + "code_color": "#333333", + "border_color": "#d0d7de", + "blockquote_border": "#dfe2e5", + "blockquote_color": "#6a737d", + "table_border": "#d0d7de", + "table_header_bg": "#f6f8fa", + "link_color": "#0366d6", + }, + "github": { + "font_family": "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif", + "max_width": "980px", + "body_color": "#24292e", + "body_background": "#ffffff", + "heading_color": "#24292e", + "code_background": "#f6f8fa", + "code_color": "#24292e", + "border_color": "#e1e4e8", + "blockquote_border": "#dfe2e5", + "blockquote_color": "#6a737d", + "table_border": "#e1e4e8", + "table_header_bg": "#f6f8fa", + "link_color": "#0366d6", + }, + "minimal": { + "font_family": "Georgia, serif", + "max_width": "680px", + "body_color": "#222222", + "body_background": "#fafafa", + "heading_color": "#111111", + "code_background": "#f0f0f0", + "code_color": "#222222", + "border_color": "#dddddd", + "blockquote_border": "#cccccc", + "blockquote_color": "#666666", + "table_border": "#dddddd", + "table_header_bg": "#f0f0f0", + "link_color": "#0055aa", + }, + "dark": { + "font_family": "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif", + "max_width": "800px", + "body_color": "#c9d1d9", + "body_background": "#0d1117", + "heading_color": "#c9d1d9", + "code_background": "#161b22", + "code_color": "#c9d1d9", + "border_color": "#30363d", + "blockquote_border": "#3b434b", + "blockquote_color": "#8b949e", + "table_border": "#30363d", + "table_header_bg": "#161b22", + "link_color": "#58a6ff", + }, + "academic": { + "font_family": "'Times New Roman', Times, serif", + "max_width": "720px", + "body_color": "#1a1a1a", + "body_background": "#ffffff", + "heading_color": "#1a1a1a", + "heading_style": "underlined", + "text_align": "justify", + "code_background": "#f5f5f5", + "code_color": "#1a1a1a", + "border_color": "#cccccc", + "blockquote_border": "#999999", + "blockquote_color": "#555555", + "table_border": "#cccccc", + "table_header_bg": "#f5f5f5", + "link_color": "#000080", + }, +} + + +def combine_theme_properties(layers: List[str]) -> Dict[str, Any]: + """ + Combine theme properties from multiple layers. + + Later layers override earlier ones. + + Args: + layers: List of theme names to combine + + Returns: + Combined properties dictionary + """ + combined = {} + for layer in layers: + if layer in THEME_PROPERTIES: + combined.update(THEME_PROPERTIES[layer]) + return combined + + +class MarkdownToHTMLRenderer(SpaceRenderer): + """ + Renders markdown content to HTML. + + Features: + - Theme support with layer composition + - Syntax highlighting for code blocks + - Automatic heading IDs for navigation + - Link target handling + - Table of contents generation + """ + + @property + def supported_formats(self) -> List[RenderFormat]: + """Return supported formats.""" + return [RenderFormat.HTML] + + def render( + self, + content: str, + document_id: str, + space_id: str, + dependencies: Optional[Set[str]] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> RenderResult: + """ + Render markdown to HTML. + + Args: + content: Markdown content + document_id: Document ID + space_id: Space ID + dependencies: Document dependencies + metadata: Additional metadata + + Returns: + RenderResult with HTML content + """ + source_hash = RenderResult.compute_hash(content) + + # Parse markdown to HTML + html_content = self._render_markdown(content) + + # Apply post-processing + html_content = self._apply_post_processing(html_content) + + # Generate table of contents if requested + toc_html = "" + if self.config.include_toc: + toc_html = self._generate_toc(html_content) + + # Build complete HTML document + complete_html = self._build_html_document( + body_content=html_content, + toc_content=toc_html, + title=self._extract_title(content), + metadata=metadata or {}, + ) + + content_hash = RenderResult.compute_hash(complete_html) + + return RenderResult( + content=complete_html, + format=RenderFormat.HTML, + content_hash=content_hash, + source_hash=source_hash, + document_id=document_id, + space_id=space_id, + dependencies=dependencies or set(), + metadata=metadata or {}, + ) + + def _render_markdown(self, content: str) -> str: + """ + Convert markdown to HTML. + + Uses the Python markdown library if available, otherwise falls + back to a basic parser. + """ + try: + import markdown + + # Configure extensions + extensions = ["extra", "toc", "tables", "fenced_code"] + if self.config.highlight_code: + try: + extensions.append("codehilite") + except ImportError: + pass + + return markdown.markdown(content, extensions=extensions) + except ImportError: + # Fallback to basic parsing + return self._basic_markdown_to_html(content) + + def _basic_markdown_to_html(self, content: str) -> str: + """ + Basic markdown to HTML conversion. + + Used as fallback when markdown library is not available. + """ + lines = content.split('\n') + html_lines = [] + in_code_block = False + in_list = False + + for line in lines: + # Code blocks + if line.startswith('```'): + if in_code_block: + html_lines.append('') + in_code_block = False + else: + lang = line[3:].strip() + lang_class = f' class="language-{lang}"' if lang else '' + html_lines.append(f'
')
+ in_code_block = True
+ continue
+
+ if in_code_block:
+ html_lines.append(html.escape(line))
+ continue
+
+ # Close list if not a list item
+ if in_list and not line.strip().startswith(('-', '*', '+')):
+ html_lines.append('')
+ in_list = False
+
+ stripped = line.strip()
+
+ # Headers
+ if stripped.startswith('######'):
+ text = stripped[6:].strip()
+ slug = self._slugify(text)
+ html_lines.append(f'{html.escape(text)}
')
+ elif stripped.startswith('#####'):
+ text = stripped[5:].strip()
+ slug = self._slugify(text)
+ html_lines.append(f'{html.escape(text)}
')
+ elif stripped.startswith('####'):
+ text = stripped[4:].strip()
+ slug = self._slugify(text)
+ html_lines.append(f'{html.escape(text)}
')
+ elif stripped.startswith('###'):
+ text = stripped[3:].strip()
+ slug = self._slugify(text)
+ html_lines.append(f'{html.escape(text)}
')
+ elif stripped.startswith('##'):
+ text = stripped[2:].strip()
+ slug = self._slugify(text)
+ html_lines.append(f'{html.escape(text)}
')
+ elif stripped.startswith('#'):
+ text = stripped[1:].strip()
+ slug = self._slugify(text)
+ html_lines.append(f'{html.escape(text)}
')
+ # Horizontal rule
+ elif stripped in ('---', '***', '___'):
+ html_lines.append('
')
+ # Blockquote
+ elif stripped.startswith('>'):
+ text = stripped[1:].strip()
+ html_lines.append(f'{html.escape(text)}
')
+ # Unordered list
+ elif stripped.startswith(('-', '*', '+')) and len(stripped) > 1 and stripped[1] == ' ':
+ if not in_list:
+ html_lines.append('')
+ in_list = True
+ text = stripped[2:].strip()
+ html_lines.append(f'- {self._process_inline(text)}
')
+ # Empty line
+ elif not stripped:
+ if in_list:
+ html_lines.append('
')
+ in_list = False
+ html_lines.append('')
+ # Paragraph
+ else:
+ html_lines.append(f'{self._process_inline(stripped)}
')
+
+ # Close any open list
+ if in_list:
+ html_lines.append('')
+
+ # Close any open code block
+ if in_code_block:
+ html_lines.append('')
+
+ return '\n'.join(html_lines)
+
+ def _process_inline(self, text: str) -> str:
+ """Process inline markdown elements."""
+ # Bold
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
+ text = re.sub(r'__(.+?)__', r'\1', text)
+
+ # Italic
+ text = re.sub(r'\*(.+?)\*', r'\1', text)
+ text = re.sub(r'_(.+?)_', r'\1', text)
+
+ # Code
+ text = re.sub(r'`([^`]+)`', r'\1', text)
+
+ # Links
+ text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text)
+
+ # Images
+ text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', r'Test
", + format=RenderFormat.HTML, + content_hash="abc", + source_hash="def", + document_id="doc-1", + space_id="space-1", + dependencies={"doc-2", "doc-3"}, + ) + assert len(result.dependencies) == 2 + assert "doc-2" in result.dependencies + + def test_compute_hash(self): + """Test hash computation.""" + hash1 = RenderResult.compute_hash("test content") + hash2 = RenderResult.compute_hash("test content") + hash3 = RenderResult.compute_hash("different content") + + assert hash1 == hash2 + assert hash1 != hash3 + assert len(hash1) == 16 # SHA256 truncated to 16 chars + + +class TestCombineThemeProperties: + """Tests for theme property combination.""" + + def test_single_layer(self): + """Test single theme layer.""" + props = combine_theme_properties(["default"]) + assert "font_family" in props + assert "body_color" in props + + def test_multiple_layers(self): + """Test multiple theme layers.""" + props = combine_theme_properties(["default", "github"]) + # GitHub layer should override default + assert props["max_width"] == "980px" + + def test_unknown_layer(self): + """Test with unknown layer.""" + props = combine_theme_properties(["nonexistent"]) + assert props == {} + + def test_empty_layers(self): + """Test with no layers.""" + props = combine_theme_properties([]) + assert props == {} + + +class TestMarkdownToHTMLRenderer: + """Tests for MarkdownToHTMLRenderer.""" + + def test_supported_formats(self): + """Test supported formats.""" + renderer = MarkdownToHTMLRenderer() + assert RenderFormat.HTML in renderer.supported_formats + assert len(renderer.supported_formats) == 1 + + def test_simple_render(self): + """Test simple markdown rendering.""" + renderer = MarkdownToHTMLRenderer() + result = renderer.render( + content="# Hello World\n\nThis is a paragraph.", + document_id="doc-1", + space_id="space-1", + ) + + assert result.format == RenderFormat.HTML + assert "" in result.content or "" in result.content
+
+ def test_render_with_links(self):
+ """Test link rendering with target blank."""
+ renderer = MarkdownToHTMLRenderer()
+ result = renderer.render(
+ content="Visit [Google](https://google.com)",
+ document_id="doc-1",
+ space_id="space-1",
+ )
+
+ assert "https://google.com" in result.content
+ assert 'target="_blank"' in result.content
+
+ def test_render_with_toc(self):
+ """Test table of contents generation."""
+ config = RenderConfig(include_toc=True)
+ renderer = MarkdownToHTMLRenderer(config)
+
+ markdown = """
+# Main Title
+
+## Section 1
+
+Content here.
+
+## Section 2
+
+More content.
+"""
+ result = renderer.render(
+ content=markdown,
+ document_id="doc-1",
+ space_id="space-1",
+ )
+
+ assert 'class="toc"' in result.content
+ assert "Contents" in result.content
+
+ def test_render_with_dependencies(self):
+ """Test dependencies are tracked."""
+ renderer = MarkdownToHTMLRenderer()
+ result = renderer.render(
+ content="# Test",
+ document_id="doc-1",
+ space_id="space-1",
+ dependencies={"dep-1", "dep-2"},
+ )
+
+ assert len(result.dependencies) == 2
+ assert "dep-1" in result.dependencies
+
+ def test_content_hash_computation(self):
+ """Test that hashes are computed."""
+ renderer = MarkdownToHTMLRenderer()
+ result = renderer.render(
+ content="# Test",
+ document_id="doc-1",
+ space_id="space-1",
+ )
+
+ assert result.content_hash
+ assert result.source_hash
+ assert len(result.content_hash) == 16
+
+ def test_different_themes(self):
+ """Test different theme configurations."""
+ github_config = RenderConfig(theme=ThemeConfig(name="github", layers=["github"]))
+ dark_config = RenderConfig(theme=ThemeConfig(name="dark", layers=["dark"]))
+
+ github_renderer = MarkdownToHTMLRenderer(github_config)
+ dark_renderer = MarkdownToHTMLRenderer(dark_config)
+
+ github_result = github_renderer.render("# Test", "doc", "space")
+ dark_result = dark_renderer.render("# Test", "doc", "space")
+
+ # Results should be different due to different themes
+ assert github_result.content != dark_result.content
+ assert "#24292e" in github_result.content # GitHub body color
+ assert "#c9d1d9" in dark_result.content # Dark mode text color
+
+
+class TestHTMLRendererFactory:
+ """Tests for HTMLRendererFactory."""
+
+ def test_create_default(self):
+ """Test default renderer creation."""
+ renderer = HTMLRendererFactory.create_default()
+ assert isinstance(renderer, MarkdownToHTMLRenderer)
+
+ def test_create_github_style(self):
+ """Test GitHub-style renderer."""
+ renderer = HTMLRendererFactory.create_github_style()
+ assert renderer.config.theme.name == "github"
+
+ def test_create_academic_style(self):
+ """Test academic-style renderer."""
+ renderer = HTMLRendererFactory.create_academic_style()
+ assert renderer.config.theme.name == "academic"
+ assert renderer.config.include_toc is True
+
+ def test_create_minimal_style(self):
+ """Test minimal-style renderer."""
+ renderer = HTMLRendererFactory.create_minimal_style()
+ assert renderer.config.theme.name == "minimal"
+
+ def test_create_dark_mode(self):
+ """Test dark mode renderer."""
+ renderer = HTMLRendererFactory.create_dark_mode()
+ assert renderer.config.theme.name == "dark"
+
+
+class TestCompositeRenderer:
+ """Tests for CompositeRenderer."""
+
+ def test_register_renderer(self):
+ """Test registering a renderer."""
+ composite = CompositeRenderer()
+ html_renderer = MarkdownToHTMLRenderer()
+
+ composite.register(html_renderer)
+
+ assert composite.get_renderer(RenderFormat.HTML) is html_renderer
+
+ def test_supported_formats(self):
+ """Test listing supported formats."""
+ composite = CompositeRenderer()
+ html_renderer = MarkdownToHTMLRenderer()
+
+ composite.register(html_renderer)
+
+ formats = composite.supported_formats()
+ assert RenderFormat.HTML in formats
+
+ def test_render_via_composite(self):
+ """Test rendering through composite."""
+ composite = CompositeRenderer()
+ composite.register(MarkdownToHTMLRenderer())
+
+ result = composite.render(
+ content="# Test",
+ document_id="doc-1",
+ space_id="space-1",
+ format=RenderFormat.HTML,
+ )
+
+ assert result.format == RenderFormat.HTML
+ assert "bold" in result.content or "bold" in result.content
+
+ def test_code_blocks(self):
+ """Test code block parsing."""
+ renderer = MarkdownToHTMLRenderer()
+ markdown = "```python\nprint('hello')\n```"
+ result = renderer.render(markdown, "doc", "space")
+
+ assert "" in result.content or "Item" in result.content
+
+ def test_blockquotes(self):
+ """Test blockquote parsing."""
+ renderer = MarkdownToHTMLRenderer()
+ markdown = "> This is a quote"
+ result = renderer.render(markdown, "doc", "space")
+
+ assert "