Files
markitect-main/markitect/spaces/rendering/service.py
tegwick 2a5c265458 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>
2026-02-08 08:42:27 +01:00

435 lines
14 KiB
Python

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