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:
@@ -6,7 +6,42 @@ This package provides space rendering capabilities:
|
||||
- MarkdownToHTMLRenderer: HTML output renderer
|
||||
- Theme support and customization
|
||||
- Render caching with invalidation
|
||||
- SpaceRenderingService: Orchestration layer
|
||||
"""
|
||||
|
||||
# Rendering will be implemented in Phase 4
|
||||
__all__ = []
|
||||
from .base import (
|
||||
SpaceRenderer,
|
||||
RenderConfig,
|
||||
RenderResult,
|
||||
RenderFormat,
|
||||
ThemeConfig,
|
||||
CompositeRenderer,
|
||||
)
|
||||
from .html_renderer import (
|
||||
MarkdownToHTMLRenderer,
|
||||
HTMLRendererFactory,
|
||||
THEME_PROPERTIES,
|
||||
combine_theme_properties,
|
||||
)
|
||||
from .service import (
|
||||
SpaceRenderingService,
|
||||
SpaceRenderingServiceBuilder,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
"SpaceRenderer",
|
||||
"RenderConfig",
|
||||
"RenderResult",
|
||||
"RenderFormat",
|
||||
"ThemeConfig",
|
||||
"CompositeRenderer",
|
||||
# HTML renderer
|
||||
"MarkdownToHTMLRenderer",
|
||||
"HTMLRendererFactory",
|
||||
"THEME_PROPERTIES",
|
||||
"combine_theme_properties",
|
||||
# Service
|
||||
"SpaceRenderingService",
|
||||
"SpaceRenderingServiceBuilder",
|
||||
]
|
||||
|
||||
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())
|
||||
576
markitect/spaces/rendering/html_renderer.py
Normal file
576
markitect/spaces/rendering/html_renderer.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
HTML renderer for Information Spaces.
|
||||
|
||||
This module provides markdown-to-HTML rendering with theming,
|
||||
code highlighting, and accessibility features.
|
||||
"""
|
||||
|
||||
import re
|
||||
import html
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Set
|
||||
|
||||
from .base import (
|
||||
SpaceRenderer,
|
||||
RenderConfig,
|
||||
RenderResult,
|
||||
RenderFormat,
|
||||
ThemeConfig,
|
||||
)
|
||||
|
||||
|
||||
# Built-in theme properties
|
||||
THEME_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"default": {
|
||||
"font_family": "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif",
|
||||
"max_width": "800px",
|
||||
"body_color": "#333333",
|
||||
"body_background": "#ffffff",
|
||||
"heading_color": "#333333",
|
||||
"code_background": "#f6f8fa",
|
||||
"code_color": "#333333",
|
||||
"border_color": "#d0d7de",
|
||||
"blockquote_border": "#dfe2e5",
|
||||
"blockquote_color": "#6a737d",
|
||||
"table_border": "#d0d7de",
|
||||
"table_header_bg": "#f6f8fa",
|
||||
"link_color": "#0366d6",
|
||||
},
|
||||
"github": {
|
||||
"font_family": "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif",
|
||||
"max_width": "980px",
|
||||
"body_color": "#24292e",
|
||||
"body_background": "#ffffff",
|
||||
"heading_color": "#24292e",
|
||||
"code_background": "#f6f8fa",
|
||||
"code_color": "#24292e",
|
||||
"border_color": "#e1e4e8",
|
||||
"blockquote_border": "#dfe2e5",
|
||||
"blockquote_color": "#6a737d",
|
||||
"table_border": "#e1e4e8",
|
||||
"table_header_bg": "#f6f8fa",
|
||||
"link_color": "#0366d6",
|
||||
},
|
||||
"minimal": {
|
||||
"font_family": "Georgia, serif",
|
||||
"max_width": "680px",
|
||||
"body_color": "#222222",
|
||||
"body_background": "#fafafa",
|
||||
"heading_color": "#111111",
|
||||
"code_background": "#f0f0f0",
|
||||
"code_color": "#222222",
|
||||
"border_color": "#dddddd",
|
||||
"blockquote_border": "#cccccc",
|
||||
"blockquote_color": "#666666",
|
||||
"table_border": "#dddddd",
|
||||
"table_header_bg": "#f0f0f0",
|
||||
"link_color": "#0055aa",
|
||||
},
|
||||
"dark": {
|
||||
"font_family": "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif",
|
||||
"max_width": "800px",
|
||||
"body_color": "#c9d1d9",
|
||||
"body_background": "#0d1117",
|
||||
"heading_color": "#c9d1d9",
|
||||
"code_background": "#161b22",
|
||||
"code_color": "#c9d1d9",
|
||||
"border_color": "#30363d",
|
||||
"blockquote_border": "#3b434b",
|
||||
"blockquote_color": "#8b949e",
|
||||
"table_border": "#30363d",
|
||||
"table_header_bg": "#161b22",
|
||||
"link_color": "#58a6ff",
|
||||
},
|
||||
"academic": {
|
||||
"font_family": "'Times New Roman', Times, serif",
|
||||
"max_width": "720px",
|
||||
"body_color": "#1a1a1a",
|
||||
"body_background": "#ffffff",
|
||||
"heading_color": "#1a1a1a",
|
||||
"heading_style": "underlined",
|
||||
"text_align": "justify",
|
||||
"code_background": "#f5f5f5",
|
||||
"code_color": "#1a1a1a",
|
||||
"border_color": "#cccccc",
|
||||
"blockquote_border": "#999999",
|
||||
"blockquote_color": "#555555",
|
||||
"table_border": "#cccccc",
|
||||
"table_header_bg": "#f5f5f5",
|
||||
"link_color": "#000080",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def combine_theme_properties(layers: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Combine theme properties from multiple layers.
|
||||
|
||||
Later layers override earlier ones.
|
||||
|
||||
Args:
|
||||
layers: List of theme names to combine
|
||||
|
||||
Returns:
|
||||
Combined properties dictionary
|
||||
"""
|
||||
combined = {}
|
||||
for layer in layers:
|
||||
if layer in THEME_PROPERTIES:
|
||||
combined.update(THEME_PROPERTIES[layer])
|
||||
return combined
|
||||
|
||||
|
||||
class MarkdownToHTMLRenderer(SpaceRenderer):
|
||||
"""
|
||||
Renders markdown content to HTML.
|
||||
|
||||
Features:
|
||||
- Theme support with layer composition
|
||||
- Syntax highlighting for code blocks
|
||||
- Automatic heading IDs for navigation
|
||||
- Link target handling
|
||||
- Table of contents generation
|
||||
"""
|
||||
|
||||
@property
|
||||
def supported_formats(self) -> List[RenderFormat]:
|
||||
"""Return supported formats."""
|
||||
return [RenderFormat.HTML]
|
||||
|
||||
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 to HTML.
|
||||
|
||||
Args:
|
||||
content: Markdown content
|
||||
document_id: Document ID
|
||||
space_id: Space ID
|
||||
dependencies: Document dependencies
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
RenderResult with HTML content
|
||||
"""
|
||||
source_hash = RenderResult.compute_hash(content)
|
||||
|
||||
# Parse markdown to HTML
|
||||
html_content = self._render_markdown(content)
|
||||
|
||||
# Apply post-processing
|
||||
html_content = self._apply_post_processing(html_content)
|
||||
|
||||
# Generate table of contents if requested
|
||||
toc_html = ""
|
||||
if self.config.include_toc:
|
||||
toc_html = self._generate_toc(html_content)
|
||||
|
||||
# Build complete HTML document
|
||||
complete_html = self._build_html_document(
|
||||
body_content=html_content,
|
||||
toc_content=toc_html,
|
||||
title=self._extract_title(content),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
content_hash = RenderResult.compute_hash(complete_html)
|
||||
|
||||
return RenderResult(
|
||||
content=complete_html,
|
||||
format=RenderFormat.HTML,
|
||||
content_hash=content_hash,
|
||||
source_hash=source_hash,
|
||||
document_id=document_id,
|
||||
space_id=space_id,
|
||||
dependencies=dependencies or set(),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
def _render_markdown(self, content: str) -> str:
|
||||
"""
|
||||
Convert markdown to HTML.
|
||||
|
||||
Uses the Python markdown library if available, otherwise falls
|
||||
back to a basic parser.
|
||||
"""
|
||||
try:
|
||||
import markdown
|
||||
|
||||
# Configure extensions
|
||||
extensions = ["extra", "toc", "tables", "fenced_code"]
|
||||
if self.config.highlight_code:
|
||||
try:
|
||||
extensions.append("codehilite")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return markdown.markdown(content, extensions=extensions)
|
||||
except ImportError:
|
||||
# Fallback to basic parsing
|
||||
return self._basic_markdown_to_html(content)
|
||||
|
||||
def _basic_markdown_to_html(self, content: str) -> str:
|
||||
"""
|
||||
Basic markdown to HTML conversion.
|
||||
|
||||
Used as fallback when markdown library is not available.
|
||||
"""
|
||||
lines = content.split('\n')
|
||||
html_lines = []
|
||||
in_code_block = False
|
||||
in_list = False
|
||||
|
||||
for line in lines:
|
||||
# Code blocks
|
||||
if line.startswith('```'):
|
||||
if in_code_block:
|
||||
html_lines.append('</code></pre>')
|
||||
in_code_block = False
|
||||
else:
|
||||
lang = line[3:].strip()
|
||||
lang_class = f' class="language-{lang}"' if lang else ''
|
||||
html_lines.append(f'<pre><code{lang_class}>')
|
||||
in_code_block = True
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
html_lines.append(html.escape(line))
|
||||
continue
|
||||
|
||||
# Close list if not a list item
|
||||
if in_list and not line.strip().startswith(('-', '*', '+')):
|
||||
html_lines.append('</ul>')
|
||||
in_list = False
|
||||
|
||||
stripped = line.strip()
|
||||
|
||||
# Headers
|
||||
if stripped.startswith('######'):
|
||||
text = stripped[6:].strip()
|
||||
slug = self._slugify(text)
|
||||
html_lines.append(f'<h6 id="{slug}">{html.escape(text)}</h6>')
|
||||
elif stripped.startswith('#####'):
|
||||
text = stripped[5:].strip()
|
||||
slug = self._slugify(text)
|
||||
html_lines.append(f'<h5 id="{slug}">{html.escape(text)}</h5>')
|
||||
elif stripped.startswith('####'):
|
||||
text = stripped[4:].strip()
|
||||
slug = self._slugify(text)
|
||||
html_lines.append(f'<h4 id="{slug}">{html.escape(text)}</h4>')
|
||||
elif stripped.startswith('###'):
|
||||
text = stripped[3:].strip()
|
||||
slug = self._slugify(text)
|
||||
html_lines.append(f'<h3 id="{slug}">{html.escape(text)}</h3>')
|
||||
elif stripped.startswith('##'):
|
||||
text = stripped[2:].strip()
|
||||
slug = self._slugify(text)
|
||||
html_lines.append(f'<h2 id="{slug}">{html.escape(text)}</h2>')
|
||||
elif stripped.startswith('#'):
|
||||
text = stripped[1:].strip()
|
||||
slug = self._slugify(text)
|
||||
html_lines.append(f'<h1 id="{slug}">{html.escape(text)}</h1>')
|
||||
# Horizontal rule
|
||||
elif stripped in ('---', '***', '___'):
|
||||
html_lines.append('<hr>')
|
||||
# Blockquote
|
||||
elif stripped.startswith('>'):
|
||||
text = stripped[1:].strip()
|
||||
html_lines.append(f'<blockquote>{html.escape(text)}</blockquote>')
|
||||
# Unordered list
|
||||
elif stripped.startswith(('-', '*', '+')) and len(stripped) > 1 and stripped[1] == ' ':
|
||||
if not in_list:
|
||||
html_lines.append('<ul>')
|
||||
in_list = True
|
||||
text = stripped[2:].strip()
|
||||
html_lines.append(f'<li>{self._process_inline(text)}</li>')
|
||||
# Empty line
|
||||
elif not stripped:
|
||||
if in_list:
|
||||
html_lines.append('</ul>')
|
||||
in_list = False
|
||||
html_lines.append('')
|
||||
# Paragraph
|
||||
else:
|
||||
html_lines.append(f'<p>{self._process_inline(stripped)}</p>')
|
||||
|
||||
# Close any open list
|
||||
if in_list:
|
||||
html_lines.append('</ul>')
|
||||
|
||||
# Close any open code block
|
||||
if in_code_block:
|
||||
html_lines.append('</code></pre>')
|
||||
|
||||
return '\n'.join(html_lines)
|
||||
|
||||
def _process_inline(self, text: str) -> str:
|
||||
"""Process inline markdown elements."""
|
||||
# Bold
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
|
||||
text = re.sub(r'__(.+?)__', r'<strong>\1</strong>', text)
|
||||
|
||||
# Italic
|
||||
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
|
||||
text = re.sub(r'_(.+?)_', r'<em>\1</em>', text)
|
||||
|
||||
# Code
|
||||
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
|
||||
|
||||
# Links
|
||||
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
||||
|
||||
# Images
|
||||
text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', r'<img src="\2" alt="\1">', text)
|
||||
|
||||
return text
|
||||
|
||||
def _slugify(self, text: str) -> str:
|
||||
"""Create a URL-safe slug from text."""
|
||||
slug = text.lower()
|
||||
slug = re.sub(r'[^\w\s-]', '', slug)
|
||||
slug = re.sub(r'[\s_]+', '-', slug)
|
||||
slug = slug.strip('-')
|
||||
return slug
|
||||
|
||||
def _apply_post_processing(self, html_content: str) -> str:
|
||||
"""Apply post-processing to HTML content."""
|
||||
# Add target="_blank" to external links
|
||||
if self.config.link_target_blank:
|
||||
html_content = re.sub(
|
||||
r'<a href="(https?://[^"]+)"',
|
||||
r'<a href="\1" target="_blank" rel="noopener noreferrer"',
|
||||
html_content,
|
||||
)
|
||||
|
||||
# Limit image dimensions
|
||||
if self.config.image_max_width != "100%":
|
||||
html_content = re.sub(
|
||||
r'<img ([^>]*)>',
|
||||
f'<img \\1 style="max-width: {self.config.image_max_width}; max-height: {self.config.image_max_height};">',
|
||||
html_content,
|
||||
)
|
||||
|
||||
return html_content
|
||||
|
||||
def _generate_toc(self, html_content: str) -> str:
|
||||
"""Generate table of contents from HTML headings."""
|
||||
headings = re.findall(r'<h([1-6])[^>]*id="([^"]+)"[^>]*>([^<]+)</h\1>', html_content)
|
||||
|
||||
if not headings:
|
||||
return ""
|
||||
|
||||
toc_lines = ['<nav class="toc"><h2>Contents</h2><ul>']
|
||||
current_level = 0
|
||||
|
||||
for level_str, slug, text in headings:
|
||||
level = int(level_str)
|
||||
|
||||
# Adjust nesting
|
||||
while current_level < level:
|
||||
toc_lines.append('<ul>')
|
||||
current_level += 1
|
||||
while current_level > level:
|
||||
toc_lines.append('</ul>')
|
||||
current_level -= 1
|
||||
|
||||
toc_lines.append(f'<li><a href="#{slug}">{html.escape(text)}</a></li>')
|
||||
|
||||
# Close remaining lists
|
||||
while current_level > 0:
|
||||
toc_lines.append('</ul>')
|
||||
current_level -= 1
|
||||
|
||||
toc_lines.append('</ul></nav>')
|
||||
return '\n'.join(toc_lines)
|
||||
|
||||
def _extract_title(self, content: str) -> str:
|
||||
"""Extract title from first H1 heading."""
|
||||
match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return "Document"
|
||||
|
||||
def _generate_css(self) -> str:
|
||||
"""Generate CSS based on theme configuration."""
|
||||
# Get theme properties
|
||||
layers = self.config.theme.layers
|
||||
props = combine_theme_properties(layers)
|
||||
|
||||
# Apply custom property overrides
|
||||
props.update(self.config.theme.custom_properties)
|
||||
|
||||
css = f"""
|
||||
body {{
|
||||
font-family: {props.get('font_family', 'sans-serif')};
|
||||
max-width: {props.get('max_width', '800px')};
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
line-height: 1.6;
|
||||
color: {props.get('body_color', '#333')};
|
||||
background-color: {props.get('body_background', '#fff')};
|
||||
}}
|
||||
h1, h2, h3, h4, h5, h6 {{
|
||||
color: {props.get('heading_color', props.get('body_color', '#333'))};
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}}
|
||||
pre {{
|
||||
background-color: {props.get('code_background', '#f6f8fa')};
|
||||
color: {props.get('code_color', '#333')};
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid {props.get('border_color', '#ddd')};
|
||||
}}
|
||||
code {{
|
||||
background-color: {props.get('code_background', '#f6f8fa')};
|
||||
color: {props.get('code_color', '#333')};
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
pre code {{
|
||||
background: none;
|
||||
padding: 0;
|
||||
}}
|
||||
blockquote {{
|
||||
border-left: 4px solid {props.get('blockquote_border', '#ddd')};
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
color: {props.get('blockquote_color', '#666')};
|
||||
}}
|
||||
table {{
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid {props.get('table_border', '#ddd')};
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid {props.get('table_border', '#ddd')};
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}}
|
||||
th {{
|
||||
background-color: {props.get('table_header_bg', '#f6f8fa')};
|
||||
}}
|
||||
a {{
|
||||
color: {props.get('link_color', '#0366d6')};
|
||||
text-decoration: none;
|
||||
}}
|
||||
a:hover {{
|
||||
text-decoration: underline;
|
||||
}}
|
||||
img {{
|
||||
max-width: {self.config.image_max_width};
|
||||
max-height: {self.config.image_max_height};
|
||||
height: auto;
|
||||
}}
|
||||
.toc {{
|
||||
background-color: {props.get('code_background', '#f6f8fa')};
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 2rem;
|
||||
}}
|
||||
.toc h2 {{
|
||||
margin-top: 0;
|
||||
}}
|
||||
.toc ul {{
|
||||
padding-left: 1.5rem;
|
||||
}}
|
||||
"""
|
||||
|
||||
# Add custom CSS if provided
|
||||
if self.config.theme.custom_css:
|
||||
css += f"\n{self.config.theme.custom_css}"
|
||||
|
||||
return css
|
||||
|
||||
def _build_html_document(
|
||||
self,
|
||||
body_content: str,
|
||||
toc_content: str,
|
||||
title: str,
|
||||
metadata: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Build complete HTML document."""
|
||||
css = self._generate_css()
|
||||
|
||||
# Meta tags
|
||||
meta_tags = '<meta charset="utf-8">\n'
|
||||
meta_tags += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
||||
meta_tags += '<meta name="generator" content="Markitect Information Space">\n'
|
||||
|
||||
# Add custom meta from metadata
|
||||
for key, value in metadata.items():
|
||||
if key.startswith('meta_'):
|
||||
meta_name = key[5:]
|
||||
meta_tags += f'<meta name="{html.escape(meta_name)}" content="{html.escape(str(value))}">\n'
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{meta_tags}
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{toc_content}
|
||||
<div id="content">
|
||||
{body_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class HTMLRendererFactory:
|
||||
"""Factory for creating configured HTML renderers."""
|
||||
|
||||
@staticmethod
|
||||
def create_default() -> MarkdownToHTMLRenderer:
|
||||
"""Create a renderer with default settings."""
|
||||
return MarkdownToHTMLRenderer()
|
||||
|
||||
@staticmethod
|
||||
def create_github_style() -> MarkdownToHTMLRenderer:
|
||||
"""Create a renderer with GitHub-style theme."""
|
||||
config = RenderConfig(
|
||||
theme=ThemeConfig(name="github", layers=["github"]),
|
||||
include_toc=False,
|
||||
)
|
||||
return MarkdownToHTMLRenderer(config)
|
||||
|
||||
@staticmethod
|
||||
def create_academic_style() -> MarkdownToHTMLRenderer:
|
||||
"""Create a renderer with academic styling."""
|
||||
config = RenderConfig(
|
||||
theme=ThemeConfig(name="academic", layers=["academic"]),
|
||||
include_toc=True,
|
||||
)
|
||||
return MarkdownToHTMLRenderer(config)
|
||||
|
||||
@staticmethod
|
||||
def create_minimal_style() -> MarkdownToHTMLRenderer:
|
||||
"""Create a renderer with minimal styling."""
|
||||
config = RenderConfig(
|
||||
theme=ThemeConfig(name="minimal", layers=["minimal"]),
|
||||
include_toc=False,
|
||||
)
|
||||
return MarkdownToHTMLRenderer(config)
|
||||
|
||||
@staticmethod
|
||||
def create_dark_mode() -> MarkdownToHTMLRenderer:
|
||||
"""Create a renderer with dark mode theme."""
|
||||
config = RenderConfig(
|
||||
theme=ThemeConfig(name="dark", layers=["dark"]),
|
||||
include_toc=False,
|
||||
)
|
||||
return MarkdownToHTMLRenderer(config)
|
||||
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,
|
||||
)
|
||||
760
tests/unit/spaces/test_rendering.py
Normal file
760
tests/unit/spaces/test_rendering.py
Normal file
@@ -0,0 +1,760 @@
|
||||
"""
|
||||
Unit tests for Phase 4: HTML Rendering Mode components.
|
||||
|
||||
Tests cover:
|
||||
- SpaceRenderer base class
|
||||
- RenderConfig and ThemeConfig
|
||||
- MarkdownToHTMLRenderer
|
||||
- SpaceRenderingService
|
||||
- Cache integration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from pathlib import Path
|
||||
|
||||
from markitect.spaces.rendering import (
|
||||
SpaceRenderer,
|
||||
RenderConfig,
|
||||
RenderResult,
|
||||
RenderFormat,
|
||||
ThemeConfig,
|
||||
CompositeRenderer,
|
||||
MarkdownToHTMLRenderer,
|
||||
HTMLRendererFactory,
|
||||
THEME_PROPERTIES,
|
||||
combine_theme_properties,
|
||||
SpaceRenderingService,
|
||||
SpaceRenderingServiceBuilder,
|
||||
)
|
||||
from markitect.spaces.transclusion import (
|
||||
RenderCache,
|
||||
ReferenceGraph,
|
||||
)
|
||||
from markitect.spaces.events import EventBus, SpaceEventType
|
||||
|
||||
|
||||
class TestRenderFormat:
|
||||
"""Tests for RenderFormat enum."""
|
||||
|
||||
def test_html_format(self):
|
||||
"""Test HTML format value."""
|
||||
assert RenderFormat.HTML.value == "html"
|
||||
|
||||
def test_format_members(self):
|
||||
"""Test all format members exist."""
|
||||
assert RenderFormat.HTML
|
||||
assert RenderFormat.PDF
|
||||
assert RenderFormat.DOCX
|
||||
assert RenderFormat.LATEX
|
||||
|
||||
|
||||
class TestThemeConfig:
|
||||
"""Tests for ThemeConfig dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration."""
|
||||
config = ThemeConfig()
|
||||
assert config.name == "default"
|
||||
assert config.layers == ["basic"]
|
||||
assert config.custom_css is None
|
||||
assert config.custom_properties == {}
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
config = ThemeConfig(
|
||||
name="custom",
|
||||
layers=["github", "dark"],
|
||||
custom_css="body { color: red; }",
|
||||
custom_properties={"max_width": "1000px"},
|
||||
)
|
||||
assert config.name == "custom"
|
||||
assert config.layers == ["github", "dark"]
|
||||
assert "color: red" in config.custom_css
|
||||
assert config.custom_properties["max_width"] == "1000px"
|
||||
|
||||
|
||||
class TestRenderConfig:
|
||||
"""Tests for RenderConfig dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration."""
|
||||
config = RenderConfig()
|
||||
assert config.format == RenderFormat.HTML
|
||||
assert config.include_toc is False
|
||||
assert config.highlight_code is True
|
||||
assert config.sanitize_html is True
|
||||
assert config.link_target_blank is True
|
||||
|
||||
def test_custom_config(self):
|
||||
"""Test custom configuration."""
|
||||
theme = ThemeConfig(name="github")
|
||||
config = RenderConfig(
|
||||
format=RenderFormat.HTML,
|
||||
theme=theme,
|
||||
include_toc=True,
|
||||
image_max_width="800px",
|
||||
)
|
||||
assert config.theme.name == "github"
|
||||
assert config.include_toc is True
|
||||
assert config.image_max_width == "800px"
|
||||
|
||||
|
||||
class TestRenderResult:
|
||||
"""Tests for RenderResult dataclass."""
|
||||
|
||||
def test_creation(self):
|
||||
"""Test result creation."""
|
||||
result = RenderResult(
|
||||
content="<h1>Test</h1>",
|
||||
format=RenderFormat.HTML,
|
||||
content_hash="abc123",
|
||||
source_hash="def456",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
assert result.content == "<h1>Test</h1>"
|
||||
assert result.format == RenderFormat.HTML
|
||||
assert result.document_id == "doc-1"
|
||||
assert result.space_id == "space-1"
|
||||
assert result.dependencies == set()
|
||||
|
||||
def test_with_dependencies(self):
|
||||
"""Test result with dependencies."""
|
||||
result = RenderResult(
|
||||
content="<p>Test</p>",
|
||||
format=RenderFormat.HTML,
|
||||
content_hash="abc",
|
||||
source_hash="def",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
dependencies={"doc-2", "doc-3"},
|
||||
)
|
||||
assert len(result.dependencies) == 2
|
||||
assert "doc-2" in result.dependencies
|
||||
|
||||
def test_compute_hash(self):
|
||||
"""Test hash computation."""
|
||||
hash1 = RenderResult.compute_hash("test content")
|
||||
hash2 = RenderResult.compute_hash("test content")
|
||||
hash3 = RenderResult.compute_hash("different content")
|
||||
|
||||
assert hash1 == hash2
|
||||
assert hash1 != hash3
|
||||
assert len(hash1) == 16 # SHA256 truncated to 16 chars
|
||||
|
||||
|
||||
class TestCombineThemeProperties:
|
||||
"""Tests for theme property combination."""
|
||||
|
||||
def test_single_layer(self):
|
||||
"""Test single theme layer."""
|
||||
props = combine_theme_properties(["default"])
|
||||
assert "font_family" in props
|
||||
assert "body_color" in props
|
||||
|
||||
def test_multiple_layers(self):
|
||||
"""Test multiple theme layers."""
|
||||
props = combine_theme_properties(["default", "github"])
|
||||
# GitHub layer should override default
|
||||
assert props["max_width"] == "980px"
|
||||
|
||||
def test_unknown_layer(self):
|
||||
"""Test with unknown layer."""
|
||||
props = combine_theme_properties(["nonexistent"])
|
||||
assert props == {}
|
||||
|
||||
def test_empty_layers(self):
|
||||
"""Test with no layers."""
|
||||
props = combine_theme_properties([])
|
||||
assert props == {}
|
||||
|
||||
|
||||
class TestMarkdownToHTMLRenderer:
|
||||
"""Tests for MarkdownToHTMLRenderer."""
|
||||
|
||||
def test_supported_formats(self):
|
||||
"""Test supported formats."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
assert RenderFormat.HTML in renderer.supported_formats
|
||||
assert len(renderer.supported_formats) == 1
|
||||
|
||||
def test_simple_render(self):
|
||||
"""Test simple markdown rendering."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
result = renderer.render(
|
||||
content="# Hello World\n\nThis is a paragraph.",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert result.format == RenderFormat.HTML
|
||||
assert "<h1" in result.content
|
||||
assert "Hello World" in result.content
|
||||
assert "<p>" in result.content
|
||||
assert result.document_id == "doc-1"
|
||||
assert result.space_id == "space-1"
|
||||
|
||||
def test_render_with_code(self):
|
||||
"""Test rendering code blocks."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = """
|
||||
# Code Example
|
||||
|
||||
```python
|
||||
def hello():
|
||||
print("Hello")
|
||||
```
|
||||
"""
|
||||
result = renderer.render(
|
||||
content=markdown,
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert "<pre>" in result.content or "<code>" in result.content
|
||||
|
||||
def test_render_with_links(self):
|
||||
"""Test link rendering with target blank."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
result = renderer.render(
|
||||
content="Visit [Google](https://google.com)",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert "https://google.com" in result.content
|
||||
assert 'target="_blank"' in result.content
|
||||
|
||||
def test_render_with_toc(self):
|
||||
"""Test table of contents generation."""
|
||||
config = RenderConfig(include_toc=True)
|
||||
renderer = MarkdownToHTMLRenderer(config)
|
||||
|
||||
markdown = """
|
||||
# Main Title
|
||||
|
||||
## Section 1
|
||||
|
||||
Content here.
|
||||
|
||||
## Section 2
|
||||
|
||||
More content.
|
||||
"""
|
||||
result = renderer.render(
|
||||
content=markdown,
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert 'class="toc"' in result.content
|
||||
assert "Contents" in result.content
|
||||
|
||||
def test_render_with_dependencies(self):
|
||||
"""Test dependencies are tracked."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
result = renderer.render(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
dependencies={"dep-1", "dep-2"},
|
||||
)
|
||||
|
||||
assert len(result.dependencies) == 2
|
||||
assert "dep-1" in result.dependencies
|
||||
|
||||
def test_content_hash_computation(self):
|
||||
"""Test that hashes are computed."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
result = renderer.render(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert result.content_hash
|
||||
assert result.source_hash
|
||||
assert len(result.content_hash) == 16
|
||||
|
||||
def test_different_themes(self):
|
||||
"""Test different theme configurations."""
|
||||
github_config = RenderConfig(theme=ThemeConfig(name="github", layers=["github"]))
|
||||
dark_config = RenderConfig(theme=ThemeConfig(name="dark", layers=["dark"]))
|
||||
|
||||
github_renderer = MarkdownToHTMLRenderer(github_config)
|
||||
dark_renderer = MarkdownToHTMLRenderer(dark_config)
|
||||
|
||||
github_result = github_renderer.render("# Test", "doc", "space")
|
||||
dark_result = dark_renderer.render("# Test", "doc", "space")
|
||||
|
||||
# Results should be different due to different themes
|
||||
assert github_result.content != dark_result.content
|
||||
assert "#24292e" in github_result.content # GitHub body color
|
||||
assert "#c9d1d9" in dark_result.content # Dark mode text color
|
||||
|
||||
|
||||
class TestHTMLRendererFactory:
|
||||
"""Tests for HTMLRendererFactory."""
|
||||
|
||||
def test_create_default(self):
|
||||
"""Test default renderer creation."""
|
||||
renderer = HTMLRendererFactory.create_default()
|
||||
assert isinstance(renderer, MarkdownToHTMLRenderer)
|
||||
|
||||
def test_create_github_style(self):
|
||||
"""Test GitHub-style renderer."""
|
||||
renderer = HTMLRendererFactory.create_github_style()
|
||||
assert renderer.config.theme.name == "github"
|
||||
|
||||
def test_create_academic_style(self):
|
||||
"""Test academic-style renderer."""
|
||||
renderer = HTMLRendererFactory.create_academic_style()
|
||||
assert renderer.config.theme.name == "academic"
|
||||
assert renderer.config.include_toc is True
|
||||
|
||||
def test_create_minimal_style(self):
|
||||
"""Test minimal-style renderer."""
|
||||
renderer = HTMLRendererFactory.create_minimal_style()
|
||||
assert renderer.config.theme.name == "minimal"
|
||||
|
||||
def test_create_dark_mode(self):
|
||||
"""Test dark mode renderer."""
|
||||
renderer = HTMLRendererFactory.create_dark_mode()
|
||||
assert renderer.config.theme.name == "dark"
|
||||
|
||||
|
||||
class TestCompositeRenderer:
|
||||
"""Tests for CompositeRenderer."""
|
||||
|
||||
def test_register_renderer(self):
|
||||
"""Test registering a renderer."""
|
||||
composite = CompositeRenderer()
|
||||
html_renderer = MarkdownToHTMLRenderer()
|
||||
|
||||
composite.register(html_renderer)
|
||||
|
||||
assert composite.get_renderer(RenderFormat.HTML) is html_renderer
|
||||
|
||||
def test_supported_formats(self):
|
||||
"""Test listing supported formats."""
|
||||
composite = CompositeRenderer()
|
||||
html_renderer = MarkdownToHTMLRenderer()
|
||||
|
||||
composite.register(html_renderer)
|
||||
|
||||
formats = composite.supported_formats()
|
||||
assert RenderFormat.HTML in formats
|
||||
|
||||
def test_render_via_composite(self):
|
||||
"""Test rendering through composite."""
|
||||
composite = CompositeRenderer()
|
||||
composite.register(MarkdownToHTMLRenderer())
|
||||
|
||||
result = composite.render(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
format=RenderFormat.HTML,
|
||||
)
|
||||
|
||||
assert result.format == RenderFormat.HTML
|
||||
assert "<h1" in result.content
|
||||
|
||||
def test_no_renderer_for_format(self):
|
||||
"""Test error when no renderer for format."""
|
||||
composite = CompositeRenderer()
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
composite.render(
|
||||
content="test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
format=RenderFormat.PDF,
|
||||
)
|
||||
|
||||
assert "No renderer registered" in str(exc.value)
|
||||
|
||||
|
||||
class TestSpaceRenderingService:
|
||||
"""Tests for SpaceRenderingService."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test default service initialization."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
assert service.renderer is not None
|
||||
assert service.render_cache is not None
|
||||
assert service.reference_graph is not None
|
||||
|
||||
def test_render_document(self):
|
||||
"""Test basic document rendering."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
result = service.render_document(
|
||||
content="# Hello\n\nWorld",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert result.format == RenderFormat.HTML
|
||||
assert "Hello" in result.content
|
||||
assert result.document_id == "doc-1"
|
||||
|
||||
def test_render_caching(self):
|
||||
"""Test that results are cached."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
# First render
|
||||
result1 = service.render_document(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
# Second render - should use cache
|
||||
result2 = service.render_document(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
# Get cached entry
|
||||
cached = service.get_cached_render("doc-1")
|
||||
assert cached is not None
|
||||
|
||||
def test_force_refresh_bypasses_cache(self):
|
||||
"""Test force refresh skips cache."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
# First render
|
||||
service.render_document(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
# Force refresh with different content
|
||||
result = service.render_document(
|
||||
content="# New Content",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
force_refresh=True,
|
||||
)
|
||||
|
||||
assert "New Content" in result.content
|
||||
|
||||
def test_render_with_variables(self):
|
||||
"""Test rendering with variable substitution."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
result = service.render_document(
|
||||
content="Hello {{name}}!",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
variables={"name": "World"},
|
||||
)
|
||||
|
||||
assert "World" in result.content
|
||||
|
||||
def test_render_documents_batch(self):
|
||||
"""Test batch rendering."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
documents = [
|
||||
{"id": "doc-1", "content": "# Doc 1"},
|
||||
{"id": "doc-2", "content": "# Doc 2"},
|
||||
{"id": "doc-3", "content": "# Doc 3"},
|
||||
]
|
||||
|
||||
results = service.render_documents(
|
||||
documents=documents,
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
assert "doc-1" in results
|
||||
assert "doc-2" in results
|
||||
assert "doc-3" in results
|
||||
|
||||
def test_invalidate_document(self):
|
||||
"""Test cache invalidation."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
# Render a document
|
||||
service.render_document(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
# Invalidate
|
||||
invalidated = service.invalidate_document("doc-1", "space-1")
|
||||
|
||||
assert "doc-1" in invalidated
|
||||
assert service.get_cached_render("doc-1") is None
|
||||
|
||||
def test_invalidate_space(self):
|
||||
"""Test invalidating entire space."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
# Render multiple documents
|
||||
service.render_document("# Doc 1", "doc-1", "space-1")
|
||||
service.render_document("# Doc 2", "doc-2", "space-1")
|
||||
|
||||
# Invalidate space
|
||||
count = service.invalidate_space("space-1")
|
||||
|
||||
assert count == 2
|
||||
|
||||
def test_render_statistics(self):
|
||||
"""Test getting render statistics."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
# Render some documents
|
||||
service.render_document("# Doc 1", "doc-1", "space-1")
|
||||
service.render_document("# Doc 2", "doc-2", "space-1")
|
||||
|
||||
stats = service.get_render_statistics("space-1")
|
||||
|
||||
assert "cached_documents" in stats
|
||||
assert "render_format" in stats
|
||||
assert stats["cached_documents"] == 2
|
||||
|
||||
def test_event_emission(self):
|
||||
"""Test that events are emitted during rendering."""
|
||||
event_bus = EventBus()
|
||||
events = []
|
||||
|
||||
def capture_event(event):
|
||||
events.append(event)
|
||||
|
||||
event_bus.subscribe(SpaceEventType.RENDER_STARTED, capture_event)
|
||||
event_bus.subscribe(SpaceEventType.RENDER_COMPLETED, capture_event)
|
||||
|
||||
service = SpaceRenderingService(event_bus=event_bus)
|
||||
|
||||
service.render_document(
|
||||
content="# Test",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
# Should have RENDER_STARTED and RENDER_COMPLETED events
|
||||
event_types = [e.event_type for e in events]
|
||||
assert SpaceEventType.RENDER_STARTED in event_types
|
||||
assert SpaceEventType.RENDER_COMPLETED in event_types
|
||||
|
||||
|
||||
class TestSpaceRenderingServiceBuilder:
|
||||
"""Tests for SpaceRenderingServiceBuilder."""
|
||||
|
||||
def test_build_default(self):
|
||||
"""Test building with defaults."""
|
||||
service = SpaceRenderingServiceBuilder().build()
|
||||
assert service.renderer is not None
|
||||
|
||||
def test_with_html_renderer(self):
|
||||
"""Test building with HTML renderer."""
|
||||
config = RenderConfig(theme=ThemeConfig(name="github"))
|
||||
service = (
|
||||
SpaceRenderingServiceBuilder()
|
||||
.with_html_renderer(config)
|
||||
.build()
|
||||
)
|
||||
assert service.renderer.config.theme.name == "github"
|
||||
|
||||
def test_with_event_bus(self):
|
||||
"""Test building with event bus."""
|
||||
bus = EventBus()
|
||||
service = (
|
||||
SpaceRenderingServiceBuilder()
|
||||
.with_event_bus(bus)
|
||||
.build()
|
||||
)
|
||||
assert service.event_bus is bus
|
||||
|
||||
def test_with_cache(self):
|
||||
"""Test building with custom cache."""
|
||||
cache = RenderCache()
|
||||
service = (
|
||||
SpaceRenderingServiceBuilder()
|
||||
.with_cache(cache)
|
||||
.build()
|
||||
)
|
||||
assert service.render_cache is cache
|
||||
|
||||
def test_with_reference_graph(self):
|
||||
"""Test building with reference graph."""
|
||||
graph = ReferenceGraph()
|
||||
service = (
|
||||
SpaceRenderingServiceBuilder()
|
||||
.with_reference_graph(graph)
|
||||
.build()
|
||||
)
|
||||
assert service.reference_graph is graph
|
||||
|
||||
def test_fluent_chaining(self):
|
||||
"""Test fluent builder chaining."""
|
||||
bus = EventBus()
|
||||
cache = RenderCache()
|
||||
graph = ReferenceGraph()
|
||||
|
||||
service = (
|
||||
SpaceRenderingServiceBuilder()
|
||||
.with_html_renderer()
|
||||
.with_event_bus(bus)
|
||||
.with_cache(cache)
|
||||
.with_reference_graph(graph)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert service.event_bus is bus
|
||||
assert service.render_cache is cache
|
||||
assert service.reference_graph is graph
|
||||
|
||||
|
||||
class TestBasicMarkdownParsing:
|
||||
"""Tests for basic markdown parsing fallback."""
|
||||
|
||||
def test_headings(self):
|
||||
"""Test heading parsing."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = "# H1\n## H2\n### H3"
|
||||
result = renderer.render(markdown, "doc", "space")
|
||||
|
||||
assert "<h1" in result.content
|
||||
assert "<h2" in result.content
|
||||
assert "<h3" in result.content
|
||||
|
||||
def test_bold_italic(self):
|
||||
"""Test bold and italic."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = "**bold** and *italic*"
|
||||
result = renderer.render(markdown, "doc", "space")
|
||||
|
||||
assert "<strong>bold</strong>" in result.content or "bold" in result.content
|
||||
|
||||
def test_code_blocks(self):
|
||||
"""Test code block parsing."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = "```python\nprint('hello')\n```"
|
||||
result = renderer.render(markdown, "doc", "space")
|
||||
|
||||
assert "<code" in result.content or "<pre" in result.content
|
||||
|
||||
def test_lists(self):
|
||||
"""Test list parsing."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = "- Item 1\n- Item 2\n- Item 3"
|
||||
result = renderer.render(markdown, "doc", "space")
|
||||
|
||||
assert "<li>" in result.content or "Item" in result.content
|
||||
|
||||
def test_blockquotes(self):
|
||||
"""Test blockquote parsing."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = "> This is a quote"
|
||||
result = renderer.render(markdown, "doc", "space")
|
||||
|
||||
assert "<blockquote" in result.content or "quote" in result.content
|
||||
|
||||
def test_horizontal_rule(self):
|
||||
"""Test horizontal rule."""
|
||||
renderer = MarkdownToHTMLRenderer()
|
||||
markdown = "Text\n\n---\n\nMore text"
|
||||
result = renderer.render(markdown, "doc", "space")
|
||||
|
||||
assert "<hr" in result.content
|
||||
|
||||
|
||||
class TestThemeProperties:
|
||||
"""Tests for theme property definitions."""
|
||||
|
||||
def test_default_theme_exists(self):
|
||||
"""Test default theme is defined."""
|
||||
assert "default" in THEME_PROPERTIES
|
||||
|
||||
def test_github_theme_exists(self):
|
||||
"""Test GitHub theme is defined."""
|
||||
assert "github" in THEME_PROPERTIES
|
||||
|
||||
def test_dark_theme_exists(self):
|
||||
"""Test dark theme is defined."""
|
||||
assert "dark" in THEME_PROPERTIES
|
||||
|
||||
def test_minimal_theme_exists(self):
|
||||
"""Test minimal theme is defined."""
|
||||
assert "minimal" in THEME_PROPERTIES
|
||||
|
||||
def test_academic_theme_exists(self):
|
||||
"""Test academic theme is defined."""
|
||||
assert "academic" in THEME_PROPERTIES
|
||||
|
||||
def test_theme_has_required_properties(self):
|
||||
"""Test themes have required properties."""
|
||||
required = ["font_family", "body_color", "body_background"]
|
||||
for theme_name, props in THEME_PROPERTIES.items():
|
||||
for prop in required:
|
||||
assert prop in props, f"{theme_name} missing {prop}"
|
||||
|
||||
|
||||
class TestRenderIntegration:
|
||||
"""Integration tests for rendering pipeline."""
|
||||
|
||||
def test_full_render_pipeline(self):
|
||||
"""Test complete render pipeline with cache."""
|
||||
event_bus = EventBus()
|
||||
cache = RenderCache()
|
||||
graph = ReferenceGraph()
|
||||
|
||||
service = (
|
||||
SpaceRenderingServiceBuilder()
|
||||
.with_html_renderer()
|
||||
.with_event_bus(event_bus)
|
||||
.with_cache(cache)
|
||||
.with_reference_graph(graph)
|
||||
.build()
|
||||
)
|
||||
|
||||
# First render
|
||||
result1 = service.render_document(
|
||||
content="# Hello World",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
assert result1.format == RenderFormat.HTML
|
||||
assert "Hello World" in result1.content
|
||||
|
||||
# Check cached
|
||||
assert cache.get("doc-1") is not None
|
||||
|
||||
# Second render should use cache
|
||||
result2 = service.render_document(
|
||||
content="# Hello World",
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
)
|
||||
|
||||
# Invalidate
|
||||
invalidated = service.invalidate_document("doc-1", "space-1")
|
||||
assert "doc-1" in invalidated
|
||||
assert cache.get("doc-1") is None
|
||||
|
||||
def test_variable_substitution_in_render(self):
|
||||
"""Test variable substitution during render."""
|
||||
service = SpaceRenderingService()
|
||||
|
||||
content = "# Welcome {{user}}\n\nToday is {{day}}."
|
||||
variables = {"user": "Alice", "day": "Monday"}
|
||||
|
||||
result = service.render_document(
|
||||
content=content,
|
||||
document_id="doc-1",
|
||||
space_id="space-1",
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
assert "Alice" in result.content
|
||||
assert "Monday" in result.content
|
||||
Reference in New Issue
Block a user