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