Add versioned independent projection contracts
This commit is contained in:
parent
4c5773c865
commit
96e3965855
22 changed files with 3561 additions and 133 deletions
1
src/docforge_renderers/__init__.py
Normal file
1
src/docforge_renderers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Capability-isolated renderer implementations for DocForge projection packages."""
|
||||
172
src/docforge_renderers/manual.py
Normal file
172
src/docforge_renderers/manual.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Plan-only renderer for the built-in DocForge manual artifact."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from time import perf_counter_ns
|
||||
from typing import cast
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.projection_contract import (
|
||||
ProjectionArtifact,
|
||||
ProjectionPackageV1,
|
||||
ProjectionReceiptV1,
|
||||
ProjectionRenderResult,
|
||||
)
|
||||
|
||||
_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",
|
||||
}
|
||||
)
|
||||
_ACTIVE_TEMPLATE_CONTENT = re.compile(
|
||||
r"<\s*(?:script|iframe|object|embed)\b"
|
||||
r"|\son[a-z0-9_-]+\s*="
|
||||
r"|javascript\s*:"
|
||||
r"|<\s*meta\b[^>]*\bhttp-equiv\s*=\s*[\"']?\s*refresh\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class ManualHtmlRenderer:
|
||||
"""Transform one validated path-free package without graph-selection authority."""
|
||||
|
||||
renderer_id = "generic_html"
|
||||
|
||||
def __init__(self, renderer_version: str) -> None:
|
||||
self.renderer_version = renderer_version
|
||||
self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False})
|
||||
|
||||
def render(
|
||||
self,
|
||||
package: ProjectionPackageV1,
|
||||
*,
|
||||
render_identity: str | None = None,
|
||||
) -> ProjectionRenderResult:
|
||||
started = perf_counter_ns()
|
||||
package = ProjectionPackageV1.from_dict(package.as_dict())
|
||||
document = package.document
|
||||
if package.kind != "manual":
|
||||
raise DocForgeError("invalid_projection", "Manual renderer requires a manual package")
|
||||
renderer = cast(dict[str, object], document["renderer"])
|
||||
if renderer != {
|
||||
"renderer_id": self.renderer_id,
|
||||
"renderer_version": self.renderer_version,
|
||||
}:
|
||||
raise DocForgeError("unsupported_renderer", "Manual renderer identity is incompatible")
|
||||
plan = cast(dict[str, object], document["plan"])
|
||||
assets = cast(list[object], document["assets"])
|
||||
if len(assets) != 1 or not isinstance(assets[0], dict):
|
||||
raise DocForgeError("invalid_projection", "Manual template asset is invalid")
|
||||
asset = cast(dict[str, object], assets[0])
|
||||
if (
|
||||
set(asset) != {"asset_id", "media_type", "sha256", "text"}
|
||||
or asset.get("asset_id") != "manual.template"
|
||||
or asset.get("media_type") != "text/html; charset=utf-8"
|
||||
or not isinstance(asset.get("text"), str)
|
||||
):
|
||||
raise DocForgeError("invalid_projection", "Manual template asset is invalid")
|
||||
template = cast(str, asset["text"])
|
||||
template_bytes = template.encode("utf-8")
|
||||
if hashlib.sha256(template_bytes).hexdigest() != asset.get("sha256"):
|
||||
raise DocForgeError("invalid_projection", "Manual template asset hash is invalid")
|
||||
if _ACTIVE_TEMPLATE_CONTENT.search(template):
|
||||
raise DocForgeError(
|
||||
"invalid_template",
|
||||
"Render template contains active or executable content",
|
||||
)
|
||||
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",
|
||||
)
|
||||
identity = render_identity or cast(str, plan["plan_id"])
|
||||
project = cast(dict[str, object], plan["project"])
|
||||
view = cast(dict[str, object], plan["view"])
|
||||
replacements = {
|
||||
"docforge_content": self._content(plan),
|
||||
"docforge_project_id": html.escape(cast(str, project["project_id"]), quote=True),
|
||||
"docforge_render_identity": identity,
|
||||
"docforge_title": html.escape(cast(str, view["title"]), quote=True),
|
||||
"docforge_view_id": html.escape(cast(str, view["view_id"]), quote=True),
|
||||
}
|
||||
rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template)
|
||||
output = rendered.rstrip().encode("utf-8") + b"\n"
|
||||
policy = cast(dict[str, object], document["output_policy"])
|
||||
maximum = policy.get("max_total_bytes")
|
||||
if type(maximum) is not int or maximum < 1 or len(output) > maximum:
|
||||
raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit")
|
||||
artifact = ProjectionArtifact(
|
||||
artifact_id="manual.html",
|
||||
media_type="text/html; charset=utf-8",
|
||||
content=output,
|
||||
)
|
||||
receipt = ProjectionReceiptV1.create(
|
||||
kind="manual",
|
||||
package_id=package.package_id,
|
||||
plan_id=cast(str, document["plan_id"]),
|
||||
renderer=dict(renderer),
|
||||
artifacts=[artifact.evidence()],
|
||||
diagnostics={"warnings": []},
|
||||
timing={"elapsed_ns": perf_counter_ns() - started},
|
||||
peak_memory_bytes=None,
|
||||
)
|
||||
return ProjectionRenderResult((artifact,), receipt)
|
||||
|
||||
def _content(self, plan: dict[str, object]) -> str:
|
||||
navigation = ['<nav aria-label="Documentation"><ul>']
|
||||
for value in cast(list[object], plan["navigation"]):
|
||||
item = cast(dict[str, object], value)
|
||||
navigation.append(
|
||||
f'<li><a href="#node-{html.escape(cast(str, item["node_id"]), quote=True)}">'
|
||||
f"{html.escape(cast(str, item['title']))}</a></li>"
|
||||
)
|
||||
navigation.append("</ul></nav>")
|
||||
sections = [*navigation]
|
||||
for value in cast(list[object], plan["pages"]):
|
||||
page = cast(dict[str, object], value)
|
||||
node_id = cast(str, page["node_id"])
|
||||
sections.extend(
|
||||
[
|
||||
f'<section id="node-{html.escape(node_id, quote=True)}">',
|
||||
f"<h2>{html.escape(cast(str, page['title']))}</h2>",
|
||||
'<dl class="docforge-node-meta">',
|
||||
f"<dt>ID</dt><dd>{html.escape(node_id)}</dd>",
|
||||
f"<dt>Family</dt><dd>{html.escape(cast(str, page['family']))}</dd>",
|
||||
f"<dt>Status</dt><dd>{html.escape(cast(str, page['status']))}</dd>",
|
||||
f"<dt>Authority</dt><dd>{html.escape(cast(str, page['authority']))}</dd>",
|
||||
"</dl>",
|
||||
f'<p class="docforge-summary">{html.escape(cast(str, page["summary"]))}</p>',
|
||||
self.markdown.render(cast(str, page["content"])).rstrip(),
|
||||
]
|
||||
)
|
||||
relationships = cast(list[object], page["cross_references"])
|
||||
if relationships:
|
||||
sections.append('<ul class="docforge-relationships">')
|
||||
for relationship_value in relationships:
|
||||
relationship = cast(dict[str, object], relationship_value)
|
||||
sections.append(
|
||||
f"<li>{html.escape(cast(str, relationship['relation']))}: "
|
||||
f"{html.escape(cast(str, relationship['target_id']))}</li>"
|
||||
)
|
||||
sections.append("</ul>")
|
||||
sections.append("</section>")
|
||||
return "\n".join(sections)
|
||||
1
src/docforge_renderers/py.typed
Normal file
1
src/docforge_renderers/py.typed
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PEP 561 marker for the typed DocForge renderer package.
|
||||
Loading…
Add table
Add a link
Reference in a new issue