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>
435 lines
14 KiB
Python
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,
|
|
)
|