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 = 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'\1', text) + + return text + + def _slugify(self, text: str) -> str: + """Create a URL-safe slug from text.""" + slug = text.lower() + slug = re.sub(r'[^\w\s-]', '', slug) + slug = re.sub(r'[\s_]+', '-', slug) + slug = slug.strip('-') + return slug + + def _apply_post_processing(self, html_content: str) -> str: + """Apply post-processing to HTML content.""" + # Add target="_blank" to external links + if self.config.link_target_blank: + html_content = re.sub( + r']*)>', + f'', + html_content, + ) + + return html_content + + def _generate_toc(self, html_content: str) -> str: + """Generate table of contents from HTML headings.""" + headings = re.findall(r']*id="([^"]+)"[^>]*>([^<]+)', html_content) + + if not headings: + return "" + + toc_lines = ['') + return '\n'.join(toc_lines) + + def _extract_title(self, content: str) -> str: + """Extract title from first H1 heading.""" + match = re.search(r'^#\s+(.+)$', content, re.MULTILINE) + if match: + return match.group(1).strip() + return "Document" + + def _generate_css(self) -> str: + """Generate CSS based on theme configuration.""" + # Get theme properties + layers = self.config.theme.layers + props = combine_theme_properties(layers) + + # Apply custom property overrides + props.update(self.config.theme.custom_properties) + + css = f""" + body {{ + font-family: {props.get('font_family', 'sans-serif')}; + max-width: {props.get('max_width', '800px')}; + margin: 0 auto; + padding: 2rem; + line-height: 1.6; + color: {props.get('body_color', '#333')}; + background-color: {props.get('body_background', '#fff')}; + }} + h1, h2, h3, h4, h5, h6 {{ + color: {props.get('heading_color', props.get('body_color', '#333'))}; + margin-top: 1.5em; + margin-bottom: 0.5em; + }} + pre {{ + background-color: {props.get('code_background', '#f6f8fa')}; + color: {props.get('code_color', '#333')}; + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + border: 1px solid {props.get('border_color', '#ddd')}; + }} + code {{ + background-color: {props.get('code_background', '#f6f8fa')}; + color: {props.get('code_color', '#333')}; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; + }} + pre code {{ + background: none; + padding: 0; + }} + blockquote {{ + border-left: 4px solid {props.get('blockquote_border', '#ddd')}; + margin: 0; + padding-left: 1rem; + color: {props.get('blockquote_color', '#666')}; + }} + table {{ + border-collapse: collapse; + margin: 1rem 0; + width: 100%; + border: 1px solid {props.get('table_border', '#ddd')}; + }} + th, td {{ + border: 1px solid {props.get('table_border', '#ddd')}; + padding: 0.5rem; + text-align: left; + }} + th {{ + background-color: {props.get('table_header_bg', '#f6f8fa')}; + }} + a {{ + color: {props.get('link_color', '#0366d6')}; + text-decoration: none; + }} + a:hover {{ + text-decoration: underline; + }} + img {{ + max-width: {self.config.image_max_width}; + max-height: {self.config.image_max_height}; + height: auto; + }} + .toc {{ + background-color: {props.get('code_background', '#f6f8fa')}; + padding: 1rem; + border-radius: 6px; + margin-bottom: 2rem; + }} + .toc h2 {{ + margin-top: 0; + }} + .toc ul {{ + padding-left: 1.5rem; + }} + """ + + # Add custom CSS if provided + if self.config.theme.custom_css: + css += f"\n{self.config.theme.custom_css}" + + return css + + def _build_html_document( + self, + body_content: str, + toc_content: str, + title: str, + metadata: Dict[str, Any], + ) -> str: + """Build complete HTML document.""" + css = self._generate_css() + + # Meta tags + meta_tags = '\n' + meta_tags += '\n' + meta_tags += '\n' + + # Add custom meta from metadata + for key, value in metadata.items(): + if key.startswith('meta_'): + meta_name = key[5:] + meta_tags += f'\n' + + return f""" + + + {meta_tags} + {html.escape(title)} + + + + {toc_content} +
+{body_content} +
+ +""" + + +class HTMLRendererFactory: + """Factory for creating configured HTML renderers.""" + + @staticmethod + def create_default() -> MarkdownToHTMLRenderer: + """Create a renderer with default settings.""" + return MarkdownToHTMLRenderer() + + @staticmethod + def create_github_style() -> MarkdownToHTMLRenderer: + """Create a renderer with GitHub-style theme.""" + config = RenderConfig( + theme=ThemeConfig(name="github", layers=["github"]), + include_toc=False, + ) + return MarkdownToHTMLRenderer(config) + + @staticmethod + def create_academic_style() -> MarkdownToHTMLRenderer: + """Create a renderer with academic styling.""" + config = RenderConfig( + theme=ThemeConfig(name="academic", layers=["academic"]), + include_toc=True, + ) + return MarkdownToHTMLRenderer(config) + + @staticmethod + def create_minimal_style() -> MarkdownToHTMLRenderer: + """Create a renderer with minimal styling.""" + config = RenderConfig( + theme=ThemeConfig(name="minimal", layers=["minimal"]), + include_toc=False, + ) + return MarkdownToHTMLRenderer(config) + + @staticmethod + def create_dark_mode() -> MarkdownToHTMLRenderer: + """Create a renderer with dark mode theme.""" + config = RenderConfig( + theme=ThemeConfig(name="dark", layers=["dark"]), + include_toc=False, + ) + return MarkdownToHTMLRenderer(config) diff --git a/markitect/spaces/rendering/service.py b/markitect/spaces/rendering/service.py new file mode 100644 index 00000000..272d82b4 --- /dev/null +++ b/markitect/spaces/rendering/service.py @@ -0,0 +1,434 @@ +""" +Space Rendering Service. + +Orchestrates transclusion processing, rendering, and caching for +Information Space documents. +""" + +import logging +from pathlib import Path +from typing import Dict, Any, Optional, List, Set + +from .base import SpaceRenderer, RenderConfig, RenderResult, RenderFormat, CompositeRenderer +from .html_renderer import MarkdownToHTMLRenderer +from ..transclusion import ( + SpaceTransclusionContext, + ReferenceGraph, + RenderCache, + CacheInvalidator, + CacheEntry, +) +from ..events import EventBus, SpaceEventType, SpaceEvent +from ..repositories.interfaces import IVariableRepository, IReferenceRepository + +logger = logging.getLogger(__name__) + + +class SpaceRenderingService: + """ + Service for rendering Information Space documents. + + Orchestrates: + - Transclusion resolution + - Content rendering to target format + - Render caching with automatic invalidation + - Event emission for render lifecycle + """ + + def __init__( + self, + renderer: Optional[SpaceRenderer] = None, + render_cache: Optional[RenderCache] = None, + reference_graph: Optional[ReferenceGraph] = None, + event_bus: Optional[EventBus] = None, + variable_repo: Optional[IVariableRepository] = None, + reference_repo: Optional[IReferenceRepository] = None, + ): + """ + Initialize the rendering service. + + Args: + renderer: Renderer for output format (defaults to HTML) + render_cache: Cache for rendered output + reference_graph: Graph for dependency tracking + event_bus: Event bus for notifications + variable_repo: Repository for transclusion variables + reference_repo: Repository for transclusion references + """ + self.renderer = renderer or MarkdownToHTMLRenderer() + self.render_cache = render_cache or RenderCache() + self.reference_graph = reference_graph or ReferenceGraph() + self.event_bus = event_bus + self.variable_repo = variable_repo + self.reference_repo = reference_repo + + # Set up cache invalidator if event bus available + self._cache_invalidator: Optional[CacheInvalidator] = None + if event_bus: + self._cache_invalidator = CacheInvalidator( + cache=self.render_cache, + reference_graph=self.reference_graph, + event_bus=event_bus, + transitive=True, + ) + + def render_document( + self, + content: str, + document_id: str, + space_id: str, + base_path: Optional[Path] = None, + variables: Optional[Dict[str, Any]] = None, + force_refresh: bool = False, + ) -> RenderResult: + """ + Render a document with transclusion processing. + + Args: + content: Raw markdown content + document_id: Document identifier + space_id: Space identifier + base_path: Base path for relative file resolution + variables: Variables for transclusion + force_refresh: Skip cache and re-render + + Returns: + RenderResult with rendered content + """ + # Check cache first (unless force refresh) + content_hash = RenderResult.compute_hash(content) + + if not force_refresh: + cached = self._get_cached_if_valid(document_id, content_hash) + if cached: + logger.debug(f"Cache hit for document {document_id}") + self._emit_event( + SpaceEventType.RENDER_COMPLETED, + space_id, + {"document_id": document_id, "cached": True}, + ) + return self._cache_entry_to_result(cached, space_id) + + logger.debug(f"Rendering document {document_id}") + self._emit_event( + SpaceEventType.RENDER_STARTED, + space_id, + {"document_id": document_id}, + ) + + try: + # Create transclusion context + context = SpaceTransclusionContext( + space_id=space_id, + base_path=base_path, + variables=variables, + variable_repo=self.variable_repo, + ) + context.set_current_document(document_id) + + # Process transclusions (this is a placeholder - actual transclusion + # processing would use TransclusionEngine from packaging) + resolved_content = self._process_transclusions(content, context) + + # Get tracked references for dependency graph + references = context.get_tracked_references() + self._update_reference_graph(document_id, references, space_id) + + # Render to target format + dependencies = {ref[1] for ref in references} + result = self.renderer.render( + content=resolved_content, + document_id=document_id, + space_id=space_id, + dependencies=dependencies, + ) + + # Cache the result + self._cache_result(result) + + # Emit success event + self._emit_event( + SpaceEventType.RENDER_COMPLETED, + space_id, + { + "document_id": document_id, + "cached": False, + "dependencies": list(dependencies), + }, + ) + + return result + + except Exception as e: + logger.error(f"Render failed for {document_id}: {e}") + self._emit_event( + SpaceEventType.RENDER_FAILED, + space_id, + {"document_id": document_id, "error": str(e)}, + ) + raise + + def render_documents( + self, + documents: List[Dict[str, Any]], + space_id: str, + base_path: Optional[Path] = None, + variables: Optional[Dict[str, Any]] = None, + force_refresh: bool = False, + ) -> Dict[str, RenderResult]: + """ + Render multiple documents. + + Args: + documents: List of dicts with 'id' and 'content' keys + space_id: Space identifier + base_path: Base path for file resolution + variables: Shared variables + force_refresh: Skip cache + + Returns: + Dict mapping document_id to RenderResult + """ + results = {} + + for doc in documents: + doc_id = doc["id"] + content = doc["content"] + + try: + result = self.render_document( + content=content, + document_id=doc_id, + space_id=space_id, + base_path=base_path, + variables=variables, + force_refresh=force_refresh, + ) + results[doc_id] = result + except Exception as e: + logger.error(f"Failed to render {doc_id}: {e}") + # Continue with other documents + + return results + + def get_cached_render(self, document_id: str) -> Optional[CacheEntry]: + """ + Get cached render for a document. + + Args: + document_id: Document ID + + Returns: + CacheEntry if cached, None otherwise + """ + return self.render_cache.get(document_id) + + def invalidate_document(self, document_id: str, space_id: str) -> Set[str]: + """ + Invalidate cache for a document and its dependents. + + Args: + document_id: Document to invalidate + space_id: Space ID + + Returns: + Set of invalidated document IDs + """ + if self._cache_invalidator: + return self._cache_invalidator.invalidate_for_document(document_id, space_id) + else: + # Manual invalidation + to_invalidate = {document_id} + to_invalidate.update( + self.reference_graph.get_transitive_dependents(document_id) + ) + self.render_cache.invalidate_many(to_invalidate) + return to_invalidate + + def invalidate_space(self, space_id: str) -> int: + """ + Invalidate all cached renders for a space. + + Args: + space_id: Space ID + + Returns: + Number of entries invalidated + """ + return self.render_cache.invalidate_space(space_id) + + def get_render_statistics(self, space_id: str) -> Dict[str, Any]: + """ + Get rendering statistics for a space. + + Args: + space_id: Space ID + + Returns: + Statistics dict + """ + cached_docs = self.render_cache.get_cached_documents(space_id) + graph_docs = self.reference_graph.get_documents_in_space(space_id) + + return { + "cached_documents": len(cached_docs), + "tracked_documents": len(graph_docs), + "render_format": self.renderer.config.format.value, + "theme": self.renderer.config.theme.name, + } + + def _process_transclusions( + self, content: str, context: SpaceTransclusionContext + ) -> str: + """ + Process transclusion directives in content. + + This is a simplified implementation. Full transclusion processing + would use the TransclusionEngine from markitect.packaging.transclusion. + """ + # Substitute variables + resolved = context.substitute_variables(content) + + # Track any {{include:path}} references + import re + + include_pattern = r'\{\{include:([^}]+)\}\}' + for match in re.finditer(include_pattern, content): + target_path = match.group(1).strip() + context.track_reference(target_path) + + # Note: Full transclusion processing would: + # 1. Parse directives using DirectiveParser + # 2. Resolve file includes recursively + # 3. Handle conditionals + # For now, we just do variable substitution + + return resolved + + def _update_reference_graph( + self, + document_id: str, + references: List[tuple], + space_id: str, + ) -> None: + """Update the reference graph with document dependencies.""" + # Clear old references from this document + self.reference_graph.clear_references_from(document_id) + + # Add new references + for source, target in references: + self.reference_graph.add_reference(source, target, space_id) + + def _get_cached_if_valid( + self, document_id: str, content_hash: str + ) -> Optional[CacheEntry]: + """Get cached entry if still valid.""" + entry = self.render_cache.get(document_id) + if entry and entry.content_hash == content_hash: + return entry + return None + + def _cache_result(self, result: RenderResult) -> None: + """Cache a render result.""" + self.render_cache.put( + document_id=result.document_id, + space_id=result.space_id, + content_hash=result.source_hash, + rendered_content=result.content, + dependencies=result.dependencies, + ) + + def _cache_entry_to_result( + self, entry: CacheEntry, space_id: str + ) -> RenderResult: + """Convert cache entry to render result.""" + return RenderResult( + content=entry.rendered_content, + format=self.renderer.config.format, + content_hash=RenderResult.compute_hash(entry.rendered_content), + source_hash=entry.content_hash, + document_id=entry.document_id, + space_id=space_id, + dependencies=entry.dependencies, + ) + + def _emit_event( + self, event_type: SpaceEventType, space_id: str, payload: Dict[str, Any] + ) -> None: + """Emit an event if event bus is available.""" + if not self.event_bus: + return + + event = SpaceEvent( + event_type=event_type, + space_id=space_id, + payload=payload, + ) + self.event_bus.emit(event) + + +class SpaceRenderingServiceBuilder: + """Builder for creating configured SpaceRenderingService instances.""" + + def __init__(self): + """Initialize builder with defaults.""" + self._renderer: Optional[SpaceRenderer] = None + self._render_cache: Optional[RenderCache] = None + self._reference_graph: Optional[ReferenceGraph] = None + self._event_bus: Optional[EventBus] = None + self._variable_repo: Optional[IVariableRepository] = None + self._reference_repo: Optional[IReferenceRepository] = None + + def with_renderer(self, renderer: SpaceRenderer) -> "SpaceRenderingServiceBuilder": + """Set the renderer.""" + self._renderer = renderer + return self + + def with_html_renderer( + self, config: Optional[RenderConfig] = None + ) -> "SpaceRenderingServiceBuilder": + """Use HTML renderer with optional config.""" + self._renderer = MarkdownToHTMLRenderer(config) + return self + + def with_cache(self, cache: RenderCache) -> "SpaceRenderingServiceBuilder": + """Set the render cache.""" + self._render_cache = cache + return self + + def with_reference_graph( + self, graph: ReferenceGraph + ) -> "SpaceRenderingServiceBuilder": + """Set the reference graph.""" + self._reference_graph = graph + return self + + def with_event_bus(self, bus: EventBus) -> "SpaceRenderingServiceBuilder": + """Set the event bus.""" + self._event_bus = bus + return self + + def with_variable_repo( + self, repo: IVariableRepository + ) -> "SpaceRenderingServiceBuilder": + """Set the variable repository.""" + self._variable_repo = repo + return self + + def with_reference_repo( + self, repo: IReferenceRepository + ) -> "SpaceRenderingServiceBuilder": + """Set the reference repository.""" + self._reference_repo = repo + return self + + def build(self) -> SpaceRenderingService: + """Build the configured service.""" + return SpaceRenderingService( + renderer=self._renderer, + render_cache=self._render_cache, + reference_graph=self._reference_graph, + event_bus=self._event_bus, + variable_repo=self._variable_repo, + reference_repo=self._reference_repo, + ) diff --git a/tests/unit/spaces/test_rendering.py b/tests/unit/spaces/test_rendering.py new file mode 100644 index 00000000..299b49a0 --- /dev/null +++ b/tests/unit/spaces/test_rendering.py @@ -0,0 +1,760 @@ +""" +Unit tests for Phase 4: HTML Rendering Mode components. + +Tests cover: +- SpaceRenderer base class +- RenderConfig and ThemeConfig +- MarkdownToHTMLRenderer +- SpaceRenderingService +- Cache integration +""" + +import pytest +from unittest.mock import Mock, MagicMock, patch +from pathlib import Path + +from markitect.spaces.rendering import ( + SpaceRenderer, + RenderConfig, + RenderResult, + RenderFormat, + ThemeConfig, + CompositeRenderer, + MarkdownToHTMLRenderer, + HTMLRendererFactory, + THEME_PROPERTIES, + combine_theme_properties, + SpaceRenderingService, + SpaceRenderingServiceBuilder, +) +from markitect.spaces.transclusion import ( + RenderCache, + ReferenceGraph, +) +from markitect.spaces.events import EventBus, SpaceEventType + + +class TestRenderFormat: + """Tests for RenderFormat enum.""" + + def test_html_format(self): + """Test HTML format value.""" + assert RenderFormat.HTML.value == "html" + + def test_format_members(self): + """Test all format members exist.""" + assert RenderFormat.HTML + assert RenderFormat.PDF + assert RenderFormat.DOCX + assert RenderFormat.LATEX + + +class TestThemeConfig: + """Tests for ThemeConfig dataclass.""" + + def test_default_values(self): + """Test default configuration.""" + config = ThemeConfig() + assert config.name == "default" + assert config.layers == ["basic"] + assert config.custom_css is None + assert config.custom_properties == {} + + def test_custom_config(self): + """Test custom configuration.""" + config = ThemeConfig( + name="custom", + layers=["github", "dark"], + custom_css="body { color: red; }", + custom_properties={"max_width": "1000px"}, + ) + assert config.name == "custom" + assert config.layers == ["github", "dark"] + assert "color: red" in config.custom_css + assert config.custom_properties["max_width"] == "1000px" + + +class TestRenderConfig: + """Tests for RenderConfig dataclass.""" + + def test_default_values(self): + """Test default configuration.""" + config = RenderConfig() + assert config.format == RenderFormat.HTML + assert config.include_toc is False + assert config.highlight_code is True + assert config.sanitize_html is True + assert config.link_target_blank is True + + def test_custom_config(self): + """Test custom configuration.""" + theme = ThemeConfig(name="github") + config = RenderConfig( + format=RenderFormat.HTML, + theme=theme, + include_toc=True, + image_max_width="800px", + ) + assert config.theme.name == "github" + assert config.include_toc is True + assert config.image_max_width == "800px" + + +class TestRenderResult: + """Tests for RenderResult dataclass.""" + + def test_creation(self): + """Test result creation.""" + result = RenderResult( + content="

Test

", + format=RenderFormat.HTML, + content_hash="abc123", + source_hash="def456", + document_id="doc-1", + space_id="space-1", + ) + assert result.content == "

Test

" + assert result.format == RenderFormat.HTML + assert result.document_id == "doc-1" + assert result.space_id == "space-1" + assert result.dependencies == set() + + def test_with_dependencies(self): + """Test result with dependencies.""" + result = RenderResult( + content="

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 + assert result.document_id == "doc-1" + assert result.space_id == "space-1" + + def test_render_with_code(self): + """Test rendering code blocks.""" + renderer = MarkdownToHTMLRenderer() + markdown = """ +# Code Example + +```python +def hello(): + print("Hello") +``` +""" + result = renderer.render( + content=markdown, + document_id="doc-1", + space_id="space-1", + ) + + 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 "