"""Deterministic built-in renderer contract and safe template primitives.""" from __future__ import annotations import hashlib import html import json import re from dataclasses import dataclass from importlib.metadata import version from pathlib import Path from typing import Protocol from markdown_it import MarkdownIt from .errors import DocForgeError from .models import Edge, Node, ProjectSnapshot, RenderView _TEMPLATE_TOKEN = re.compile(r"{{\s*([a-z_][a-z0-9_]*)\s*}}") _ALLOWED_TOKENS = frozenset( { "docforge_content", "docforge_project_id", "docforge_render_identity", "docforge_title", "docforge_view_id", } ) @dataclass(frozen=True) class PreparedRender: render_identity: str output_hash: str output: bytes renderer: str renderer_version: str template_hash: str class Renderer(Protocol): """Fixed interface implemented by explicitly registered built-in renderers.""" renderer_id: str renderer_version: str def prepare( self, snapshot: ProjectSnapshot, view: RenderView, template_bytes: bytes, *, changeset_hash: str | None, ) -> PreparedRender: ... class GenericHtmlRenderer: """Render validated nodes through escaped CommonMark and a strict token template.""" renderer_id = "generic_html" contract_version = "1" def __init__(self) -> None: self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False}) self.renderer_version = ( f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}" ) def prepare( self, snapshot: ProjectSnapshot, view: RenderView, template_bytes: bytes, *, changeset_hash: str | None, ) -> PreparedRender: try: template = template_bytes.decode("utf-8") except UnicodeDecodeError as error: raise DocForgeError("invalid_template", "Render template is not valid UTF-8") from error tokens = _TEMPLATE_TOKEN.findall(template) unknown = sorted(set(tokens) - _ALLOWED_TOKENS) remainder = _TEMPLATE_TOKEN.sub("", template) if unknown or "{{" in remainder or "}}" in remainder: raise DocForgeError( "invalid_template", "Render template contains unsupported tokens", tokens=unknown ) if tokens.count("docforge_content") != 1: raise DocForgeError( "invalid_template", "Render template must contain docforge_content exactly once" ) selected = tuple( node for node in snapshot.nodes if not view.families or node.family in view.families ) selected_ids = {node.node_id for node in selected} selected_edges = tuple( edge for edge in snapshot.edges if edge.source_id in selected_ids and edge.target_id in selected_ids ) template_hash = hashlib.sha256(template_bytes).hexdigest() identity_payload = { "schema_version": 1, "project_id": snapshot.descriptor.project_id, "adapter": snapshot.descriptor.adapter, "source_hash": snapshot.source_hash, "changeset_hash": changeset_hash, "renderer": self.renderer_id, "renderer_version": self.renderer_version, "view": { "id": view.view_id, "title": view.title, "families": list(view.families), "output": view.output_path.relative_to(snapshot.descriptor.root).as_posix(), }, "template_hash": template_hash, "nodes": [ { "id": node.node_id, "content_hash": node.content_hash, "source_path": node.source_path, } for node in selected ], "edges": [edge.as_dict() for edge in selected_edges], } render_identity = hashlib.sha256( json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() content = self._content(selected, selected_edges) replacements = { "docforge_content": content, "docforge_project_id": html.escape(snapshot.descriptor.project_id, quote=True), "docforge_render_identity": render_identity, "docforge_title": html.escape(view.title, quote=True), "docforge_view_id": html.escape(view.view_id, quote=True), } rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template) output = rendered.rstrip().encode("utf-8") + b"\n" return PreparedRender( render_identity=render_identity, output_hash=hashlib.sha256(output).hexdigest(), output=output, renderer=self.renderer_id, renderer_version=self.renderer_version, template_hash=template_hash, ) def _content(self, nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> str: navigation = ['") sections = [*navigation] edge_map: dict[str, list[Edge]] = {} for edge in edges: edge_map.setdefault(edge.source_id, []).append(edge) for node in nodes: sections.extend( [ f'
', f"

{html.escape(node.title)}

", '
', f"
ID
{html.escape(node.node_id)}
", f"
Family
{html.escape(node.family)}
", f"
Status
{html.escape(node.status)}
", f"
Authority
{html.escape(node.authority)}
", "
", f'

{html.escape(node.summary)}

', self.markdown.render(node.content).rstrip(), ] ) relationships = edge_map.get(node.node_id, []) if relationships: sections.append('") sections.append("
") return "\n".join(sections) _RENDERERS: dict[str, type[GenericHtmlRenderer]] = { GenericHtmlRenderer.renderer_id: GenericHtmlRenderer } def renderer_for(view: RenderView) -> Renderer: factory = _RENDERERS.get(view.renderer) if factory is None: raise DocForgeError( "unsupported_renderer", "View does not name a supported built-in renderer" ) return factory() def relative_output(snapshot: ProjectSnapshot, path: Path) -> str: return path.relative_to(snapshot.descriptor.root).as_posix()