Skip to content

Commit ddb1035

Browse files
yumemayuMayumi Haraclaude
authored
feat(visualizer): offline HTML proof-status report (VOKF-8 / M1) (#4)
`@verifiable-okf/visualizer` turns a bundle (or a single concept) into a single self-contained, OFFLINE HTML report with per-concept verified / unverified / failed badges and expandable reason codes, issuer, and content_hash. - render.ts: renderReport() — fully inlined CSS + a tiny expand script; no backend, no CDN, no external resources, and no network calls / data exfil (asserted in tests). HTML-escapes all content. - visualize.ts: collectConcepts() + verify each + render. - cli.ts: `vokf-visualize <dir|concept.md> [-o report.html]`. Tests (5): all three badges render; no external (CDN) refs; no fetch/XHR/form; HTML injection escaped; the fixtures tree verifies with the expected per-concept statuses. Full suite green (canon 28 + signer 7 + verifier 22 + visualizer 5 = 62). Co-authored-by: Mayumi Hara <mayumi@Mayumis-iMac.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f381bda commit ddb1035

8 files changed

Lines changed: 288 additions & 0 deletions

File tree

packages/visualizer/package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@verifiable-okf/visualizer",
3+
"version": "0.1.0",
4+
"description": "Verifiable OKF proof-status visualizer: a bundle → a single self-contained, offline HTML report with per-concept verified/unverified/failed badges. No backend, no CDN, no data leaves the page.",
5+
"license": "Apache-2.0",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js"
13+
}
14+
},
15+
"bin": {
16+
"vokf-visualize": "dist/cli.js"
17+
},
18+
"files": [
19+
"dist"
20+
],
21+
"scripts": {
22+
"build": "tsc -p tsconfig.json",
23+
"test": "vitest run",
24+
"lint": "tsc -p tsconfig.json --noEmit"
25+
},
26+
"dependencies": {
27+
"@verifiable-okf/canon": "workspace:*",
28+
"@verifiable-okf/verifier": "workspace:*"
29+
},
30+
"devDependencies": {
31+
"@types/node": "^20.14.0",
32+
"@verifiable-okf/signer": "workspace:*",
33+
"typescript": "^5.6.0",
34+
"vitest": "^2.1.0"
35+
}
36+
}

packages/visualizer/src/cli.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env node
2+
/**
3+
* `vokf-visualize` — verify a bundle (directory) or concept and write a single
4+
* self-contained, offline HTML report.
5+
*
6+
* Usage: vokf-visualize <bundle-dir|concept.md> [-o report.html]
7+
*/
8+
import { writeFileSync } from "node:fs";
9+
import { visualize } from "./visualize.js";
10+
11+
function arg(flag: string): string | undefined {
12+
const i = process.argv.indexOf(flag);
13+
return i >= 0 ? process.argv[i + 1] : undefined;
14+
}
15+
16+
function main(): void {
17+
const path = process.argv[2];
18+
if (!path || path.startsWith("-")) {
19+
console.error("usage: vokf-visualize <bundle-dir|concept.md> [-o report.html]");
20+
process.exit(2);
21+
}
22+
visualize(path)
23+
.then(({ html, items }) => {
24+
const out = arg("-o") ?? arg("--out");
25+
if (out) {
26+
writeFileSync(out, html);
27+
const failed = items.filter((i) => i.result.status === "failed").length;
28+
console.error(`report → ${out} (${items.length} concepts, ${failed} failed)`);
29+
} else {
30+
process.stdout.write(html);
31+
}
32+
})
33+
.catch((err: unknown) => {
34+
console.error(String(err));
35+
process.exit(2);
36+
});
37+
}
38+
39+
main();

packages/visualizer/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @verifiable-okf/visualizer — a bundle → a single self-contained, offline
3+
* HTML report with per-concept verified / unverified / failed badges.
4+
*/
5+
export { renderReport, type ReportItem } from "./render.js";
6+
export { visualize, collectConcepts } from "./visualize.js";

packages/visualizer/src/render.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Render a verification report as a single self-contained, OFFLINE HTML page.
3+
*
4+
* Constraints (SPEC / handoff VOKF-8): no backend, no CDN, no external
5+
* resources, and no data leaves the page — everything (CSS + the tiny
6+
* expand/collapse script) is inlined and there are no network calls.
7+
*/
8+
import type { VerifyResult } from "@verifiable-okf/verifier";
9+
10+
export interface ReportItem {
11+
readonly id: string;
12+
readonly result: VerifyResult;
13+
readonly contentHash?: string;
14+
}
15+
16+
const BADGE: Record<string, { label: string; cls: string }> = {
17+
verified: { label: "verified", cls: "ok" },
18+
unverified: { label: "unverified", cls: "neutral" },
19+
failed: { label: "failed", cls: "bad" },
20+
};
21+
22+
function esc(s: string): string {
23+
return s.replace(/[&<>"']/g, (c) =>
24+
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
25+
);
26+
}
27+
28+
function itemHtml(item: ReportItem): string {
29+
const b = BADGE[item.result.status] ?? BADGE["failed"]!;
30+
const reasons = item.result.reasons.map((r) => `<code>${esc(r)}</code>`).join(" ");
31+
const issuer = item.result.issuer ? `<div class="kv"><span>issuer</span> <code>${esc(item.result.issuer)}</code></div>` : "";
32+
const ch = item.contentHash ? `<div class="kv"><span>content_hash</span> <code>${esc(item.contentHash)}</code></div>` : "";
33+
const reasonsRow = reasons ? `<div class="kv"><span>reasons</span> ${reasons}</div>` : "";
34+
return `<details class="concept ${b.cls}">
35+
<summary><span class="badge ${b.cls}">${b.label}</span><span class="id">${esc(item.id)}</span></summary>
36+
<div class="detail">${reasonsRow}${issuer}${ch}</div>
37+
</details>`;
38+
}
39+
40+
/** Build the standalone HTML report. */
41+
export function renderReport(items: ReadonlyArray<ReportItem>, title = "Verifiable OKF — verification report"): string {
42+
const counts = { verified: 0, unverified: 0, failed: 0 } as Record<string, number>;
43+
for (const it of items) counts[it.result.status] = (counts[it.result.status] ?? 0) + 1;
44+
const summary = `${counts["verified"] ?? 0} verified · ${counts["unverified"] ?? 0} unverified · ${counts["failed"] ?? 0} failed`;
45+
const body = items.map(itemHtml).join("\n");
46+
return `<!doctype html>
47+
<html lang="en"><head><meta charset="utf-8">
48+
<meta name="viewport" content="width=device-width, initial-scale=1">
49+
<title>${esc(title)}</title>
50+
<style>
51+
:root { --ok:#2A9D6A; --bad:#B5503F; --neutral:#8A7A68; --line:#E7DACA; --ink:#3D2A1C; }
52+
* { box-sizing: border-box; }
53+
body { margin:0; background:#FBF8F3; color:var(--ink); font:15px/1.6 system-ui,-apple-system,"Hiragino Sans",sans-serif; }
54+
.wrap { max-width:840px; margin:0 auto; padding:32px 20px; }
55+
h1 { font-size:20px; margin:0 0 4px; }
56+
.sub { color:var(--neutral); margin:0 0 20px; font-size:13px; }
57+
.concept { background:#fff; border:1px solid var(--line); border-radius:10px; margin:8px 0; padding:0 14px; }
58+
.concept > summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:12px; padding:13px 0; }
59+
.concept > summary::-webkit-details-marker { display:none; }
60+
.badge { font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; padding:3px 9px; border-radius:999px; color:#fff; flex:0 0 auto; }
61+
.badge.ok { background:var(--ok); } .badge.bad { background:var(--bad); } .badge.neutral { background:var(--neutral); }
62+
.id { font-family:ui-monospace,Menlo,monospace; font-size:13px; word-break:break-all; }
63+
.detail { padding:2px 0 14px; border-top:1px dashed var(--line); }
64+
.kv { margin-top:8px; font-size:13px; } .kv span { color:var(--neutral); display:inline-block; min-width:96px; }
65+
code { font-family:ui-monospace,Menlo,monospace; font-size:12px; background:#F4EEE6; padding:1px 6px; border-radius:5px; word-break:break-all; }
66+
.concept.bad { border-color:#E6C7C0; } .concept.ok { border-color:#CDE5D8; }
67+
</style></head>
68+
<body><div class="wrap">
69+
<h1>${esc(title)}</h1>
70+
<p class="sub">${esc(summary)}</p>
71+
${body}
72+
</div>
73+
<script>
74+
// Offline only: expands the first failed concept for quick triage. No network.
75+
document.addEventListener("DOMContentLoaded", function () {
76+
var firstBad = document.querySelector(".concept.bad");
77+
if (firstBad) firstBad.setAttribute("open", "");
78+
});
79+
</script>
80+
</body></html>
81+
`;
82+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Verify a set of concept files and render an offline HTML report.
3+
*/
4+
import { readFileSync, readdirSync, statSync } from "node:fs";
5+
import { join, relative, basename } from "node:path";
6+
import { verifyConcept, type VerifyOptions } from "@verifiable-okf/verifier";
7+
import { splitConcept, parseFrontmatter } from "@verifiable-okf/canon";
8+
import { renderReport, type ReportItem } from "./render.js";
9+
10+
/** Recursively collect `.md` concept files under a path (or the file itself). */
11+
export function collectConcepts(path: string): string[] {
12+
const st = statSync(path);
13+
if (st.isFile()) return path.endsWith(".md") ? [path] : [];
14+
const out: string[] = [];
15+
for (const entry of readdirSync(path)) {
16+
out.push(...collectConcepts(join(path, entry)));
17+
}
18+
return out.sort();
19+
}
20+
21+
function declaredContentHash(raw: Uint8Array): string | undefined {
22+
const { hasFrontmatter, frontmatterText } = splitConcept(raw);
23+
if (!hasFrontmatter) return undefined;
24+
const prov = parseFrontmatter(frontmatterText)["provenance"];
25+
const ch = prov && typeof prov === "object" ? (prov as Record<string, unknown>)["content_hash"] : undefined;
26+
return typeof ch === "string" ? ch : undefined;
27+
}
28+
29+
/** Verify every concept under `path` and return the report items + HTML. */
30+
export async function visualize(
31+
path: string,
32+
opts: VerifyOptions = {},
33+
): Promise<{ items: ReportItem[]; html: string }> {
34+
const files = collectConcepts(path);
35+
const root = statSync(path).isDirectory() ? path : "";
36+
const items: ReportItem[] = [];
37+
for (const file of files) {
38+
const raw = readFileSync(file);
39+
const result = await verifyConcept(raw, opts);
40+
items.push({
41+
id: root ? relative(root, file) : basename(file),
42+
result,
43+
contentHash: declaredContentHash(raw),
44+
});
45+
}
46+
return { items, html: renderReport(items) };
47+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from "vitest";
2+
import { fileURLToPath } from "node:url";
3+
import { renderReport } from "../src/render.js";
4+
import { visualize } from "../src/visualize.js";
5+
6+
const fixtures = fileURLToPath(new URL("../../../fixtures", import.meta.url));
7+
const NOW = new Date("2026-06-15T00:00:00Z");
8+
9+
describe("renderReport (offline, self-contained)", () => {
10+
const html = renderReport([
11+
{ id: "a.md", result: { status: "verified", reasons: [], issuer: "did:key:z6Mk" }, contentHash: "sha256:" + "a".repeat(64) },
12+
{ id: "b.md", result: { status: "failed", reasons: ["integrity_mismatch"] } },
13+
{ id: "c.md", result: { status: "unverified", reasons: [] } },
14+
]);
15+
16+
it("is a complete HTML document with all three badges", () => {
17+
expect(html).toMatch(/^<!doctype html>/i);
18+
expect(html).toContain(">verified<");
19+
expect(html).toContain(">failed<");
20+
expect(html).toContain(">unverified<");
21+
expect(html).toContain("integrity_mismatch");
22+
});
23+
24+
it("references no external resources (no CDN, fully offline)", () => {
25+
// no remote stylesheet/script/image references
26+
expect(html).not.toMatch(/(?:src|href)\s*=\s*["']https?:\/\//i);
27+
expect(html).not.toMatch(/\/\/(?:cdn|unpkg|jsdelivr|fonts\.googleapis)/i);
28+
});
29+
30+
it("makes no network calls and posts no data (no fetch / XHR / form)", () => {
31+
expect(html).not.toMatch(/fetch\(|XMLHttpRequest|navigator\.sendBeacon|<form/i);
32+
});
33+
34+
it("escapes content to avoid HTML injection", () => {
35+
const out = renderReport([{ id: "<script>x</script>", result: { status: "failed", reasons: [] } }]);
36+
expect(out).toContain("&lt;script&gt;");
37+
expect(out).not.toContain("<script>x</script>");
38+
});
39+
});
40+
41+
describe("visualize (verify a bundle → report)", () => {
42+
it("verifies the fixtures tree and reflects statuses in the report", async () => {
43+
const { items, html } = await visualize(fixtures, { now: NOW });
44+
expect(items.length).toBeGreaterThanOrEqual(5);
45+
const byId = Object.fromEntries(items.map((i) => [i.id, i.result.status]));
46+
expect(byId["signed/ga4-event.md"]).toBe("verified");
47+
expect(byId["unsigned/ga4-event.md"]).toBe("unverified");
48+
expect(byId["tampered/body-modified.md"]).toBe("failed");
49+
expect(html).toContain("signed/ga4-event.md");
50+
});
51+
});

packages/visualizer/tsconfig.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
4+
"include": ["src"]
5+
}

pnpm-lock.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)