205 lines
7.2 KiB
Python
205 lines
7.2 KiB
Python
|
|
"""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 = ['<nav aria-label="Documentation"><ul>']
|
||
|
|
for node in nodes:
|
||
|
|
navigation.append(
|
||
|
|
f'<li><a href="#node-{html.escape(node.node_id, quote=True)}">'
|
||
|
|
f"{html.escape(node.title)}</a></li>"
|
||
|
|
)
|
||
|
|
navigation.append("</ul></nav>")
|
||
|
|
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'<section id="node-{html.escape(node.node_id, quote=True)}">',
|
||
|
|
f"<h2>{html.escape(node.title)}</h2>",
|
||
|
|
'<dl class="docforge-node-meta">',
|
||
|
|
f"<dt>ID</dt><dd>{html.escape(node.node_id)}</dd>",
|
||
|
|
f"<dt>Family</dt><dd>{html.escape(node.family)}</dd>",
|
||
|
|
f"<dt>Status</dt><dd>{html.escape(node.status)}</dd>",
|
||
|
|
f"<dt>Authority</dt><dd>{html.escape(node.authority)}</dd>",
|
||
|
|
"</dl>",
|
||
|
|
f'<p class="docforge-summary">{html.escape(node.summary)}</p>',
|
||
|
|
self.markdown.render(node.content).rstrip(),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
relationships = edge_map.get(node.node_id, [])
|
||
|
|
if relationships:
|
||
|
|
sections.append('<ul class="docforge-relationships">')
|
||
|
|
for edge in relationships:
|
||
|
|
sections.append(
|
||
|
|
f"<li>{html.escape(edge.relation)}: {html.escape(edge.target_id)}</li>"
|
||
|
|
)
|
||
|
|
sections.append("</ul>")
|
||
|
|
sections.append("</section>")
|
||
|
|
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()
|