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:
2026-02-08 08:42:27 +01:00
parent 7da77396a9
commit 2a5c265458
5 changed files with 2046 additions and 2 deletions

View File

@@ -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",
]

View 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())

View 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)

View 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,
)

View 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