Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/visualizer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@verifiable-okf/visualizer",
"version": "0.1.0",
"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.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"bin": {
"vokf-visualize": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"lint": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@verifiable-okf/canon": "workspace:*",
"@verifiable-okf/verifier": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@verifiable-okf/signer": "workspace:*",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
39 changes: 39 additions & 0 deletions packages/visualizer/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
/**
* `vokf-visualize` — verify a bundle (directory) or concept and write a single
* self-contained, offline HTML report.
*
* Usage: vokf-visualize <bundle-dir|concept.md> [-o report.html]
*/
import { writeFileSync } from "node:fs";
import { visualize } from "./visualize.js";

function arg(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}

function main(): void {
const path = process.argv[2];
if (!path || path.startsWith("-")) {
console.error("usage: vokf-visualize <bundle-dir|concept.md> [-o report.html]");
process.exit(2);
}
visualize(path)
.then(({ html, items }) => {
const out = arg("-o") ?? arg("--out");
if (out) {
writeFileSync(out, html);
const failed = items.filter((i) => i.result.status === "failed").length;
console.error(`report → ${out} (${items.length} concepts, ${failed} failed)`);
} else {
process.stdout.write(html);
}
})
.catch((err: unknown) => {
console.error(String(err));
process.exit(2);
});
}

main();
6 changes: 6 additions & 0 deletions packages/visualizer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @verifiable-okf/visualizer — a bundle → a single self-contained, offline
* HTML report with per-concept verified / unverified / failed badges.
*/
export { renderReport, type ReportItem } from "./render.js";
export { visualize, collectConcepts } from "./visualize.js";
82 changes: 82 additions & 0 deletions packages/visualizer/src/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Render a verification report as a single self-contained, OFFLINE HTML page.
*
* Constraints (SPEC / handoff VOKF-8): no backend, no CDN, no external
* resources, and no data leaves the page — everything (CSS + the tiny
* expand/collapse script) is inlined and there are no network calls.
*/
import type { VerifyResult } from "@verifiable-okf/verifier";

export interface ReportItem {
readonly id: string;
readonly result: VerifyResult;
readonly contentHash?: string;
}

const BADGE: Record<string, { label: string; cls: string }> = {
verified: { label: "verified", cls: "ok" },
unverified: { label: "unverified", cls: "neutral" },
failed: { label: "failed", cls: "bad" },
};

function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
}

function itemHtml(item: ReportItem): string {
const b = BADGE[item.result.status] ?? BADGE["failed"]!;
const reasons = item.result.reasons.map((r) => `<code>${esc(r)}</code>`).join(" ");
const issuer = item.result.issuer ? `<div class="kv"><span>issuer</span> <code>${esc(item.result.issuer)}</code></div>` : "";
const ch = item.contentHash ? `<div class="kv"><span>content_hash</span> <code>${esc(item.contentHash)}</code></div>` : "";
const reasonsRow = reasons ? `<div class="kv"><span>reasons</span> ${reasons}</div>` : "";
return `<details class="concept ${b.cls}">
<summary><span class="badge ${b.cls}">${b.label}</span><span class="id">${esc(item.id)}</span></summary>
<div class="detail">${reasonsRow}${issuer}${ch}</div>
</details>`;
}

/** Build the standalone HTML report. */
export function renderReport(items: ReadonlyArray<ReportItem>, title = "Verifiable OKF — verification report"): string {
const counts = { verified: 0, unverified: 0, failed: 0 } as Record<string, number>;
for (const it of items) counts[it.result.status] = (counts[it.result.status] ?? 0) + 1;
const summary = `${counts["verified"] ?? 0} verified · ${counts["unverified"] ?? 0} unverified · ${counts["failed"] ?? 0} failed`;
const body = items.map(itemHtml).join("\n");
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<style>
:root { --ok:#2A9D6A; --bad:#B5503F; --neutral:#8A7A68; --line:#E7DACA; --ink:#3D2A1C; }
* { box-sizing: border-box; }
body { margin:0; background:#FBF8F3; color:var(--ink); font:15px/1.6 system-ui,-apple-system,"Hiragino Sans",sans-serif; }
.wrap { max-width:840px; margin:0 auto; padding:32px 20px; }
h1 { font-size:20px; margin:0 0 4px; }
.sub { color:var(--neutral); margin:0 0 20px; font-size:13px; }
.concept { background:#fff; border:1px solid var(--line); border-radius:10px; margin:8px 0; padding:0 14px; }
.concept > summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:12px; padding:13px 0; }
.concept > summary::-webkit-details-marker { display:none; }
.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; }
.badge.ok { background:var(--ok); } .badge.bad { background:var(--bad); } .badge.neutral { background:var(--neutral); }
.id { font-family:ui-monospace,Menlo,monospace; font-size:13px; word-break:break-all; }
.detail { padding:2px 0 14px; border-top:1px dashed var(--line); }
.kv { margin-top:8px; font-size:13px; } .kv span { color:var(--neutral); display:inline-block; min-width:96px; }
code { font-family:ui-monospace,Menlo,monospace; font-size:12px; background:#F4EEE6; padding:1px 6px; border-radius:5px; word-break:break-all; }
.concept.bad { border-color:#E6C7C0; } .concept.ok { border-color:#CDE5D8; }
</style></head>
<body><div class="wrap">
<h1>${esc(title)}</h1>
<p class="sub">${esc(summary)}</p>
${body}
</div>
<script>
// Offline only: expands the first failed concept for quick triage. No network.
document.addEventListener("DOMContentLoaded", function () {
var firstBad = document.querySelector(".concept.bad");
if (firstBad) firstBad.setAttribute("open", "");
});
</script>
</body></html>
`;
}
47 changes: 47 additions & 0 deletions packages/visualizer/src/visualize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Verify a set of concept files and render an offline HTML report.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, basename } from "node:path";
import { verifyConcept, type VerifyOptions } from "@verifiable-okf/verifier";
import { splitConcept, parseFrontmatter } from "@verifiable-okf/canon";
import { renderReport, type ReportItem } from "./render.js";

/** Recursively collect `.md` concept files under a path (or the file itself). */
export function collectConcepts(path: string): string[] {
const st = statSync(path);
if (st.isFile()) return path.endsWith(".md") ? [path] : [];
const out: string[] = [];
for (const entry of readdirSync(path)) {
out.push(...collectConcepts(join(path, entry)));
}
return out.sort();
}

function declaredContentHash(raw: Uint8Array): string | undefined {
const { hasFrontmatter, frontmatterText } = splitConcept(raw);
if (!hasFrontmatter) return undefined;
const prov = parseFrontmatter(frontmatterText)["provenance"];
const ch = prov && typeof prov === "object" ? (prov as Record<string, unknown>)["content_hash"] : undefined;
return typeof ch === "string" ? ch : undefined;
}

/** Verify every concept under `path` and return the report items + HTML. */
export async function visualize(
path: string,
opts: VerifyOptions = {},
): Promise<{ items: ReportItem[]; html: string }> {
const files = collectConcepts(path);
const root = statSync(path).isDirectory() ? path : "";
const items: ReportItem[] = [];
for (const file of files) {
const raw = readFileSync(file);
const result = await verifyConcept(raw, opts);
items.push({
id: root ? relative(root, file) : basename(file),
result,
contentHash: declaredContentHash(raw),
});
}
return { items, html: renderReport(items) };
}
51 changes: 51 additions & 0 deletions packages/visualizer/test/render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { fileURLToPath } from "node:url";
import { renderReport } from "../src/render.js";
import { visualize } from "../src/visualize.js";

const fixtures = fileURLToPath(new URL("../../../fixtures", import.meta.url));
const NOW = new Date("2026-06-15T00:00:00Z");

describe("renderReport (offline, self-contained)", () => {
const html = renderReport([
{ id: "a.md", result: { status: "verified", reasons: [], issuer: "did:key:z6Mk" }, contentHash: "sha256:" + "a".repeat(64) },
{ id: "b.md", result: { status: "failed", reasons: ["integrity_mismatch"] } },
{ id: "c.md", result: { status: "unverified", reasons: [] } },
]);

it("is a complete HTML document with all three badges", () => {
expect(html).toMatch(/^<!doctype html>/i);
expect(html).toContain(">verified<");
expect(html).toContain(">failed<");
expect(html).toContain(">unverified<");
expect(html).toContain("integrity_mismatch");
});

it("references no external resources (no CDN, fully offline)", () => {
// no remote stylesheet/script/image references
expect(html).not.toMatch(/(?:src|href)\s*=\s*["']https?:\/\//i);
expect(html).not.toMatch(/\/\/(?:cdn|unpkg|jsdelivr|fonts\.googleapis)/i);
});

it("makes no network calls and posts no data (no fetch / XHR / form)", () => {
expect(html).not.toMatch(/fetch\(|XMLHttpRequest|navigator\.sendBeacon|<form/i);
});

it("escapes content to avoid HTML injection", () => {
const out = renderReport([{ id: "<script>x</script>", result: { status: "failed", reasons: [] } }]);
expect(out).toContain("&lt;script&gt;");
expect(out).not.toContain("<script>x</script>");
});
});

describe("visualize (verify a bundle → report)", () => {
it("verifies the fixtures tree and reflects statuses in the report", async () => {
const { items, html } = await visualize(fixtures, { now: NOW });
expect(items.length).toBeGreaterThanOrEqual(5);
const byId = Object.fromEntries(items.map((i) => [i.id, i.result.status]));
expect(byId["signed/ga4-event.md"]).toBe("verified");
expect(byId["unsigned/ga4-event.md"]).toBe("unverified");
expect(byId["tampered/body-modified.md"]).toBe("failed");
expect(html).toContain("signed/ga4-event.md");
});
});
5 changes: 5 additions & 0 deletions packages/visualizer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading