#!/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 = `${escapeHtml(requirement.title ?? '需求總覽')}

${escapeHtml(requirement.title ?? '需求總覽')}

來源:${escapeHtml(document.source.requirement.repo)}#${document.source.requirement.index}

${escapeHtml(requirement.summary ?? '')}

${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}

工作包

${packages.map(renderWorkPackage).join('')}
${renderFormulas(requirement.formulas)}
`; 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) => `${renderSvgContents(diagram)}`).join(''); return `${escapeHtml(document.requirement.title ?? '需求總覽')}${escapeHtml(document.requirement.summary ?? '')}${diagramMarkup}`; } function renderWorkPackage(workPackage, index) { const slug = slugify(workPackage.title) || `work-package-${index + 1}`; const done = workPackage.todos.filter((todo) => todo.done).length; return `

${escapeHtml(workPackage.title)}

${escapeHtml(workPackage.description)}

類型
${escapeHtml(workPackage.type)}
進度
${done}/${workPackage.todos.length} 項待辦
依賴
${escapeHtml(workPackage.depends.join(', ') || '無')}
repo
${escapeHtml(workPackage.repos.join(', ') || '無')}

查看工作包詳細內容

`; } function renderDiagrams(diagrams) { return diagrams.map((diagram, index) => `

${escapeHtml(diagram.title ?? diagram.kind)}

${renderSvg(diagram)}
${diagram.omitted ? `

未產生:${escapeHtml(diagram.omitted)}

` : ''}
`).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 ? `${escapeHtml(edge.label ?? '')}` : ''; }).join(''); const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `${escapeHtml(node.label ?? node.id)}`; }).join(''); return `${edges}${nodes}`; } function renderSvgContents(diagram) { return renderSvg(diagram).replace(/^]*>/, '').replace(/<\/svg>$/, ''); } function renderListSection(title, values, id) { if (!Array.isArray(values) || values.length === 0) return ''; return `

${title}

    ${values.map((value) => `
  • ${escapeHtml(typeof value === 'string' ? value : value.text ?? '')}
  • `).join('')}
`; } function renderFormulas(formulas) { if (!Array.isArray(formulas) || formulas.length === 0) return ''; return `

公式

${formulas.map((formula) => { const latex = String(formula.latex ?? ''); const supported = /^[A-Za-z0-9\s+\-*/=().,_^{}]+$/.test(latex); return `

${escapeHtml(formula.title ?? '公式')}

${escapeHtml(formula.description ?? '')}

${supported ? `${escapeHtml(latex)}` : `

公式未渲染,原文如下:

${escapeHtml(latex)}
`}
`; }).join('')}
`; } 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; }); }