feat(overview-artifact): 產生可預覽總覽與截圖 fallback
This commit is contained in:
Executable
+96
@@ -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(';')}`);
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
Executable
+76
@@ -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; } }
|
||||
Executable
+144
@@ -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('&', '&').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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user