Add durable portable graph publication
This commit is contained in:
parent
96e3965855
commit
1134c2d375
19 changed files with 2542 additions and 13 deletions
321
src/docforge_renderers/graph.py
Normal file
321
src/docforge_renderers/graph.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Deterministic self-contained renderer for one portable graph package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
from time import perf_counter_ns
|
||||
from typing import cast
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.projection_contract import (
|
||||
ProjectionArtifact,
|
||||
ProjectionPackageV1,
|
||||
ProjectionReceiptV1,
|
||||
ProjectionRenderResult,
|
||||
)
|
||||
|
||||
PORTABLE_GRAPH_CSS = """
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: Canvas; color: CanvasText; }
|
||||
.skip { position: absolute; left: -10000px; top: auto; }
|
||||
.skip:focus { left: 1rem; top: 1rem; z-index: 2; padding: .5rem; background: Canvas; }
|
||||
header, main { width: min(96%, 1100px); margin: 0 auto; }
|
||||
header { padding: 1rem 0; }
|
||||
.controls { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; }
|
||||
label { display: grid; gap: .25rem; font-weight: 600; }
|
||||
input, select, button { font: inherit; min-height: 2.75rem; padding: .45rem .65rem; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible { outline: .2rem solid Highlight; }
|
||||
.summary { margin: 1rem 0; }
|
||||
.layout { display: grid; grid-template-columns: minmax(16rem, 1fr) minmax(20rem, 2fr); gap: 1rem; }
|
||||
.panel { border: 1px solid GrayText; border-radius: .5rem; padding: 1rem; overflow: auto; }
|
||||
html[data-enhanced="true"] main[data-mode="nodes"] .layout,
|
||||
html[data-enhanced="true"] main[data-mode="flow"] .layout { grid-template-columns: 1fr; }
|
||||
html[data-enhanced="true"] main[data-mode="nodes"] [data-panel="relationships"] { display: none; }
|
||||
html[data-enhanced="true"] main[data-mode="flow"] [data-panel="nodes"] { display: none; }
|
||||
.node-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }
|
||||
.node-list button {
|
||||
width: 100%; text-align: left; border: 1px solid GrayText; border-radius: .35rem;
|
||||
}
|
||||
.node-list button[aria-current="true"] { border-width: .2rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { text-align: left; border-bottom: 1px solid GrayText; padding: .5rem; vertical-align: top; }
|
||||
caption { text-align: left; font-weight: 700; margin-bottom: .5rem; }
|
||||
.muted { color: GrayText; }
|
||||
dialog {
|
||||
max-width: min(42rem, calc(100% - 2rem));
|
||||
border: 1px solid GrayText; border-radius: .5rem;
|
||||
}
|
||||
dialog::backdrop { background: rgb(0 0 0 / 55%); }
|
||||
@media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.panel, .node-list button, dialog { border: 2px solid CanvasText; }
|
||||
}
|
||||
""".strip()
|
||||
|
||||
PORTABLE_GRAPH_JAVASCRIPT = r"""
|
||||
(() => {
|
||||
"use strict";
|
||||
const plan = JSON.parse(document.getElementById("docforge-graph-plan").textContent);
|
||||
const nodes = plan.graph.nodes;
|
||||
const edges = plan.graph.edges;
|
||||
const list = document.getElementById("node-list");
|
||||
const rows = document.getElementById("edge-rows");
|
||||
const filter = document.getElementById("filter");
|
||||
const mode = document.getElementById("mode");
|
||||
const main = document.getElementById("main");
|
||||
const status = document.getElementById("status");
|
||||
const dialog = document.getElementById("node-dialog");
|
||||
const detail = document.getElementById("node-detail");
|
||||
const close = document.getElementById("close-dialog");
|
||||
let opener = null;
|
||||
document.documentElement.dataset.enhanced = "true";
|
||||
|
||||
const matches = (node) => {
|
||||
const query = filter.value.trim().toLocaleLowerCase();
|
||||
const fields = [
|
||||
node.node_id, node.title, node.summary, node.family, node.status, ...node.tags
|
||||
];
|
||||
return !query || fields
|
||||
.join(" ").toLocaleLowerCase().includes(query);
|
||||
};
|
||||
const selectedIds = () => new Set(nodes.filter(matches).map((node) => node.node_id));
|
||||
const render = () => {
|
||||
main.dataset.mode = mode.value;
|
||||
const visible = nodes.filter(matches);
|
||||
const ids = selectedIds();
|
||||
list.replaceChildren(...visible.map((node) => {
|
||||
const item = document.createElement("li");
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = `${node.title} (${node.node_id})`;
|
||||
button.dataset.nodeId = node.node_id;
|
||||
button.addEventListener("click", () => inspect(node, button));
|
||||
item.append(button);
|
||||
return item;
|
||||
}));
|
||||
const visibleEdges = edges.filter((edge) => ids.has(edge.source_id) && ids.has(edge.target_id));
|
||||
rows.replaceChildren(...visibleEdges.map((edge) => {
|
||||
const row = document.createElement("tr");
|
||||
[edge.source_id, edge.relation, edge.target_id].forEach((value) => {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value;
|
||||
row.append(cell);
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
status.textContent = `${visible.length} nodes and ${visibleEdges.length} relationships `
|
||||
+ `shown in ${mode.value} mode.`;
|
||||
};
|
||||
const inspect = (node, button) => {
|
||||
opener = button;
|
||||
detail.replaceChildren();
|
||||
const heading = document.createElement("h2");
|
||||
heading.id = "node-dialog-title";
|
||||
heading.textContent = node.title;
|
||||
const identity = document.createElement("p");
|
||||
identity.textContent = `${node.node_id} · ${node.family} · ${node.status}`;
|
||||
const summary = document.createElement("p");
|
||||
summary.textContent = node.summary;
|
||||
detail.append(heading, identity, summary);
|
||||
dialog.showModal();
|
||||
close.focus();
|
||||
};
|
||||
close.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("close", () => opener?.focus());
|
||||
filter.addEventListener("input", render);
|
||||
mode.addEventListener("change", render);
|
||||
render();
|
||||
})();
|
||||
""".strip()
|
||||
|
||||
|
||||
def _csp_hash(content: str) -> str:
|
||||
digest = hashlib.sha256(content.encode("utf-8")).digest()
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
|
||||
def _embedded_json(value: object) -> str:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
.replace("&", "\\u0026")
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
)
|
||||
|
||||
|
||||
def _static_node_markup(plan: dict[str, object]) -> str:
|
||||
graph = cast(dict[str, object], plan["graph"])
|
||||
nodes = cast(list[dict[str, object]], graph["nodes"])
|
||||
parts: list[str] = []
|
||||
for node in nodes:
|
||||
node_id = html.escape(cast(str, node["node_id"]))
|
||||
attribute_node_id = html.escape(cast(str, node["node_id"]), quote=True)
|
||||
title = html.escape(cast(str, node["title"]))
|
||||
family = html.escape(cast(str, node["family"]))
|
||||
status = html.escape(cast(str, node["status"]))
|
||||
summary = html.escape(cast(str, node["summary"]))
|
||||
parts.append(
|
||||
"<li><article>"
|
||||
f'<button type="button" data-node-id="{attribute_node_id}">'
|
||||
f"{title} ({node_id})</button>"
|
||||
f'<p class="muted">{family} · {status}</p>'
|
||||
f"<p>{summary}</p>"
|
||||
"</article></li>"
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _static_edge_markup(plan: dict[str, object]) -> str:
|
||||
graph = cast(dict[str, object], plan["graph"])
|
||||
edges = cast(list[dict[str, object]], graph["edges"])
|
||||
return "".join(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(cast(str, edge['source_id']))}</td>"
|
||||
f"<td>{html.escape(cast(str, edge['relation']))}</td>"
|
||||
f"<td>{html.escape(cast(str, edge['target_id']))}</td>"
|
||||
"</tr>"
|
||||
for edge in edges
|
||||
)
|
||||
|
||||
|
||||
class PortableGraphHtmlRenderer:
|
||||
"""Render a validated graph package without querying or publishing project state."""
|
||||
|
||||
renderer_id = "portable_graph_html"
|
||||
renderer_version = "1"
|
||||
|
||||
def render(self, package: ProjectionPackageV1) -> ProjectionRenderResult:
|
||||
started = perf_counter_ns()
|
||||
package = ProjectionPackageV1.from_dict(package.as_dict())
|
||||
document = package.document
|
||||
if package.kind != "graph":
|
||||
raise DocForgeError("invalid_projection", "Graph renderer requires a graph package")
|
||||
renderer = cast(dict[str, object], document["renderer"])
|
||||
if renderer != {
|
||||
"renderer_id": self.renderer_id,
|
||||
"renderer_version": self.renderer_version,
|
||||
}:
|
||||
raise DocForgeError("unsupported_renderer", "Graph renderer identity is incompatible")
|
||||
if document["components"] != [
|
||||
{"component_id": "graph.portable-document@1"},
|
||||
{"component_id": "graph.accessible-list@1"},
|
||||
{"component_id": "graph.relationship-table@1"},
|
||||
]:
|
||||
raise DocForgeError(
|
||||
"invalid_projection",
|
||||
"Portable graph renderer component declarations are incompatible",
|
||||
)
|
||||
if document["assets"] != []:
|
||||
raise DocForgeError(
|
||||
"invalid_projection",
|
||||
"Portable graph renderer does not accept project-provided assets",
|
||||
)
|
||||
plan = cast(dict[str, object], document["plan"])
|
||||
view = cast(dict[str, object], plan["view"])
|
||||
initial_mode = view.get("initial_mode")
|
||||
policy = cast(dict[str, object], plan["policy"])
|
||||
if (
|
||||
initial_mode not in {"nodes", "flow", "web"}
|
||||
or policy.get("logic_requested") is not False
|
||||
):
|
||||
raise DocForgeError(
|
||||
"unsupported_renderer",
|
||||
"Portable graph renderer version 1 does not render Logic projections",
|
||||
)
|
||||
title = html.escape(cast(str, view["title"]))
|
||||
project = cast(dict[str, object], plan["project"])
|
||||
embedded = _embedded_json(plan)
|
||||
static_nodes = _static_node_markup(plan)
|
||||
static_edges = _static_edge_markup(plan)
|
||||
csp = (
|
||||
"default-src 'none'; "
|
||||
f"style-src 'sha256-{_csp_hash(PORTABLE_GRAPH_CSS)}'; "
|
||||
f"script-src 'sha256-{_csp_hash(PORTABLE_GRAPH_JAVASCRIPT)}'; "
|
||||
"img-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; "
|
||||
"form-action 'none'; frame-ancestors 'none'"
|
||||
)
|
||||
output = (
|
||||
"<!DOCTYPE html>\n"
|
||||
'<html lang="en">\n'
|
||||
"<head>\n"
|
||||
'<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
'<meta http-equiv="Content-Security-Policy" '
|
||||
f'content="{html.escape(csp, quote=True)}">\n'
|
||||
f"<title>{title} · DocForge graph</title>\n"
|
||||
f"<style>{PORTABLE_GRAPH_CSS}</style>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
'<a class="skip" href="#main">Skip to graph content</a>\n'
|
||||
"<header>\n"
|
||||
f"<h1>{title}</h1>\n"
|
||||
f'<p class="muted">Generation {html.escape(cast(str, project["source_hash"]))}</p>\n'
|
||||
'<div class="controls" role="group" aria-label="Graph controls">\n'
|
||||
'<label>Filter nodes<input id="filter" type="search" autocomplete="off"></label>\n'
|
||||
'<label>View mode<select id="mode">'
|
||||
f'<option value="nodes"{" selected" if initial_mode == "nodes" else ""}>Nodes</option>'
|
||||
f'<option value="flow"{" selected" if initial_mode == "flow" else ""}>Flow</option>'
|
||||
f'<option value="web"{" selected" if initial_mode == "web" else ""}>Web</option>'
|
||||
"</select></label>\n"
|
||||
"</div>\n"
|
||||
'<p id="status" class="summary" role="status" aria-live="polite"></p>\n'
|
||||
"</header>\n"
|
||||
f'<main id="main" tabindex="-1" data-mode="{initial_mode}">\n'
|
||||
'<div class="layout">\n'
|
||||
'<section class="panel" data-panel="nodes" aria-labelledby="nodes-title">'
|
||||
'<h2 id="nodes-title">Nodes</h2>'
|
||||
f'<ul id="node-list" class="node-list">{static_nodes}</ul></section>\n'
|
||||
'<section class="panel" data-panel="relationships" aria-labelledby="relations-title">'
|
||||
'<h2 id="relations-title">Relationships</h2>'
|
||||
"<table><caption>Selected graph facts</caption><thead><tr>"
|
||||
'<th scope="col">Source</th><th scope="col">Relation</th>'
|
||||
f'<th scope="col">Target</th></tr></thead><tbody id="edge-rows">{static_edges}</tbody>'
|
||||
"</table>"
|
||||
"</section>\n"
|
||||
"</div>\n"
|
||||
"</main>\n"
|
||||
'<dialog id="node-dialog" aria-labelledby="node-dialog-title">'
|
||||
'<div id="node-detail"><h2 id="node-dialog-title">Node details</h2></div>'
|
||||
'<button id="close-dialog" type="button">Close</button></dialog>\n'
|
||||
f'<script id="docforge-graph-plan" type="application/json">{embedded}</script>\n'
|
||||
f"<script>{PORTABLE_GRAPH_JAVASCRIPT}</script>\n"
|
||||
"</body>\n"
|
||||
"</html>\n"
|
||||
).encode()
|
||||
policy = cast(dict[str, object], document["output_policy"])
|
||||
maximum = policy.get("max_total_bytes")
|
||||
if set(policy) != {"artifact_ids", "max_total_bytes"} or policy.get("artifact_ids") != [
|
||||
"portable-graph.html"
|
||||
]:
|
||||
raise DocForgeError(
|
||||
"invalid_projection",
|
||||
"Portable graph output policy is incompatible",
|
||||
)
|
||||
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="portable-graph.html",
|
||||
media_type="text/html; charset=utf-8",
|
||||
content=output,
|
||||
)
|
||||
receipt = ProjectionReceiptV1.create(
|
||||
kind="graph",
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue