"""Compatibility shim over the versioned manual projection boundary.""" from __future__ import annotations import hashlib import html import json from dataclasses import dataclass from importlib.metadata import version from pathlib import Path from typing import Protocol, cast from .errors import DocForgeError from .manual_projection import build_manual_projection_package, build_manual_render_plan from .models import ProjectSnapshot, RenderView from .projection_contract import ( ManualRenderPlanV1, ProjectionPackageV1, ProjectionRenderResult, ) from .projection_fragments import ( FragmentKey, FragmentRecord, ProjectionFragmentCache, fragment_semantic_hash, ) from .projection_worker import render_projection_in_worker @dataclass(frozen=True) class PreparedRender: render_identity: str output_hash: str output: bytes renderer: str renderer_version: str template_hash: str projection_receipt: dict[str, object] | None = None 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" page_component_version = "manual.page@1" def __init__(self, *, incremental: bool = True) -> None: self.renderer_version = ( f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}" ) self.incremental = incremental 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) full_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, render_identity=render_identity, ) if self.incremental: result = self._incremental_result( snapshot, plan, template_bytes, full_package=full_package, render_identity=render_identity, ) else: result = render_projection_in_worker(full_package) 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, projection_receipt=result.receipt.as_dict(), ) def _incremental_result( self, snapshot: ProjectSnapshot, plan: ManualRenderPlanV1, template_bytes: bytes, *, full_package: ProjectionPackageV1, render_identity: str, ) -> ProjectionRenderResult: cache = ProjectionFragmentCache( snapshot.descriptor.root, snapshot.descriptor.cache_root, ) pages = cast(list[object], plan.document["pages"]) records: list[FragmentRecord | None] = [] missing: list[tuple[int, FragmentKey]] = [] for value in pages: page = cast(dict[str, object], value) key = FragmentKey.create( projection_kind="manual", renderer_id=self.renderer_id, renderer_version=self.renderer_version, component_version=self.page_component_version, semantic_input_hash=fragment_semantic_hash(page), ) record = cache.get(key) if record is None: missing.append((len(records), key)) records.append(record) if not records: return render_projection_in_worker(full_package) full_result: ProjectionRenderResult | None = None new_records: list[FragmentRecord] = [] if missing: full_result = render_projection_in_worker(full_package) fragments = self._extract_page_fragments( pages, full_result.artifacts[0].content, ) try: for position, key in missing: record = FragmentRecord.create(key, fragments[position]) records[position] = record new_records.append(record) except DocForgeError: return full_result complete_records = [record for record in records if record is not None] if len(complete_records) != len(records): return full_result or render_projection_in_worker(full_package) try: incremental_package = build_manual_projection_package( plan, template_bytes, fragment_records=[record.as_dict() for record in complete_records], renderer_id=self.renderer_id, renderer_version=self.renderer_version, max_output_bytes=snapshot.descriptor.limits.max_render_bytes, render_identity=render_identity, ) incremental_result = render_projection_in_worker(incremental_package) except DocForgeError: cache.prune(()) return full_result or render_projection_in_worker(full_package) if full_result is None: cache.prune(tuple(record.key for record in complete_records)) return incremental_result if tuple( (artifact.artifact_id, artifact.media_type, artifact.content) for artifact in incremental_result.artifacts ) != tuple( (artifact.artifact_id, artifact.media_type, artifact.content) for artifact in full_result.artifacts ): cache.prune(()) return full_result for record in new_records: cache.put(record.key, record.content) cache.prune(tuple(record.key for record in complete_records)) return incremental_result @staticmethod def _extract_page_fragments( pages: list[object], output: bytes, ) -> list[bytes]: """Extract exact deterministic page sections from one trusted full artifact.""" fragments: list[bytes] = [] cursor = 0 closing = b"" for value in pages: page = cast(dict[str, object], value) node_id = cast(str, page["node_id"]) marker = f'
'.encode() start = output.find(marker, cursor) end = output.find(closing, start + len(marker)) if start >= 0 else -1 if start < 0 or end < 0: raise DocForgeError( "invalid_projection", "Full manual artifact does not contain its planned page fragments", ) end += len(closing) fragments.append(output[start:end]) cursor = end return fragments _RENDERERS: dict[str, type[GenericHtmlRenderer]] = { GenericHtmlRenderer.renderer_id: GenericHtmlRenderer } def renderer_for(view: RenderView, *, incremental: bool = True) -> 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(incremental=incremental) def relative_output(snapshot: ProjectSnapshot, path: Path) -> str: return path.relative_to(snapshot.descriptor.root).as_posix()