#!/usr/bin/env node // Regenerate workflows/dashboard.html from the .dot files under workflows/. // Single-file output: every workflow's SVG is pre-rendered and inlined, so // the dashboard has no runtime JS dependency on viz.js. Run after adding or // editing a workflow. import { readdir, readFile, writeFile } from "node:fs/promises"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { instance } from "@viz-js/viz"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, "..", ".."); const WORKFLOWS_DIR = join(REPO_ROOT, "workflows"); const INVALID_DIR = join(WORKFLOWS_DIR, "invalid"); const OUTPUT = join(WORKFLOWS_DIR, "dashboard.html"); async function listDots(dir) { const entries = await readdir(dir, { withFileTypes: true }); return entries .filter((e) => e.isFile() && e.name.endsWith(".dot")) .map((e) => e.name) .sort(); } function parseHeader(content) { const lines = content.split(/\r?\n/); const headerLines = []; let sawContent = false; for (const line of lines) { const t = line.trim(); if (t === "") { if (sawContent) headerLines.push(""); continue; } if (t.startsWith("//")) { headerLines.push(t.replace(/^\/\/\s?/, "")); sawContent = true; continue; } break; } while (headerLines.length && headerLines[headerLines.length - 1] === "") { headerLines.pop(); } const summary = headerLines[0] || ""; const description = headerLines.join("\n"); const reqs = Array.from(new Set(description.match(/REQ-[A-Z0-9-]+/g) || [])).sort(); return { summary, description, reqs }; } async function buildEntry(viz, dir, name, pathPrefix) { const filepath = join(dir, name); const content = await readFile(filepath, "utf8"); const { summary, description, reqs } = parseHeader(content); let svg = ""; let renderError = null; try { svg = viz.renderString(content, { format: "svg" }); svg = svg.replace(/<\?xml[^?]*\?>/, "").replace(/]*>/, ""); } catch (e) { renderError = e.message; } return { name, path: `${pathPrefix}/${name}`, summary, description, reqs, svg, renderError, }; } const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); function renderCard(w, kind) { const reqChips = w.reqs .map((r) => `${escapeHtml(r)}`) .join(""); const graph = w.renderError ? `
render failed: ${escapeHtml(w.renderError)}
` : w.svg; return `

${escapeHtml(w.name)}

source

${escapeHtml(w.summary)}

${w.reqs.length ? `
${reqChips}
` : ""}
description
${escapeHtml(w.description)}
${graph}
`; } function renderHtml({ valid, invalid, allReqs, generated }) { return ` Attractor — workflow dashboard

Attractor — workflow dashboard

${valid.length} valid · ${invalid.length} invalid fixtures · generated ${escapeHtml(generated)}

Filter by REQ tag (AND)

${allReqs .map( (r) => ``, ) .join("")}

Valid workflows (${valid.length})

${valid.map((w) => renderCard(w, "valid")).join("")}

Invalid fixtures (${invalid.length})

${invalid.map((w) => renderCard(w, "invalid")).join("")}
`; } async function main() { const viz = await instance(); const validNames = await listDots(WORKFLOWS_DIR); const invalidNames = await listDots(INVALID_DIR); const valid = []; for (const name of validNames) { if (name === "dashboard.html") continue; valid.push(await buildEntry(viz, WORKFLOWS_DIR, name, "workflows")); } const invalid = []; for (const name of invalidNames) { invalid.push(await buildEntry(viz, INVALID_DIR, name, "workflows/invalid")); } const allReqs = Array.from( new Set([...valid, ...invalid].flatMap((w) => w.reqs)), ).sort(); const html = renderHtml({ valid, invalid, allReqs, generated: new Date().toISOString().replace(/\.\d+Z$/, "Z"), }); await writeFile(OUTPUT, html, "utf8"); console.log( `wrote ${OUTPUT} (${valid.length} valid, ${invalid.length} invalid, ${allReqs.length} unique REQ tags)`, ); const errs = [...valid, ...invalid].filter((w) => w.renderError); if (errs.length) { console.error(`render errors in ${errs.length} workflow(s):`); for (const w of errs) console.error(` ${w.path}: ${w.renderError}`); process.exit(2); } } main().catch((e) => { console.error(e); process.exit(1); });