Files
markitect-main/markitect/spaces/rendering/base.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

240 lines
6.7 KiB
Python

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