145 lines
12 KiB
JavaScript
Executable File
145 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Validate an overview artifact JSON document and render a self-contained HTML file.
|
|
* The renderer never calls Gitea; callers provide a JSON snapshot from the extractors.
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { ScriptError, main, parseFlags } from './lib.js';
|
|
|
|
const SCHEMA_VERSION = 1;
|
|
const WORK_TYPES = new Set(['implementation', 'data', 'architecture', 'schedule', 'documentation']);
|
|
const DIAGRAM_KINDS = new Set(['flowchart', 'dependency', 'architecture', 'network', 'repo', 'schedule']);
|
|
|
|
export function validateOverview(document) {
|
|
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('SCHEMA_INVALID', '根資料必須是物件');
|
|
if (document.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_UNSUPPORTED', `只支援 schemaVersion ${SCHEMA_VERSION}`);
|
|
if (!document.source?.requirement?.repo || !Number.isInteger(document.source.requirement.index)) {
|
|
fail('SCHEMA_INVALID', 'source.requirement 必須包含 repo 與整數 index');
|
|
}
|
|
if (!document.requirement || typeof document.requirement !== 'object') fail('SCHEMA_INVALID', '缺少 requirement');
|
|
if (!Array.isArray(document.workPackages)) fail('SCHEMA_INVALID', 'workPackages 必須是陣列');
|
|
for (const [position, workPackage] of document.workPackages.entries()) validateWorkPackage(workPackage, position);
|
|
validateDiagrams(document.requirement.diagrams, 'requirement');
|
|
return document;
|
|
}
|
|
|
|
function validateWorkPackage(value, position) {
|
|
if (!value || typeof value !== 'object') fail('SCHEMA_INVALID', `workPackages[${position}] 必須是物件`);
|
|
for (const key of ['title', 'description', 'scope', 'issue']) {
|
|
if (typeof value[key] !== 'string' || value[key].trim() === '') fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必填`);
|
|
}
|
|
if (!WORK_TYPES.has(value.type)) fail('SCHEMA_INVALID', `workPackages[${position}].type 不支援:${value.type}`);
|
|
for (const key of ['repos', 'depends', 'todos', 'acceptance']) {
|
|
if (!Array.isArray(value[key])) fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必須是陣列`);
|
|
}
|
|
if (value.interfaces !== undefined) {
|
|
if (!Array.isArray(value.interfaces)) fail('SCHEMA_INVALID', `workPackages[${position}].interfaces 必須是陣列`);
|
|
for (const contract of value.interfaces) validateInterface(contract, position);
|
|
}
|
|
validateDiagrams(value.diagrams, `workPackages[${position}]`);
|
|
if (value.formulas !== undefined && !Array.isArray(value.formulas)) fail('SCHEMA_INVALID', `workPackages[${position}].formulas 必須是陣列`);
|
|
}
|
|
|
|
function validateInterface(contract, position) {
|
|
const kinds = {
|
|
http: ['method', 'path', 'request', 'response', 'errors', 'example'],
|
|
cli: ['command', 'args', 'stdout', 'stderr', 'exitCodes', 'example'],
|
|
function: ['signature', 'input', 'output', 'errors', 'example'],
|
|
event: ['topic', 'payload', 'producer', 'consumer', 'delivery', 'errors', 'example'],
|
|
storage: ['operation', 'entity', 'schema', 'constraints', 'transaction', 'example'],
|
|
};
|
|
if (!kinds[contract?.interfaceType]) fail('SCHEMA_INVALID', `workPackages[${position}] 有未知 interfaceType`);
|
|
for (const key of kinds[contract.interfaceType]) {
|
|
if (contract[key] === undefined) fail('SCHEMA_INVALID', `介面契約缺少 ${contract.interfaceType}.${key}`);
|
|
}
|
|
}
|
|
|
|
function validateDiagrams(diagrams, owner) {
|
|
if (diagrams === undefined) return;
|
|
if (!Array.isArray(diagrams)) fail('SCHEMA_INVALID', `${owner}.diagrams 必須是陣列`);
|
|
for (const diagram of diagrams) {
|
|
if (!DIAGRAM_KINDS.has(diagram?.kind)) fail('DIAGRAM_UNSUPPORTED', `${owner} 有未知圖表類型`);
|
|
if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) fail('SCHEMA_INVALID', `${owner} 圖表必須有 nodes 與 edges`);
|
|
const ids = new Set(diagram.nodes.map((node) => node.id));
|
|
for (const edge of diagram.edges) if (!ids.has(edge.from) || !ids.has(edge.to)) fail('SCHEMA_INVALID', `${owner} 圖表有不存在的邊端點`);
|
|
}
|
|
}
|
|
|
|
export function renderOverview(document) {
|
|
validateOverview(document);
|
|
const requirement = document.requirement;
|
|
const packages = document.workPackages;
|
|
const diagrams = [...(requirement.diagrams ?? []), ...packages.flatMap((workPackage) => workPackage.diagrams ?? [])];
|
|
const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(requirement.title ?? '需求總覽')}</title><style>${styles()}</style></head><body><main><header><h1>${escapeHtml(requirement.title ?? '需求總覽')}</h1><p class="meta">來源:${escapeHtml(document.source.requirement.repo)}#${document.source.requirement.index}</p></header><p class="lede">${escapeHtml(requirement.summary ?? '')}</p>${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}<section id="work-packages"><h2>工作包</h2>${packages.map(renderWorkPackage).join('')}</section>${renderFormulas(requirement.formulas)}<footer>schemaVersion ${document.schemaVersion} · 產生時間 ${escapeHtml(document.overview?.generatedAt ?? '')}</footer></main></body></html>`;
|
|
return html;
|
|
}
|
|
export function renderFallbackSvg(document) {
|
|
validateOverview(document);
|
|
const diagrams = [...(document.requirement.diagrams ?? []), ...document.workPackages.flatMap((workPackage) => workPackage.diagrams ?? [])];
|
|
const height = 180 + diagrams.length * 300;
|
|
const diagramMarkup = diagrams.map((diagram, index) => `<g transform="translate(40 ${150 + index * 300})">${renderSvgContents(diagram)}</g>`).join('');
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="${height}" viewBox="0 0 1200 ${height}"><rect width="100%" height="100%" fill="#ffffff"/><text x="40" y="55" font-size="30" font-family="sans-serif">${escapeHtml(document.requirement.title ?? '需求總覽')}</text><text x="40" y="95" font-size="18" font-family="sans-serif">${escapeHtml(document.requirement.summary ?? '')}</text>${diagramMarkup}</svg>`;
|
|
}
|
|
|
|
function renderWorkPackage(workPackage, index) {
|
|
const slug = slugify(workPackage.title) || `work-package-${index + 1}`;
|
|
const done = workPackage.todos.filter((todo) => todo.done).length;
|
|
return `<article id="work-package-${slug}"><h3>${escapeHtml(workPackage.title)}</h3><p>${escapeHtml(workPackage.description)}</p><dl><dt>類型</dt><dd>${escapeHtml(workPackage.type)}</dd><dt>進度</dt><dd>${done}/${workPackage.todos.length} 項待辦</dd><dt>依賴</dt><dd>${escapeHtml(workPackage.depends.join(', ') || '無')}</dd><dt>repo</dt><dd>${escapeHtml(workPackage.repos.join(', ') || '無')}</dd></dl><p><a href="${escapeAttr(workPackage.issue)}">查看工作包詳細內容</a></p></article>`;
|
|
}
|
|
|
|
function renderDiagrams(diagrams) {
|
|
return diagrams.map((diagram, index) => `<section id="diagram-${index + 1}"><h2>${escapeHtml(diagram.title ?? diagram.kind)}</h2><div class="diagram">${renderSvg(diagram)}</div>${diagram.omitted ? `<p class="omitted">未產生:${escapeHtml(diagram.omitted)}</p>` : ''}</section>`).join('');
|
|
}
|
|
|
|
function renderSvg(diagram) {
|
|
const columns = Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length || 1)));
|
|
const width = Math.max(640, columns * 220);
|
|
const height = Math.max(180, Math.ceil((diagram.nodes.length || 1) / columns) * 100 + 80);
|
|
const positions = new Map(diagram.nodes.map((node, index) => [node.id, { x: 30 + (index % columns) * 210, y: 35 + Math.floor(index / columns) * 100 }]));
|
|
const edges = diagram.edges.map((edge) => { const from = positions.get(edge.from); const to = positions.get(edge.to); return from && to ? `<line x1="${from.x + 150}" y1="${from.y + 24}" x2="${to.x}" y2="${to.y + 24}" marker-end="url(#arrow)"/><text x="${(from.x + to.x + 150) / 2}" y="${(from.y + to.y) / 2 + 18}">${escapeHtml(edge.label ?? '')}</text>` : ''; }).join('');
|
|
const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `<g><rect x="${point.x}" y="${point.y}" width="150" height="48" rx="8"/><text x="${point.x + 75}" y="${point.y + 29}">${escapeHtml(node.label ?? node.id)}</text></g>`; }).join('');
|
|
return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(diagram.title ?? diagram.kind)}"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>${edges}${nodes}</svg>`;
|
|
}
|
|
|
|
function renderSvgContents(diagram) {
|
|
return renderSvg(diagram).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
|
|
}
|
|
function renderListSection(title, values, id) { if (!Array.isArray(values) || values.length === 0) return ''; return `<section id="${id}"><h2>${title}</h2><ul>${values.map((value) => `<li>${escapeHtml(typeof value === 'string' ? value : value.text ?? '')}</li>`).join('')}</ul></section>`; }
|
|
|
|
function renderFormulas(formulas) {
|
|
if (!Array.isArray(formulas) || formulas.length === 0) return '';
|
|
return `<section id="formulas"><h2>公式</h2>${formulas.map((formula) => {
|
|
const latex = String(formula.latex ?? '');
|
|
const supported = /^[A-Za-z0-9\s+\-*/=().,_^{}]+$/.test(latex);
|
|
return `<article class="formula"><h3>${escapeHtml(formula.title ?? '公式')}</h3><p>${escapeHtml(formula.description ?? '')}</p>${supported ? `<svg class="formula-svg" viewBox="0 0 700 60" role="img" aria-label="${escapeAttr(latex)}"><text x="12" y="38">${escapeHtml(latex)}</text></svg>` : `<p class="omitted">公式未渲染,原文如下:</p><pre>${escapeHtml(latex)}</pre>`}</article>`;
|
|
}).join('')}</section>`;
|
|
}
|
|
function styles() { return `:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;--line:#d1d9e0;--surface:#f6f8fa;--accent:#0969da}*{box-sizing:border-box}body{margin:0;padding:48px 16px 96px;background:var(--bg);color:var(--fg);font:16px/1.7 -apple-system,"Noto Sans TC","Microsoft JhengHei",sans-serif}main{max-width:980px;margin:0 auto}header{border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:40px}h1{font-size:30px;line-height:1.3}h2{font-size:15px;letter-spacing:.08em;color:var(--muted);margin:32px 0 16px}h3{line-height:1.4}.meta,.omitted{color:var(--muted)}.lede{font-size:21px;padding:20px 24px;background:var(--surface);border-left:3px solid var(--accent)}ul{padding-left:24px}.diagram{padding:20px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:auto}.diagram svg{display:block;min-width:620px;height:auto}.diagram rect{fill:var(--bg);stroke:var(--accent);stroke-width:2}.diagram text{fill:var(--fg);font-size:14px;text-anchor:middle}.diagram line{stroke:var(--accent);stroke-width:2}.diagram marker path{fill:var(--accent)}article{border-top:1px solid var(--line);padding:16px 0}dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;color:var(--muted)}dt{font-weight:600}dd{margin:0}a{color:var(--accent)}pre{white-space:pre-wrap;background:var(--surface);padding:12px;border-radius:6px}footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--line:#3d444d;--surface:#151b23;--accent:#4493f8}}`; }
|
|
function slugify(value) { return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
|
|
function escapeHtml(value) { return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
|
const escapeAttr = escapeHtml;
|
|
function fail(code, message) { throw new ScriptError(code, message); }
|
|
|
|
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
main(async () => {
|
|
const flags = parseFlags(process.argv.slice(2), { required: ['input', 'output'], optional: ['manifest', 'svg'] });
|
|
const inputPath = resolve(flags.input);
|
|
const outputPath = resolve(flags.output);
|
|
const document = JSON.parse(readFileSync(inputPath, 'utf8'));
|
|
validateOverview(document);
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, renderOverview(document));
|
|
const result = { schemaVersion: SCHEMA_VERSION, html: outputPath, sha256: createHash('sha256').update(readFileSync(outputPath)).digest('hex') };
|
|
if (flags.svg) {
|
|
const svgPath = resolve(flags.svg);
|
|
mkdirSync(dirname(svgPath), { recursive: true });
|
|
writeFileSync(svgPath, renderFallbackSvg(document));
|
|
result.svg = svgPath;
|
|
}
|
|
if (flags.manifest) { mkdirSync(dirname(resolve(flags.manifest)), { recursive: true }); writeFileSync(resolve(flags.manifest), `${JSON.stringify({ ...result, screenshots: [] }, null, 2)}\n`); }
|
|
return result;
|
|
});
|
|
}
|