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:
434
markitect/spaces/rendering/service.py
Normal file
434
markitect/spaces/rendering/service.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Space Rendering Service.
|
||||
|
||||
Orchestrates transclusion processing, rendering, and caching for
|
||||
Information Space documents.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Set
|
||||
|
||||
from .base import SpaceRenderer, RenderConfig, RenderResult, RenderFormat, CompositeRenderer
|
||||
from .html_renderer import MarkdownToHTMLRenderer
|
||||
from ..transclusion import (
|
||||
SpaceTransclusionContext,
|
||||
ReferenceGraph,
|
||||
RenderCache,
|
||||
CacheInvalidator,
|
||||
CacheEntry,
|
||||
)
|
||||
from ..events import EventBus, SpaceEventType, SpaceEvent
|
||||
from ..repositories.interfaces import IVariableRepository, IReferenceRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpaceRenderingService:
|
||||
"""
|
||||
Service for rendering Information Space documents.
|
||||
|
||||
Orchestrates:
|
||||
- Transclusion resolution
|
||||
- Content rendering to target format
|
||||
- Render caching with automatic invalidation
|
||||
- Event emission for render lifecycle
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
renderer: Optional[SpaceRenderer] = None,
|
||||
render_cache: Optional[RenderCache] = None,
|
||||
reference_graph: Optional[ReferenceGraph] = None,
|
||||
event_bus: Optional[EventBus] = None,
|
||||
variable_repo: Optional[IVariableRepository] = None,
|
||||
reference_repo: Optional[IReferenceRepository] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the rendering service.
|
||||
|
||||
Args:
|
||||
renderer: Renderer for output format (defaults to HTML)
|
||||
render_cache: Cache for rendered output
|
||||
reference_graph: Graph for dependency tracking
|
||||
event_bus: Event bus for notifications
|
||||
variable_repo: Repository for transclusion variables
|
||||
reference_repo: Repository for transclusion references
|
||||
"""
|
||||
self.renderer = renderer or MarkdownToHTMLRenderer()
|
||||
self.render_cache = render_cache or RenderCache()
|
||||
self.reference_graph = reference_graph or ReferenceGraph()
|
||||
self.event_bus = event_bus
|
||||
self.variable_repo = variable_repo
|
||||
self.reference_repo = reference_repo
|
||||
|
||||
# Set up cache invalidator if event bus available
|
||||
self._cache_invalidator: Optional[CacheInvalidator] = None
|
||||
if event_bus:
|
||||
self._cache_invalidator = CacheInvalidator(
|
||||
cache=self.render_cache,
|
||||
reference_graph=self.reference_graph,
|
||||
event_bus=event_bus,
|
||||
transitive=True,
|
||||
)
|
||||
|
||||
def render_document(
|
||||
self,
|
||||
content: str,
|
||||
document_id: str,
|
||||
space_id: str,
|
||||
base_path: Optional[Path] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> RenderResult:
|
||||
"""
|
||||
Render a document with transclusion processing.
|
||||
|
||||
Args:
|
||||
content: Raw markdown content
|
||||
document_id: Document identifier
|
||||
space_id: Space identifier
|
||||
base_path: Base path for relative file resolution
|
||||
variables: Variables for transclusion
|
||||
force_refresh: Skip cache and re-render
|
||||
|
||||
Returns:
|
||||
RenderResult with rendered content
|
||||
"""
|
||||
# Check cache first (unless force refresh)
|
||||
content_hash = RenderResult.compute_hash(content)
|
||||
|
||||
if not force_refresh:
|
||||
cached = self._get_cached_if_valid(document_id, content_hash)
|
||||
if cached:
|
||||
logger.debug(f"Cache hit for document {document_id}")
|
||||
self._emit_event(
|
||||
SpaceEventType.RENDER_COMPLETED,
|
||||
space_id,
|
||||
{"document_id": document_id, "cached": True},
|
||||
)
|
||||
return self._cache_entry_to_result(cached, space_id)
|
||||
|
||||
logger.debug(f"Rendering document {document_id}")
|
||||
self._emit_event(
|
||||
SpaceEventType.RENDER_STARTED,
|
||||
space_id,
|
||||
{"document_id": document_id},
|
||||
)
|
||||
|
||||
try:
|
||||
# Create transclusion context
|
||||
context = SpaceTransclusionContext(
|
||||
space_id=space_id,
|
||||
base_path=base_path,
|
||||
variables=variables,
|
||||
variable_repo=self.variable_repo,
|
||||
)
|
||||
context.set_current_document(document_id)
|
||||
|
||||
# Process transclusions (this is a placeholder - actual transclusion
|
||||
# processing would use TransclusionEngine from packaging)
|
||||
resolved_content = self._process_transclusions(content, context)
|
||||
|
||||
# Get tracked references for dependency graph
|
||||
references = context.get_tracked_references()
|
||||
self._update_reference_graph(document_id, references, space_id)
|
||||
|
||||
# Render to target format
|
||||
dependencies = {ref[1] for ref in references}
|
||||
result = self.renderer.render(
|
||||
content=resolved_content,
|
||||
document_id=document_id,
|
||||
space_id=space_id,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
self._cache_result(result)
|
||||
|
||||
# Emit success event
|
||||
self._emit_event(
|
||||
SpaceEventType.RENDER_COMPLETED,
|
||||
space_id,
|
||||
{
|
||||
"document_id": document_id,
|
||||
"cached": False,
|
||||
"dependencies": list(dependencies),
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Render failed for {document_id}: {e}")
|
||||
self._emit_event(
|
||||
SpaceEventType.RENDER_FAILED,
|
||||
space_id,
|
||||
{"document_id": document_id, "error": str(e)},
|
||||
)
|
||||
raise
|
||||
|
||||
def render_documents(
|
||||
self,
|
||||
documents: List[Dict[str, Any]],
|
||||
space_id: str,
|
||||
base_path: Optional[Path] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, RenderResult]:
|
||||
"""
|
||||
Render multiple documents.
|
||||
|
||||
Args:
|
||||
documents: List of dicts with 'id' and 'content' keys
|
||||
space_id: Space identifier
|
||||
base_path: Base path for file resolution
|
||||
variables: Shared variables
|
||||
force_refresh: Skip cache
|
||||
|
||||
Returns:
|
||||
Dict mapping document_id to RenderResult
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for doc in documents:
|
||||
doc_id = doc["id"]
|
||||
content = doc["content"]
|
||||
|
||||
try:
|
||||
result = self.render_document(
|
||||
content=content,
|
||||
document_id=doc_id,
|
||||
space_id=space_id,
|
||||
base_path=base_path,
|
||||
variables=variables,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
results[doc_id] = result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to render {doc_id}: {e}")
|
||||
# Continue with other documents
|
||||
|
||||
return results
|
||||
|
||||
def get_cached_render(self, document_id: str) -> Optional[CacheEntry]:
|
||||
"""
|
||||
Get cached render for a document.
|
||||
|
||||
Args:
|
||||
document_id: Document ID
|
||||
|
||||
Returns:
|
||||
CacheEntry if cached, None otherwise
|
||||
"""
|
||||
return self.render_cache.get(document_id)
|
||||
|
||||
def invalidate_document(self, document_id: str, space_id: str) -> Set[str]:
|
||||
"""
|
||||
Invalidate cache for a document and its dependents.
|
||||
|
||||
Args:
|
||||
document_id: Document to invalidate
|
||||
space_id: Space ID
|
||||
|
||||
Returns:
|
||||
Set of invalidated document IDs
|
||||
"""
|
||||
if self._cache_invalidator:
|
||||
return self._cache_invalidator.invalidate_for_document(document_id, space_id)
|
||||
else:
|
||||
# Manual invalidation
|
||||
to_invalidate = {document_id}
|
||||
to_invalidate.update(
|
||||
self.reference_graph.get_transitive_dependents(document_id)
|
||||
)
|
||||
self.render_cache.invalidate_many(to_invalidate)
|
||||
return to_invalidate
|
||||
|
||||
def invalidate_space(self, space_id: str) -> int:
|
||||
"""
|
||||
Invalidate all cached renders for a space.
|
||||
|
||||
Args:
|
||||
space_id: Space ID
|
||||
|
||||
Returns:
|
||||
Number of entries invalidated
|
||||
"""
|
||||
return self.render_cache.invalidate_space(space_id)
|
||||
|
||||
def get_render_statistics(self, space_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get rendering statistics for a space.
|
||||
|
||||
Args:
|
||||
space_id: Space ID
|
||||
|
||||
Returns:
|
||||
Statistics dict
|
||||
"""
|
||||
cached_docs = self.render_cache.get_cached_documents(space_id)
|
||||
graph_docs = self.reference_graph.get_documents_in_space(space_id)
|
||||
|
||||
return {
|
||||
"cached_documents": len(cached_docs),
|
||||
"tracked_documents": len(graph_docs),
|
||||
"render_format": self.renderer.config.format.value,
|
||||
"theme": self.renderer.config.theme.name,
|
||||
}
|
||||
|
||||
def _process_transclusions(
|
||||
self, content: str, context: SpaceTransclusionContext
|
||||
) -> str:
|
||||
"""
|
||||
Process transclusion directives in content.
|
||||
|
||||
This is a simplified implementation. Full transclusion processing
|
||||
would use the TransclusionEngine from markitect.packaging.transclusion.
|
||||
"""
|
||||
# Substitute variables
|
||||
resolved = context.substitute_variables(content)
|
||||
|
||||
# Track any {{include:path}} references
|
||||
import re
|
||||
|
||||
include_pattern = r'\{\{include:([^}]+)\}\}'
|
||||
for match in re.finditer(include_pattern, content):
|
||||
target_path = match.group(1).strip()
|
||||
context.track_reference(target_path)
|
||||
|
||||
# Note: Full transclusion processing would:
|
||||
# 1. Parse directives using DirectiveParser
|
||||
# 2. Resolve file includes recursively
|
||||
# 3. Handle conditionals
|
||||
# For now, we just do variable substitution
|
||||
|
||||
return resolved
|
||||
|
||||
def _update_reference_graph(
|
||||
self,
|
||||
document_id: str,
|
||||
references: List[tuple],
|
||||
space_id: str,
|
||||
) -> None:
|
||||
"""Update the reference graph with document dependencies."""
|
||||
# Clear old references from this document
|
||||
self.reference_graph.clear_references_from(document_id)
|
||||
|
||||
# Add new references
|
||||
for source, target in references:
|
||||
self.reference_graph.add_reference(source, target, space_id)
|
||||
|
||||
def _get_cached_if_valid(
|
||||
self, document_id: str, content_hash: str
|
||||
) -> Optional[CacheEntry]:
|
||||
"""Get cached entry if still valid."""
|
||||
entry = self.render_cache.get(document_id)
|
||||
if entry and entry.content_hash == content_hash:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _cache_result(self, result: RenderResult) -> None:
|
||||
"""Cache a render result."""
|
||||
self.render_cache.put(
|
||||
document_id=result.document_id,
|
||||
space_id=result.space_id,
|
||||
content_hash=result.source_hash,
|
||||
rendered_content=result.content,
|
||||
dependencies=result.dependencies,
|
||||
)
|
||||
|
||||
def _cache_entry_to_result(
|
||||
self, entry: CacheEntry, space_id: str
|
||||
) -> RenderResult:
|
||||
"""Convert cache entry to render result."""
|
||||
return RenderResult(
|
||||
content=entry.rendered_content,
|
||||
format=self.renderer.config.format,
|
||||
content_hash=RenderResult.compute_hash(entry.rendered_content),
|
||||
source_hash=entry.content_hash,
|
||||
document_id=entry.document_id,
|
||||
space_id=space_id,
|
||||
dependencies=entry.dependencies,
|
||||
)
|
||||
|
||||
def _emit_event(
|
||||
self, event_type: SpaceEventType, space_id: str, payload: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Emit an event if event bus is available."""
|
||||
if not self.event_bus:
|
||||
return
|
||||
|
||||
event = SpaceEvent(
|
||||
event_type=event_type,
|
||||
space_id=space_id,
|
||||
payload=payload,
|
||||
)
|
||||
self.event_bus.emit(event)
|
||||
|
||||
|
||||
class SpaceRenderingServiceBuilder:
|
||||
"""Builder for creating configured SpaceRenderingService instances."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize builder with defaults."""
|
||||
self._renderer: Optional[SpaceRenderer] = None
|
||||
self._render_cache: Optional[RenderCache] = None
|
||||
self._reference_graph: Optional[ReferenceGraph] = None
|
||||
self._event_bus: Optional[EventBus] = None
|
||||
self._variable_repo: Optional[IVariableRepository] = None
|
||||
self._reference_repo: Optional[IReferenceRepository] = None
|
||||
|
||||
def with_renderer(self, renderer: SpaceRenderer) -> "SpaceRenderingServiceBuilder":
|
||||
"""Set the renderer."""
|
||||
self._renderer = renderer
|
||||
return self
|
||||
|
||||
def with_html_renderer(
|
||||
self, config: Optional[RenderConfig] = None
|
||||
) -> "SpaceRenderingServiceBuilder":
|
||||
"""Use HTML renderer with optional config."""
|
||||
self._renderer = MarkdownToHTMLRenderer(config)
|
||||
return self
|
||||
|
||||
def with_cache(self, cache: RenderCache) -> "SpaceRenderingServiceBuilder":
|
||||
"""Set the render cache."""
|
||||
self._render_cache = cache
|
||||
return self
|
||||
|
||||
def with_reference_graph(
|
||||
self, graph: ReferenceGraph
|
||||
) -> "SpaceRenderingServiceBuilder":
|
||||
"""Set the reference graph."""
|
||||
self._reference_graph = graph
|
||||
return self
|
||||
|
||||
def with_event_bus(self, bus: EventBus) -> "SpaceRenderingServiceBuilder":
|
||||
"""Set the event bus."""
|
||||
self._event_bus = bus
|
||||
return self
|
||||
|
||||
def with_variable_repo(
|
||||
self, repo: IVariableRepository
|
||||
) -> "SpaceRenderingServiceBuilder":
|
||||
"""Set the variable repository."""
|
||||
self._variable_repo = repo
|
||||
return self
|
||||
|
||||
def with_reference_repo(
|
||||
self, repo: IReferenceRepository
|
||||
) -> "SpaceRenderingServiceBuilder":
|
||||
"""Set the reference repository."""
|
||||
self._reference_repo = repo
|
||||
return self
|
||||
|
||||
def build(self) -> SpaceRenderingService:
|
||||
"""Build the configured service."""
|
||||
return SpaceRenderingService(
|
||||
renderer=self._renderer,
|
||||
render_cache=self._render_cache,
|
||||
reference_graph=self._reference_graph,
|
||||
event_bus=self._event_bus,
|
||||
variable_repo=self._variable_repo,
|
||||
reference_repo=self._reference_repo,
|
||||
)
|
||||
Reference in New Issue
Block a user