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