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:
239
markitect/spaces/rendering/base.py
Normal file
239
markitect/spaces/rendering/base.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user