feat(overview-artifact): 產生可預覽總覽與截圖 fallback

This commit is contained in:
2026-09-18 15:59:34 +08:00
parent c545f11ec1
commit 867a23497f
16 changed files with 605 additions and 324 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Upload overview screenshots to a Gitea issue and update its attachment index.
* The manifest is produced by the preview step and contains local PNG paths and hashes.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
ScriptError,
expectOk,
fetchIssue,
giteaRequest,
giteaUpload,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { upsertLineInSection } from './issue-body.js';
const INDEX_LINE = '圖解版總覽附件:';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index', 'manifest'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8'));
const screenshots = manifest.screenshots;
if (!Array.isArray(screenshots) || screenshots.length === 0) {
throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots');
}
for (const item of screenshots) {
if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) {
throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在');
}
}
const login = resolveLogin({ host: flags.host });
const issuePath = `/repos/${repo}/issues/${index}`;
const assetsPath = `${issuePath}/assets`;
if (flags['dry-run']) {
return {
dryRun: true,
repo,
index,
requests: [
{ method: 'GET', path: issuePath },
{ method: 'GET', path: assetsPath },
...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })),
],
};
}
await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`);
const existing = Array.isArray(assets) ? assets : [];
const indexed = parseIndexedAssets(issue.body ?? '');
const uploaded = [];
for (const item of screenshots) {
const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256);
const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256);
const asset = indexedAsset ?? existingAsset;
if (asset) {
uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true });
continue;
}
const response = expectOk(
await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName),
`POST ${assetsPath}`,
);
uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false });
}
const body = updateIndex(issue.body ?? '', uploaded);
const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`);
return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url };
});
function parseIndexedAssets(body) {
const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? '';
return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({
role: match[1],
fileName: match[2],
sha256: match[3],
url: match[4],
}));
}
function updateIndex(body, assets) {
const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`);
return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`);
}
+25
View File
@@ -422,6 +422,31 @@ export async function giteaRequest(login, method, path, { body, query } = {}) {
const text = await response.text();
return { status: response.status, body: text ? safeJson(text) : null };
}
/**
* 上傳 Gitea issue attachment。這是唯一的 multipart HTTP 出口。
* @param {{base:string, token:string}} login
* @param {string} path
* @param {string} filePath
* @param {string} fileName
* @returns {Promise<{status:number, body:any}>}
*/
export async function giteaUpload(login, path, filePath, fileName) {
const form = new FormData();
form.append('attachment', new Blob([readFileSync(filePath)]), fileName);
const url = new URL(`${login.base}${path}`);
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: { Authorization: `token ${login.token}`, Accept: 'application/json' },
body: form,
});
} catch (cause) {
throw new ScriptError('NETWORK_ERROR', `連不上 Gitea(POST ${path}):${cause.message}`);
}
const text = await response.text();
return { status: response.status, body: safeJson(text) };
}
/**
* 把「非預期狀態碼」收斂成帶狀態碼的錯誤,成功則回傳 body。
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Capture an overview using an available non-managed backend.
* Managed browser preview remains the preferred platform-level path; this CLI handles
* Firefox and deterministic SVG rasterization when those binaries are installed.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['output'],
optional: ['url', 'svg', 'backend', 'width', 'height'],
});
const output = resolve(flags.output);
mkdirSync(dirname(output), { recursive: true });
const backend = chooseBackend(flags.backend, flags.url, flags.svg);
if (backend === 'firefox') captureFirefox(flags.url, output, flags.width, flags.height);
else captureSvg(flags.svg, output, flags.width, flags.height);
return { backend, output };
});
function chooseBackend(requested, url, svg) {
if (requested && requested !== 'auto' && requested !== 'firefox' && requested !== 'svg') {
throw new ScriptError('BACKEND_UNKNOWN', `不支援的 capture backend:${requested}`);
}
if (requested === 'firefox') {
requireBinary('firefox');
if (!url) throw new ScriptError('URL_REQUIRED', 'Firefox backend 需要 --url');
return 'firefox';
}
if (requested === 'svg') {
requireSvgInput(svg);
requireAnyBinary(['rsvg-convert', 'resvg']);
return 'svg';
}
if (url && hasBinary('firefox')) return 'firefox';
if (svg && (hasBinary('rsvg-convert') || hasBinary('resvg'))) return 'svg';
throw new ScriptError('NO_CAPTURE_BACKEND', '沒有可用的 Firefox 或 SVG rasterizer;請使用平台 preview,或安裝 firefox、rsvg-convert、resvg');
}
function captureFirefox(url, output, width, height) {
const args = ['--headless'];
if (width) args.push('--window-size', `${width}${height ? `,${height}` : ''}`);
args.push('--screenshot', output, url);
const result = spawnSync('firefox', args, { encoding: 'utf8' });
if (result.status !== 0 || !existsSync(output)) throw new ScriptError('CAPTURE_FAILED', `Firefox 截圖失敗:${result.stderr || result.stdout || '沒有輸出檔案'}`);
}
function captureSvg(svg, output, width, height) {
requireSvgInput(svg);
if (hasBinary('rsvg-convert')) {
const args = [`--output=${output}`];
if (width) args.push(`--width=${width}`);
if (height) args.push(`--height=${height}`);
args.push(resolve(svg));
const result = spawnSync('rsvg-convert', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `rsvg-convert 失敗:${result.stderr || result.stdout}`);
} else {
const args = [];
if (width) args.push('-w', String(width));
args.push(resolve(svg), output);
const result = spawnSync('resvg', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `resvg 失敗:${result.stderr || result.stdout}`);
}
if (!existsSync(output)) throw new ScriptError('CAPTURE_FAILED', 'rasterizer 沒有產生輸出檔案');
}
function requireSvgInput(svg) {
if (!svg || !existsSync(resolve(svg))) throw new ScriptError('SVG_REQUIRED', 'SVG backend 需要存在的 --svg');
}
function requireBinary(binary) { if (!hasBinary(binary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binary}`); }
function requireAnyBinary(binaries) { if (!binaries.some(hasBinary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binaries.join(' 或 ')}`); }
function hasBinary(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } }
+144
View File
@@ -0,0 +1,144 @@
#!/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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;'); }
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;
});
}