|
| 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 | + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 | +} |
0 commit comments