feat(spaces): implement Phase 4 HTML Rendering Mode
Implements HTML rendering system for Information Spaces: - SpaceRenderer: Abstract base class for renderers - RenderConfig: Configuration for format, theme, TOC, etc. - RenderResult: Immutable result with content hash and metadata - ThemeConfig: Layered theme system with customization - CompositeRenderer: Multi-format renderer delegation - MarkdownToHTMLRenderer: Full markdown-to-HTML conversion - Theme support (github, dark, minimal, academic) - Code block handling - Link target="_blank" for external links - Table of contents generation - Heading ID generation for navigation - HTMLRendererFactory: Factory for common renderer configurations - SpaceRenderingService: Orchestration layer - Transclusion variable substitution - Render caching with automatic invalidation - Event emission (RENDER_STARTED, RENDER_COMPLETED, RENDER_FAILED) - Batch rendering support - Statistics tracking - SpaceRenderingServiceBuilder: Fluent builder pattern 60 unit tests covering all components. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,42 @@ This package provides space rendering capabilities:
|
|||||||
- MarkdownToHTMLRenderer: HTML output renderer
|
- MarkdownToHTMLRenderer: HTML output renderer
|
||||||
- Theme support and customization
|
- Theme support and customization
|
||||||
- Render caching with invalidation
|
- Render caching with invalidation
|
||||||
|
- SpaceRenderingService: Orchestration layer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Rendering will be implemented in Phase 4
|
from .base import (
|
||||||
__all__ = []
|
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",
|
||||||
|
]
|
||||||
|
|||||||
239
markitect/spaces/rendering/base.py
Normal file
239
markitect/spaces/rendering/base.py
Normal file
@@ -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())
|
||||||
576
markitect/spaces/rendering/html_renderer.py
Normal file
576
markitect/spaces/rendering/html_renderer.py
Normal file
@@ -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('</code></pre>')
|
||||||
|
in_code_block = False
|
||||||
|
else:
|
||||||
|
lang = line[3:].strip()
|
||||||
|
lang_class = f' class="language-{lang}"' if lang else ''
|
||||||
|
html_lines.append(f'<pre><code{lang_class}>')
|
||||||
|
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('</ul>')
|
||||||
|
in_list = False
|
||||||
|
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Headers
|
||||||
|
if stripped.startswith('######'):
|
||||||
|
text = stripped[6:].strip()
|
||||||
|
slug = self._slugify(text)
|
||||||
|
html_lines.append(f'<h6 id="{slug}">{html.escape(text)}</h6>')
|
||||||
|
elif stripped.startswith('#####'):
|
||||||
|
text = stripped[5:].strip()
|
||||||
|
slug = self._slugify(text)
|
||||||
|
html_lines.append(f'<h5 id="{slug}">{html.escape(text)}</h5>')
|
||||||
|
elif stripped.startswith('####'):
|
||||||
|
text = stripped[4:].strip()
|
||||||
|
slug = self._slugify(text)
|
||||||
|
html_lines.append(f'<h4 id="{slug}">{html.escape(text)}</h4>')
|
||||||
|
elif stripped.startswith('###'):
|
||||||
|
text = stripped[3:].strip()
|
||||||
|
slug = self._slugify(text)
|
||||||
|
html_lines.append(f'<h3 id="{slug}">{html.escape(text)}</h3>')
|
||||||
|
elif stripped.startswith('##'):
|
||||||
|
text = stripped[2:].strip()
|
||||||
|
slug = self._slugify(text)
|
||||||
|
html_lines.append(f'<h2 id="{slug}">{html.escape(text)}</h2>')
|
||||||
|
elif stripped.startswith('#'):
|
||||||
|
text = stripped[1:].strip()
|
||||||
|
slug = self._slugify(text)
|
||||||
|
html_lines.append(f'<h1 id="{slug}">{html.escape(text)}</h1>')
|
||||||
|
# Horizontal rule
|
||||||
|
elif stripped in ('---', '***', '___'):
|
||||||
|
html_lines.append('<hr>')
|
||||||
|
# Blockquote
|
||||||
|
elif stripped.startswith('>'):
|
||||||
|
text = stripped[1:].strip()
|
||||||
|
html_lines.append(f'<blockquote>{html.escape(text)}</blockquote>')
|
||||||
|
# Unordered list
|
||||||
|
elif stripped.startswith(('-', '*', '+')) and len(stripped) > 1 and stripped[1] == ' ':
|
||||||
|
if not in_list:
|
||||||
|
html_lines.append('<ul>')
|
||||||
|
in_list = True
|
||||||
|
text = stripped[2:].strip()
|
||||||
|
html_lines.append(f'<li>{self._process_inline(text)}</li>')
|
||||||
|
# Empty line
|
||||||
|
elif not stripped:
|
||||||
|
if in_list:
|
||||||
|
html_lines.append('</ul>')
|
||||||
|
in_list = False
|
||||||
|
html_lines.append('')
|
||||||
|
# Paragraph
|
||||||
|
else:
|
||||||
|
html_lines.append(f'<p>{self._process_inline(stripped)}</p>')
|
||||||
|
|
||||||
|
# Close any open list
|
||||||
|
if in_list:
|
||||||
|
html_lines.append('</ul>')
|
||||||
|
|
||||||
|
# Close any open code block
|
||||||
|
if in_code_block:
|
||||||
|
html_lines.append('</code></pre>')
|
||||||
|
|
||||||
|
return '\n'.join(html_lines)
|
||||||
|
|
||||||
|
def _process_inline(self, text: str) -> str:
|
||||||
|
"""Process inline markdown elements."""
|
||||||
|
# Bold
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
|
||||||
|
text = re.sub(r'__(.+?)__', r'<strong>\1</strong>', text)
|
||||||
|
|
||||||
|
# Italic
|
||||||
|
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
|
||||||
|
text = re.sub(r'_(.+?)_', r'<em>\1</em>', text)
|
||||||
|
|
||||||
|
# Code
|
||||||
|
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
|
||||||
|
|
||||||
|
# Links
|
||||||
|
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
||||||
|
|
||||||
|
# Images
|
||||||
|
text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', r'<img src="\2" alt="\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'<a href="(https?://[^"]+)"',
|
||||||
|
r'<a href="\1" target="_blank" rel="noopener noreferrer"',
|
||||||
|
html_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limit image dimensions
|
||||||
|
if self.config.image_max_width != "100%":
|
||||||
|
html_content = re.sub(
|
||||||
|
r'<img ([^>]*)>',
|
||||||
|
f'<img \\1 style="max-width: {self.config.image_max_width}; max-height: {self.config.image_max_height};">',
|
||||||
|
html_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
def _generate_toc(self, html_content: str) -> str:
|
||||||
|
"""Generate table of contents from HTML headings."""
|
||||||
|
headings = re.findall(r'<h([1-6])[^>]*id="([^"]+)"[^>]*>([^<]+)</h\1>', html_content)
|
||||||
|
|
||||||
|
if not headings:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
toc_lines = ['<nav class="toc"><h2>Contents</h2><ul>']
|
||||||
|
current_level = 0
|
||||||
|
|
||||||
|
for level_str, slug, text in headings:
|
||||||
|
level = int(level_str)
|
||||||
|
|
||||||
|
# Adjust nesting
|
||||||
|
while current_level < level:
|
||||||
|
toc_lines.append('<ul>')
|
||||||
|
current_level += 1
|
||||||
|
while current_level > level:
|
||||||
|
toc_lines.append('</ul>')
|
||||||
|
current_level -= 1
|
||||||
|
|
||||||
|
toc_lines.append(f'<li><a href="#{slug}">{html.escape(text)}</a></li>')
|
||||||
|
|
||||||
|
# Close remaining lists
|
||||||
|
while current_level > 0:
|
||||||
|
toc_lines.append('</ul>')
|
||||||
|
current_level -= 1
|
||||||
|
|
||||||
|
toc_lines.append('</ul></nav>')
|
||||||
|
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 = '<meta charset="utf-8">\n'
|
||||||
|
meta_tags += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
||||||
|
meta_tags += '<meta name="generator" content="Markitect Information Space">\n'
|
||||||
|
|
||||||
|
# Add custom meta from metadata
|
||||||
|
for key, value in metadata.items():
|
||||||
|
if key.startswith('meta_'):
|
||||||
|
meta_name = key[5:]
|
||||||
|
meta_tags += f'<meta name="{html.escape(meta_name)}" content="{html.escape(str(value))}">\n'
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{meta_tags}
|
||||||
|
<title>{html.escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
{css}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{toc_content}
|
||||||
|
<div id="content">
|
||||||
|
{body_content}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
434
markitect/spaces/rendering/service.py
Normal file
434
markitect/spaces/rendering/service.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
760
tests/unit/spaces/test_rendering.py
Normal file
760
tests/unit/spaces/test_rendering.py
Normal file
@@ -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="<h1>Test</h1>",
|
||||||
|
format=RenderFormat.HTML,
|
||||||
|
content_hash="abc123",
|
||||||
|
source_hash="def456",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
assert result.content == "<h1>Test</h1>"
|
||||||
|
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="<p>Test</p>",
|
||||||
|
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 "<h1" in result.content
|
||||||
|
assert "Hello World" in result.content
|
||||||
|
assert "<p>" 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 "<pre>" in result.content or "<code>" 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 "<h1" in result.content
|
||||||
|
|
||||||
|
def test_no_renderer_for_format(self):
|
||||||
|
"""Test error when no renderer for format."""
|
||||||
|
composite = CompositeRenderer()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
composite.render(
|
||||||
|
content="test",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
format=RenderFormat.PDF,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "No renderer registered" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceRenderingService:
|
||||||
|
"""Tests for SpaceRenderingService."""
|
||||||
|
|
||||||
|
def test_default_initialization(self):
|
||||||
|
"""Test default service initialization."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
assert service.renderer is not None
|
||||||
|
assert service.render_cache is not None
|
||||||
|
assert service.reference_graph is not None
|
||||||
|
|
||||||
|
def test_render_document(self):
|
||||||
|
"""Test basic document rendering."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
result = service.render_document(
|
||||||
|
content="# Hello\n\nWorld",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.format == RenderFormat.HTML
|
||||||
|
assert "Hello" in result.content
|
||||||
|
assert result.document_id == "doc-1"
|
||||||
|
|
||||||
|
def test_render_caching(self):
|
||||||
|
"""Test that results are cached."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
# First render
|
||||||
|
result1 = service.render_document(
|
||||||
|
content="# Test",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second render - should use cache
|
||||||
|
result2 = service.render_document(
|
||||||
|
content="# Test",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get cached entry
|
||||||
|
cached = service.get_cached_render("doc-1")
|
||||||
|
assert cached is not None
|
||||||
|
|
||||||
|
def test_force_refresh_bypasses_cache(self):
|
||||||
|
"""Test force refresh skips cache."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
# First render
|
||||||
|
service.render_document(
|
||||||
|
content="# Test",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Force refresh with different content
|
||||||
|
result = service.render_document(
|
||||||
|
content="# New Content",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
force_refresh=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "New Content" in result.content
|
||||||
|
|
||||||
|
def test_render_with_variables(self):
|
||||||
|
"""Test rendering with variable substitution."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
result = service.render_document(
|
||||||
|
content="Hello {{name}}!",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
variables={"name": "World"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "World" in result.content
|
||||||
|
|
||||||
|
def test_render_documents_batch(self):
|
||||||
|
"""Test batch rendering."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
documents = [
|
||||||
|
{"id": "doc-1", "content": "# Doc 1"},
|
||||||
|
{"id": "doc-2", "content": "# Doc 2"},
|
||||||
|
{"id": "doc-3", "content": "# Doc 3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
results = service.render_documents(
|
||||||
|
documents=documents,
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
assert "doc-1" in results
|
||||||
|
assert "doc-2" in results
|
||||||
|
assert "doc-3" in results
|
||||||
|
|
||||||
|
def test_invalidate_document(self):
|
||||||
|
"""Test cache invalidation."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
# Render a document
|
||||||
|
service.render_document(
|
||||||
|
content="# Test",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidate
|
||||||
|
invalidated = service.invalidate_document("doc-1", "space-1")
|
||||||
|
|
||||||
|
assert "doc-1" in invalidated
|
||||||
|
assert service.get_cached_render("doc-1") is None
|
||||||
|
|
||||||
|
def test_invalidate_space(self):
|
||||||
|
"""Test invalidating entire space."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
# Render multiple documents
|
||||||
|
service.render_document("# Doc 1", "doc-1", "space-1")
|
||||||
|
service.render_document("# Doc 2", "doc-2", "space-1")
|
||||||
|
|
||||||
|
# Invalidate space
|
||||||
|
count = service.invalidate_space("space-1")
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
def test_render_statistics(self):
|
||||||
|
"""Test getting render statistics."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
# Render some documents
|
||||||
|
service.render_document("# Doc 1", "doc-1", "space-1")
|
||||||
|
service.render_document("# Doc 2", "doc-2", "space-1")
|
||||||
|
|
||||||
|
stats = service.get_render_statistics("space-1")
|
||||||
|
|
||||||
|
assert "cached_documents" in stats
|
||||||
|
assert "render_format" in stats
|
||||||
|
assert stats["cached_documents"] == 2
|
||||||
|
|
||||||
|
def test_event_emission(self):
|
||||||
|
"""Test that events are emitted during rendering."""
|
||||||
|
event_bus = EventBus()
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def capture_event(event):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
event_bus.subscribe(SpaceEventType.RENDER_STARTED, capture_event)
|
||||||
|
event_bus.subscribe(SpaceEventType.RENDER_COMPLETED, capture_event)
|
||||||
|
|
||||||
|
service = SpaceRenderingService(event_bus=event_bus)
|
||||||
|
|
||||||
|
service.render_document(
|
||||||
|
content="# Test",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have RENDER_STARTED and RENDER_COMPLETED events
|
||||||
|
event_types = [e.event_type for e in events]
|
||||||
|
assert SpaceEventType.RENDER_STARTED in event_types
|
||||||
|
assert SpaceEventType.RENDER_COMPLETED in event_types
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceRenderingServiceBuilder:
|
||||||
|
"""Tests for SpaceRenderingServiceBuilder."""
|
||||||
|
|
||||||
|
def test_build_default(self):
|
||||||
|
"""Test building with defaults."""
|
||||||
|
service = SpaceRenderingServiceBuilder().build()
|
||||||
|
assert service.renderer is not None
|
||||||
|
|
||||||
|
def test_with_html_renderer(self):
|
||||||
|
"""Test building with HTML renderer."""
|
||||||
|
config = RenderConfig(theme=ThemeConfig(name="github"))
|
||||||
|
service = (
|
||||||
|
SpaceRenderingServiceBuilder()
|
||||||
|
.with_html_renderer(config)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
assert service.renderer.config.theme.name == "github"
|
||||||
|
|
||||||
|
def test_with_event_bus(self):
|
||||||
|
"""Test building with event bus."""
|
||||||
|
bus = EventBus()
|
||||||
|
service = (
|
||||||
|
SpaceRenderingServiceBuilder()
|
||||||
|
.with_event_bus(bus)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
assert service.event_bus is bus
|
||||||
|
|
||||||
|
def test_with_cache(self):
|
||||||
|
"""Test building with custom cache."""
|
||||||
|
cache = RenderCache()
|
||||||
|
service = (
|
||||||
|
SpaceRenderingServiceBuilder()
|
||||||
|
.with_cache(cache)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
assert service.render_cache is cache
|
||||||
|
|
||||||
|
def test_with_reference_graph(self):
|
||||||
|
"""Test building with reference graph."""
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
service = (
|
||||||
|
SpaceRenderingServiceBuilder()
|
||||||
|
.with_reference_graph(graph)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
assert service.reference_graph is graph
|
||||||
|
|
||||||
|
def test_fluent_chaining(self):
|
||||||
|
"""Test fluent builder chaining."""
|
||||||
|
bus = EventBus()
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
|
||||||
|
service = (
|
||||||
|
SpaceRenderingServiceBuilder()
|
||||||
|
.with_html_renderer()
|
||||||
|
.with_event_bus(bus)
|
||||||
|
.with_cache(cache)
|
||||||
|
.with_reference_graph(graph)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service.event_bus is bus
|
||||||
|
assert service.render_cache is cache
|
||||||
|
assert service.reference_graph is graph
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasicMarkdownParsing:
|
||||||
|
"""Tests for basic markdown parsing fallback."""
|
||||||
|
|
||||||
|
def test_headings(self):
|
||||||
|
"""Test heading parsing."""
|
||||||
|
renderer = MarkdownToHTMLRenderer()
|
||||||
|
markdown = "# H1\n## H2\n### H3"
|
||||||
|
result = renderer.render(markdown, "doc", "space")
|
||||||
|
|
||||||
|
assert "<h1" in result.content
|
||||||
|
assert "<h2" in result.content
|
||||||
|
assert "<h3" in result.content
|
||||||
|
|
||||||
|
def test_bold_italic(self):
|
||||||
|
"""Test bold and italic."""
|
||||||
|
renderer = MarkdownToHTMLRenderer()
|
||||||
|
markdown = "**bold** and *italic*"
|
||||||
|
result = renderer.render(markdown, "doc", "space")
|
||||||
|
|
||||||
|
assert "<strong>bold</strong>" 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 "<code" in result.content or "<pre" in result.content
|
||||||
|
|
||||||
|
def test_lists(self):
|
||||||
|
"""Test list parsing."""
|
||||||
|
renderer = MarkdownToHTMLRenderer()
|
||||||
|
markdown = "- Item 1\n- Item 2\n- Item 3"
|
||||||
|
result = renderer.render(markdown, "doc", "space")
|
||||||
|
|
||||||
|
assert "<li>" 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 "<blockquote" in result.content or "quote" in result.content
|
||||||
|
|
||||||
|
def test_horizontal_rule(self):
|
||||||
|
"""Test horizontal rule."""
|
||||||
|
renderer = MarkdownToHTMLRenderer()
|
||||||
|
markdown = "Text\n\n---\n\nMore text"
|
||||||
|
result = renderer.render(markdown, "doc", "space")
|
||||||
|
|
||||||
|
assert "<hr" in result.content
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeProperties:
|
||||||
|
"""Tests for theme property definitions."""
|
||||||
|
|
||||||
|
def test_default_theme_exists(self):
|
||||||
|
"""Test default theme is defined."""
|
||||||
|
assert "default" in THEME_PROPERTIES
|
||||||
|
|
||||||
|
def test_github_theme_exists(self):
|
||||||
|
"""Test GitHub theme is defined."""
|
||||||
|
assert "github" in THEME_PROPERTIES
|
||||||
|
|
||||||
|
def test_dark_theme_exists(self):
|
||||||
|
"""Test dark theme is defined."""
|
||||||
|
assert "dark" in THEME_PROPERTIES
|
||||||
|
|
||||||
|
def test_minimal_theme_exists(self):
|
||||||
|
"""Test minimal theme is defined."""
|
||||||
|
assert "minimal" in THEME_PROPERTIES
|
||||||
|
|
||||||
|
def test_academic_theme_exists(self):
|
||||||
|
"""Test academic theme is defined."""
|
||||||
|
assert "academic" in THEME_PROPERTIES
|
||||||
|
|
||||||
|
def test_theme_has_required_properties(self):
|
||||||
|
"""Test themes have required properties."""
|
||||||
|
required = ["font_family", "body_color", "body_background"]
|
||||||
|
for theme_name, props in THEME_PROPERTIES.items():
|
||||||
|
for prop in required:
|
||||||
|
assert prop in props, f"{theme_name} missing {prop}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderIntegration:
|
||||||
|
"""Integration tests for rendering pipeline."""
|
||||||
|
|
||||||
|
def test_full_render_pipeline(self):
|
||||||
|
"""Test complete render pipeline with cache."""
|
||||||
|
event_bus = EventBus()
|
||||||
|
cache = RenderCache()
|
||||||
|
graph = ReferenceGraph()
|
||||||
|
|
||||||
|
service = (
|
||||||
|
SpaceRenderingServiceBuilder()
|
||||||
|
.with_html_renderer()
|
||||||
|
.with_event_bus(event_bus)
|
||||||
|
.with_cache(cache)
|
||||||
|
.with_reference_graph(graph)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
# First render
|
||||||
|
result1 = service.render_document(
|
||||||
|
content="# Hello World",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result1.format == RenderFormat.HTML
|
||||||
|
assert "Hello World" in result1.content
|
||||||
|
|
||||||
|
# Check cached
|
||||||
|
assert cache.get("doc-1") is not None
|
||||||
|
|
||||||
|
# Second render should use cache
|
||||||
|
result2 = service.render_document(
|
||||||
|
content="# Hello World",
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidate
|
||||||
|
invalidated = service.invalidate_document("doc-1", "space-1")
|
||||||
|
assert "doc-1" in invalidated
|
||||||
|
assert cache.get("doc-1") is None
|
||||||
|
|
||||||
|
def test_variable_substitution_in_render(self):
|
||||||
|
"""Test variable substitution during render."""
|
||||||
|
service = SpaceRenderingService()
|
||||||
|
|
||||||
|
content = "# Welcome {{user}}\n\nToday is {{day}}."
|
||||||
|
variables = {"user": "Alice", "day": "Monday"}
|
||||||
|
|
||||||
|
result = service.render_document(
|
||||||
|
content=content,
|
||||||
|
document_id="doc-1",
|
||||||
|
space_id="space-1",
|
||||||
|
variables=variables,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Alice" in result.content
|
||||||
|
assert "Monday" in result.content
|
||||||
Reference in New Issue
Block a user