"""Compatibility shim over the versioned manual projection boundary.""" from __future__ import annotations import hashlib import json from dataclasses import dataclass from importlib.metadata import version from pathlib import Path from typing import Protocol from .errors import DocForgeError from .manual_projection import build_manual_projection_package, build_manual_render_plan from .models import ProjectSnapshot, RenderView @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: """Preserve the public v1 renderer API over the plan-only manual renderer.""" renderer_id = "generic_html" contract_version = "1" def __init__(self) -> None: 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: 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() plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash) package = build_manual_projection_package( plan, template_bytes, renderer_id=self.renderer_id, renderer_version=self.renderer_version, max_output_bytes=snapshot.descriptor.limits.max_render_bytes, ) from docforge_renderers.manual import ManualHtmlRenderer result = ManualHtmlRenderer(self.renderer_version).render( package, render_identity=render_identity, ) if len(result.artifacts) != 1: raise DocForgeError( "invalid_projection", "Manual renderer returned an unsupported artifact set", ) output = result.artifacts[0].content 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, ) _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()