Add archify-vscode-ext skill: Archify diagrams and models for the Archify Diagram Viewer extension

Model tasks (validate, list, scaffold, resolve), diagram tasks (validate,
render), the bundled Archify 3.0.1 runtime and yaml parser under
scripts/vendor, references for authoring, formats and folders, and the
online-shop example model.
This commit is contained in:
root-at-skic committed 2026-10-19 11:42:06 +03:00
commit b6b5e57922
226 files changed
+67520

No files matched your search

File diff suppressed because it is too large. Load diff
+670
View File
@@ -0,0 +1,670 @@
import { createHash } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
import { BRAND_MARKS } from './generated-brand-marks.mjs';
import { throwDiagnosticError } from './diagnostics.mjs';
import { esc, textUnits } from './utils.mjs';
const COLLECTIONS = Object.freeze({
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
});
const MARK_BY_LOOKUP = new Map();
const MARK_BY_DOMAIN = new Map();
const RESOLVED_BY_NODE = new WeakMap();
const RESOLVED_MARK = Symbol('archify.brandMark');
const MAX_HTML_BYTES = 256 * 1024;
const MAX_IMAGE_BYTES = 1024 * 1024;
const MAX_CAPTURE_CONCURRENCY = 3;
const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
const USER_AGENT = 'Archify/2.15 brand-preview';
function lookupForms(value) {
const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
if (!raw) return [];
const dashed = raw.replace(/[\s_]+/g, '-');
const compact = raw.replace(/[\s_.-]+/g, '');
return [...new Set([raw, dashed, compact])];
}
for (const mark of BRAND_MARKS) {
for (const value of [mark.id, mark.title, ...mark.aliases]) {
for (const form of lookupForms(value)) {
if (!MARK_BY_LOOKUP.has(form)) MARK_BY_LOOKUP.set(form, mark);
}
}
for (const domain of mark.domains) MARK_BY_DOMAIN.set(domain, mark);
}
function asUrl(value) {
try {
const url = new URL(String(value));
return ['https:', 'http:'].includes(url.protocol) ? url : null;
} catch {
return null;
}
}
function domainMark(hostname) {
const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
const candidates = [...MARK_BY_DOMAIN.entries()]
.filter(([domain]) => host === domain || host.endsWith(`.${domain}`))
.sort(([left], [right]) => right.length - left.length);
return candidates[0]?.[1] || null;
}
export function findBrandMark(value) {
const url = asUrl(value);
if (url) return domainMark(url.hostname);
for (const form of lookupForms(value)) {
const mark = MARK_BY_LOOKUP.get(form);
if (mark) return mark;
}
return null;
}
export function listBrandMarks(query = '') {
const needle = String(query).trim().toLocaleLowerCase('en-US');
return BRAND_MARKS.filter((mark) => {
if (!needle) return true;
return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains]
.some((value) => String(value).toLocaleLowerCase('en-US').includes(needle));
}).map(({ path, ...mark }) => mark);
}
function ipv4Private(address) {
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
const [a, b, c] = parts;
return a === 0 || a === 10 || a === 127 || a >= 224
|| (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|| (a === 192 && b === 88 && c === 99)
|| (a === 192 && b === 168)
|| (a === 198 && (b === 18 || b === 19))
|| (a === 198 && b === 51 && c === 100)
|| (a === 203 && b === 0 && c === 113);
}
function ipv6Private(address) {
const normalized = address.toLocaleLowerCase('en-US').split('%')[0];
if (normalized === '::' || normalized === '::1') return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true;
if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:')
|| normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true;
const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mappedDotted) return ipv4Private(mappedDotted[1]);
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mappedHex) {
const high = Number.parseInt(mappedHex[1], 16);
const low = Number.parseInt(mappedHex[2], 16);
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
}
const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (compatibleHex) {
const high = Number.parseInt(compatibleHex[1], 16);
const low = Number.parseInt(compatibleHex[2], 16);
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
}
return false;
}
export function isPrivateBrandAddress(address) {
const family = net.isIP(address);
return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true);
}
function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') {
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported');
if (url.username || url.password) throw new Error('brand links cannot contain credentials');
const expectedPort = url.protocol === 'https:' ? '443' : '80';
if (!allowPrivate && url.port && url.port !== expectedPort) {
throw new Error('brand links must use a standard web port');
}
const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, '');
if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) {
throw new Error('private brand links are not fetched');
}
return host;
}
function beforeDeadline(promise, deadline) {
const remaining = deadline - Date.now();
if (remaining <= 0) return Promise.reject(new Error('brand capture timed out'));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining);
timer.unref?.();
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
);
});
}
async function resolveRequestTarget(url, deadline) {
const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1';
const host = validateUrlShape(url, allowPrivate);
const directFamily = net.isIP(host);
const addresses = directFamily
? [{ address: host, family: directFamily }]
: await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline);
if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) {
throw new Error('private brand links are not fetched');
}
return addresses[0];
}
function timeoutSignal(milliseconds) {
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), milliseconds);
timer.unref?.();
return controller.signal;
}
function captureTimeoutMilliseconds() {
const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS);
if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS;
return Math.max(100, Math.min(30000, Math.round(configured)));
}
function requestPinned(url, accept, target, deadline) {
return new Promise((resolve, reject) => {
const transport = url.protocol === 'https:' ? https : http;
const request = transport.request(url, {
method: 'GET',
signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
headers: { accept, 'accept-encoding': 'identity', 'user-agent': USER_AGENT },
// Reuse the exact public address that passed validation. This closes the
// DNS-rebinding gap between checking a hostname and opening its socket.
lookup(_hostname, options, callback) {
if (options?.all) callback(null, [target]);
else callback(null, target.address, target.family);
},
}, (response) => {
const status = response.statusCode || 0;
resolve({
status,
ok: status >= 200 && status < 300,
headers: {
get(name) {
const value = response.headers[String(name).toLocaleLowerCase('en-US')];
return Array.isArray(value) ? value.join(', ') : (value ?? null);
},
},
body: response,
});
});
request.on('error', reject);
request.end();
});
}
async function checkedFetch(input, accept, deadline) {
let current = new URL(input);
for (let redirects = 0; redirects <= 3; redirects += 1) {
if (Date.now() >= deadline) throw new Error('brand capture timed out');
const target = await resolveRequestTarget(current, deadline);
const response = await requestPinned(current, accept, target, deadline);
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
response.body.resume();
if (!location || redirects === 3) throw new Error('brand link redirected too many times');
current = new URL(location, current);
continue;
}
if (!response.ok) {
response.body.resume();
throw new Error(`brand link returned HTTP ${response.status}`);
}
// Raw HTTP responses are not decompressed. Check successful bodies before
// HTML discovery or image validation so byte limits and digests stay valid.
const contentEncoding = (response.headers.get('content-encoding') || '').trim().toLowerCase();
if (contentEncoding && contentEncoding !== 'identity') {
response.body.destroy();
throw new Error(`unsupported brand content encoding ${contentEncoding}`);
}
return { response, finalUrl: current };
}
throw new Error('brand link redirected too many times');
}
async function readLimited(response, maximum) {
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maximum) {
response.body?.destroy?.();
throw new Error('brand asset is too large');
}
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
const chunks = [];
let total = 0;
for await (const value of response.body) {
total += value.byteLength;
if (total > maximum) {
response.body.destroy?.();
throw new Error('brand asset is too large');
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
if (!response.body?.getReader) {
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > maximum) throw new Error('brand asset is too large');
return buffer;
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maximum) {
await reader.cancel();
throw new Error('brand asset is too large');
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
// Read only the bounded head, independent of network chunk boundaries. Scan
// bytes once so many tiny chunks cannot cause repeated concatenation/rescanning.
// This is a boundary scanner, not a DOM parser: comments, quoted attributes and
// raw-text elements must not turn a literal </head> into an early stop.
async function readHtmlHead(response, maximum) {
const chunks = response.body && typeof response.body[Symbol.asyncIterator] === 'function'
? response.body : [await readLimited(response, maximum)];
const buffer = Buffer.alloc(maximum);
let total = 0;
let tagStart = -1;
let quote = 0;
let comment = false;
let rawClosing = '';
let matched = 0;
for await (const value of chunks) {
const chunk = Buffer.from(value);
const length = Math.min(chunk.length, maximum - total);
chunk.copy(buffer, total, 0, length);
for (let offset = 0; offset < length; offset++) {
const byte = chunk[offset];
const position = total + offset;
if (comment) {
if (byte === 0x3e && buffer[position - 1] === 0x2d && buffer[position - 2] === 0x2d) comment = false;
continue;
}
if (rawClosing) {
const lower = byte >= 65 && byte <= 90 ? byte + 32 : byte;
if (matched === rawClosing.length && [9, 10, 12, 13, 32, 47, 62].includes(byte)) {
tagStart = position - matched;
rawClosing = '';
matched = 0;
} else {
matched = lower === rawClosing.charCodeAt(matched) ? matched + 1 : (byte === 0x3c ? 1 : 0);
continue;
}
}
if (tagStart < 0) {
if (byte === 0x3c) tagStart = position;
continue;
}
if (position === tagStart + 1 && !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || [33, 47, 63].includes(byte))) {
tagStart = byte === 0x3c ? position : -1;
continue;
}
if (position === tagStart + 3 && buffer[tagStart + 1] === 0x21 && buffer[tagStart + 2] === 0x2d && byte === 0x2d) {
comment = true;
tagStart = -1;
continue;
}
if (quote) {
if (byte === quote) quote = 0;
continue;
}
if (byte === 0x22 || byte === 0x27) {
quote = byte;
continue;
}
if (byte === 0x3e) {
const tag = buffer.toString('utf8', tagStart, position + 1);
if (/^<\/head[\t\n\f\r ]*>$/i.test(tag)) {
response.body?.destroy?.();
return buffer.toString('utf8', 0, position + 1);
}
const raw = /^<(script|style|title|textarea|xmp|iframe|noembed|noframes)(?=[\t\n\f\r />])/i.exec(tag);
if (raw) rawClosing = `</${raw[1].toLowerCase()}`;
tagStart = -1;
}
}
total += length;
if (chunk.length > length) {
response.body?.destroy?.();
throw new Error('brand asset is too large');
}
}
return buffer.toString('utf8', 0, total);
}
function attribute(tag, name) {
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
}
// HTML numeric references in the C1 range use the legacy Windows-1252 mapping.
// https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
const HTML_C1_REFERENCES = [
0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021,
0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f,
0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
];
const BASIC_HTML_REFERENCES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' };
function decodeIconHref(value) {
// Decode only after extracting the attribute, in one pass. Leave percent
// escapes to URL parsing and do not reinterpret decoded quotes as markup.
return value.replace(/&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|&(amp|AMP|quot|QUOT|lt|LT|gt|GT)(?:;|(?![A-Za-z0-9=]))|&(apos);/g,
(_match, hex, decimal, named, apostrophe) => {
if (named || apostrophe) return BASIC_HTML_REFERENCES[(named || apostrophe).toLowerCase()];
let point = Number.parseInt(hex || decimal, hex ? 16 : 10);
if (point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return '\uFFFD';
if (point >= 0x80 && point <= 0x9f) point = HTML_C1_REFERENCES[point - 0x80];
return String.fromCodePoint(point);
});
}
function iconCandidates(html, pageUrl) {
const candidates = [];
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
const tag = match[0];
const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
const href = decodeIconHref(attribute(tag, 'href'));
if (!href) continue;
try {
const url = new URL(href, pageUrl);
if (!['https:', 'http:'].includes(url.protocol)) continue;
const type = attribute(tag, 'type').toLocaleLowerCase('en-US');
const sizes = attribute(tag, 'sizes');
const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
.reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
+ (rel.includes('apple-touch-icon') ? 500000 : 0)
+ area;
candidates.push({ url, score });
} catch {
// A malformed icon candidate is ignored; the deterministic fallback remains available.
}
}
candidates.sort((left, right) => right.score - left.score);
const fallback = new URL('/favicon.ico', pageUrl);
const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate]));
unique.delete(fallback.href);
return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
}
async function imageData(response) {
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
const allowed = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/x-icon',
'image/vnd.microsoft.icon',
]);
if (!allowed.has(contentType)) {
response.body?.destroy?.();
throw new Error(`unsupported brand image type ${contentType || 'unknown'}`);
}
const buffer = await readLimited(response, MAX_IMAGE_BYTES);
const signatureMatches = contentType === 'image/png'
? buffer.length >= 45
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
&& buffer.readUInt32BE(8) === 13
&& buffer.toString('ascii', 12, 16) === 'IHDR'
&& buffer.readUInt32BE(16) > 0
&& buffer.readUInt32BE(20) > 0
&& buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND'
: (contentType === 'image/jpeg'
? buffer.length >= 20
&& buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
&& buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9
: (contentType === 'image/webp'
? buffer.length >= 16
&& buffer.toString('ascii', 0, 4) === 'RIFF'
&& buffer.toString('ascii', 8, 12) === 'WEBP'
&& buffer.readUInt32LE(4) + 8 <= buffer.length
: buffer.length >= 22
&& buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0
&& buffer.readUInt16LE(4) > 0
&& 6 + buffer.readUInt16LE(4) * 16 <= buffer.length));
if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
return {
dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
sha256: createHash('sha256').update(buffer).digest('hex'),
contentType,
};
}
async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
const sourceUrl = new URL(value);
const fallback = (reason) => ({
id: sourceUrl.hostname,
title: sourceUrl.hostname,
category: 'link',
kind: 'fallback',
status: 'unavailable',
sourceUrl: sourceUrl.href,
reason,
});
try {
const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline);
const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US');
if (pageType.startsWith('image/')) {
const image = await imageData(page.response);
return {
id: sourceUrl.hostname,
title: sourceUrl.hostname,
category: 'link',
kind: 'remote',
status: 'captured',
sourceUrl: sourceUrl.href,
resolvedUrl: page.finalUrl.href,
...image,
};
}
if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) {
page.response.body?.destroy?.();
return fallback('linked page is not HTML');
}
const html = await readHtmlHead(page.response, MAX_HTML_BYTES);
const iconErrors = [];
for (const candidate of iconCandidates(html, page.finalUrl)) {
try {
const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
const image = await imageData(fetched.response);
return {
id: sourceUrl.hostname,
title: sourceUrl.hostname,
category: 'link',
kind: 'remote',
status: 'captured',
sourceUrl: sourceUrl.href,
resolvedUrl: fetched.finalUrl.href,
...image,
};
} catch (error) {
iconErrors.push(error);
// Try the next declared favicon before using the generic link mark.
}
}
const usefulError = iconErrors.find((error) => /unsupported brand (?:image type|content encoding)/i.test(error?.message))
|| iconErrors.at(-1);
return fallback(usefulError?.message || 'no usable site icon was found');
} catch (error) {
return fallback(error.message);
}
}
export async function captureBrandReference(value) {
const url = asUrl(value);
if (!url) throw new Error('brand capture requires one HTTP(S) URL');
validateUrlShape(url);
const preset = findBrandMark(url.href);
if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } };
const resolved = await captureRemoteBrand(url.href);
if (resolved.status !== 'captured' || !resolved.sha256) {
throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`);
}
return {
brand: { url: url.href, sha256: resolved.sha256 },
resolved,
};
}
function remoteBrand(value, cache, deadline) {
const key = new URL(value).href;
if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline));
return cache.get(key);
}
function suggestions(value) {
const needle = lookupForms(value)[0] || '';
return BRAND_MARKS.map((mark) => ({
id: mark.id,
score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1,
})).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
.slice(0, 5)
.map((entry) => entry.id);
}
async function mapConcurrent(values, limit, visit) {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
while (cursor < values.length) {
const index = cursor;
cursor += 1;
await visit(values[index], index);
}
});
await Promise.all(workers);
}
export async function prepareDiagramBrandMarks(diagramType, diagram) {
const collection = COLLECTIONS[diagramType];
const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
const unknown = [];
const remoteByUrl = new Map();
const deadline = Date.now() + captureTimeoutMilliseconds();
await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => {
if (!node.brand) return;
if (typeof node.brand === 'object') {
const url = asUrl(node.brand.url);
const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null;
if (!resolved || resolved.status !== 'captured') {
unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`);
return;
}
if (resolved.sha256 !== node.brand.sha256) {
unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`);
return;
}
node[RESOLVED_MARK] = resolved;
RESOLVED_BY_NODE.set(node, resolved);
return;
}
const preset = findBrandMark(node.brand);
if (preset) {
const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source };
node[RESOLVED_MARK] = resolved;
RESOLVED_BY_NODE.set(node, resolved);
return;
}
const url = asUrl(node.brand);
if (url) {
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
return;
}
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
});
if (unknown.length) {
throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({
code: message.includes('is an unpinned URL') ? 'brand/unpinned-url'
: (message.includes('digest changed') ? 'brand/digest-mismatch'
: (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')),
severity: 'error',
message,
subject: { diagramType, collection },
evidence: {},
supportedFixes: message.includes('is an unpinned URL')
? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
: ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
})));
}
}
export function brandMarkFor(node) {
return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null;
}
export function brandMetadataFor(node) {
const mark = brandMarkFor(node);
return mark ? {
brand: mark.title,
brandId: mark.id,
brandStatus: mark.status,
brandSource: mark.sourceUrl,
} : {};
}
export function brandLabelFitWidth(node, width) {
return brandMarkFor(node) ? Math.max(1, width - 48) : width;
}
export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') {
if (!brandMarkFor(node)) return null;
const available = width - 48;
const required = textUnits(node.label) * minimumFontSize * 0.6;
if (available >= required) return null;
return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
+ `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
}
function markAttrs(mark) {
return [
`data-brand-mark="${esc(mark.id)}"`,
`data-brand-title="${esc(mark.title)}"`,
`data-brand-status="${esc(mark.status)}"`,
mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '',
mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '',
].filter(Boolean).join(' ');
}
export function renderBrandMark(node, { x, y, size = 16 } = {}) {
const mark = brandMarkFor(node);
if (!mark) return '';
const inset = 3;
let content;
if (mark.kind === 'preset') {
const scale = (size - inset * 2) / mark.viewBox;
content = `<path d="${esc(mark.path)}" transform="translate(${inset} ${inset}) scale(${scale})" fill="#${esc(mark.hex)}"/>`;
} else if (mark.kind === 'remote') {
content = `<image href="${esc(mark.dataUrl)}" x="${inset}" y="${inset}" width="${size - inset * 2}" height="${size - inset * 2}" preserveAspectRatio="xMidYMid meet"/>`;
} else {
const scale = size / 20;
content = `<g transform="scale(${scale})" class="brand-mark-fallback"><circle cx="10" cy="10" r="5.2"/><path d="M4.8 10h10.4M10 4.8c1.6 1.6 2.4 3.3 2.4 5.2s-.8 3.6-2.4 5.2M10 4.8C8.4 6.4 7.6 8.1 7.6 10s.8 3.6 2.4 5.2"/></g>`;
}
return `<g aria-hidden="true" ${markAttrs(mark)} class="brand-mark" transform="translate(${x} ${y})">
<rect width="${size}" height="${size}" rx="4" class="brand-mark-badge"/>
${content}
<rect width="${size}" height="${size}" rx="4" class="brand-mark-frame"/>
</g>`;
}
+451
View File
@@ -0,0 +1,451 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { applyTemplate, renderCards, esc } from './utils.mjs';
import { validateSchema } from './validator.mjs';
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
import { validateEngineeringProfile } from './engineering-profiles.mjs';
import {
resolveOutputPath,
validateAuthoredOutputPath,
} from './output-path.mjs';
import {
captureAtomicOutput,
captureRegularFileBinding,
publishRegularFileBinding,
releaseRegularFileBinding,
removeOwnedRegularFile,
verifyAtomicOutput,
} from './atomic-output.mjs';
import { resolveLocale, translateMessage, registerLocale, SUPPORTED_LOCALES } from './i18n.mjs';
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
const outputPathGuards = new Map();
let renderCandidateSequence = 0;
// meta.locale is renderer-owned Viewer UI, not authored content.
// en and zh-CN ship as built-in catalogs.
// Any other tag needs meta.translations (validated against the English
// message-key set, layered over English per-key so partial/invalid entries
// never break rendering) or it falls back to the English Viewer chrome —
// the same "omit locale, disclose the fallback" contract as before, just
// resolved from data instead of a hard-coded enum. See i18n.mjs.
function applyLocaleTranslations(diagramType, diagram) {
const locale = diagram.meta?.locale;
if (!locale) return;
const translations = diagram.meta?.translations;
if (translations && Object.keys(translations).length) {
const report = registerLocale(locale, translations);
if (report.missingKeys.length || report.unknownKeys.length || report.placeholderMismatches.length) {
recordDiagnostic({
code: 'i18n/translation-coverage',
severity: 'warning',
message: `meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`,
subject: { diagramType, path: '/meta/translations' },
evidence: {
missingKeys: report.missingKeys.slice(0, 10),
missingKeysTotal: report.missingKeys.length,
unknownKeys: report.unknownKeys.slice(0, 10),
unknownKeysTotal: report.unknownKeys.length,
placeholderMismatches: report.placeholderMismatches.slice(0, 10),
placeholderMismatchesTotal: report.placeholderMismatches.length,
},
supportedFixes: ['Add the missing keys to meta.translations.', 'Match each translation\'s {placeholders} to the English source string.'],
});
// Coverage is a fact about this render, not just a diagnostic-mode
// artifact: print it to stderr unconditionally so `render`/`deliver`/
// `validate` disclose the fallback even without ARCHIFY_DIAGNOSTIC_FORMAT.
console.warn(`archify: meta.translations for locale ${JSON.stringify(locale)} covers ${report.coveredKeys}/${report.totalKeys} renderer-owned messages (${Math.round(report.coverage * 100)}%); uncovered keys fall back to English.`);
}
} else if (!SUPPORTED_LOCALES.includes(locale)) {
recordDiagnostic({
code: 'i18n/locale-fallback',
severity: 'warning',
message: `meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`,
subject: { diagramType, path: '/meta/locale' },
supportedFixes: ['Supply meta.translations for this locale.', `Use a built-in locale: ${SUPPORTED_LOCALES.join(', ')}.`],
});
console.warn(`archify: meta.locale ${JSON.stringify(locale)} has no built-in catalog and no meta.translations; the Viewer chrome and <html lang> fall back to English.`);
}
}
// Common CLI head: node render-<type>.mjs [input.json] [output.html]
// Keep this synchronous because callers also use it to establish the guarded
// output path before testing a last-moment filesystem alias change.
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
// Compilers also import this module for SVG helpers. Only CLI execution
// should install a process-level handler, before reading or validating input.
installRendererDiagnosticBoundary();
const skillRoot = path.resolve(rendererDir, '../..');
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
let input;
try {
input = fs.readFileSync(inputPath, 'utf8');
} catch (error) {
if (!isFilesystemError(error)) throw error;
const message = `Input could not be read: ${error.message}`;
throwDiagnosticError(message, [{
code: 'input/read', message,
subject: { input: inputPath },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['provide one readable JSON input file'],
}]);
}
let diagram;
try {
diagram = JSON.parse(input);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
const message = `Input JSON could not be parsed: ${error.message}`;
throwDiagnosticError(message, [{
code: 'input/json-parse', message,
subject: { input: inputPath },
evidence: { reason: error.message },
supportedFixes: ['repair the JSON syntax and run validation again'],
}]);
}
const authoredOutput = diagram?.meta?.output;
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
validateSchema(diagramType, diagram);
applyLocaleTranslations(diagramType, diagram);
validateCrossCollectionContracts(diagramType, diagram);
validateEngineeringProfile(diagramType, diagram);
const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
const outputRequest = {
requestedOutput: argv[3],
authoredOutput: diagram.meta?.output,
defaultOutput: `${diagramType}.html`,
inputPaths: [inputPath],
cwd: process.cwd(),
};
let outPath;
try {
({ outputPath: outPath } = resolveOutputPath(outputRequest));
} catch (error) {
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
}
outputPathGuards.set(outPath, outputRequest);
return { diagram, template, outPath, sourceEvidence };
}
// Brand URL capture is the only asynchronous authoring step. Typed renderers
// opt into it through this wrapper without changing loadDiagram's long-lived
// synchronous safety contract.
export async function loadDiagramWithBrandMarks(options) {
const loaded = loadDiagram(options);
await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
return loaded;
}
const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
function isFilesystemError(error) {
return typeof error?.code === 'string'
&& typeof error?.syscall === 'string'
&& typeof error?.errno === 'number';
}
function throwOutputError(error, output) {
if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
const message = `Output could not be written: ${error.message}`;
throwDiagnosticError(message, [{
code: 'output/write', message,
subject: { output },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
}]);
}
function throwAtomicOutputFailure(result, output) {
const reason = result.reason || { code: 'unclassified' };
const changed = result.status === 'different';
const candidate = reason.code.startsWith('candidate-');
const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
const message = changed
? 'Output target changed while the rendered artifact was being prepared.'
: nonRegular
? candidate
? 'Temporary output candidate is no longer a regular file.'
: 'Output already exists and is not a regular file.'
: hardlinked
? candidate
? 'Temporary output candidate has multiple hard-link names.'
: 'Output already exists through multiple hard-link names.'
: 'Output target stability could not be determined safely before commit.';
throwDiagnosticError(message, [{
code: changed
? 'output/target-changed'
: nonRegular
? 'output/target-not-regular-file'
: hardlinked
? 'output/target-hardlinked'
: 'output/target-indeterminate',
message,
subject: { output },
evidence: { relation: reason },
supportedFixes: [hardlinked
? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
: 'retry after other processes stop replacing or redirecting the output path'],
}]);
}
function stageRenderedHtml(outputPath, html, mode) {
for (let attempt = 0; attempt < 100; attempt += 1) {
renderCandidateSequence += 1;
const candidatePath = path.join(
path.dirname(outputPath),
`.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
);
let descriptor;
let identity;
try {
const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
descriptor = fs.openSync(
candidatePath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
mode ?? 0o666,
);
let metadata;
try {
metadata = fs.fstatSync(descriptor, { bigint: true });
} catch (error) {
// A transient first inspection failure must not strand the exclusive
// candidate. A successful retry binds cleanup to the still-open file;
// if both inspections fail, preserving the unknown entry is safer.
try {
const retry = fs.fstatSync(descriptor, { bigint: true });
if (retry.isFile() && retry.ino !== 0n) {
identity = { device: retry.dev, inode: retry.ino };
}
} catch {}
throw error;
}
if (!metadata.isFile() || metadata.ino === 0n) {
throw new Error('Temporary render candidate identity could not be verified safely.');
}
identity = { device: metadata.dev, inode: metadata.ino };
fs.writeFileSync(descriptor, html);
// Creation modes are filtered through the process umask. An atomic
// replacement must retain the exact permissions of an existing target,
// while a brand-new target should keep normal umask behavior.
if (mode !== null) fs.fchmodSync(descriptor, mode);
fs.closeSync(descriptor);
descriptor = undefined;
return { candidatePath, identity };
} catch (error) {
if (descriptor !== undefined) {
try { fs.closeSync(descriptor); } catch {}
}
if (error.code === 'EEXIST') continue;
if (identity) {
const cleanup = removeOwnedRegularFile(candidatePath, identity);
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
cleanupError.cause = error;
throw cleanupError;
}
}
throw error;
}
}
const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
error.code = 'EEXIST';
error.errno = -17;
error.syscall = 'open';
throw error;
}
// Common CLI tail: fill the template and write the standalone HTML file.
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
const outputGuard = outputPathGuards.get(outPath);
const html = applyTemplate(template, {
title: meta.title,
subtitle: meta.subtitle,
svg,
cards: renderCards(cards),
locale: meta.locale,
visualPreset: meta.visual_preset || 'classic',
sourceEvidence,
});
let candidatePath;
let candidateIdentity;
let candidateBinding;
try {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const outputCapture = captureAtomicOutput(outPath);
if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
outputCapture.commitPath,
html,
outputCapture.mode,
));
// The renderer may spend substantial time building HTML after loadDiagram
// establishes the guard. Re-run it after staging so a last-moment alias
// cannot redirect the commit onto an input file.
if (outputGuard) resolveOutputPath(outputGuard);
const candidateCapture = captureRegularFileBinding(candidatePath, {
subject: 'candidate',
expectedSha256: createHash('sha256').update(html).digest('hex'),
expectedBytes: Buffer.byteLength(html),
expectedIdentity: candidateIdentity,
...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
});
if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
candidateBinding = candidateCapture.binding;
const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
const publication = publishRegularFileBinding(
candidateBinding,
candidatePath,
outputCapture.snapshot,
{ subject: 'candidate' },
);
if (!['committed', 'committed-with-warning'].includes(publication.status)) {
throwAtomicOutputFailure(publication, outPath);
}
const releasedCandidate = releaseRegularFileBinding(candidateBinding);
candidateBinding = undefined;
if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
candidatePath = undefined;
candidateIdentity = undefined;
} catch (error) {
throwOutputError(error, outPath);
} finally {
outputPathGuards.delete(outPath);
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
if (candidatePath && candidateIdentity) {
const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
throwAtomicOutputFailure(cleanup, outPath);
}
}
}
console.log(outPath);
}
const SEMANTIC_COLLECTIONS = {
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
};
const RELATIONSHIP_COLLECTIONS = {
architecture: 'connections',
workflow: 'edges',
sequence: 'messages',
dataflow: 'flows',
lifecycle: 'transitions',
};
// Relationship IDs are optional for backwards compatibility, but once an
// author supplies one it becomes the durable identity used by viewer links.
// Keep uniqueness enforcement in the shared zero-install path so every typed
// renderer fails the same way even when development dependencies are absent.
export function validateRelationshipIds(diagramType, diagram) {
const collection = RELATIONSHIP_COLLECTIONS[diagramType];
const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
const seen = new Set();
const problems = [];
relationships.forEach((relationship, index) => {
if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
if (seen.has(relationship.id)) {
problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
}
seen.add(relationship.id);
});
if (problems.length) {
throwDiagnosticProblems('Relationship identity validation failed', problems, {
code: 'relationship/duplicate-id',
subject: { diagramType, collection },
});
}
}
// Share relationship-ID semantic checks between the loader
// and workflow compiler without performing filesystem operations (see #429).
export function validateCrossCollectionContracts(diagramType, diagram) {
validateRelationshipIds(diagramType, diagram);
}
// Accessible name for the generated diagram SVG.
export function svgRootAttrs(meta, explicitQualityProfile) {
const animation = meta.animation === 'trace' ? ' data-animation="trace"' : '';
const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
const engineeringProfile = meta.engineering_profile
? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
: '';
const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
}
// Keep the accessible name inside the SVG so it survives standalone SVG
// export and embedding. The fixed IDs are deterministic because an Archify
// artifact intentionally contains one primary diagram SVG.
export function svgAccessibleText(meta, kind) {
const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
}
export function animateAttr(meta, kind, step) {
if (meta.animation !== 'trace') return '';
// Ambient trace must finish inside the fixed six-second WebM capture. The
// cap affects visual delay only; authored order and semantic identity stay
// untouched in the JSON, DOM, and relationship contracts.
const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
return ` data-animate="${kind}" style="--step:${safeStep}"`;
}
// Stable semantic hooks for the standalone HTML explorer. IDs already pass
// the schema's conservative identifier pattern; escape again at the markup
// boundary so these helpers remain safe if that contract expands later.
export function focusNodeAttrs(id, label, metadata = {}, locale) {
const optional = [
['data-node-kind', metadata.kind],
['data-node-sublabel', metadata.sublabel],
['data-node-tag', metadata.tag],
['data-node-context', metadata.context],
['data-node-brand', metadata.brand],
['data-node-brand-id', metadata.brandId],
['data-node-brand-status', metadata.brandStatus],
['data-node-brand-source', metadata.brandSource],
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
.map(([name, value]) => ` ${name}="${esc(String(value))}"`)
.join('');
const detail = [metadata.sublabel, metadata.context, metadata.brand]
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
.join(', ');
const aria = detail
? translateMessage(locale, 'node.focus.detail', { label, detail })
: translateMessage(locale, 'node.focus', { label });
return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
}
// Native SVG titles preserve a compact details-on-demand fallback when the
// canonical SVG is embedded inline outside the full Archify viewer.
export function focusNodeTitle(label, metadata = {}) {
const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
return `<title>${esc(parts.join(' · '))}</title>`;
}
export function focusEdgeAttrs(from, to, label, key, id) {
const named = label ? ` data-edge-label="${esc(label)}"` : '';
const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
const identified = id !== undefined && id !== null && String(id).trim() !== ''
? ` data-edge-id="${esc(String(id))}"`
: '';
return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;
}
@@ -0,0 +1,132 @@
export const DESKTOP_READABILITY_VIEWPORT = Object.freeze({ width: 1440, height: 900 });
export const DESKTOP_READER_MIN_WIDTH = 960;
export const DESKTOP_READER_HORIZONTAL_CHROME = 30;
export const DESKTOP_READER_DIAGRAM_WIDTH = DESKTOP_READER_MIN_WIDTH - DESKTOP_READER_HORIZONTAL_CHROME;
export const MIN_PROJECTED_NODE_TEXT_PX = 6;
export const DECLARED_WIDE_READER_CONTRACT = 'declared-wide-v1';
export const DECLARED_WIDE_READER_RATIO = 1.55;
export const DECLARED_WIDE_READER_MAX_WIDTH = 1920;
export const DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX = 64;
export const DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX = 30;
export function projectedNodeTextPx(sourceFontPx, viewBoxWidth, diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH) {
if (![sourceFontPx, viewBoxWidth, diagramWidth].every(Number.isFinite) || viewBoxWidth <= 0 || diagramWidth <= 0) {
return Number.NaN;
}
return sourceFontPx * Math.min(1, diagramWidth / viewBoxWidth);
}
export function minimumReadableSourceTextPx(
viewBoxWidth,
diagramWidth = DESKTOP_READER_DIAGRAM_WIDTH,
minimumProjectedPx = MIN_PROJECTED_NODE_TEXT_PX,
) {
if (![viewBoxWidth, diagramWidth, minimumProjectedPx].every(Number.isFinite)
|| viewBoxWidth <= 0
|| diagramWidth <= 0
|| minimumProjectedPx <= 0) {
return Number.NaN;
}
return minimumProjectedPx / Math.min(1, diagramWidth / viewBoxWidth);
}
// This is deliberately separate from the legacy 930px projection. Architecture
// boundary convergence depends on that legacy default, while only a recognized
// v2 wide Reader may use this declared-width proof.
export function declaredWideReadabilityBudget({
viewBoxWidth,
viewBoxHeight,
minimumSourceTextPx,
requestedMinimumTextPx,
viewportWidth = DESKTOP_READABILITY_VIEWPORT.width,
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
minimumReaderWidth = DESKTOP_READER_MIN_WIDTH,
maximumReaderWidth = DECLARED_WIDE_READER_MAX_WIDTH,
} = {}) {
const values = [
viewBoxWidth, viewBoxHeight, minimumSourceTextPx, requestedMinimumTextPx,
viewportWidth, bodyHorizontalPx, diagramHorizontalPx, minimumReaderWidth, maximumReaderWidth,
];
if (!values.every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0
|| minimumSourceTextPx <= 0 || requestedMinimumTextPx <= 0 || viewportWidth <= 0
|| bodyHorizontalPx < 0 || diagramHorizontalPx < 0 || minimumReaderWidth <= 0
|| maximumReaderWidth < minimumReaderWidth || viewBoxWidth / viewBoxHeight < DECLARED_WIDE_READER_RATIO) {
return null;
}
const requestedTargetPx = Math.max(MIN_PROJECTED_NODE_TEXT_PX, requestedMinimumTextPx);
const requestedScale = Math.min(1, requestedTargetPx / minimumSourceTextPx);
const desiredReaderWidth = Math.max(minimumReaderWidth, viewBoxWidth * requestedScale + diagramHorizontalPx);
const viewportCap = Math.max(0, viewportWidth - bodyHorizontalPx);
const cap = Math.min(maximumReaderWidth, viewportCap);
const actualReaderWidth = Math.min(desiredReaderWidth, cap);
const guaranteedSvgWidth = Math.max(0, actualReaderWidth - diagramHorizontalPx);
const projectedMinimumTextPx = projectedNodeTextPx(minimumSourceTextPx, viewBoxWidth, guaranteedSvgWidth);
const limit = actualReaderWidth < desiredReaderWidth
? (viewportCap <= maximumReaderWidth ? 'viewport-cap' : 'reader-cap')
: 'source-size';
return {
requestedTargetPx,
requestedScale,
desiredReaderWidth,
viewportCap,
maximumReaderWidth,
actualReaderWidth,
guaranteedSvgWidth,
projectedMinimumTextPx,
hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
hardFloorMet: projectedMinimumTextPx >= MIN_PROJECTED_NODE_TEXT_PX,
requestedTargetMet: projectedMinimumTextPx >= requestedMinimumTextPx,
limit,
};
}
// Vertical chrome that always stacks with the SVG at the 1440x900 desktop
// viewport, measured from the delivered Viewer with the shortest one-line
// header and no cards: body padding 12, header 39, diagram padding/border 75.
// Cards are excluded so the prediction stays a lower bound.
export const DESKTOP_FIXED_VERTICAL_CHROME_PX = Object.freeze({ body: 12, header: 39, diagram: 75 });
// A canvas the Reader can neither narrow (viewBox ratio below the wide
// threshold) nor scroll readably (no intrinsic-height fit) renders at the full
// reader width, so its page height is a function of the viewBox alone. Returns
// null when the Reader has a way to fit the page; otherwise the certain
// overflow at 1440x900 before any cards are counted.
export function predictedFixedWidthOverflow({
viewBoxWidth,
viewBoxHeight,
readerFit,
diagramType,
viewport = DESKTOP_READABILITY_VIEWPORT,
bodyHorizontalPx = DECLARED_WIDE_REFERENCE_BODY_HORIZONTAL_PX,
diagramHorizontalPx = DECLARED_WIDE_REFERENCE_DIAGRAM_HORIZONTAL_PX,
chrome = DESKTOP_FIXED_VERTICAL_CHROME_PX,
} = {}) {
if (![viewBoxWidth, viewBoxHeight].every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0) return null;
const ratio = viewBoxWidth / viewBoxHeight;
if (readerFit === 'intrinsic-height'
|| (readerFit === 'authored-height' && diagramType === 'architecture')
|| ratio >= DECLARED_WIDE_READER_RATIO) return null;
const svgWidthPx = viewport.width - bodyHorizontalPx - diagramHorizontalPx;
const svgHeightPx = Math.round(svgWidthPx * viewBoxHeight / viewBoxWidth);
const fixedChromePx = chrome.body + chrome.header + chrome.diagram;
const pageHeightPx = svgHeightPx + fixedChromePx;
if (pageHeightPx <= viewport.height) return null;
return {
viewportWidth: viewport.width,
viewportHeight: viewport.height,
ratio: Math.round(ratio * 100) / 100,
wideRatio: DECLARED_WIDE_READER_RATIO,
svgWidthPx,
svgHeightPx,
fixedChromePx,
pageHeightPx,
overflowPx: pageHeightPx - viewport.height,
};
}
export function describeFixedWidthOverflow(issue) {
const maximumViewBoxHeight = Math.floor(issue.viewBoxWidth / issue.wideRatio);
const wideViewBoxWidth = Math.ceil(issue.viewBoxHeight * issue.wideRatio);
return `Preserve every node, relationship, and label. This ${issue.viewBoxWidth}x${issue.viewBoxHeight} canvas (ratio ${issue.ratio}) declares no intrinsic-height fit and is below the ${issue.wideRatio} wide ratio, so the desktop Reader can neither narrow it nor accept vertical scroll: it renders ${issue.svgHeightPx}px tall at the full ${issue.svgWidthPx}px width and the page reaches ${issue.pageHeightPx}px before cards against ${issue.viewportHeight}px, a certain visual-check failure. Either compact vertical spacing so meta.viewBox height is at most ${maximumViewBoxHeight} at this width, or spread content sideways so the width is at least ${wideViewBoxWidth} at this height; for architecture, omitting meta.viewBox lets the renderer size the canvas and declare the fit.`;
}
+173
View File
@@ -0,0 +1,173 @@
import fs from 'node:fs';
import path from 'node:path';
const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
const recorded = [];
const recordedMessages = new Set();
const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
let recordingSuppressionDepth = 0;
function plainObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
}
function normalizedDiagnostic(diagnostic) {
const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
return {
code: String(diagnostic?.code || 'internal/unclassified'),
severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
message,
subject: plainObject(diagnostic?.subject),
evidence: plainObject(diagnostic?.evidence),
supportedFixes: Array.isArray(diagnostic?.supportedFixes)
? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
: [],
...(Array.isArray(diagnostic?.suppresses) ? {
suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
} : {}),
};
}
export function recordDiagnostic(diagnostic) {
if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
const normalized = normalizedDiagnostic(diagnostic);
if (recordedMessages.has(normalized.message)) return;
recordedMessages.add(normalized.message);
recorded.push(normalized);
}
export function withDiagnosticRecordingSuppressed(callback) {
recordingSuppressionDepth += 1;
try {
return callback();
} finally {
recordingSuppressionDepth -= 1;
}
}
export function throwDiagnosticError(message, diagnostics) {
for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
const error = new Error(message);
error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
throw error;
}
export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {}, diagnostics: details = [] } = {}) {
const messages = (problems || []).map((problem) => String(problem));
const byMessage = new Map(details.map((entry) => [entry.message, entry]));
const diagnostics = messages.map((message) => normalizedDiagnostic(byMessage.get(message) || {
code,
severity: 'error',
message,
subject,
evidence: {},
supportedFixes: [],
}));
throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
}
function fallbackDiagnostic(error) {
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
return normalizedDiagnostic({
code: 'internal/unclassified',
severity: 'error',
message: error?.message || 'Renderer failed without a diagnostic.',
subject: { input },
evidence: { errorName: error?.name || 'Error' },
supportedFixes: [],
});
}
export function rendererFailure(error) {
const attached = Array.isArray(error?.archifyDiagnostics)
? error.archifyDiagnostics.map(normalizedDiagnostic)
: [];
// Earlier diagnostics do not classify a later, unrelated implementation error.
const diagnostics = attached.length
? (recorded.length ? recorded : attached)
: [fallbackDiagnostic(error)];
return {
schemaVersion: 1,
ok: false,
source: 'renderer',
error: error?.message || 'Renderer failed without a diagnostic.',
diagnostics,
};
}
// Match the public CLI's text format without making its standalone doctor
// bootstrap depend on this renderer runtime being present.
function formatDiagnostics(error, diagnostics = []) {
if (!diagnostics.length) return error;
return [
error,
...diagnostics.map((entry) => {
const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : '';
return `[${entry.code}] ${entry.message}${fix}`;
}),
].join('\n');
}
const readerSignal = new Int32Array(new SharedArrayBuffer(4));
function waitForReader() {
// Sleep instead of spinning on EAGAIN. A retry budget looks like a safeguard
// and behaves like a truncation gate: a spinning loop burns thousands of
// attempts in a few milliseconds, so a reader that is merely slow to start
// exhausts it and loses the tail of the receipt. Waiting costs nothing while
// the reader catches up, and a reader that goes away raises EPIPE, which the
// caller already treats as a real write failure.
Atomics.wait(readerSignal, 0, 0, 1);
}
export function installRendererDiagnosticBoundary() {
if (globalThis[boundaryKey]) return;
globalThis[boundaryKey] = true;
if (!DIAGNOSTIC_MODE) {
process.once('uncaughtException', (error) => {
// Only errors classified at their operation boundary are author-facing.
// Preserve Node's debugging information for unexpected implementation errors.
if (!error?.archifyDiagnostics?.length) {
// The once-listener is already removed. Rethrow outside the exception
// handler so Node retains its normal stack and exit code (not code 7).
process.nextTick(() => { throw error; });
return;
}
const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`;
process.stderr.once('error', () => process.exit(1));
process.stderr.write(payload, () => process.exit(1));
});
return;
}
process.on('uncaughtException', (error) => {
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
try {
// stderr may be a pipe. fs.writeSync performs a PARTIAL write once the
// payload exceeds the OS pipe buffer (8KB on macOS) and returns the byte
// count actually written. Ignoring that return value silently truncated
// large diagnostic payloads mid-JSON, so the parent CLI's JSON.parse
// failed and the fail-closed boundary reported internal/unclassified
// instead of the diagnostics we had already computed. Loop until drained.
// A full pipe also makes writeSync throw EAGAIN; wait for the reader
// rather than treat it as a stream failure, otherwise the tail is
// dropped just the same.
const buffer = Buffer.from(payload, 'utf8');
let written = 0;
while (written < buffer.length) {
try {
written += fs.writeSync(process.stderr.fd, buffer, written, buffer.length - written);
} catch (writeError) {
if (writeError?.code === 'EAGAIN') {
waitForReader();
continue;
}
throw writeError;
}
}
} catch {
// The renderer is already failing. Avoid replacing its real error with a
// secondary stream failure; the parent CLI still has the exit status.
}
process.exit(1);
});
}
@@ -0,0 +1,157 @@
import { throwDiagnosticError } from './diagnostics.mjs';
const DEPLOYMENT_PROFILE = 'deployment-ownership';
const DEPLOYMENT_BOUNDARY_KINDS = new Set(['region', 'security-group']);
const PRIVATE_STATE_TYPES = new Set(['database']);
function subject(collection, index, item = {}) {
return {
diagramType: 'architecture',
profile: DEPLOYMENT_PROFILE,
collection,
index,
...(item.id ? { id: item.id } : {}),
};
}
function membership(boundaries, componentId, kind) {
return boundaries
.map((boundary, index) => ({ boundary, index }))
.filter(({ boundary }) => boundary.kind === kind && boundary.wraps.includes(componentId));
}
export function deploymentOwnershipDiagnostics(diagram) {
const components = Array.isArray(diagram.components) ? diagram.components : [];
const boundaries = (Array.isArray(diagram.boundaries) ? diagram.boundaries : [])
.map((boundary) => ({ ...boundary, wraps: Array.isArray(boundary.wraps) ? boundary.wraps : [] }));
const connections = Array.isArray(diagram.connections) ? diagram.connections : [];
const diagnostics = [];
for (const kind of DEPLOYMENT_BOUNDARY_KINDS) {
const count = boundaries.filter((boundary) => boundary.kind === kind).length;
if (count > 0) continue;
diagnostics.push({
code: 'engineering/deployment-boundary-kind',
severity: 'error',
message: `Deployment ownership requires at least one ${kind} boundary.`,
subject: subject('boundaries', -1),
evidence: { requiredKind: kind, found: count },
supportedFixes: [`add one ${kind} boundary with an explicit wraps list`],
});
}
components.forEach((component, index) => {
if (component.type === 'external') return;
if (typeof component.tag !== 'string' || component.tag.trim() === '') {
diagnostics.push({
code: 'engineering/deployment-owner-missing',
severity: 'error',
message: `Deployment component ${JSON.stringify(component.id)} does not name its owner in tag.`,
subject: subject('components', index, component),
evidence: { componentType: component.type, ownerField: 'tag' },
supportedFixes: [`set /components/${index}/tag to the responsible team or owner`],
});
}
const regions = membership(boundaries, component.id, 'region');
if (regions.length === 0) {
diagnostics.push({
code: 'engineering/deployment-region-scope',
severity: 'error',
message: `Deployment component ${JSON.stringify(component.id)} is not assigned to a region boundary.`,
subject: subject('components', index, component),
evidence: { componentType: component.type, regionMemberships: 0 },
supportedFixes: ['add the component id to the real region boundary wraps list'],
});
} else if (regions.length > 1) {
diagnostics.push({
code: 'engineering/deployment-region-ambiguous',
severity: 'error',
message: `Deployment component ${JSON.stringify(component.id)} belongs to more than one region boundary.`,
subject: subject('components', index, component),
evidence: {
componentType: component.type,
regions: regions.map(({ boundary, index: boundaryIndex }) => ({ boundaryIndex, label: boundary.label })),
},
supportedFixes: ['keep the component id in exactly one real region boundary wraps list'],
});
}
if (PRIVATE_STATE_TYPES.has(component.type)) {
const privateScopes = membership(boundaries, component.id, 'security-group');
if (privateScopes.length === 0) {
diagnostics.push({
code: 'engineering/deployment-private-state',
severity: 'error',
message: `Stateful component ${JSON.stringify(component.id)} is not assigned to a private security-group boundary.`,
subject: subject('components', index, component),
evidence: { componentType: component.type, privateMemberships: 0 },
supportedFixes: ['add the component id to the real private security-group boundary wraps list'],
});
}
}
});
boundaries.forEach((boundary, index) => {
if (boundary.kind !== 'security-group') return;
const members = boundary.wraps.map((id) => ({
id,
regions: membership(boundaries, id, 'region').map(({ boundary: region, index: boundaryIndex }) => ({
boundaryIndex,
label: region.label,
})),
}));
const regionIndexes = new Set(members.flatMap((member) => member.regions.map((region) => region.boundaryIndex)));
const consistent = members.length > 0
&& members.every((member) => member.regions.length === 1)
&& regionIndexes.size === 1;
if (consistent) return;
diagnostics.push({
code: 'engineering/deployment-private-region-consistency',
severity: 'error',
message: `Private boundary ${JSON.stringify(boundary.label)} must contain components from exactly one shared region.`,
subject: subject('boundaries', index, boundary),
evidence: { boundaryKind: boundary.kind, members },
supportedFixes: ['assign every private-boundary component to exactly one shared region boundary'],
});
});
connections.forEach((connection, index) => {
const crossedBoundaries = boundaries
.map((boundary, boundaryIndex) => ({
boundaryIndex,
kind: boundary.kind,
label: boundary.label,
fromInside: boundary.wraps.includes(connection.from),
toInside: boundary.wraps.includes(connection.to),
}))
.filter((boundary) => DEPLOYMENT_BOUNDARY_KINDS.has(boundary.kind) && boundary.fromInside !== boundary.toInside);
if (crossedBoundaries.length === 0 || (typeof connection.label === 'string' && connection.label.trim() !== '')) return;
diagnostics.push({
code: 'engineering/deployment-crossing-mechanism',
severity: 'error',
message: `Cross-boundary connection ${JSON.stringify(connection.id || `${connection.from}->${connection.to}`)} does not name its mechanism.`,
subject: subject('connections', index, connection),
evidence: {
from: connection.from,
to: connection.to,
crossedBoundaries: crossedBoundaries.map(({ boundaryIndex, kind, label }) => ({ boundaryIndex, kind, label })),
},
supportedFixes: [`set /connections/${index}/label to the real cross-boundary mechanism`],
});
});
return diagnostics;
}
export function validateEngineeringProfile(diagramType, diagram) {
const profile = diagram.meta?.engineering_profile;
if (!profile) return;
if (diagramType !== 'architecture' || profile !== DEPLOYMENT_PROFILE) return;
const diagnostics = deploymentOwnershipDiagnostics(diagram);
if (!diagnostics.length) return;
throwDiagnosticError(
`Engineering profile ${JSON.stringify(profile)} failed:\n${diagnostics.map((entry) => `- ${entry.message}`).join('\n')}`,
diagnostics,
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,134 @@
// Generated by scripts/generate-validators.mjs. Do not edit by hand.
import { validatePortablePath } from './portable-path.mjs';
"use strict";const workflowSchema = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/tt-a1i/archify/schemas/workflow.schema.json","title":"Archify Workflow Diagram","type":"object","additionalProperties":false,"required":["schema_version","diagram_type","meta","lanes","nodes","edges"],"properties":{"schema_version":{"enum":[1,2]},"diagram_type":{"const":"workflow"},"meta":{"type":"object","additionalProperties":false,"required":["title","output"],"properties":{"title":{"type":"string","minLength":1},"locale":{"$ref":"common.schema.json#/$defs/locale"},"translations":{"$ref":"common.schema.json#/$defs/translations"},"subtitle":{"type":"string"},"output":{"$ref":"common.schema.json#/$defs/portableOutputPath"},"animation":{"enum":["trace","none"]},"visual_preset":{"enum":["classic","signal-flow","blueprint","editorial"]},"quality_profile":{"enum":["standard","showcase"]},"repository":{"$ref":"common.schema.json#/$defs/repository"},"views":{"$ref":"common.schema.json#/$defs/guidedViews"},"legend":{"type":"object","additionalProperties":false,"properties":{"mode":{"$ref":"common.schema.json#/$defs/legendMode"},"entries":{"type":"object","additionalProperties":false,"properties":{"frontend":{"$ref":"common.schema.json#/$defs/legendEntry"},"backend":{"$ref":"common.schema.json#/$defs/legendEntry"},"database":{"$ref":"common.schema.json#/$defs/legendEntry"},"cloud":{"$ref":"common.schema.json#/$defs/legendEntry"},"security":{"$ref":"common.schema.json#/$defs/legendEntry"},"messagebus":{"$ref":"common.schema.json#/$defs/legendEntry"},"external":{"$ref":"common.schema.json#/$defs/legendEntry"}}}}},"viewBox":{"type":"array","prefixItems":[{"type":"number","minimum":700},{"type":"number","minimum":240}],"items":false,"minItems":2,"maxItems":2}}},"lanes":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["id","label"],"properties":{"id":{"$ref":"common.schema.json#/$defs/id"},"label":{"type":"string","minLength":1},"variant":{"enum":["normal","exception"]}}}},"phases":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["id","label","fromCol","toCol"],"properties":{"id":{"$ref":"common.schema.json#/$defs/id"},"label":{"type":"string","minLength":1},"fromCol":{"type":"integer","minimum":0,"maximum":5},"toCol":{"type":"integer","minimum":0,"maximum":5},"variant":{"enum":["default","emphasis","security","dashed"]}}}},"groups":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["id","label","lane","fromCol","toCol"],"properties":{"id":{"$ref":"common.schema.json#/$defs/id"},"label":{"type":"string","minLength":1},"lane":{"$ref":"common.schema.json#/$defs/id"},"fromCol":{"type":"integer","minimum":0,"maximum":5},"toCol":{"type":"integer","minimum":0,"maximum":5},"variant":{"enum":["default","emphasis","security","dashed"]}}}},"mainPath":{"type":"array","minItems":2,"items":{"$ref":"common.schema.json#/$defs/id"}},"semanticChecks":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"allowedRoots":{"type":"array","items":{"$ref":"common.schema.json#/$defs/id"}},"allowedTerminals":{"type":"array","items":{"$ref":"common.schema.json#/$defs/id"}},"requiredEdges":{"type":"array","items":{"$ref":"#/$defs/semanticRelation"}},"requiredPaths":{"type":"array","items":{"$ref":"#/$defs/semanticRelation"}}}},"nodes":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["id","lane","col","type","label"],"properties":{"id":{"$ref":"common.schema.json#/$defs/id"},"lane":{"$ref":"common.schema.json#/$defs/id"},"col":{"type":"integer","minimum":0,"maximum":5},"type":{"$ref":"common.schema.json#/$defs/componentType"},"label":{"type":"string","minLength":1},"sublabel":{"type":"string"},"tag":{"type":"string"},"icon":{"$ref":"common.schema.json#/$defs/nodeIcon"},"brand":{"$ref":"common.schema.json#/$defs/brandMark"},"sources":{"$ref":"common.schema.json#/$defs/sourceReferences"},"width":{"type":"number","minimum":32},"height":{"type":"number","minimum":32},"yOffset":{"type":"number"}}}},"edges":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["from","to"],"properties":{"id":{"$ref":"common.schema.json#/$defs/id"},"from":{"$ref":"common.schema.json#/$defs/id"},"to":{"$ref":"common.schema.json#/$defs/id"},"label":{"type":"string"},"variant":{"$ref":"common.schema.json#/$defs/variant"},"role":{"enum":["main","branch","async","return","error"]},"fromSide":{"$ref":"#/$defs/side"},"toSide":{"$ref":"#/$defs/side"},"route":{"enum":["auto","straight","drop","outside-right","return-left","bottom-channel","up-channel"]},"via":{"type":"array","items":{"$ref":"common.schema.json#/$defs/point"}},"labelAt":{"$ref":"common.schema.json#/$defs/point"},"labelDx":{"type":"number"},"labelDy":{"type":"number"},"labelSegment":{"type":"integer","minimum":0},"channelX":{"type":"number"},"channelY":{"type":"nuLine truncated
const len = str.length;
let length = 0;
let pos = 0;
while (pos < len) {
length += 1;
const value = str.charCodeAt(pos++);
if (value >= 0xd800 && value <= 0xdbff && pos < len
&& (str.charCodeAt(pos) & 0xfc00) === 0xdc00) pos += 1;
}
return length;
};const pattern4 = new RegExp("^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$", "u");const pattern5 = new RegExp("^[a-z][a-zA-Z0-9]*(\\.[a-z][a-zA-Z0-9]*)*$", "u");const pattern6 = new RegExp("(?:^|/)[^/]+[.][Hh][Tt][Mm][Ll]$", "u");const pattern7 = new RegExp("[\\\\<>:\"|?*\\u0000-\\u001F\\u007F-\\u009F]", "u");const pattern8 = new RegExp("[\\uD800-\\uDFFF]", "u");const pattern9 = new RegExp("(^|/)[.]{1,2}(/|$)", "u");const pattern10 = new RegExp("[. ](/|$)", "u");const pattern11 = new RegExp("(^|/)[^/]{256}", "u");const pattern12 = new RegExp("(^|/)(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Nn][Ii][Nn][$]|[Cc][Oo][Nn][Oo][Uu][Tt][$]|[Cc][Oo][Mm][1-9¹²³]|[Ll][Pp][Tt][1-9¹²³])(?:[.]|/|$)", "u");const pattern13 = new RegExp("(^|/)[^/]*~[1-9][0-9]*(?:[.]|/|$)", "u");const pattern14 = new RegExp("^[^/]+(?:/[^/]+)*$", "u");const pattern15 = new RegExp("^[a-fA-F0-9]{40}$", "u");const pattern16 = new RegExp("^[a-zA-Z][a-zA-Z0-9_-]*$", "u");const pattern29 = new RegExp("^[^\\r\\n]+$", "u");const pattern30 = new RegExp("^https?://", "u");const pattern32 = new RegExp("^[a-f0-9]{64}$", "u");const schema37 = {"type":"array","maxItems":5,"items":{"type":"object","additionalProperties":false,"required":["id","label","focus"],"properties":{"id":{"$ref":"#/$defs/id"},"label":{"type":"string","minLength":1,"maxLength":48},"focus":{"type":"array","minItems":1,"items":{"$ref":"#/$defs/id"}},"note":{"type":"string","maxLength":140}}}};function validate22(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate22.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(Array.isArray(data)){if(data.length > 5){const err0 = {instancePath,schemaPath:"#/maxItems",keyword:"maxItems",params:{limit: 5},message:"must NOT have more than 5 items"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}const len0 = data.length;for(let i0=0; i0<len0; i0++){let data0 = data[i0];if(data0 && typeof data0 == "object" && !Array.isArray(data0)){if(data0.id === undefined){const err1 = {instancePath:instancePath+"/" + i0,schemaPath:"#/items/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data0.label === undefined){const err2 = {instancePath:instancePath+"/" + i0,schemaPath:"#/items/required",keyword:"required",params:{missingProperty: "label"},message:"must have required property '"+"label"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data0.focus === undefined){const err3 = {instancePath:instancePath+"/" + i0,schemaPath:"#/items/required",keyword:"required",params:{missingProperty: "focus"},message:"must have required property '"+"focus"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}for(const key0 in data0){if(!((((key0 === "id") || (key0 === "label")) || (key0 === "focus")) || (key0 === "note"))){const err4 = {instancePath:instancePath+"/" + i0,schemaPath:"#/items/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data0.id !== undefined){let data1 = data0.id;if(typeof data1 === "string"){if(!pattern16.test(data1)){const err5 = {instancePath:instancePath+"/" + i0+"/id",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z][a-zA-Z0-9_-]*$"},message:"must match pattern \""+"^[a-zA-Z][a-zA-Z0-9_-]*$"+"\""};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}else {const err6 = {instancePath:instancePath+"/" + i0+"/id",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data0.label !== undefined){let data2 = data0.label;if(typeof data2 === "string"){if(func3(data2) > 48){const err7 = {instancePath:instancePath+"/" + i0+"/label",schemaPath:"#/items/properties/label/maxLength",keyword:"maxLength",params:{limit: 48},message:"must NOT have more than 48 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func3(data2) < 1){const err8 = {instancePath:instancePath+"/" + i0+"/label",schemaPath:"#/items/properties/label/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/" + i0+"/label",schemaPath:"#/items/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data0.focus !== undefined){let data3 = data0.focus;if(Line truncated
export function workflow(data, context = undefined) {
if (!workflowSchema(data, context)) {
workflow.errors = workflowSchema.errors;
return false;
}
const output = data?.meta?.output;
if (typeof output === 'string') {
try {
validatePortablePath(output, { profile: 'output' });
} catch (error) {
workflow.errors = [{
instancePath: `${context?.instancePath || ''}/meta/output`,
schemaPath: 'common.schema.json#/$defs/portableOutputPath',
keyword: 'portablePath',
params: { reason: error?.reason || 'invalid' },
message: 'must satisfy the portable output path contract',
}];
return false;
}
}
workflow.errors = null;
return true;
}
workflow.evaluated = workflowSchema.evaluated;
export function sequence(data, context = undefined) {
if (!sequenceSchema(data, context)) {
sequence.errors = sequenceSchema.errors;
return false;
}
const output = data?.meta?.output;
if (typeof output === 'string') {
try {
validatePortablePath(output, { profile: 'output' });
} catch (error) {
sequence.errors = [{
instancePath: `${context?.instancePath || ''}/meta/output`,
schemaPath: 'common.schema.json#/$defs/portableOutputPath',
keyword: 'portablePath',
params: { reason: error?.reason || 'invalid' },
message: 'must satisfy the portable output path contract',
}];
return false;
}
}
sequence.errors = null;
return true;
}
sequence.evaluated = sequenceSchema.evaluated;
export function dataflow(data, context = undefined) {
if (!dataflowSchema(data, context)) {
dataflow.errors = dataflowSchema.errors;
return false;
}
const output = data?.meta?.output;
if (typeof output === 'string') {
try {
validatePortablePath(output, { profile: 'output' });
} catch (error) {
dataflow.errors = [{
instancePath: `${context?.instancePath || ''}/meta/output`,
schemaPath: 'common.schema.json#/$defs/portableOutputPath',
keyword: 'portablePath',
params: { reason: error?.reason || 'invalid' },
message: 'must satisfy the portable output path contract',
}];
return false;
}
}
dataflow.errors = null;
return true;
}
dataflow.evaluated = dataflowSchema.evaluated;
export function lifecycle(data, context = undefined) {
if (!lifecycleSchema(data, context)) {
lifecycle.errors = lifecycleSchema.errors;
return false;
}
const output = data?.meta?.output;
if (typeof output === 'string') {
try {
validatePortablePath(output, { profile: 'output' });
} catch (error) {
lifecycle.errors = [{
instancePath: `${context?.instancePath || ''}/meta/output`,
schemaPath: 'common.schema.json#/$defs/portableOutputPath',
keyword: 'portablePath',
params: { reason: error?.reason || 'invalid' },
message: 'must satisfy the portable output path contract',
}];
return false;
}
}
lifecycle.errors = null;
return true;
}
lifecycle.evaluated = lifecycleSchema.evaluated;
export function architecture(data, context = undefined) {
if (!architectureSchema(data, context)) {
architecture.errors = architectureSchema.errors;
return false;
}
const output = data?.meta?.output;
if (typeof output === 'string') {
try {
validatePortablePath(output, { profile: 'output' });
} catch (error) {
architecture.errors = [{
instancePath: `${context?.instancePath || ''}/meta/output`,
schemaPath: 'common.schema.json#/$defs/portableOutputPath',
keyword: 'portablePath',
params: { reason: error?.reason || 'invalid' },
message: 'must satisfy the portable output path contract',
}];
return false;
}
}
architecture.errors = null;
return true;
}
architecture.evaluated = architectureSchema.evaluated;
File diff suppressed because it is too large. Load diff
+603
View File
@@ -0,0 +1,603 @@
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
export const DEFAULT_LOCALE = 'en';
const ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
export function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
}
// One catalog feeds renderer-time SVG/HTML copy and the selected runtime
// catalog embedded in each standalone artifact. Keeping both built-in locales in one
// tuple makes missing translations impossible to hide behind an English
// fallback during development.
const MESSAGE_PAIRS = {
'page.title': ['{title} Diagram', '{title}'],
'diagram.description.architecture': ['An architecture diagram generated by Archify.', '由 Archify 生成的架构图。'],
'diagram.description.workflow': ['A workflow diagram generated by Archify.', '由 Archify 生成的工作流图。'],
'diagram.description.sequence': ['A sequence diagram generated by Archify.', '由 Archify 生成的时序图。'],
'diagram.description.dataflow': ['A data-flow diagram generated by Archify.', '由 Archify 生成的数据流图。'],
'diagram.description.lifecycle': ['A lifecycle diagram generated by Archify.', '由 Archify 生成的生命周期图。'],
'node.focus': ['Focus {label}', '聚焦{label}'],
'node.focus.detail': ['Focus {label}, {detail}', '聚焦{label},{detail}'],
'node.context.architecture': ['Architecture component', '架构组件'],
'node.context.workflow': ['Workflow node', '工作流节点'],
'node.context.sequence': ['Sequence participant', '时序参与者'],
'node.context.dataflow': ['Data-flow node', '数据流节点'],
'node.context.lifecycle': ['Lifecycle state', '生命周期状态'],
'legend.title': ['Legend', '图例'],
'legend.architecture.frontend': ['Frontend', '前端'],
'legend.architecture.backend': ['Backend', '后端'],
'legend.architecture.database': ['Database', '数据库'],
'legend.architecture.cloud': ['Cloud', '云服务'],
'legend.architecture.security': ['Security', '安全'],
'legend.architecture.messagebus': ['Message bus', '消息总线'],
'legend.architecture.external': ['External', '外部系统'],
'legend.workflow.frontend': ['User UI', '用户界面'],
'legend.workflow.backend': ['Agent logic', 'Agent 逻辑'],
'legend.workflow.security': ['Policy', '策略'],
'legend.workflow.messagebus': ['Tool action', '工具操作'],
'legend.workflow.database': ['Context / trace', '上下文 / 追踪'],
'legend.workflow.cloud': ['Cloud service', '云服务'],
'legend.workflow.external': ['External system', '外部系统'],
'legend.sequence.emphasis': ['main request', '主路径请求'],
'legend.sequence.return': ['return', '返回'],
'legend.sequence.security': ['security', '安全'],
'legend.sequence.dashed': ['async trace', '异步追踪'],
'legend.sequence.default': ['message', '普通消息'],
'legend.dataflow.emphasis': ['primary data', '主要数据'],
'legend.dataflow.security': ['policy / PII', '策略 / PII'],
'legend.dataflow.dashed': ['async batch', '异步批处理'],
'legend.dataflow.database': ['data store', '数据存储'],
'legend.dataflow.default': ['data flow', '数据流'],
'legend.lifecycle.start': ['initial state', '起点'],
'legend.lifecycle.active': ['active', '进行中'],
'legend.lifecycle.waiting': ['waiting', '等待'],
'legend.lifecycle.decision': ['decision', '决策'],
'legend.lifecycle.success': ['success', '成功'],
'legend.lifecycle.failure': ['failure / exit', '失败 / 退出'],
'legend.lifecycle.neutral': ['neutral', '中性状态'],
'legend.lifecycle.external': ['external', '外部状态'],
'legend.lifecycle.final': ['final state', '终态'],
'viewer.kind.frontend': ['Frontend', '前端'],
'viewer.kind.backend': ['Backend', '后端'],
'viewer.kind.database': ['Database', '数据库'],
'viewer.kind.cloud': ['Cloud', '云服务'],
'viewer.kind.security': ['Security', '安全'],
'viewer.kind.messagebus': ['Message bus', '消息总线'],
'viewer.kind.external': ['External', '外部系统'],
'viewer.kind.neutral': ['Neutral', '中性'],
'viewer.kind.node': ['Node', '节点'],
'viewer.kind.start': ['Start', '开始'],
'viewer.kind.active': ['Active', '活动'],
'viewer.kind.waiting': ['Waiting', '等待'],
'viewer.kind.decision': ['Decision', '决策'],
'viewer.kind.success': ['Success', '成功'],
'viewer.kind.failure': ['Failure', '失败'],
'viewer.toolbar.actions': ['Diagram actions', '图表操作'],
'viewer.theme.toggle.title': ['Toggle theme (T)', '切换主题(T)'],
'viewer.theme.toggle': ['Toggle color theme', '切换颜色主题'],
'viewer.theme.dark': ['Dark', '深色'],
'viewer.theme.light': ['Light', '浅色'],
'viewer.preset.choose.title': ['Choose visual style (S cycles)', '选择视觉风格(S 循环切换)'],
'viewer.preset.choose': ['Choose visual style', '选择视觉风格'],
'viewer.preset.style': ['Style', '风格'],
'viewer.preset.menu': ['Visual style', '视觉风格'],
'viewer.preset.identity': ['Visual identity', '视觉表达'],
'viewer.preset.cycles': ['to cycle', '循环切换'],
'viewer.preset.classic': ['Classic', '经典'],
'viewer.preset.classic.short': ['Classic', '经典'],
'viewer.preset.classic.hint': ['Stable technical default', '稳定的技术默认风格'],
'viewer.preset.flow': ['Signal Flow', '信号流'],
'viewer.preset.flow.short': ['Flow', '流动'],
'viewer.preset.flow.hint': ['Motion-forward presentation', '突出动态流向'],
'viewer.preset.blueprint': ['Blueprint', '蓝图'],
'viewer.preset.blueprint.hint': ['Engineering review', '工程评审'],
'viewer.preset.editorial': ['Editorial', '编辑风格'],
'viewer.preset.editorial.hint': ['Publication and launch notes', '适合发布与上线说明'],
'viewer.preset.badge.signalFlow': ['SIGNAL FLOW', '信号流'],
'viewer.preset.badge.blueprint': ['BLUEPRINT / REV 01', '蓝图 / 修订 01'],
'viewer.preset.badge.editorial': ['EDITORIAL / FIELD NOTE', '编辑风格 / 现场笔记'],
'viewer.preset.badge.editorialPlate': ['ARCHIFY / PLATE 04', 'ARCHIFY / 图版 04'],
'viewer.preset.current': ['Visual style: {style}. Choose visual style', '当前视觉风格:{style}。选择视觉风格'],
'viewer.motion.live': ['Live', '动态'],
'viewer.motion.still': ['Still', '静态'],
'viewer.motion.pause': ['Pause motion', '暂停动效'],
'viewer.motion.resume': ['Resume motion', '恢复动效'],
'viewer.motion.reduced': ['Motion paused by reduced-motion preference', '已根据减少动态效果偏好暂停动效'],
'viewer.motion.hidden': ['Motion paused while this page is hidden', '页面不可见时已暂停动效'],
'viewer.motion.yielding': ['Pause motion; currently yielding to {owner}', '暂停动效;当前让位于{owner}'],
'viewer.motion.yielding.title': ['Live preview enabled · yielding to {owner}', '动态预览已启用 · 正在让位于{owner}'],
'viewer.owner.route': ['Route Probe', '路径探测'],
'viewer.owner.lens': ['Semantic Lens', '语义透镜'],
'viewer.owner.relationship': ['Relationship Preview', '关系预览'],
'viewer.owner.intent': ['Intent Trace', '意图追踪'],
'viewer.owner.focus': ['semantic focus', '语义聚焦'],
'viewer.owner.legend': ['legend preview', '图例预览'],
'viewer.owner.reader': ['reader interaction', '读者交互'],
'viewer.present.enter': ['Enter presentation stage', '进入演示模式'],
'viewer.present.enter.title': ['Presentation stage (F)', '演示模式(F)'],
'viewer.present.exit': ['Exit presentation stage', '退出演示模式'],
'viewer.present.exit.title': ['Exit presentation stage (F or Escape)', '退出演示模式(F 或 Escape)'],
'viewer.present.present': ['Present', '演示'],
'viewer.present.exit.label': ['Exit', '退出'],
'viewer.export.button': ['Export', '导出'],
'viewer.export.button.title': ['Export diagram (E)', '导出图表(E)'],
'viewer.export.diagram': ['Export diagram', '导出图表'],
'viewer.export.menu': ['Export', '导出'],
'viewer.export.subtitle': ['Portable, clean outputs', '便携、整洁的输出'],
'viewer.export.share': ['Share', '分享'],
'viewer.export.shareCard': ['Share Card', '分享卡片'],
'viewer.export.routeShareCard': ['Route Share Card', '路径分享卡片'],
'viewer.export.reachShareCard': ['Reach Share Card', '可达范围分享卡片'],
'viewer.export.copyDiagram': ['Copy diagram', '复制图表'],
'viewer.export.clipboardPng': ['PNG to clipboard', '复制 PNG 到剪贴板'],
'viewer.export.raster': ['Raster images', '位图'],
'viewer.export.image': ['Image', '图像'],
'viewer.export.lossless': ['Lossless image', '无损图像'],
'viewer.export.compact': ['Compact image', '紧凑图像'],
'viewer.export.modern': ['Modern image', '现代图像格式'],
'viewer.export.vectorMotion': ['Vector and motion', '矢量与动效'],
'viewer.export.vectorMotion.heading': ['Vector & motion', '矢量与动效'],
'viewer.export.editable': ['Editable vector', '可编辑矢量图'],
'viewer.export.svg.auto': ['SVG · Auto', 'SVG · 自动'],
'viewer.export.svg.auto.hint': ['Matches host theme', '匹配宿主主题'],
'viewer.export.svg.light': ['SVG · Light', 'SVG · 浅色'],
'viewer.export.svg.light.hint': ['Always light', '始终浅色'],
'viewer.export.svg.dark': ['SVG · Dark', 'SVG · 深色'],
'viewer.export.svg.dark.hint': ['Always dark', '始终深色'],
'viewer.export.motion6s': ['6s motion', '6 秒动效'],
'viewer.export.unsupported': ['Not supported by this browser', '当前浏览器不支持'],
'viewer.export.clipboardUnsupported': ['Clipboard image write not supported by this browser', '当前浏览器不支持写入图片剪贴板'],
'viewer.export.clipboardUnsupported.short': ['Clipboard image write not supported in this browser.', '此浏览器不支持写入图片剪贴板。'],
'viewer.export.motionUnavailable': ['Motion capture unavailable in this browser', '当前浏览器无法录制动效'],
'viewer.export.webmUnavailable': ['WebM unavailable in this browser', '当前浏览器不支持 WebM'],
'viewer.export.failed': ['Export failed: {message}', '导出失败:{message}'],
'viewer.export.unknownVariant': ['Unknown Share Card variant: {variant}', '未知的分享卡片类型:{variant}'],
'viewer.export.routeRequired': ['Trace a route before exporting a Route Share Card', '请先追踪路径,再导出路径分享卡片'],
'viewer.export.reachRequired': ['Trace authored reach before exporting a Reach Share Card', '请先追踪编写可达范围,再导出可达范围分享卡片'],
'viewer.export.unknown': ['unknown', '未知错误'],
'viewer.export.routeFailed': ['Route Share Card export failed: {message}', '路径分享卡片导出失败:{message}'],
'viewer.export.reachFailed': ['Reach Share Card export failed: {message}', '可达范围分享卡片导出失败:{message}'],
'viewer.export.copyFailed': ['Copy failed: {message}', '复制失败:{message}'],
'viewer.export.copiedPng': ['Copied PNG to clipboard', '已将 PNG 复制到剪贴板'],
'viewer.export.downloadedRoute': ['Downloaded Route Share Card', '已下载路径分享卡片'],
'viewer.export.downloadedReach': ['Downloaded Reach Share Card', '已下载可达范围分享卡片'],
'viewer.export.downloadedWebm': ['Downloaded WebM', '已下载 WebM'],
'viewer.export.recording': ['Recording 6 seconds of motion…', '正在录制 6 秒动效…'],
'viewer.export.card.routeSummary.one': ['Path: {source} → {target} · {count} step', '路径:{source} → {target} · {count} 步'],
'viewer.export.card.routeSummary.other': ['Path: {source} → {target} · {count} steps', '路径:{source} → {target} · {count} 步'],
'viewer.export.card.reachSummary': ['Authored {direction} from {origin} · {nodes} · {links} · max {hops}', '从{origin}开始的编写{direction} · {nodes} · {links} · 最深 {hops}'],
'viewer.export.card.node.one': ['{count} node', '{count} 个节点'],
'viewer.export.card.node.other': ['{count} nodes', '{count} 个节点'],
'viewer.export.card.link.one': ['{count} link', '{count} 条连接'],
'viewer.export.card.link.other': ['{count} links', '{count} 条连接'],
'viewer.export.card.hop.one': ['{count} hop', '{count} 跳'],
'viewer.export.card.hop.other': ['{count} hops', '{count} 跳'],
'viewer.export.card.routeBadge': ['ARCHIFY · ROUTE · {hops}', 'ARCHIFY · 路径 · {hops}'],
'viewer.export.card.reachBadge': ['ARCHIFY · {direction} REACH', 'ARCHIFY · {direction}可达范围'],
'viewer.export.direction.upstream': ['Upstream', '上游'],
'viewer.export.direction.downstream': ['Downstream', '下游'],
'viewer.export.error.canvasUnavailable': ['Canvas unavailable for {label}', '无法为{label}使用画布'],
'viewer.export.error.contextUnavailable': ['2D canvas context unavailable for {label}', '无法为{label}创建二维画布上下文'],
'viewer.export.error.toBlobUnavailable': ['canvas.toBlob unavailable for {label}', '{label}无法使用 canvas.toBlob'],
'viewer.export.error.toBlobNull': ['canvas.toBlob returned no data for {label}', '{label}的 canvas.toBlob 未返回数据'],
'viewer.export.error.variantsCombined': ['Share Card variants cannot be combined', '无法同时组合多种分享卡片类型'],
'viewer.export.error.viewerState': ['Share Card export could not remove temporary viewer state', '分享卡片导出无法移除临时 Viewer 状态'],
'viewer.export.error.routeState': ['Route Card export could not preserve the resolved route safely', '路径卡片导出无法安全保留已解析路径'],
'viewer.export.error.reachState': ['Reach Card export could not preserve authored reach safely', '可达范围卡片导出无法安全保留编写的可达范围'],
'viewer.export.error.webmRequirements': ['WebM motion export requires a trace animation and browser MediaRecorder support', 'WebM 动效导出需要追踪动画及浏览器 MediaRecorder 支持'],
'viewer.export.error.mediaRecorder': ['MediaRecorder failed', 'MediaRecorder 录制失败'],
'viewer.export.error.emptyWebm': ['MediaRecorder produced an empty WebM', 'MediaRecorder 生成了空的 WebM'],
'viewer.export.error.webmBackground': ['SVG background could not be loaded for WebM export', '无法为 WebM 导出加载 SVG 背景'],
'viewer.guide.title': ['Explore this system', '探索此系统'],
'viewer.focus.selectedNodes': ['{count} selected nodes', '已选择 {count} 个节点'],
'viewer.guide.eyebrow': ['Diagram guide', '图表指南'],
'viewer.guide.close': ['Close diagram guide', '关闭图表指南'],
'viewer.guide.inspecting': ['Inspecting compiled semantics', '正在检查已编译语义'],
'viewer.guide.actions': ['Diagram exploration actions', '图表探索操作'],
'viewer.guide.find': ['Find any node', '查找任意节点'],
'viewer.guide.find.hint': ['Search labels, responsibilities, kinds, and stable IDs.', '搜索标签、职责、类型和稳定 ID。'],
'viewer.guide.route': ['Trace a route', '追踪路径'],
'viewer.guide.route.aria': ['Trace a directed route', '追踪有向路径'],
'viewer.guide.route.hint': ['Ask how two semantic nodes connect in authored direction.', '查看两个语义节点如何按编写方向连接。'],
'viewer.guide.map': ['See the whole system', '查看完整系统'],
'viewer.guide.map.hint': ['Open Semantic Radar with a live viewport and stable nodes.', '打开带实时视口和稳定节点的语义雷达。'],
'viewer.guide.lens': ['Compare semantic kinds', '比较语义类型'],
'viewer.guide.lens.hint': ['Count roles, reveal their traffic, and compare direct authored links.', '统计角色、显示流量并比较直接编写的连接。'],
'viewer.guide.present': ['Enter Presentation Stage', '进入演示模式'],
'viewer.guide.present.hint': ['Give the live diagram the viewport without changing export.', '让实时图表占满视口,同时不改变导出。'],
'viewer.guide.shortcuts': ['Additional keyboard shortcuts', '其他键盘快捷键'],
'viewer.guide.shortcut.export': ['Export', '导出'],
'viewer.guide.shortcut.theme': ['Theme', '主题'],
'viewer.guide.shortcut.style': ['Style', '风格'],
'viewer.guide.shortcut.reset': ['Reset', '重置'],
'viewer.guide.shortcut.zoomIn': ['Zoom in', '放大'],
'viewer.guide.shortcut.zoomOut': ['Zoom out', '缩小'],
'viewer.guide.shortcut.close': ['Close', '关闭'],
'viewer.guide.facts': ['{nodes} · {relationships}', '{nodes} · {relationships}'],
'viewer.guide.fact.node.one': ['{count} semantic node', '{count} 个语义节点'],
'viewer.guide.fact.node.other': ['{count} semantic nodes', '{count} 个语义节点'],
'viewer.guide.fact.relationship.one': ['{count} relationship', '{count} 条关系'],
'viewer.guide.fact.relationship.other': ['{count} relationships', '{count} 条关系'],
'viewer.guide.open': ['Open diagram guide', '打开图表指南'],
'viewer.finder.title': ['Find a node', '查找节点'],
'viewer.finder.close': ['Close node finder', '关闭节点查找器'],
'viewer.finder.placeholder': ['Search labels or IDs', '搜索标签或 ID'],
'viewer.finder.search': ['Search diagram nodes', '搜索图表节点'],
'viewer.finder.results': ['Diagram nodes', '图表节点'],
'viewer.finder.empty': ['No matching nodes', '没有匹配的节点'],
'viewer.finder.result.focus': ['Focus {label}', '聚焦{label}'],
'viewer.finder.result.routeStart': ['Choose {label} as route start', '选择{label}作为路径起点'],
'viewer.finder.result.routeTarget': ['Choose {label} as route destination, {links}', '选择{label}作为路径终点,{links}'],
'viewer.finder.status.empty': ['No matching nodes', '没有匹配的节点'],
'viewer.finder.status.count.one': ['{count} matching node', '{count} 个匹配节点'],
'viewer.finder.status.count.other': ['{count} matching nodes', '{count} 个匹配节点'],
'viewer.finder.noun.nodes': ['nodes', '个节点'],
'viewer.finder.link.one': ['{count} link', '{count} 条连接'],
'viewer.finder.link.other': ['{count} links', '{count} 条连接'],
'viewer.finder.result.focus.one': ['Focus {label}, {count} related connection', '聚焦{label},{count} 条相关连接'],
'viewer.finder.result.focus.other': ['Focus {label}, {count} related connections', '聚焦{label},{count} 条相关连接'],
'viewer.finder.status.filtered': ['{visible} of {available} {noun}', '{visible}/{available} {noun}'],
'viewer.finder.status.all': ['{available} {noun}', '{available} {noun}'],
'viewer.passport.eyebrow': ['Semantic passport', '语义护照'],
'viewer.passport.metadata': ['Node metadata', '节点元数据'],
'viewer.passport.evidence': ['Verified source evidence', '已验证的源代码证据'],
'viewer.passport.verified': ['Verified source', '已验证来源'],
'viewer.passport.verificationScope': ['Verified against local Git at the pinned revision. Remote access has not been checked.', '已按固定修订版本验证本地 Git 证据,未检查远程访问权限。'],
'viewer.passport.reach': ['Authored reach', '编写可达范围'],
'viewer.passport.reach.trace': ['Trace authored reachability', '追踪编写的可达性'],
'viewer.passport.upstream': ['Upstream', '上游'],
'viewer.passport.downstream': ['Downstream', '下游'],
'viewer.passport.upstream.trace': ['Trace upstream authored reachability', '追踪上游编写可达性'],
'viewer.passport.downstream.trace': ['Trace downstream authored reachability', '追踪下游编写可达性'],
'viewer.passport.close': ['Close semantic passport', '关闭语义护照'],
'viewer.passport.move': ['Move semantic passport. Drag, use arrow keys, or press Home to reset.', '移动语义护照。可拖动、使用方向键移动,或按 Home 恢复自动位置。'],
'viewer.passport.copy': ['Copy link', '复制链接'],
'viewer.passport.copy.focus': ['Copy link to focused node', '复制聚焦节点的链接'],
'viewer.passport.relations': ['Relations', '关系'],
'viewer.passport.relations.show': ['Show connected relationships', '显示关联关系'],
'viewer.passport.relations.hide': ['Hide connected relationships', '隐藏关联关系'],
'viewer.passport.relations.list': ['Connected relationships', '关联关系'],
'viewer.passport.copyRelation': ['Copy relation', '复制关系'],
'viewer.passport.copyNode': ['Copy node', '复制节点'],
'viewer.passport.copyPinned': ['Copy link to pinned relationship', '复制固定关系的链接'],
'viewer.passport.copySource': ['Copy link to source node', '复制来源节点的链接'],
'viewer.passport.copy.focused.success': ['Focused node link copied', '已复制聚焦节点链接'],
'viewer.passport.copy.pinned.success': ['Pinned relationship link copied', '已复制固定关系链接'],
'viewer.passport.copy.focused.failed': ['Could not copy focused node link', '无法复制聚焦节点链接'],
'viewer.passport.copy.pinned.failed': ['Could not copy pinned relationship link', '无法复制固定关系链接'],
'viewer.passport.relationship.none': ['No connected relationships', '没有关联关系'],
'viewer.passport.relationship.count.one': ['{count} relation', '{count} 条关系'],
'viewer.passport.relationship.count.other': ['{count} relations', '{count} 条关系'],
'viewer.passport.relationship.show.one': ['Show {count} connected relationship', '显示 {count} 条关联关系'],
'viewer.passport.relationship.show.other': ['Show {count} connected relationships', '显示 {count} 条关联关系'],
'viewer.passport.relationship.summary': ['{out} outgoing · {in} incoming{loops}', '{out} 条出向 · {in} 条入向{loops}'],
'viewer.passport.relationship.loops': [' · {count} loop', ' · {count} 条自环'],
'viewer.passport.relationship.explorer': ['Direct relationship explorer', '直接关系浏览器'],
'viewer.passport.relationship.help': ['Use arrow keys to explore relationships. Press Enter or Space to pin details; Escape clears.', '使用方向键浏览关系。按 Enter 或空格键固定详情;按 Escape 清除。'],
'viewer.passport.relationship.loopsBack': ['loops back', '回环'],
'viewer.passport.relationship.connectsTo': ['connects to', '连接到'],
'viewer.passport.relationship.connectsFrom': ['connects from', '连接自'],
'viewer.passport.relationship.pinned': ['Pinned relationship · {from} → {to} · {label}', '已固定关系 · {from} → {to} · {label}'],
'viewer.passport.relationship.inspect': ['Inspect relationship {index} of {total}: {from} to {to}, {label}. Press Enter for details.', '检查第 {index}/{total} 条关系:{from} 到 {to},{label}。按 Enter 查看详情。'],
'viewer.passport.relationship.group.out': ['Outgoing', '出向'],
'viewer.passport.relationship.group.in': ['Incoming', '入向'],
'viewer.passport.relationship.group.loop': ['Self loops', '自环'],
'viewer.passport.relationship.row': ['{group}: {relationship}, {neighbor}', '{group}:{relationship},{neighbor}'],
'viewer.passport.relationship.direction.out': ['OUT →', '出 →'],
'viewer.passport.relationship.direction.in': ['← IN', '← 入'],
'viewer.passport.relationship.direction.loop': ['LOOP', '自环'],
'viewer.passport.sourceCount.one': ['{count} verified source reference', '{count} 个已验证来源引用'],
'viewer.passport.sourceCount.other': ['{count} verified source references', '{count} 个已验证来源引用'],
'viewer.passport.sourceMarker': ['SRC', '来源'],
'viewer.passport.beacon.one': ['{count} verified source; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
'viewer.passport.beacon.other': ['{count} verified sources; focus this node to inspect', '{count} 个已验证来源;聚焦此节点以检查'],
'viewer.passport.repository.open': ['Open verified repository revision {revision}', '打开已验证的仓库修订版本 {revision}'],
'viewer.passport.source.open': ['Open verified source {path} at revision {revision}', '打开修订版本 {revision} 中已验证的来源 {path}'],
'viewer.passport.source.openLink': ['Open ↗', '打开 ↗'],
'viewer.passport.reach.upstream.one': ['Trace {count} upstream authored node', '追踪 {count} 个上游编写节点'],
'viewer.passport.reach.upstream.other': ['Trace {count} upstream authored nodes', '追踪 {count} 个上游编写节点'],
'viewer.passport.reach.downstream.one': ['Trace {count} downstream authored node', '追踪 {count} 个下游编写节点'],
'viewer.passport.reach.downstream.other': ['Trace {count} downstream authored nodes', '追踪 {count} 个下游编写节点'],
'viewer.passport.reach.noUpstream': ['No upstream authored nodes', '没有上游编写节点'],
'viewer.passport.reach.noDownstream': ['No downstream authored nodes', '没有下游编写节点'],
'viewer.passport.reach.status': ['{direction} · {nodes} nodes · {links} links · max {hops} hops', '{direction} · {nodes} 个节点 · {links} 条连接 · 最深 {hops} 跳'],
'viewer.route.eyebrow': ['Path', '路径'],
'viewer.route.start': ['Click where the path starts', '点击路径的起点'],
'viewer.route.start.find': ['Find start', '查找起点'],
'viewer.route.start.find.aria': ['Find a route start', '查找路径起点'],
'viewer.route.copy': ['Copy link', '复制链接'],
'viewer.route.copy.aria': ['Copy link to traced route', '复制已追踪路径的链接'],
'viewer.route.clear': ['Clear', '清除'],
'viewer.route.clear.aria': ['Clear route probe', '清除路径探测'],
'viewer.route.traced': ['Path', '路径'],
'viewer.route.pickTwo': ['Click two nodes on the diagram', '在图上点两个节点'],
'viewer.route.pickOne': ['Click a node on the diagram', '在图上点一个节点'],
'viewer.route.controls': ['Route journey controls', '路径旅程控制'],
'viewer.route.previous': ['Previous route position', '上一个路径位置'],
'viewer.route.play': ['Play route journey', '播放路径旅程'],
'viewer.route.pause': ['Pause route journey', '暂停路径旅程'],
'viewer.route.replay': ['Replay route journey', '重播路径旅程'],
'viewer.route.next': ['Next route position', '下一个路径位置'],
'viewer.route.journey': ['Journey', '旅程'],
'viewer.route.pause.label': ['Pause', '暂停'],
'viewer.route.replay.label': ['Replay', '重播'],
'viewer.route.overview': ['Overview', '总览'],
'viewer.route.overview.aria': ['Show complete route overview', '显示完整路径总览'],
'viewer.route.instructions': ['Click a start, then an end. Paths follow the arrows.', '先点起点,再点终点,沿箭头方向找路。'],
'viewer.route.destination': ['Where does the path from {label} end?', '从{label}出发,走到哪里?'],
'viewer.route.destination.find': ['Find target', '查找目标'],
'viewer.route.destination.find.aria': ['Find a reachable route destination', '查找可达的路径目标'],
'viewer.route.differentDestination': ['Choose a different destination', '选择其他目标'],
'viewer.route.distinct': ['Pick two different nodes.', '请选两个不同的节点。'],
'viewer.route.unreachable': ['Cannot reach {label} from here', '走不到{label}'],
'viewer.route.unreachable.detail': ['{target} cannot be reached from {source} along the arrows. Pick a highlighted node.', '沿箭头方向,从{source}走不到{target}。请选一个高亮节点。'],
'viewer.route.start.instructions': ['Next, pick the end. Only nodes you can reach will light up.', '接着选终点,能走到的节点会亮起。'],
'viewer.route.copy.success': ['Traced route link copied', '已复制路径链接'],
'viewer.route.copy.failed': ['Could not copy traced route link', '无法复制路径链接'],
'viewer.route.position': ['Route position {index} of {total}: {label}', '路径位置 {index}/{total}:{label}'],
'viewer.route.step': ['Step {index} of {total} · {phase} · {label}', '第 {index}/{total} 步 · {phase} · {label}'],
'viewer.route.motionRequired': ['Automatic journey requires Live motion', '自动旅程需要动态模式'],
'viewer.route.trigger.clear': ['Clear traced route', '清除已追踪路径'],
'viewer.route.overview.status': ['{nodes} · {hops} · shortest path', '{nodes} · {hops} · 最短路径'],
'viewer.route.overview.node.one': ['{count} node', '{count} 个节点'],
'viewer.route.overview.node.other': ['{count} nodes', '{count} 个节点'],
'viewer.route.overview.hop.one': ['{count} step', '{count} 步'],
'viewer.route.overview.hop.other': ['{count} steps', '{count} 步'],
'viewer.route.phase.playing': ['Playing', '播放中'],
'viewer.route.phase.complete': ['Complete', '已完成'],
'viewer.route.phase.inspecting': ['Inspecting', '检查中'],
'viewer.route.destination.count.one': ['{count} reachable node is highlighted. Click it.', '有 {count} 个能走到的节点已高亮,点一下。'],
'viewer.route.destination.count.other': ['{count} reachable nodes are highlighted. Click one.', '有 {count} 个能走到的节点已高亮,点一个。'],
'viewer.route.noOutgoing': ['No arrows lead out of this node. Clear it and pick another start.', '这个节点没有向外的箭头。清除后换一个起点。'],
'viewer.route.result.title': ['{source} to {target}', '{source} 到 {target}'],
'viewer.route.finder.source.title': ['Choose route start', '选择路径起点'],
'viewer.route.finder.source.placeholder': ['Search route sources', '搜索路径来源'],
'viewer.route.finder.source.empty': ['No matching route sources', '没有匹配的路径来源'],
'viewer.route.finder.source.results': ['Nodes that can start a route', '可作为路径起点的节点'],
'viewer.route.finder.source.noun': ['route sources', '个路径来源'],
'viewer.route.finder.source.badge': ['start', '起点'],
'viewer.route.finder.target.title': ['Destination from {label}', '从{label}出发的目标'],
'viewer.route.finder.target.placeholder': ['Search reachable destinations', '搜索可达目标'],
'viewer.route.finder.target.empty': ['No matching reachable destinations', '没有匹配的可达目标'],
'viewer.route.finder.target.results': ['Reachable route destinations', '可达路径目标'],
'viewer.route.finder.target.noun': ['reachable destinations', '个可达目标'],
'viewer.route.hop.one': ['{count} hop', '{count} 跳'],
'viewer.route.hop.other': ['{count} hops', '{count} 跳'],
'viewer.lens.eyebrow': ['Semantic lens', '语义透镜'],
'viewer.lens.title': ['Compare system roles', '比较系统角色'],
'viewer.lens.close': ['Close semantic lens', '关闭语义透镜'],
'viewer.lens.instruction': ['Choose up to two semantic kinds. One reveals its real traffic; two compare only direct authored relationships.', '最多选择两种语义类型。选择一种可显示其真实流量;选择两种只比较直接编写的关系。'],
'viewer.lens.kinds': ['Semantic kinds', '语义类型'],
'viewer.lens.choose': ['Choose a kind to inspect its nodes and touching relationships.', '选择一种类型以检查其节点和相连关系。'],
'viewer.lens.copy': ['Copy link to semantic lens', '复制语义透镜链接'],
'viewer.lens.clear': ['Clear semantic lens', '清除语义透镜'],
'viewer.lens.open': ['Open semantic lens', '打开语义透镜'],
'viewer.lens.openActive': ['Open active semantic lens', '打开当前语义透镜'],
'viewer.lens.legend': ['Semantic legend', '语义图例'],
'viewer.lens.legend.inspect.one': ['Inspect {label}, {count} node', '检查{label},{count} 个节点'],
'viewer.lens.legend.inspect.other': ['Inspect {label}, {count} nodes', '检查{label},{count} 个节点'],
'viewer.lens.kind.count.one': ['{label}, {count} node', '{label},{count} 个节点'],
'viewer.lens.kind.count.other': ['{label}, {count} nodes', '{label},{count} 个节点'],
'viewer.lens.compare.one': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationship', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
'viewer.lens.compare.other': ['{first} → {second}: {forward} · {second} → {first}: {reverse} · {count} direct relationships', '{first} → {second}:{forward} · {second} → {first}:{reverse} · 共 {count} 条直接关系'],
'viewer.lens.single': ['{nodes} · {relationships} · connected peers remain visible', '{nodes} · {relationships} · 已连接节点保持可见'],
'viewer.lens.node.one': ['{count} {label} node', '{count} 个{label}节点'],
'viewer.lens.node.other': ['{count} {label} nodes', '{count} 个{label}节点'],
'viewer.lens.relationship.one': ['{count} touching relationship', '{count} 条相连关系'],
'viewer.lens.relationship.other': ['{count} touching relationships', '{count} 条相连关系'],
'viewer.radar.title': ['Semantic radar', '语义雷达'],
'viewer.radar.building': ['Building overview', '正在构建总览'],
'viewer.radar.openFull': ['Open full semantic radar', '打开完整语义雷达'],
'viewer.radar.open': ['Open radar', '打开雷达'],
'viewer.radar.close': ['Close semantic radar', '关闭语义雷达'],
'viewer.radar.surface': ['Diagram overview. Click a node to focus it, or use arrow keys to pan.', '图表总览。点击节点进行聚焦,或使用方向键平移。'],
'viewer.radar.click': ['Click node', '点击节点'],
'viewer.radar.drag': ['Drag to pan', '拖动平移'],
'viewer.radar.space': ['Semantic radar needs more MAP space.', '语义雷达需要更多地图可见空间。'],
'viewer.radar.nodes': ['Semantic diagram radar nodes', '语义图表雷达节点'],
'viewer.radar.focus': ['Focus {label} from Semantic Radar', '从语义雷达聚焦{label}'],
'viewer.radar.status': ['{count} nodes · {viewport}', '{count} 个节点 · {viewport}'],
'viewer.radar.fullMap': ['{count} nodes · full map', '{count} 个节点 · 完整地图'],
'viewer.radar.compacted': ['Radar compacted to avoid covering the Semantic Passport or MAP controls.', '已收紧雷达,避免遮挡语义护照或地图控件。'],
'viewer.radar.cancelWaiting': ['Cancel semantic radar waiting for more MAP space', '取消等待更多地图空间的语义雷达'],
'viewer.radar.needsSpace': ['Semantic radar needs more visible MAP space', '语义雷达需要更多可见地图空间'],
'viewer.radar.viewport.full': ['full map', '完整地图'],
'viewer.radar.viewport.width': ['{percent}% width', '宽度 {percent}%'],
'viewer.radar.viewport.scale': ['{percent}% viewport', '视口 {percent}%'],
'viewer.nav.controls': ['Diagram view controls', '图表视图控制'],
'viewer.nav.route': ['Trace a directed route', '追踪有向路径'],
'viewer.nav.route.title': ['Trace route (R)', '追踪路径(R)'],
'viewer.nav.route.short': ['PATH', '路径'],
'viewer.nav.radar': ['Open semantic radar', '打开语义雷达'],
'viewer.nav.radar.title': ['Semantic radar (M)', '语义雷达(M)'],
'viewer.nav.radar.short': ['MAP', '地图'],
'viewer.nav.lens': ['Open semantic lens', '打开语义透镜'],
'viewer.nav.lens.title': ['Semantic lens (L)', '语义透镜(L)'],
'viewer.nav.lens.short': ['LENS', '透镜'],
'viewer.nav.find': ['Find a node', '查找节点'],
'viewer.nav.find.title': ['Find a node (/)', '查找节点(/)'],
'viewer.outline.title': ['Node index', '节点索引'],
'viewer.rail.show': ['Notes & index', '要点与索引'],
'viewer.rail.controls': ['Side panel', '侧栏'],
'viewer.rail.collapse': ['Collapse side panel', '收起侧栏'],
'viewer.rail.bottom': ['Move panel below the diagram', '移到图下方'],
'viewer.rail.right': ['Move panel beside the diagram', '移到图右侧'],
'viewer.nav.guide': ['Open diagram guide', '打开图表指南'],
'viewer.nav.guide.title': ['Diagram guide (?)', '图表指南(?)'],
'viewer.nav.zoomOut': ['Zoom out', '缩小'],
'viewer.nav.zoomOut.title': ['Zoom out (-)', '缩小(-)'],
'viewer.nav.reset': ['Reset diagram view', '重置图表视图'],
'viewer.nav.reset.title': ['Reset view (0)', '重置视图(0)'],
'viewer.nav.read': ['READ', '阅读'],
'viewer.nav.zoomIn': ['Zoom in', '放大'],
'viewer.nav.zoomIn.title': ['Zoom in (+)', '放大(+)'],
'viewer.nav.camera': ['{hint}. Reset diagram view', '{hint}。重置图表视图'],
'viewer.nav.camera.title': ['{semantic}{hint} · reset view (0)', '{semantic}{hint} · 重置视图(0)'],
'viewer.nav.camera.semantic': ['Semantic camera active · ', '语义相机已启用 · '],
'viewer.nav.level.map': ['MAP', '概览'],
'viewer.nav.level.read': ['READ', '阅读'],
'viewer.nav.level.full': ['FULL', '完整'],
'viewer.nav.level.auto': ['AUTO', '自动'],
'viewer.nav.detail.map': ['Zoom in to reveal relationship labels and node context', '放大以显示关系标签和节点上下文'],
'viewer.nav.detail.read': ['Zoom in again to reveal tags and annotations', '再次放大以显示标签和注释'],
'viewer.nav.detail.full': ['Full diagram detail', '完整图表详情'],
'viewer.intent.summary': ['{label}. {out} outgoing, {in} incoming{loops}. {total} connections. Press Enter for details.', '{label}。{out} 条出向,{in} 条入向{loops}。共 {total} 条连接。按 Enter 查看详情。'],
'viewer.intent.loops': [', {count} self loop', ',{count} 条自环'],
'viewer.common.copied': ['Copied', '已复制'],
'viewer.common.copyFailed': ['Copy failed', '复制失败'],
'viewer.common.copyLink': ['Copy link', '复制链接'],
'viewer.common.clear': ['Clear', '清除'],
'viewer.common.close': ['Close', '关闭'],
};
for (const [key, messages] of Object.entries(MESSAGE_PAIRS)) {
if (messages.length !== SUPPORTED_LOCALES.length || messages.some((message) => typeof message !== 'string')) {
throw new Error(`Incomplete Archify i18n tuple ${JSON.stringify(key)}`);
}
}
const BUILTIN_CATALOGS = Object.fromEntries(SUPPORTED_LOCALES.map((locale, index) => [
locale,
Object.fromEntries(Object.entries(MESSAGE_PAIRS).map(([key, pair]) => [key, pair[index]])),
]));
const EN = BUILTIN_CATALOGS.en;
const CANONICAL_KEYS = Object.keys(EN);
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
function extractPlaceholders(message) {
return new Set([...String(message).matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]));
}
const CANONICAL_PLACEHOLDERS = Object.fromEntries(
CANONICAL_KEYS.map((key) => [key, extractPlaceholders(EN[key])]),
);
function placeholdersMatch(expected, actual) {
if (expected.size !== actual.size) return false;
for (const token of expected) if (!actual.has(token)) return false;
return true;
}
// Runtime catalogs registered by registerLocale(), keyed by whatever locale
// tag the caller supplied (e.g. an agent-authored 'fr'). Kept separate from
// BUILTIN_CATALOGS so a caller can never accidentally shadow a shipped
// catalog with a partial one.
const RUNTIME_CATALOGS = new Map();
function catalogFor(locale) {
return RUNTIME_CATALOGS.get(locale) || BUILTIN_CATALOGS[locale];
}
// Validates caller-supplied translation data against the canonical (English)
// message-key set. Pure and side-effect free: registerLocale() calls this
// and additionally builds/installs the resolved catalog.
export function validateTranslations(translations = {}) {
const supplied = Object.keys(translations || {});
const suppliedSet = new Set(supplied);
const missingKeys = CANONICAL_KEYS.filter((key) => !suppliedSet.has(key));
const unknownKeys = supplied.filter((key) => !Object.hasOwn(CANONICAL_PLACEHOLDERS, key));
const placeholderMismatches = [];
const usableKeys = [];
for (const key of supplied) {
if (!Object.hasOwn(CANONICAL_PLACEHOLDERS, key)) continue;
const value = translations[key];
if (typeof value !== 'string' || value.length === 0) {
placeholderMismatches.push({ key, expected: [...CANONICAL_PLACEHOLDERS[key]].sort(), actual: null });
continue;
}
const actual = extractPlaceholders(value);
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], actual)) {
usableKeys.push(key);
} else {
placeholderMismatches.push({
key,
expected: [...CANONICAL_PLACEHOLDERS[key]].sort(),
actual: [...actual].sort(),
});
}
}
return {
totalKeys: CANONICAL_KEYS.length,
coveredKeys: usableKeys.length,
coverage: CANONICAL_KEYS.length ? usableKeys.length / CANONICAL_KEYS.length : 1,
missingKeys,
unknownKeys,
placeholderMismatches,
};
}
// Registers a fully-resolved catalog for an arbitrary locale tag, built by
// layering validated translations over the English base. A key that is
// missing, non-string, or whose interpolation placeholders don't match the
// canonical set falls back to its English string — partial or malformed
// translation data can never break rendering. Returns the same coverage
// report validateTranslations() would, for the caller to surface as an
// explicit fallback/coverage diagnostic before rendering.
export function registerLocale(locale, translations = {}) {
const report = validateTranslations(translations);
const catalog = { ...EN };
for (const key of CANONICAL_KEYS) {
const value = translations?.[key];
if (typeof value !== 'string' || value.length === 0) continue;
if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], extractPlaceholders(value))) {
catalog[key] = value;
}
}
RUNTIME_CATALOGS.set(locale, catalog);
return { locale, ...report };
}
export function resolveLocale(locale) {
return catalogFor(locale) ? locale : DEFAULT_LOCALE;
}
export function formatMessage(template, values = {}) {
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
Object.hasOwn(values, key) ? String(values[key]) : match
));
}
export function translateMessage(locale, key, values = {}) {
const resolved = resolveLocale(locale);
const catalog = catalogFor(resolved);
if (!Object.hasOwn(catalog, key)) {
throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolved}`);
}
return formatMessage(catalog[key], values);
}
export function translateCount(locale, key, count, values = {}) {
const suffix = count === 1 ? 'one' : 'other';
return translateMessage(locale, `${key}.${suffix}`, { ...values, count });
}
export function viewerCatalog(locale) {
const resolved = resolveLocale(locale);
return Object.fromEntries(Object.entries(catalogFor(resolved)).filter(([key]) => key.startsWith('viewer.')));
}
export function localizeTemplate(template, locale) {
return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key)));
}
export function catalogKeys() {
return [...CANONICAL_KEYS];
}
@@ -0,0 +1,42 @@
/** Serialize computed layout for dry-run / inspect (#9). */
export function componentBox(c) {
return {
id: c.id,
type: c.type,
label: c.label,
x: Math.round(c.x),
y: Math.round(c.y),
width: c.width,
height: c.height,
...(Number.isInteger(c.row) ? { row: c.row } : {}),
...(Number.isInteger(c.col) ? { col: c.col } : {}),
...(Array.isArray(c.pos) ? { pos: c.pos.map(Math.round) } : {}),
};
}
export function boundaryBox(b) {
return {
kind: b.kind,
label: b.label,
x: Math.round(b.x),
y: Math.round(b.y),
width: Math.round(b.width),
height: Math.round(b.height),
wraps: b.wraps,
};
}
export function connectionPath(conn, routed, labelAt) {
return {
from: conn.from,
to: conn.to,
label: conn.label ?? null,
variant: conn.variant ?? 'default',
route: conn.route ?? 'auto',
// These points are repair inputs: rounding a fractional port makes a
// reused waypoint diagonal relative to the actual endpoint.
points: routed.points.map(([x, y]) => [x, y]),
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
};
}
+217
View File
@@ -0,0 +1,217 @@
import { throwDiagnosticError } from './diagnostics.mjs';
import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
import { esc, textUnits } from './utils.mjs';
import { translateMessage } from './i18n.mjs';
const DEFAULT_FONT_SIZE = 8;
const DEFAULT_ITEM_GAP = 22;
const DEFAULT_LINE_GAP = 22;
const DEFAULT_SWATCH_GAP = 8;
const TEXT_ADVANCE_EM = 0.62;
const INTERACTIVE_BADGE_ALLOWANCE = 21;
export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
const obstacles = [];
for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
));
for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
obstacles.push({
kind: 'relationship-segment',
start: finitePoints[pointIndex],
end: finitePoints[pointIndex + 1],
});
}
const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
obstacles.push({ kind: 'relationship-label', ...labelRect });
}
}
return obstacles;
}
export function resolveLegend(config, catalog, presentKinds) {
const mode = config?.mode || 'auto';
if (mode === 'hidden') return [];
const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
const overrides = config?.entries || {};
return catalog.flatMap((catalogEntry) => {
const override = overrides[catalogEntry.kind] || {};
const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
const visible = override.visible === true || (selectedByMode && override.visible !== false);
if (!visible) return [];
return [{
...catalogEntry,
label: override.label || catalogEntry.label,
present: present.has(catalogEntry.kind),
interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
}];
});
}
function measuredEntryWidth(entry, fontSize, swatchGap) {
const swatchWidth = entry.swatchWidth ?? 14;
return Math.ceil(
swatchWidth
+ swatchGap
+ textUnits(entry.label) * fontSize * TEXT_ADVANCE_EM
+ (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0),
);
}
// One pure footprint calculation owns both auto-viewBox sizing and final SVG
// placement. Callers must not maintain a second approximation of legend width
// or row count; that would make generated geometry disagree with validation.
export function legendFootprint(entries, {
width,
fontSize = DEFAULT_FONT_SIZE,
itemGap = DEFAULT_ITEM_GAP,
lineGap = DEFAULT_LINE_GAP,
swatchGap = DEFAULT_SWATCH_GAP,
} = {}) {
if (!entries.length) {
return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
}
const measured = entries.map((entry) => ({
...entry,
width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
}));
const rows = [[]];
let cursor = 0;
for (const entry of measured) {
const row = rows.at(-1);
const required = (row.length ? itemGap : 0) + entry.width;
if (row.length && cursor + required > width) {
rows.push([entry]);
cursor = entry.width;
} else {
row.push(entry);
cursor += required;
}
}
return {
measured,
rows,
rowCount: rows.length,
minWidth: measured.reduce((width, entry) => Math.max(width, entry.width), 0),
extraHeight: (rows.length - 1) * lineGap,
};
}
export function measureLegend(entries, {
x,
baselineY,
width,
fontSize = DEFAULT_FONT_SIZE,
itemGap = DEFAULT_ITEM_GAP,
lineGap = DEFAULT_LINE_GAP,
swatchGap = DEFAULT_SWATCH_GAP,
minTitleY = 0,
obstacles = [],
unfit = 'error',
diagramType = 'diagram',
} = {}) {
if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
const tooWide = footprint.measured.find((entry) => entry.width > width);
if (tooWide) {
if (unfit === 'hide') return null;
const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
throwDiagnosticError(message, [{
code: 'legend/label-too-wide',
severity: 'error',
message,
subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
supportedFixes: ['shorten the legend label or use a wider viewBox'],
}]);
}
const titleY = baselineY - footprint.extraHeight - 20;
const legendTopY = titleY - 10;
if (legendTopY < minTitleY) {
if (unfit === 'hide') return null;
const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
throwDiagnosticError(message, [{
code: 'legend/vertical-overflow',
severity: 'error',
message,
subject: { diagramType, path: '/meta/legend' },
evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
}]);
}
const positioned = [];
footprint.rows.forEach((row, rowIndex) => {
let entryX = x;
const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
for (const entry of row) {
positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
entryX += entry.width + itemGap;
}
});
const legendRects = [
{ kind: 'title', x, y: legendTopY, width: 48, height: 14 },
...positioned.map((entry) => ({
kind: entry.kind,
x: entry.x,
y: entry.baseline - 10,
width: entry.width,
height: 14,
})),
];
const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
: rectsOverlap(obstacle, legendRect)
)));
if (collision) {
if (unfit === 'hide') return null;
const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
throwDiagnosticError(message, [{
code: 'legend/content-overlap',
severity: 'error',
message,
subject: { diagramType, path: '/meta/legend' },
evidence: { legendKind: collision.kind, legendRect: collision },
supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
}]);
}
return {
entries: positioned,
rowCount: footprint.rowCount,
titleY,
fontSize,
};
}
export function renderLegend({ entries, layout, renderSwatch, locale }) {
if (!entries.length) return '';
const measured = measureLegend(entries, layout);
if (!measured) return '';
const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
const renderedFontSize = measured.fontSize < 8 ? measured.fontSize + 0.5 : measured.fontSize + 2;
const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
const parts = [
` <g${rootAttributes}>`,
` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
];
for (const entry of measured.entries) {
const interactive = entry.interactive
? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
: '';
parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
parts.push(` ${renderSwatch(entry)}`);
parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="t-muted" font-size="${renderedFontSize}" font-weight="500">${esc(entry.label)}</text>`);
parts.push(' </g>');
}
parts.push(' </g>');
return parts.join('\n');
}
+440
View File
@@ -0,0 +1,440 @@
import path from 'node:path';
import {
containedBy,
isValidWindowsSmbShareName,
isWindowsIpcShare,
resolvePhysicalLocation,
sameLocation,
} from './path-semantics.mjs';
import { PortablePathError, validatePortablePath } from './portable-path.mjs';
export function canonicalFuturePath(targetPath) {
const resolution = resolvePhysicalLocation(targetPath);
if (resolution.status === 'resolved') {
if (resolution.location.kind === 'existing') return resolution.location.path;
return path.join(
resolution.location.ancestorPath,
...resolution.location.unresolved,
);
}
const output = path.resolve(targetPath);
if (resolution.reason.code === 'symlink-cycle') {
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
code: 'output/symlink-cycle',
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
subject: { output },
evidence: { relation: resolution.reason },
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
});
}
throw new OutputPathError('Output path could not be resolved safely.', {
code: 'output/path-resolution-indeterminate',
message: 'Output path could not be resolved safely for the requested filesystem location.',
subject: { output },
evidence: { relation: resolution.reason },
supportedFixes: ['use an ordinary local filesystem path that can be resolved safely, then retry'],
});
}
export function pathsAlias(leftPath, rightPath) {
const relation = sameLocation(leftPath, rightPath);
if (relation.status === 'match') return true;
if (relation.status === 'different') return false;
if (relation.reason.code === 'ancestor-not-directory') return false;
if (relation.reason.code === 'symlink-cycle') {
const output = path.resolve(relation.reason.side === 'right' ? rightPath : leftPath);
throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
code: 'output/symlink-cycle',
message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
subject: { output },
evidence: { relation: relation.reason },
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
});
}
throw new OutputPathError('Path identity could not be determined safely.', {
code: 'output/path-identity-indeterminate',
message: 'Path identity could not be determined safely for the requested filesystem location.',
subject: { left: path.resolve(leftPath), right: path.resolve(rightPath) },
evidence: { relation: relation.reason },
supportedFixes: ['use ordinary local filesystem paths whose identity can be verified, then retry'],
});
}
function pathIsInside(directoryPath, targetPath) {
const relation = containedBy(directoryPath, targetPath);
if (relation.status === 'match') return true;
if (relation.status === 'different') return false;
throw new OutputPathError('Output containment could not be determined safely.', {
code: 'output/containment-indeterminate',
message: 'Output containment could not be determined safely for the requested filesystem location.',
subject: { output: path.resolve(targetPath), cwd: path.resolve(directoryPath) },
evidence: { relation: relation.reason },
supportedFixes: ['use an output beneath an ordinary local directory whose identity can be verified'],
});
}
function authoredOutputDiagnostic(error, rawOutput) {
const absolute = error?.reason === 'absolute';
const code = absolute ? 'output/meta-absolute' : 'output/meta-path-syntax';
const message = absolute
? 'meta.output must be a relative path resolved from the current working directory.'
: 'meta.output must be a portable POSIX-relative path.';
return {
code,
message,
subject: { output: rawOutput, path: '/meta/output' },
evidence: {
reason: error?.reason || 'invalid',
...(error?.segmentIndex !== undefined ? { segmentIndex: error.segmentIndex } : {}),
...(error?.segment !== undefined ? { segment: error.segment } : {}),
...(error?.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
...(error?.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
...(error?.limit !== undefined ? { limit: error.limit } : {}),
},
supportedFixes: ['set meta.output to a portable relative .html path such as reports/diagram.html'],
};
}
function nativeOutputDiagnostic(rawOutput, reason, details = {}) {
return {
code: 'output/native-path-syntax',
message: 'The output path is not a valid native filesystem path on this host.',
subject: { output: rawOutput },
evidence: { reason, ...details },
supportedFixes: ['choose an ordinary filesystem path without device names, alternate data streams, trailing dots or spaces, or overlong components'],
};
}
function throwNativeOutputDiagnostic(rawOutput, reason, details = {}) {
const diagnostic = nativeOutputDiagnostic(rawOutput, reason, details);
throw new OutputPathError(diagnostic.message, diagnostic);
}
function windowsExtendedTailComponents(rawOutput, tail) {
const authoredComponents = tail.split('\\');
// Preserve one trailing separator for directory arguments, but never repair
// an empty component inside the raw extended namespace.
if (authoredComponents.slice(0, -1).some((component) => component.length === 0)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
return authoredComponents.filter(Boolean);
}
function rejectWindowsIpcShare(rawOutput, share) {
if (isWindowsIpcShare(share)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-ipc-namespace', { share });
}
}
function validateWindowsNativeComponent(
rawOutput,
component,
componentIndex,
{ allowReservedName = false } = {},
) {
try {
// Prefix the component so a colon is classified as an ADS separator,
// rather than allowing the generic URI detector to claim it first.
validatePortablePath(`native/${component}`, { profile: 'output' });
} catch (error) {
if (!(error instanceof PortablePathError)) throw error;
if (allowReservedName && error.reason === 'windows-reserved-name') return;
// Native Windows arguments may intentionally name an existing 8.3 alias.
// Portable authored/archive paths reject that ambiguous spelling, while
// native resolution lets the filesystem prove the existing target.
if (error.reason === 'windows-short-name') {
if (component.length <= 255) return;
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
component,
componentIndex,
utf16CodeUnits: component.length,
limit: 255,
});
}
// Native Windows filesystems bound components in UTF-16 code units. The
// stricter UTF-8 bound belongs to portable authored/archive names only.
if (error.reason === 'component-too-long' && error.utf16CodeUnits <= 255) return;
throwNativeOutputDiagnostic(rawOutput, error.reason, {
component,
componentIndex,
...(error.character !== undefined ? { character: error.character } : {}),
...(error.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
...(error.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
...(error.limit !== undefined ? { limit: error.limit } : {}),
});
}
}
function validateWindowsUncRootComponents(rawOutput, server, share) {
rejectWindowsIpcShare(rawOutput, share);
if (!isValidWindowsSmbShareName(share)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-share-name', {
share,
utf16CodeUnits: share.length,
limit: 80,
});
}
// UNC servers and shares are root components, not DOS file names. Keep the
// server's ordinary native syntax checks while allowing names such as CON.
validateWindowsNativeComponent(rawOutput, server, 0, { allowReservedName: true });
}
function windowsExtendedPathComponents(rawOutput) {
// The extended-length namespace deliberately bypasses Win32 normalization.
// Inspect its original spelling so a dot segment cannot retarget a UNC share.
if (rawOutput.includes('/')) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-separator', { character: '/' });
}
if (/^\\\\\?\\(?:GLOBALROOT|Device)(?:\\|$)/iu.test(rawOutput)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
}
const drive = rawOutput.match(/^\\\\\?\\[A-Za-z]:\\/u);
if (drive) {
return windowsExtendedTailComponents(rawOutput, rawOutput.slice(drive[0].length));
}
const uncPrefix = rawOutput.match(/^\\\\\?\\UNC\\/iu);
if (uncPrefix) {
const authoredTail = rawOutput.slice(uncPrefix[0].length);
const components = authoredTail.split('\\');
if (components.length < 2 || components[0].length === 0 || components[1].length === 0) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
validateWindowsUncRootComponents(rawOutput, components[0], components[1]);
return windowsExtendedTailComponents(rawOutput, components.slice(2).join('\\'));
}
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
function validateWindowsRawUncRoot(rawOutput) {
if (!/^[\\/]{2}/u.test(rawOutput) || /^[\\/]{2}[.?][\\/]/u.test(rawOutput)) return;
// Validate the raw server/share boundary before win32.normalize can collapse
// an empty share or mix the two UNC separator spellings.
const unc = rawOutput.match(/^([\\/])\1([^\\/]+)\1([^\\/]+)(?:[\\/]|$)/u);
if (!unc) {
throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
}
validateWindowsUncRootComponents(rawOutput, unc[2], unc[3]);
}
function windowsPathComponents(rawOutput, normalized) {
const authoredUncPrefix = /^[\\/]{2}/u.test(rawOutput);
if (/^\\\\\.\\/u.test(normalized)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
}
if (/^\\\\\?\\/u.test(normalized)) {
throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
}
if (normalized.startsWith('\\\\')) {
const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/u);
if (!unc) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
return normalized.slice(unc[0].length).split('\\').filter(Boolean);
}
if (authoredUncPrefix) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
if (normalized.startsWith('\\')) {
throwNativeOutputDiagnostic(rawOutput, 'current-drive-rooted');
}
const root = path.win32.parse(normalized).root;
return normalized
.slice(root.length)
.split(/[\\/]+/u)
// normalize() retains leading navigation for a relative path. Those dot
// segments are path syntax, not filename components subject to name rules.
.filter((component) => component && component !== '.' && component !== '..');
}
/**
* Validate command-line/default output paths using the active host's native
* syntax. Unlike authored portable paths, absolute paths and native separators
* remain supported. File outputs reject a trailing separator before native
* resolution can erase it; directory callers must opt in explicitly. The
* component bound also protects derived sidecars from failing after an
* operation has already started mutating the destination.
*/
export function validateNativeOutputPath(
rawOutput,
{ platform = process.platform, kind = 'file' } = {},
) {
if (kind !== 'file' && kind !== 'directory') {
throw new TypeError(`Unsupported native output path kind: ${JSON.stringify(kind)}`);
}
if (typeof rawOutput !== 'string' || rawOutput.length === 0) {
throwNativeOutputDiagnostic(rawOutput, 'empty');
}
if (rawOutput.includes('\0')) throwNativeOutputDiagnostic(rawOutput, 'nul-character');
if (/[\uD800-\uDFFF]/u.test(rawOutput)) {
throwNativeOutputDiagnostic(rawOutput, 'unpaired-surrogate');
}
const hasTrailingSeparator = platform === 'win32'
? /[\\/]$/u.test(rawOutput)
: rawOutput.endsWith(path.posix.sep);
if (kind === 'file' && hasTrailingSeparator) {
throwNativeOutputDiagnostic(rawOutput, 'trailing-separator', { kind });
}
let normalized;
let components;
if (platform === 'win32') {
if (/^[A-Za-z]:(?:$|[^\\/])/u.test(rawOutput)) {
throwNativeOutputDiagnostic(rawOutput, 'drive-relative');
}
const extendedPrefix = rawOutput.startsWith('\\\\?\\');
if (extendedPrefix) {
components = windowsExtendedPathComponents(rawOutput);
} else {
validateWindowsRawUncRoot(rawOutput);
normalized = path.win32.normalize(rawOutput);
components = windowsPathComponents(rawOutput, normalized);
}
for (const [componentIndex, component] of components.entries()) {
validateWindowsNativeComponent(rawOutput, component, componentIndex);
}
} else {
normalized = path.resolve(rawOutput);
const root = path.parse(normalized).root;
components = normalized.slice(root.length).split(path.sep).filter(Boolean);
for (const [componentIndex, component] of components.entries()) {
const utf8Bytes = Buffer.byteLength(component, 'utf8');
if (utf8Bytes > 255) {
throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
component,
componentIndex,
utf8Bytes,
limit: 255,
});
}
}
}
return rawOutput;
}
/** Validate a CLI directory argument before native resolution can normalize away its raw syntax. */
export function resolveNativeOutputDirectory(
rawDirectory,
{ platform = process.platform, cwd = process.cwd() } = {},
) {
validateNativeOutputPath(rawDirectory, { platform, kind: 'directory' });
const pathApi = platform === 'win32' ? path.win32 : path.posix;
return pathApi.resolve(cwd, rawDirectory);
}
export function validateAuthoredOutputPath(rawOutput, { cwd = process.cwd() } = {}) {
try {
validatePortablePath(rawOutput, { profile: 'output' });
} catch (error) {
if (!(error instanceof PortablePathError)) throw error;
const diagnostic = authoredOutputDiagnostic(error, rawOutput);
throw new OutputPathError(diagnostic.message, diagnostic);
}
if (path.posix.extname(rawOutput).toLowerCase() !== '.html') {
throw new OutputPathError('meta.output must target an .html file.', {
code: 'output/meta-extension',
message: 'meta.output must target an .html file.',
subject: { output: rawOutput, path: '/meta/output' },
supportedFixes: ['change meta.output to a portable path ending in .html'],
});
}
const outputPath = path.resolve(cwd, rawOutput);
if (path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
throw new OutputPathError('meta.output must resolve to an .html file.', {
code: 'output/meta-resolved-extension',
message: 'meta.output must resolve to an .html file after symbolic links are followed.',
subject: { output: rawOutput },
supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
});
}
if (!pathIsInside(cwd, outputPath)) {
throw new OutputPathError('meta.output must stay inside the current working directory.', {
code: 'output/meta-outside-cwd',
message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
subject: { output: rawOutput, cwd: path.resolve(cwd) },
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
});
}
return rawOutput;
}
export class OutputPathError extends Error {
constructor(message, diagnostic) {
super(message);
this.name = 'OutputPathError';
this.archifyDiagnostics = [{
severity: 'error',
subject: {},
evidence: {},
supportedFixes: [],
...diagnostic,
}];
}
}
export function resolveOutputPath({
requestedOutput,
authoredOutput,
defaultOutput,
inputPaths = [],
inputDescription = 'an input',
otherOutputPaths = [],
cwd = process.cwd(),
requiredExtension = '.html',
platform = process.platform,
}) {
if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput, { cwd });
const source = requestedOutput !== undefined
? 'cli'
: (authoredOutput !== undefined ? 'meta' : 'default');
const rawOutput = source === 'cli'
? requestedOutput
: (source === 'meta' ? authoredOutput : defaultOutput);
if (source !== 'meta') validateNativeOutputPath(rawOutput, { platform, kind: 'file' });
const outputPath = path.resolve(cwd, rawOutput);
for (const inputPath of inputPaths) {
if (!pathsAlias(outputPath, inputPath)) continue;
throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
code: 'output/input-alias',
message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
subject: { output: outputPath, input: path.resolve(inputPath) },
supportedFixes: ['choose an output path that is distinct from every input path'],
});
}
for (const otherOutputPath of otherOutputPaths) {
if (!pathsAlias(outputPath, otherOutputPath)) continue;
throw new OutputPathError('Output targets must use distinct paths.', {
code: 'output/target-alias',
message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
supportedFixes: ['choose distinct paths for every generated output'],
});
}
// Keep explicit CLI directories unrestricted, but reject mistaken file types.
// Alias checks above retain priority when a target would overwrite an input.
if (source === 'cli') {
const resolvedOutput = canonicalFuturePath(outputPath);
const authoredExtension = path.extname(rawOutput).toLowerCase();
const existingWindowsHtmlAlias = platform === 'win32'
&& requiredExtension === '.html'
&& authoredExtension === '.htm'
&& path.extname(resolvedOutput).toLowerCase() === requiredExtension
&& pathsAlias(outputPath, resolvedOutput);
const authoredMatches = authoredExtension === requiredExtension || existingWindowsHtmlAlias;
const resolvedMatches = path.extname(resolvedOutput).toLowerCase() === requiredExtension;
if (!authoredMatches || !resolvedMatches) {
const message = `CLI output must ${authoredMatches ? 'resolve to' : 'target'} a ${requiredExtension} file.`;
throw new OutputPathError(message, {
code: authoredMatches ? 'output/cli-resolved-extension' : 'output/cli-extension',
message,
subject: { output: rawOutput },
evidence: { resolvedOutput, requiredExtension },
supportedFixes: [`choose a path ending in ${requiredExtension} whose symbolic-link target also ends in ${requiredExtension}`],
});
}
}
return {
outputPath,
source,
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,408 @@
const PORTABLE_PATH_PROFILES = new Set(['output', 'repo', 'archive']);
const WINDOWS_SAFE_PROFILES = new Set(['output', 'archive']);
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
const WINDOWS_SHORT_NAME = /~[1-9][0-9]*(?:\.|$)/iu;
const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
const CONTROL_CHARACTER = /\p{Cc}/u;
const WINDOWS_INVALID_CHARACTER = /[<>"|?*]/u;
const MAX_SEMANTIC_VARIANTS = 32;
export class PortablePathError extends Error {
constructor(message, {
code,
reason,
value,
profile,
segment,
segmentIndex,
character,
index,
conflictIndex,
conflictValue,
collisionKind,
semantics,
pathPart,
conflictPathPart,
utf8Bytes,
utf16CodeUnits,
limit,
} = {}) {
super(message);
this.name = 'PortablePathError';
this.code = code;
this.reason = reason;
this.value = value;
this.profile = profile;
if (segment !== undefined) this.segment = segment;
if (segmentIndex !== undefined) this.segmentIndex = segmentIndex;
if (character !== undefined) this.character = character;
if (index !== undefined) this.index = index;
if (conflictIndex !== undefined) this.conflictIndex = conflictIndex;
if (conflictValue !== undefined) this.conflictValue = conflictValue;
if (collisionKind !== undefined) this.collisionKind = collisionKind;
if (semantics !== undefined) this.semantics = semantics;
if (pathPart !== undefined) this.pathPart = pathPart;
if (conflictPathPart !== undefined) this.conflictPathPart = conflictPathPart;
if (utf8Bytes !== undefined) this.utf8Bytes = utf8Bytes;
if (utf16CodeUnits !== undefined) this.utf16CodeUnits = utf16CodeUnits;
if (limit !== undefined) this.limit = limit;
}
}
function pathError(value, profile, reason, message, details = {}) {
return new PortablePathError(message, {
code: `portable-path/${reason}`,
reason,
value,
profile,
...details,
});
}
function assertProfile(profile) {
if (PORTABLE_PATH_PROFILES.has(profile)) return;
throw pathError(
undefined,
profile,
'profile',
`Portable path profile must be one of: ${[...PORTABLE_PATH_PROFILES].join(', ')}.`,
);
}
export function validatePortablePath(value, options = {}) {
const profile = options?.profile;
assertProfile(profile);
if (typeof value !== 'string') {
throw pathError(value, profile, 'type', 'Portable path must be a string.');
}
if (value.length === 0) {
throw pathError(value, profile, 'empty', 'Portable path must not be empty.');
}
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('/') || value.startsWith('\\')) {
throw pathError(value, profile, 'absolute', 'Portable path must be relative.');
}
if (/^[A-Za-z]:/u.test(value)) {
throw pathError(
value,
profile,
'drive-relative',
'Portable path must not use a drive-relative Windows path.',
);
}
if (URI_SCHEME.test(value)) {
throw pathError(value, profile, 'uri', 'Portable path must not be a URI.');
}
if (value.includes('\\')) {
throw pathError(
value,
profile,
'backslash',
'Portable path must use forward slashes as separators.',
);
}
const segments = value.split('/');
for (const [segmentIndex, segment] of segments.entries()) {
const segmentDetails = { segment, segmentIndex };
if (segment.length === 0) {
throw pathError(
value,
profile,
'empty-segment',
'Portable path must not contain empty segments.',
segmentDetails,
);
}
if (segment === '.' || segment === '..') {
throw pathError(
value,
profile,
'dot-segment',
'Portable path must not contain dot segments.',
segmentDetails,
);
}
const controlMatch = segment.match(CONTROL_CHARACTER);
if (controlMatch) {
throw pathError(
value,
profile,
'control',
'Portable path must not contain control characters.',
{ ...segmentDetails, character: controlMatch[0] },
);
}
const surrogateMatch = segment.match(/[\uD800-\uDFFF]/u);
if (surrogateMatch) {
throw pathError(
value,
profile,
'unpaired-surrogate',
'Portable path must not contain unpaired UTF-16 surrogates.',
{ ...segmentDetails, character: surrogateMatch[0] },
);
}
if (!WINDOWS_SAFE_PROFILES.has(profile)) continue;
if (segment.includes(':')) {
throw pathError(
value,
profile,
'windows-ads',
'Portable path must not select a Windows alternate data stream.',
{ ...segmentDetails, character: ':' },
);
}
const invalidMatch = segment.match(WINDOWS_INVALID_CHARACTER);
if (invalidMatch) {
throw pathError(
value,
profile,
'windows-invalid-character',
'Portable path contains a character that is invalid in Windows file names.',
{ ...segmentDetails, character: invalidMatch[0] },
);
}
if (/[. ]$/u.test(segment)) {
throw pathError(
value,
profile,
'windows-trailing-dot-space',
'Portable path segments must not end with a dot or space.',
segmentDetails,
);
}
if (WINDOWS_RESERVED_NAME.test(segment)) {
throw pathError(
value,
profile,
'windows-reserved-name',
'Portable path must not use a reserved Windows device name.',
segmentDetails,
);
}
if (WINDOWS_SHORT_NAME.test(segment)) {
throw pathError(
value,
profile,
'windows-short-name',
'Portable path must not use a Windows 8.3 short-name shape.',
segmentDetails,
);
}
const utf8Bytes = Buffer.byteLength(segment, 'utf8');
const utf16CodeUnits = segment.length;
if (utf8Bytes > 255 || utf16CodeUnits > 255) {
throw pathError(
value,
profile,
'component-too-long',
'Portable path segments must fit both UTF-8 and UTF-16 filesystem component limits.',
{
...segmentDetails,
utf8Bytes,
utf16CodeUnits,
limit: 255,
},
);
}
}
return value;
}
function collisionError(values, profile, index, conflictIndex, reason, details = {}) {
return new PortablePathError(
`Portable paths at indexes ${conflictIndex} and ${index} collide under portable filesystem semantics.`,
{
code: 'portable-path/collision',
reason,
value: values[index],
profile,
index,
conflictIndex,
conflictValue: values[conflictIndex],
...details,
},
);
}
function createSemanticIndex() {
return new Map();
}
function semanticEnvelope(value, profile, index) {
const variants = [value];
const seen = new Set(variants);
const transforms = [
(candidate) => candidate.normalize('NFC'),
(candidate) => candidate.normalize('NFD'),
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
(candidate) => candidate.toLocaleLowerCase('en-US'),
// path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
(candidate) => candidate.toLocaleUpperCase('en-US'),
];
for (let cursor = 0; cursor < variants.length; cursor += 1) {
for (const transform of transforms) {
const transformed = transform(variants[cursor]);
if (seen.has(transformed)) continue;
if (variants.length >= MAX_SEMANTIC_VARIANTS) {
throw pathError(
value,
profile,
'semantic-expansion',
'Portable path semantic comparison exceeded its bounded Unicode expansion.',
{ index, limit: MAX_SEMANTIC_VARIANTS },
);
}
seen.add(transformed);
variants.push(transformed);
}
}
return variants;
}
function classifySemanticCollision(value, conflictValue) {
if (value === conflictValue) return 'exact';
if (value.normalize('NFC') === conflictValue.normalize('NFC')) return 'normalization';
const directLowerMatch = value.toLocaleLowerCase('en-US')
=== conflictValue.toLocaleLowerCase('en-US');
const directUpperMatch = value.toLocaleUpperCase('en-US')
=== conflictValue.toLocaleUpperCase('en-US');
return directLowerMatch || directUpperMatch ? 'case' : 'case-and-normalization';
}
function findSemanticCollision(semanticIndex, value, profile, sourceIndex) {
const variants = semanticEnvelope(value, profile, sourceIndex);
for (const variant of variants) {
const entry = semanticIndex.get(variant);
if (entry) {
return {
entry,
variants,
semantics: classifySemanticCollision(value, entry.value),
};
}
}
return { entry: null, variants, semantics: null };
}
function rememberSemanticEntry(semanticIndex, entry, variants) {
for (const variant of variants) {
if (!semanticIndex.has(variant)) semanticIndex.set(variant, entry);
}
}
export function validatePortablePathSet(values, options = {}) {
const profile = options?.profile;
assertProfile(profile);
if (!Array.isArray(values)) {
throw pathError(values, profile, 'set-type', 'Portable path set must be an array.');
}
const leafEntries = createSemanticIndex();
const directoryEntries = createSemanticIndex();
for (const [index, value] of values.entries()) {
try {
validatePortablePath(value, { profile });
} catch (error) {
if (error instanceof PortablePathError && error.index === undefined) error.index = index;
throw error;
}
const leafCollision = findSemanticCollision(leafEntries, value, profile, index);
if (leafCollision.entry) {
const conflictIndex = leafCollision.entry.index;
throw collisionError(
values,
profile,
index,
conflictIndex,
leafCollision.semantics === 'exact' ? 'duplicate' : leafCollision.semantics,
{
collisionKind: 'entry',
semantics: leafCollision.semantics,
pathPart: value,
conflictPathPart: leafCollision.entry.value,
},
);
}
const segments = value.split('/');
const prefixes = segments.slice(0, -1).map((_, prefixIndex) => (
segments.slice(0, prefixIndex + 1).join('/')
));
for (const prefix of prefixes) {
const directoryCollision = findSemanticCollision(directoryEntries, prefix, profile, index);
if (directoryCollision.entry && directoryCollision.semantics !== 'exact') {
throw collisionError(
values,
profile,
index,
directoryCollision.entry.index,
`directory-${directoryCollision.semantics}`,
{
collisionKind: 'directory-spelling',
semantics: directoryCollision.semantics,
pathPart: prefix,
conflictPathPart: directoryCollision.entry.value,
},
);
}
const fileCollision = findSemanticCollision(leafEntries, prefix, profile, index);
if (fileCollision.entry) {
throw collisionError(
values,
profile,
index,
fileCollision.entry.index,
fileCollision.semantics === 'exact'
? 'tree-file'
: `tree-file-${fileCollision.semantics}`,
{
collisionKind: 'tree-file',
semantics: fileCollision.semantics,
pathPart: prefix,
conflictPathPart: fileCollision.entry.value,
},
);
}
}
const directoryCollision = findSemanticCollision(directoryEntries, value, profile, index);
if (directoryCollision.entry) {
throw collisionError(
values,
profile,
index,
directoryCollision.entry.index,
directoryCollision.semantics === 'exact'
? 'tree-file'
: `tree-file-${directoryCollision.semantics}`,
{
collisionKind: 'tree-file',
semantics: directoryCollision.semantics,
pathPart: value,
conflictPathPart: directoryCollision.entry.value,
},
);
}
rememberSemanticEntry(leafEntries, { index, value }, leafCollision.variants);
for (const prefix of prefixes) {
rememberSemanticEntry(
directoryEntries,
{ index, value: prefix },
semanticEnvelope(prefix, profile, index),
);
}
}
return values;
}
@@ -0,0 +1,352 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { throwDiagnosticError, withDiagnosticRecordingSuppressed } from './diagnostics.mjs';
import { sameEntry } from './path-semantics.mjs';
import { parseRepositoryRemote, redactRepositoryRemote, repositorySourceHref } from './repository-location.mjs';
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
throwDiagnosticError(message, [{
code,
severity: 'error',
message,
subject: { surface: 'repository-evidence', ...subject },
evidence,
supportedFixes,
}]);
}
function runGit(repoRoot, args) {
// 固定 SHA 的来源必须读取原始对象,不能使用本地 replacement refs 的替换内容。
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, ...args], {
encoding: 'utf8',
maxBuffer: MAX_SOURCE_BYTES,
});
if (result.error) evidenceFailure('repository-evidence/git-unavailable', `Could not run Git: ${result.error.message}`, {
evidence: { reason: result.error.message },
supportedFixes: ['install Git and ensure it is available on PATH'],
});
return result;
}
// Check types in one session, then read only blobs whose cited lines need
// verification. Path-only references never require loading the file contents.
function prefetchBlobs(repoRoot, objectNeedsContent) {
const blobs = readBatchObjects(repoRoot, [...objectNeedsContent.keys()], false);
if (!blobs) return null;
const readable = [...objectNeedsContent].filter(([object, needsContent]) => {
const blob = blobs.get(object);
return needsContent && blob?.type === 'blob' && blob.size <= MAX_SOURCE_BYTES;
}).map(([object]) => object);
const contents = readBatchObjects(repoRoot, readable, true);
if (contents) for (const [object, blob] of contents) blobs.set(object, blob);
// Failed or oversized reads fall back in source order to the original
// per-file path, preserving its size limit and diagnostic behavior.
return blobs;
}
function readBatchObjects(repoRoot, objects, includeContent) {
if (!objects.length) return new Map();
const mode = includeContent ? '--batch' : '--batch-check';
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, 'cat-file', mode], {
input: objects.join('\n') + '\n',
maxBuffer: 64 * 1024 * 1024,
});
if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) return null;
const buffer = result.stdout;
const blobs = new Map();
let cursor = 0;
for (const object of objects) {
const newline = buffer.indexOf(0x0a, cursor);
if (newline < 0) return null;
const header = buffer.toString('utf8', cursor, newline);
cursor = newline + 1;
if (header.endsWith(' missing')) {
blobs.set(object, { missing: true });
continue;
}
const parts = header.split(' ');
const size = Number(parts[2]);
if (parts.length !== 3 || !Number.isSafeInteger(size) || size < 0) return null;
const blob = { type: parts[1], size };
if (includeContent) {
if (cursor + size >= buffer.length || buffer[cursor + size] !== 0x0a) return null;
blob.content = buffer.toString('utf8', cursor, cursor + size);
cursor += size + 1;
}
blobs.set(object, blob);
}
return blobs;
}
function gitValue(repoRoot, args, failure) {
const result = runGit(repoRoot, args);
if (result.status !== 0) evidenceFailure('repository-evidence/git-command', failure, {
evidence: { gitArgs: args, exitCode: result.status },
supportedFixes: ['use the intended local Git repository and verify its origin and revision'],
});
return result.stdout.trim();
}
function verifiedSourcePath(value, where) {
const sourcePath = String(value || '');
// path-contract-allow: git-path -- Git tree entries use repository-relative POSIX syntax.
if (!sourcePath || sourcePath.startsWith('/') || sourcePath.includes('\\') || CONTROL_CHARACTER_RE.test(sourcePath)) {
evidenceFailure('repository-evidence/path-invalid', `${where} must be a repo-relative POSIX path.`, {
subject: { path: where },
evidence: { authoredPath: sourcePath },
supportedFixes: ['use a repository-relative path with forward slashes'],
});
}
const segments = sourcePath.split('/');
if (segments.some((segment) => !segment || segment === '.' || segment === '..') || segments[0] === '.git') {
evidenceFailure('repository-evidence/path-escape', `${where} must stay inside the repository and may not address .git.`, {
subject: { path: where },
evidence: { authoredPath: sourcePath },
supportedFixes: ['remove empty, dot, parent, or .git path segments'],
});
}
return segments.join('/');
}
function sourceLineCount(content) {
if (!content.length) return 0;
const lines = content.split(/\r\n|\n|\r/);
return lines.length - (/(?:\r\n|\n|\r)$/.test(content) ? 1 : 0);
}
// Every diagram type carries its nodes under a different property name, and
// source evidence is authored on those nodes. One table keeps the verification
// below identical for all five types instead of branching per type: the only
// per-type fact is which array to read and which JSON pointer to quote back.
const EVIDENCE_NODE_COLLECTIONS = {
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
};
function evidenceNodes(diagramType, diagram) {
const collection = EVIDENCE_NODE_COLLECTIONS[diagramType];
if (!collection) return null;
return { collection, nodes: Array.isArray(diagram?.[collection]) ? diagram[collection] : [] };
}
export function hasRepositoryEvidence(diagramType, diagram) {
const authored = evidenceNodes(diagramType, diagram);
if (!authored) return false;
return Boolean(diagram?.meta?.repository) || authored.nodes.some((node) => Array.isArray(node?.sources) && node.sources.length);
}
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
const { collection, nodes: authoredNodes } = evidenceNodes(diagramType, diagram);
const repository = diagram.meta?.repository;
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
subject: { path: '/meta/repository', diagramType, collection },
supportedFixes: [`add the pinned repository metadata or remove /${collection} sources`],
});
if (!FULL_SHA_RE.test(repository.revision || '')) {
evidenceFailure('repository-evidence/revision-invalid', '/meta/repository/revision must be a full 40-character commit SHA.', {
subject: { path: '/meta/repository/revision' },
evidence: { revision: repository.revision },
supportedFixes: ['pin one full 40-character commit SHA'],
});
}
const location = parseRepositoryRemote(repository.url, { authored: true });
if (!location) {
// A filesystem path is the common authoring mistake: the field carries the
// remote origin identity, which `git remote get-url origin` reports.
const filesystemPath = /^(?:[\\/]|~|\.{1,2}(?:[\\/]|$)|[A-Za-z]:[\\/])/.test(String(repository.url ?? ''));
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a credential-free HTTP(S) or Git SSH repository address without query, fragment, or dot segments.', {
subject: { path: '/meta/repository/url' },
evidence: filesystemPath ? { authoredValueLooksLike: 'local filesystem path; the expected value is the remote origin address' } : {},
supportedFixes: ['run `git remote get-url origin` inside --repo-root and declare that credential-free address', 'use link_mode: local-only for internal repositories'],
});
}
const linkMode = repository.link_mode ?? 'web';
if (!['web', 'local-only'].includes(linkMode)) evidenceFailure('repository-evidence/link-mode-invalid', 'Repository link_mode must be web or local-only.');
if (repository.provider !== undefined && (!['github', 'gitee'].includes(repository.provider) || repository.provider !== location.provider)) {
evidenceFailure('repository-evidence/provider-invalid', 'Repository provider must match its supported public host (github.com or gitee.com).', {
subject: { path: '/meta/repository/provider' },
supportedFixes: ['use the matching provider or omit provider and select link_mode: local-only'],
});
}
if (linkMode === 'web' && (!location.provider || location.protocol !== 'https:' || location.endpoint !== 'standard' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(location.path))) {
evidenceFailure('repository-evidence/links-unsupported', 'Web source links require a canonical GitHub or Gitee HTTPS owner/repository URL.', {
subject: { path: '/meta/repository/url' },
supportedFixes: ['use a canonical GitHub or Gitee URL, or select link_mode: local-only to retain local verification without web links'],
});
}
if (!repoRootInput) {
evidenceFailure('repository-evidence/root-required', 'This diagram declares source evidence. Pass --repo-root <repository> so Archify can verify it before rendering.', {
subject: { path: '/meta/repository' },
supportedFixes: ['pass --repo-root with the matching local Git checkout'],
});
}
const requestedRoot = path.resolve(repoRootInput);
let realRoot;
try {
realRoot = fs.realpathSync(requestedRoot);
} catch (error) {
evidenceFailure('repository-evidence/root-unreadable', `Could not resolve evidence repository root "${requestedRoot}": ${error.message}`, {
subject: { repoRoot: requestedRoot },
evidence: { reason: error.message },
supportedFixes: ['pass one readable local repository directory'],
});
}
const gitRoot = gitValue(realRoot, ['rev-parse', '--show-toplevel'], `Evidence root "${realRoot}" is not a Git repository.`);
const rootIdentity = sameEntry(realRoot, gitRoot);
if (rootIdentity.status === 'unknown') {
evidenceFailure('repository-evidence/root-identity-indeterminate', 'Could not determine whether the evidence root is the Git top-level directory.', {
subject: { repoRoot: realRoot },
evidence: { gitTopLevel: gitRoot, relation: rootIdentity.reason },
supportedFixes: ['pass the readable Git top-level directory using its canonical filesystem path'],
});
}
if (rootIdentity.status === 'different') {
evidenceFailure('repository-evidence/root-not-top-level', `Evidence root must be the Git top-level directory: ${gitRoot}`, {
subject: { repoRoot: realRoot },
evidence: { gitTopLevel: gitRoot },
supportedFixes: [`pass --repo-root ${gitRoot}`],
});
}
const origin = gitValue(realRoot, ['remote', 'get-url', 'origin'], 'Evidence repository must have an origin remote.');
if (parseRepositoryRemote(origin)?.identity !== location.identity) {
const safeOrigin = redactRepositoryRemote(origin);
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(safeOrigin)} does not match ${JSON.stringify(repository.url)}.`, {
subject: { repoRoot: realRoot },
evidence: { localOrigin: safeOrigin, authoredRepository: repository.url },
supportedFixes: ['use the matching local checkout or correct the authored repository URL'],
});
}
const revision = repository.revision.toLowerCase();
const commit = runGit(realRoot, ['cat-file', '-e', `${revision}^{commit}`]);
if (commit.status !== 0) {
evidenceFailure('repository-evidence/revision-unavailable', `Evidence revision ${revision} is not available in the local repository.`, {
subject: { repoRoot: realRoot },
evidence: { revision },
supportedFixes: ['fetch the pinned commit or pin an available full commit SHA'],
});
}
// The batch is an optimization only: every path, line-range, file and line
// check still runs in source order in the verification loop below, so a
// citation the batch cannot answer for never reorders the first diagnostic.
const citedObjects = new Map();
for (const [nodeIndex, node] of authoredNodes.entries()) {
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
for (const [sourceIndex, authored] of node.sources.entries()) {
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
let sourcePath;
try {
sourcePath = withDiagnosticRecordingSuppressed(() => verifiedSourcePath(authored.path, `${at}/path`));
} catch {
continue;
}
const object = `${revision}:${sourcePath}`;
citedObjects.set(object, citedObjects.get(object) || Boolean(authored.line));
}
}
const prefetchedBlobs = prefetchBlobs(realRoot, citedObjects);
const nodes = Object.create(null);
let referenceCount = 0;
for (const [nodeIndex, node] of authoredNodes.entries()) {
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
// `componentId` shipped with the architecture-only path; keep it beside the
// type-neutral `nodeId` so existing agent handling stays valid.
const nodeSubject = collection === 'components'
? { diagramType, collection, nodeId: node.id, componentId: node.id }
: { diagramType, collection, nodeId: node.id };
const verified = [];
for (const [sourceIndex, authored] of node.sources.entries()) {
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
const where = `${at}/path`;
const source = {
path: verifiedSourcePath(authored.path, where),
...(authored.line ? { line: authored.line } : {}),
...(authored.end_line ? { endLine: authored.end_line } : {}),
...(authored.label ? { label: authored.label } : {}),
};
if (source.endLine && !source.line) {
evidenceFailure('repository-evidence/line-required', `${at}/end_line requires line.`, {
subject: { path: `${at}/end_line`, ...nodeSubject },
supportedFixes: ['add line or remove end_line'],
});
}
if (source.endLine && source.endLine < source.line) {
evidenceFailure('repository-evidence/line-range-invalid', `${at}/end_line must be greater than or equal to line.`, {
subject: { path: at, ...nodeSubject },
evidence: { line: source.line, endLine: source.endLine },
supportedFixes: ['use an end_line greater than or equal to line'],
});
}
const object = `${revision}:${source.path}`;
const prefetched = prefetchedBlobs ? prefetchedBlobs.get(object) : undefined;
const objectIsBlob = prefetched
? !prefetched.missing && prefetched.type === 'blob'
: (() => {
const type = runGit(realRoot, ['cat-file', '-t', object]);
return type.status === 0 && type.stdout.trim() === 'blob';
})();
if (!objectIsBlob) {
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
subject: { path: where, ...nodeSubject },
evidence: { sourcePath: source.path, revision },
supportedFixes: ['use a file path that exists at the pinned revision'],
});
}
if (source.line) {
const content = prefetched && Object.hasOwn(prefetched, 'content')
? { status: 0, stdout: prefetched.content }
: runGit(realRoot, ['show', object]);
if (content.status !== 0) evidenceFailure('repository-evidence/file-unreadable', `${where} could not be read at revision ${revision}.`, {
subject: { path: where, ...nodeSubject },
evidence: { sourcePath: source.path, revision },
supportedFixes: ['verify the pinned blob is readable in the local checkout'],
});
const lineCount = sourceLineCount(content.stdout);
const requestedLine = source.endLine || source.line;
if (requestedLine > lineCount) {
evidenceFailure('repository-evidence/line-out-of-range', `${at} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
subject: { path: at, ...nodeSubject },
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
supportedFixes: ['use a line range that exists at the pinned revision'],
});
}
}
verified.push({ ...source, ...(linkMode === 'web' ? { href: repositorySourceHref(location.provider, location.url, revision, source) } : {}) });
referenceCount += 1;
}
nodes[node.id] = verified;
}
if (referenceCount === 0) {
evidenceFailure('repository-evidence/source-required', `/meta/repository requires at least one /${collection} source reference.`, {
subject: { path: '/meta/repository', diagramType, collection },
supportedFixes: [`add at least one verified /${collection} source or remove repository metadata`],
});
}
return {
schemaVersion: 1,
verified: true,
repository: {
url: location.url,
revision,
shortRevision: revision.slice(0, 7),
label: location.provider === 'github' ? location.path : location.url.replace(/^(?:https?:\/\/|ssh:\/\/git@|git@)/, ''),
...(linkMode === 'web' ? { href: `${location.url}/tree/${revision}` } : { linkMode }),
},
referenceCount,
nodes,
};
}
@@ -0,0 +1,59 @@
// Repository identity and forge links are independent of local Git object checks.
// This module never contacts a remote server or reads the user's SSH config.
export function parseRepositoryRemote(value, { authored = false } = {}) {
if (typeof value !== 'string') return null;
const raw = authored ? value : value.trim();
if (!raw || /[\s\\\u0000-\u001f\u007f?#]/.test(raw)) return null;
const scp = raw.match(/^git@([^/:]+):(.+)$/);
const scpAbsolute = Boolean(scp && scp[2].startsWith('/'));
const expanded = scp ? `ssh://git@${scp[1]}/${scp[2].replace(/^\//, '')}` : raw;
const match = expanded.match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i);
if (!match) return null;
// Validate the original path before URL parsing can collapse dot segments.
let segments;
try {
segments = match[3].replace(/\/$/, '').split('/');
// Git passes SCP paths literally; percent escapes are decoded only in URIs.
if (!scp) segments = segments.map(decodeURIComponent);
}
catch { return null; }
if (segments.some((part) => !part || part === '.' || part === '..' || /[/\\\s\u0000-\u001f\u007f?#]/.test(part))) return null;
let url;
try { url = new URL(expanded); } catch { return null; }
const protocol = url.protocol;
if (protocol === 'ssh:' && (url.username !== 'git' || url.password)) return null;
if (authored && protocol !== 'ssh:' && (url.username || url.password)) return null;
const hostname = url.hostname.toLowerCase();
if (!hostname) return null;
const provider = hostname === 'github.com' ? 'github' : hostname === 'gitee.com' ? 'gitee' : null;
const last = segments.length - 1;
if (provider) segments[last] = segments[last].replace(provider === 'github' ? /\.git$/i : /\.git$/, '');
if (!segments[last] || segments[last] === '.' || segments[last] === '..') return null;
const repositoryPath = segments.join('/');
// Only known forges map HTTPS and SSH to one repository namespace. Other
// hosts retain transport, port and remote-relative/absolute path semantics.
const port = url.port || (protocol === 'ssh:' ? '22' : protocol === 'https:' ? '443' : '80');
const endpoint = provider && ((protocol === 'https:' && port === '443') || (protocol === 'ssh:' && port === '22'))
? 'standard' : `${protocol}${port}`;
const pathKind = provider ? 'repository' : scp && !scpAbsolute ? 'relative' : 'absolute';
// path-contract-allow: url-path -- GitHub repository names are case-insensitive URL identities.
const identityPath = provider === 'github' ? repositoryPath.toLowerCase() : repositoryPath;
const encodedPath = segments.map(encodeURIComponent).join('/');
const canonicalUrl = scp ? `git@${hostname}:${scpAbsolute ? '/' : ''}${repositoryPath}`
: `${protocol}//${protocol === 'ssh:' ? 'git@' : ''}${url.host}/${encodedPath}`;
return { identity: JSON.stringify([hostname, endpoint, pathKind, identityPath]), url: canonicalUrl, provider, protocol, path: repositoryPath, endpoint };
}
export function redactRepositoryRemote(value) {
return String(value || '')
.replace(/^((?:https?|ssh):\/\/)[^/]*@/i, '$1REDACTED@')
.replace(/[?#].*$/s, '?REDACTED');
}
export function repositorySourceHref(provider, url, revision, source) {
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
const end = source.endLine && source.endLine !== source.line
? `-${provider === 'github' ? 'L' : ''}${source.endLine}` : '';
const fragment = source.line ? `#L${source.line}${end}` : '';
return `${url}/blob/${revision}/${encodedPath}${fragment}`;
}
@@ -0,0 +1,614 @@
import { recordDiagnostic } from './diagnostics.mjs';
import {
asArray,
isFinitePoint,
normalizeRoutePoints,
properSegmentIntersection,
segmentIntersectsRect,
} from './geometry.mjs';
const DEFAULTS = Object.freeze({
clearance: 2,
minimumDetourRatio: 2.5,
minimumExcessLengthPx: 200,
minimumEmptyExcursionPx: 96,
maximumObstacleCount: 80,
sharedCorridorMinimumPx: 32,
});
const OUTWARD = Object.freeze({
left: [-1, 0],
right: [1, 0],
top: [0, -1],
bottom: [0, 1],
});
function rounded(value) {
return Math.round(value * 100) / 100;
}
function pointKey(point) {
return `${point[0]}\u0000${point[1]}`;
}
class MinHeap {
constructor() {
this.entries = [];
}
push(key, distance) {
const entry = { key, distance };
this.entries.push(entry);
let index = this.entries.length - 1;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (this.entries[parent].distance <= distance) break;
this.entries[index] = this.entries[parent];
index = parent;
}
this.entries[index] = entry;
}
pop() {
if (!this.entries.length) return null;
const first = this.entries[0];
const last = this.entries.pop();
if (!this.entries.length) return first;
let index = 0;
while (true) {
const left = index * 2 + 1;
const right = left + 1;
if (left >= this.entries.length) break;
const child = right < this.entries.length
&& this.entries[right].distance < this.entries[left].distance ? right : left;
if (this.entries[child].distance >= last.distance) break;
this.entries[index] = this.entries[child];
index = child;
}
this.entries[index] = last;
return first;
}
}
function orthogonalLength(points) {
let total = 0;
for (let index = 0; index < points.length - 1; index += 1) {
const [x1, y1] = points[index];
const [x2, y2] = points[index + 1];
if (x1 !== x2 && y1 !== y2) return null;
total += Math.abs(x2 - x1) + Math.abs(y2 - y1);
}
return total;
}
function inferredSide(points, endpoint) {
if (points.length < 2) return null;
const start = endpoint === 'source' ? points[0] : points.at(-2);
const end = endpoint === 'source' ? points[1] : points.at(-1);
const dx = end[0] - start[0];
const dy = end[1] - start[1];
if (endpoint === 'source') {
if (dx > 0 && dy === 0) return 'right';
if (dx < 0 && dy === 0) return 'left';
if (dy > 0 && dx === 0) return 'bottom';
if (dy < 0 && dx === 0) return 'top';
} else {
if (dx > 0 && dy === 0) return 'left';
if (dx < 0 && dy === 0) return 'right';
if (dy > 0 && dx === 0) return 'top';
if (dy < 0 && dx === 0) return 'bottom';
}
return null;
}
function moveOutward(point, side, distance) {
const [dx, dy] = OUTWARD[side] || [0, 0];
return [point[0] + dx * distance, point[1] + dy * distance];
}
function expandedRect(rect, clearance) {
return {
id: rect.id,
x: rect.x - clearance,
y: rect.y - clearance,
width: rect.width + clearance * 2,
height: rect.height + clearance * 2,
};
}
function boundsForRects(rects) {
const usable = [...rects].filter((rect) => (
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
&& rect.width >= 0 && rect.height >= 0
));
if (!usable.length) return null;
const left = Math.min(...usable.map((rect) => rect.x));
const top = Math.min(...usable.map((rect) => rect.y));
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
return { left, top, right, bottom, width: right - left, height: bottom - top };
}
function boundsForPoints(points) {
if (!points.length) return null;
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const left = Math.min(...xs);
const top = Math.min(...ys);
const right = Math.max(...xs);
const bottom = Math.max(...ys);
return { left, top, right, bottom, width: right - left, height: bottom - top };
}
function outsideExcursion(routeBounds, contentBounds) {
if (!routeBounds || !contentBounds) return null;
const sides = {
left: Math.max(0, contentBounds.left - routeBounds.left),
top: Math.max(0, contentBounds.top - routeBounds.top),
right: Math.max(0, routeBounds.right - contentBounds.right),
bottom: Math.max(0, routeBounds.bottom - contentBounds.bottom),
};
return { ...sides, maximum: Math.max(...Object.values(sides)) };
}
function pointDistanceFromRect(point, rect) {
const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
return dx + dy;
}
function emptyControlPointClearance(points, contentRects) {
const controls = points.slice(1, -1);
const rects = [...contentRects].filter((rect) => (
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
&& rect.width >= 0 && rect.height >= 0
));
if (!controls.length || !rects.length) return null;
const distances = controls.map((point) => Math.min(
...rects.map((rect) => pointDistanceFromRect(point, rect)),
));
const maximum = Math.max(...distances);
return { maximum, point: controls[distances.indexOf(maximum)] };
}
function pointBlocked(point, obstacles) {
return obstacles.some((rect) => (
point[0] >= rect.x && point[0] <= rect.x + rect.width
&& point[1] >= rect.y && point[1] <= rect.y + rect.height
));
}
function segmentBlocked(start, end, obstacles) {
return obstacles.some((rect) => segmentIntersectsRect({ start, end }, rect));
}
function segmentConflictsWithAvoided(start, end, avoidedSegments, minimumOverlapPx, allowCrossings) {
return avoidedSegments.some((segment) => (
(!allowCrossings && (properSegmentIntersection(start, end, segment.start, segment.end)
|| orthogonalTouchOnAvoidedInterior(start, end, segment.start, segment.end)))
|| collinearOverlap(start, end, segment.start, segment.end) >= minimumOverlapPx
));
}
function orthogonalTouchOnAvoidedInterior(start, end, avoidedStart, avoidedEnd) {
const epsilon = 0.0001;
const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
const avoidedHorizontal = Math.abs(avoidedStart[1] - avoidedEnd[1]) <= epsilon;
const avoidedVertical = Math.abs(avoidedStart[0] - avoidedEnd[0]) <= epsilon;
if (candidateHorizontal && avoidedVertical) {
const x = avoidedStart[0];
const y = start[1];
return x >= Math.min(start[0], end[0]) - epsilon
&& x <= Math.max(start[0], end[0]) + epsilon
&& y > Math.min(avoidedStart[1], avoidedEnd[1]) + epsilon
&& y < Math.max(avoidedStart[1], avoidedEnd[1]) - epsilon;
}
if (candidateVertical && avoidedHorizontal) {
const x = start[0];
const y = avoidedStart[1];
return y >= Math.min(start[1], end[1]) - epsilon
&& y <= Math.max(start[1], end[1]) + epsilon
&& x > Math.min(avoidedStart[0], avoidedEnd[0]) + epsilon
&& x < Math.max(avoidedStart[0], avoidedEnd[0]) - epsilon;
}
return false;
}
function pointOnSegmentInterior(point, start, end) {
const epsilon = 0.0001;
const cross = (end[0] - start[0]) * (point[1] - start[1])
- (end[1] - start[1]) * (point[0] - start[0]);
if (Math.abs(cross) > epsilon) return false;
const dot = (point[0] - start[0]) * (point[0] - end[0])
+ (point[1] - start[1]) * (point[1] - end[1]);
return dot < -epsilon;
}
function pointOnAvoidedInterior(point, avoidedSegments) {
return avoidedSegments.some((segment) => (
pointOnSegmentInterior(point, segment.start, segment.end)
));
}
function writeGridMetrics(metrics, patch) {
if (!metrics || typeof metrics !== 'object') return;
Object.assign(metrics, patch);
}
export function shortestOrthogonalGridRoute({
start,
end,
points,
obstacles,
fromSide,
toSide,
clearance,
maximumObstacleCount,
endpointStubPx = clearance + 2,
maximumGridNodes = Infinity,
avoidedSegments = [],
allowAvoidedCrossings = false,
minimumAvoidedOverlapPx = 8,
routeSeparationPx = 8,
minimumSegmentPx = 8,
borderSegments = [],
bendPenaltyPx = 0,
metrics,
}) {
writeGridMetrics(metrics, {
status: 'initializing',
maximumGridNodes,
obstacleCount: 0,
avoidedSegmentCount: 0,
coordinateCount: 0,
candidateNodeCount: 0,
usableNodeCount: 0,
graphEdgeCount: 0,
visitedNodeCount: 0,
});
if (!OUTWARD[fromSide] || !OUTWARD[toSide]) {
writeGridMetrics(metrics, { status: 'unsupported-endpoint-side' });
return null;
}
const startStub = moveOutward(start, fromSide, endpointStubPx);
const endStub = moveOutward(end, toSide, endpointStubPx);
// The graph may legally leave the initial endpoint bounds to find a clear
// corridor. Keep every bounded obstacle and occupied relationship visible
// to that search; filtering them against the initial box lets a detour walk
// straight through geometry that only becomes relevant after it leaves the
// box. The explicit obstacle/node budgets below keep this deterministic.
const expanded = [...obstacles]
.filter((rect) => rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height))
.map((rect) => expandedRect(rect, clearance));
const relevantAvoidedSegments = [...avoidedSegments]
.filter((segment) => segment?.start && segment?.end);
// Frame borders may be crossed perpendicularly but never borrowed as a
// corridor: the composition gate rejects any collinear run along them.
const relevantBorderSegments = [...borderSegments]
.filter((segment) => segment?.start && segment?.end);
writeGridMetrics(metrics, {
obstacleCount: expanded.length,
avoidedSegmentCount: relevantAvoidedSegments.length,
});
if (expanded.length > maximumObstacleCount) {
writeGridMetrics(metrics, { status: 'obstacle-budget-exceeded' });
return null;
}
const xs = new Set([startStub[0], endStub[0], ...points.map(([x]) => x)]);
const ys = new Set([startStub[1], endStub[1], ...points.map(([, y]) => y)]);
for (const rect of expanded) {
xs.add(rect.x - 1);
xs.add(rect.x + rect.width + 1);
ys.add(rect.y - 1);
ys.add(rect.y + rect.height + 1);
}
for (const segment of relevantAvoidedSegments) {
const [segmentStart, segmentEnd] = [segment.start, segment.end];
xs.add(segmentStart[0]);
xs.add(segmentEnd[0]);
ys.add(segmentStart[1]);
ys.add(segmentEnd[1]);
if (Math.abs(segmentStart[0] - segmentEnd[0]) <= 0.0001) {
xs.add(segmentStart[0] - routeSeparationPx);
xs.add(segmentStart[0] + routeSeparationPx);
}
if (Math.abs(segmentStart[1] - segmentEnd[1]) <= 0.0001) {
ys.add(segmentStart[1] - routeSeparationPx);
ys.add(segmentStart[1] + routeSeparationPx);
}
}
for (const segment of relevantBorderSegments) {
if (Math.abs(segment.start[0] - segment.end[0]) <= 0.0001) {
xs.add(segment.start[0] - routeSeparationPx);
xs.add(segment.start[0] + routeSeparationPx);
}
if (Math.abs(segment.start[1] - segment.end[1]) <= 0.0001) {
ys.add(segment.start[1] - routeSeparationPx);
ys.add(segment.start[1] + routeSeparationPx);
}
}
// Grid lines closer than a readable segment would let the search emit a
// micro jog between two obstacle edges; keep the endpoint stubs and coalesce
// the rest so every turn the route can take is at least one segment long.
const coalesce = (values, keep) => values.sort((a, b) => a - b).filter((value, index, sorted) => (
index === 0 || keep.has(value) || value - sorted[index - 1] >= minimumSegmentPx
));
const orderedX = coalesce([...xs], new Set([startStub[0], endStub[0]]));
const orderedY = coalesce([...ys], new Set([startStub[1], endStub[1]]));
const candidateNodeCount = orderedX.length * orderedY.length;
writeGridMetrics(metrics, {
coordinateCount: orderedX.length + orderedY.length,
candidateNodeCount,
});
if (candidateNodeCount > maximumGridNodes) {
writeGridMetrics(metrics, { status: 'node-budget-exceeded' });
return null;
}
const nodes = new Map();
for (const x of orderedX) {
for (const y of orderedY) {
const point = [x, y];
if (!pointBlocked(point, expanded)
&& (allowAvoidedCrossings || !pointOnAvoidedInterior(point, relevantAvoidedSegments))) {
nodes.set(pointKey(point), point);
}
}
}
writeGridMetrics(metrics, { usableNodeCount: nodes.size });
if (!nodes.has(pointKey(startStub)) || !nodes.has(pointKey(endStub))) {
writeGridMetrics(metrics, { status: 'endpoint-blocked' });
return null;
}
const adjacency = new Map([...nodes.keys()].map((key) => [key, []]));
let graphEdgeCount = 0;
const connectLine = (line, axis) => {
for (let index = 0; index < line.length - 1; index += 1) {
const left = line[index];
const right = line[index + 1];
if (segmentBlocked(left, right, expanded)) continue;
if (segmentConflictsWithAvoided(
left,
right,
relevantAvoidedSegments,
minimumAvoidedOverlapPx,
allowAvoidedCrossings,
)) continue;
if (relevantBorderSegments.some((segment) => (
collinearOverlap(left, right, segment.start, segment.end) > 0.0001
))) continue;
const distance = Math.abs(right[0] - left[0]) + Math.abs(right[1] - left[1]);
const leftKey = pointKey(left);
const rightKey = pointKey(right);
adjacency.get(leftKey).push([rightKey, distance, axis === 'h' ? 'R' : 'D']);
adjacency.get(rightKey).push([leftKey, distance, axis === 'h' ? 'L' : 'U']);
graphEdgeCount += 1;
}
};
for (const y of orderedY) {
connectLine(orderedX.map((x) => nodes.get(pointKey([x, y]))).filter(Boolean), 'h');
}
for (const x of orderedX) {
connectLine(orderedY.map((y) => nodes.get(pointKey([x, y]))).filter(Boolean), 'v');
}
writeGridMetrics(metrics, { graphEdgeCount });
// The search state carries the incoming direction so a turn can cost extra
// and a reversal is never taken: the pure shortest path hugs every obstacle
// corner with a staircase of short jogs, while a bend-penalised one takes
// the same corridor in a few long strokes. The first stub already leaves
// the endpoint along its side and the last one arrives along the end side.
const directionOf = ([dx, dy]) => (dx > 0 ? 'R' : dx < 0 ? 'L' : dy > 0 ? 'D' : 'U');
const opposite = { R: 'L', L: 'R', D: 'U', U: 'D' };
const stateKey = (key, direction) => `${key}|${direction}`;
const sourceAxis = directionOf(OUTWARD[fromSide]);
const targetAxis = opposite[directionOf(OUTWARD[toSide])];
const source = pointKey(startStub);
const target = pointKey(endStub);
const sourceState = stateKey(source, sourceAxis);
const distances = new Map([[sourceState, 0]]);
const previous = new Map();
const queue = new MinHeap();
queue.push(sourceState, 0);
let visitedNodeCount = 0;
let targetState = null;
while (queue.entries.length) {
const next = queue.pop();
const current = next.key;
const currentDistance = next.distance;
if (currentDistance !== distances.get(current)) continue;
visitedNodeCount += 1;
const [currentNode, currentAxis] = current.split('|');
if (currentNode === target) {
// Arriving on the wrong axis costs one final turn onto the end stub.
const arrival = currentDistance + (currentAxis === targetAxis ? 0 : bendPenaltyPx);
if (targetState == null || arrival < targetState.distance) {
targetState = { key: current, distance: arrival };
}
if (currentAxis === targetAxis || bendPenaltyPx === 0) break;
continue;
}
if (targetState && currentDistance >= targetState.distance) break;
for (const [neighbor, weight, axis] of adjacency.get(currentNode) || []) {
if (axis === opposite[currentAxis]) continue;
const candidate = currentDistance + weight + (axis === currentAxis ? 0 : bendPenaltyPx);
const neighborState = stateKey(neighbor, axis);
if (candidate >= (distances.get(neighborState) ?? Infinity)) continue;
distances.set(neighborState, candidate);
previous.set(neighborState, current);
queue.push(neighborState, candidate);
}
}
writeGridMetrics(metrics, { visitedNodeCount });
if (!targetState) {
writeGridMetrics(metrics, { status: 'no-route' });
return null;
}
const reversed = [];
for (let key = targetState.key; key; key = previous.get(key)) {
reversed.push(nodes.get(key.split('|')[0]));
if (key === sourceState) break;
}
if (pointKey(reversed.at(-1)) !== source) {
writeGridMetrics(metrics, { status: 'broken-predecessor-chain' });
return null;
}
const shortestPoints = normalizeRoutePoints([start, ...reversed.reverse(), end]);
writeGridMetrics(metrics, { status: 'routed' });
return {
points: shortestPoints,
length: orthogonalLength(shortestPoints),
obstacleCount: expanded.length,
};
}
function collinearOverlap(leftStart, leftEnd, rightStart, rightEnd) {
if (leftStart[0] === leftEnd[0] && rightStart[0] === rightEnd[0]
&& leftStart[0] === rightStart[0]) {
return Math.max(0, Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
- Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
}
if (leftStart[1] === leftEnd[1] && rightStart[1] === rightEnd[1]
&& leftStart[1] === rightStart[1]) {
return Math.max(0, Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
- Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
}
return 0;
}
function segmentOutsideContent(start, end, contentBounds) {
if (!contentBounds) return false;
const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
return midpoint[0] < contentBounds.left || midpoint[0] > contentBounds.right
|| midpoint[1] < contentBounds.top || midpoint[1] > contentBounds.bottom;
}
function sharesOuterCorridor({ relation, relations, pathFor, points, contentBounds, minimumOverlap }) {
for (const other of asArray(relations)) {
if (!other || other === relation) continue;
const related = relation.from === other.from || relation.from === other.to
|| relation.to === other.from || relation.to === other.to;
if (!related) continue;
const otherPoints = normalizeRoutePoints(pathFor(other)?.points || []);
for (let left = 0; left < points.length - 1; left += 1) {
if (!segmentOutsideContent(points[left], points[left + 1], contentBounds)) continue;
for (let right = 0; right < otherPoints.length - 1; right += 1) {
if (collinearOverlap(points[left], points[left + 1], otherPoints[right], otherPoints[right + 1]) >= minimumOverlap) {
return true;
}
}
}
}
return false;
}
function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
return {
diagramType,
collection: relationCollection,
index: relationIndex,
...(relation.id ? { id: relation.id } : {}),
from: relation.from,
to: relation.to,
};
}
/**
* Reject conspicuous authored detours without penalizing routes whose length is
* explained by opaque-node avoidance or a related shared outer corridor.
*/
export function cleanRouteDetourProblems({
relations,
obstacles,
contentRects = obstacles,
endpointIds,
pathFor,
fromSideFor,
toSideFor,
diagramType,
relationCollection,
profile,
thresholds = {},
}) {
if (profile !== 'showcase') return [];
const policy = { ...DEFAULTS, ...thresholds };
const obstacleList = [...obstacles];
const contentBounds = boundsForRects(contentRects);
const problems = [];
for (const [relationIndex, relation] of asArray(relations).entries()) {
if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
if (!Array.isArray(relation.via) || relation.via.length === 0) continue;
const points = normalizeRoutePoints(pathFor(relation)?.points || []);
if (points.length < 3 || !points.every((point) => Array.isArray(point) && isFinitePoint(...point))) continue;
const actualLength = orthogonalLength(points);
if (!Number.isFinite(actualLength)) continue;
const start = points[0];
const end = points.at(-1);
const manhattan = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
if (actualLength < manhattan * policy.minimumDetourRatio
|| actualLength - manhattan < policy.minimumExcessLengthPx) continue;
const routeBounds = boundsForPoints(points);
const excursion = outsideExcursion(routeBounds, contentBounds);
const emptyClearance = emptyControlPointClearance(points, obstacleList);
if (Math.max(excursion?.maximum || 0, emptyClearance?.maximum || 0)
< policy.minimumEmptyExcursionPx) continue;
if (sharesOuterCorridor({
relation,
relations,
pathFor,
points,
contentBounds,
minimumOverlap: policy.sharedCorridorMinimumPx,
})) continue;
const fromSide = fromSideFor?.(relation) || inferredSide(points, 'source');
const toSide = toSideFor?.(relation) || inferredSide(points, 'target');
const shortest = shortestOrthogonalGridRoute({
start,
end,
points,
obstacles: obstacleList,
fromSide,
toSide,
clearance: policy.clearance,
maximumObstacleCount: policy.maximumObstacleCount,
});
if (!shortest || !Number.isFinite(shortest.length) || shortest.length <= 0) continue;
const detourRatio = actualLength / shortest.length;
const excessLength = actualLength - shortest.length;
if (detourRatio < policy.minimumDetourRatio || excessLength < policy.minimumExcessLengthPx) continue;
const relationId = relation.id ? ` id "${relation.id}"` : '';
const message = `[composition/excessive-route-detour] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" travels ${Math.round(actualLength)}px, ${rounded(detourRatio)}x the ${Math.round(shortest.length)}px shortest obstacle-clearing orthogonal route, and reaches ${Math.round(excursion.maximum)}px beyond the content bounds — remove the distant via corridor or move it close to the connected content.`;
const supportedFix = 'remove the distant via points and retry automatic routing, or keep the endpoint sides and move the via corridor near the connected nodes while preserving labels and direction';
recordDiagnostic({
code: 'composition/excessive-route-detour',
severity: 'error',
message,
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
evidence: {
points,
actualLengthPx: rounded(actualLength),
shortestLegalPoints: shortest.points,
shortestLegalLengthPx: rounded(shortest.length),
detourRatio: rounded(detourRatio),
excessLengthPx: rounded(excessLength),
routeBounds,
contentBounds,
emptyExcursionPx: excursion,
emptyControlPointClearancePx: emptyClearance,
obstacleCount: shortest.obstacleCount,
thresholds: {
minimumDetourRatio: policy.minimumDetourRatio,
minimumExcessLengthPx: policy.minimumExcessLengthPx,
minimumEmptyExcursionPx: policy.minimumEmptyExcursionPx,
},
},
supportedFixes: [supportedFix],
});
problems.push(message);
}
return problems;
}
@@ -0,0 +1,38 @@
import { createHash } from 'node:crypto';
const SIDECAR_STEM_NAMESPACE = /\.~archify-[0-9a-f]{64}$/iu;
export function sidecarStemNeedsBounding(stem, suffixes) {
const fits = (value) => value.length <= 255 && Buffer.byteLength(value, 'utf8') <= 255;
return !suffixes.every((suffix) => fits(`${stem}${suffix}`));
}
export function boundedSidecarStem(stem, suffixes, { force = false, hashDomain } = {}) {
if (!force && !sidecarStemNeedsBounding(stem, suffixes)
&& !SIDECAR_STEM_NAMESPACE.test(stem)) return stem;
const hashInput = hashDomain ? `${hashDomain}\0${stem}` : stem;
const marker = `.~archify-${createHash('sha256').update(hashInput).digest('hex')}`;
const codePoints = [...stem];
while (codePoints.length
&& sidecarStemNeedsBounding(`${codePoints.join('')}${marker}`, suffixes)) {
codePoints.pop();
}
return `${codePoints.join('')}${marker}`;
}
export function sidecarStemFromComponent(component) {
if (component.endsWith('.html')) {
return {
stem: component.slice(0, -'.html'.length),
options: undefined,
};
}
return {
stem: component,
options: { force: true, hashDomain: 'full-component' },
};
}
export function isBoundedSidecarStem(stem) {
return SIDECAR_STEM_NAMESPACE.test(stem);
}
@@ -0,0 +1,66 @@
/** Uniform spatial grid for "which items can this box reach" queries (#8). */
// A cell box wider than this is not worth walking: the query returns every
// inserted item instead, which stays a superset of what the box reaches.
const DEFAULT_MAX_CELLS = 4096;
export function createSpatialGrid(cellSize, { maxCells = DEFAULT_MAX_CELLS } = {}) {
const buckets = new Map();
const items = [];
// Items whose own box cannot be walked are candidates for every query.
const overflow = [];
const keyOf = (x, y) => x + ':' + y;
const rangeOf = (box) => ({
x0: Math.floor(box.minX / cellSize), x1: Math.floor(box.maxX / cellSize),
y0: Math.floor(box.minY / cellSize), y1: Math.floor(box.maxY / cellSize),
});
// Finite coordinates can still land outside the safe-integer range, where
// incrementing an index no longer advances it; and a legal coordinate can
// name more cells than the grid is worth. Both cases stay out of the buckets
// and are answered by the item list, so they never hang or allocate.
const walkable = (range) => Number.isSafeInteger(range.x0) && Number.isSafeInteger(range.x1)
&& Number.isSafeInteger(range.y0) && Number.isSafeInteger(range.y1)
&& (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1) <= maxCells;
return {
insert(box, item) {
items.push(item);
const range = rangeOf(box);
if (!walkable(range)) {
overflow.push(item);
return;
}
for (let x = range.x0; x <= range.x1; x += 1) {
for (let y = range.y0; y <= range.y1; y += 1) {
const key = keyOf(x, y);
let bucket = buckets.get(key);
if (!bucket) { bucket = []; buckets.set(key, bucket); }
bucket.push(item);
}
}
},
query(box) {
const range = rangeOf(box);
if (!walkable(range)) return items.slice();
const seen = new Set();
const found = [];
for (let x = range.x0; x <= range.x1; x += 1) {
for (let y = range.y0; y <= range.y1; y += 1) {
const bucket = buckets.get(keyOf(x, y));
if (!bucket) continue;
for (const item of bucket) {
if (seen.has(item)) continue;
seen.add(item);
found.push(item);
}
}
}
for (const item of overflow) {
if (seen.has(item)) continue;
seen.add(item);
found.push(item);
}
return found;
},
};
}
+105
View File
@@ -0,0 +1,105 @@
// Single-line node text fitting, shared by every renderer.
//
// Node text (`label`, `sublabel`, `tag`) renders as one <text> element with
// text-anchor="middle" and is never wrapped. Left unmeasured, an over-long
// value silently spills across its neighbours while validation still reports
// a clean receipt — the failure mode this module exists to close.
//
// Two halves, always used together:
// - fittedNodeFontSize shrinks the text toward a legible minimum at render
// time, so ordinary overruns simply get smaller instead of overlapping.
// - minimumNodeTextWidth reports the width the text still needs once it has
// shrunk as far as it may, so validation can reject what shrinking cannot
// save.
//
// The geometry constants below are shared; the per-field `preferred` and
// `minimum` font sizes are not, because renderers set node text at different
// sizes (architecture sublabels are 9px, the rest are 7px).
import { textUnits, SEMANTIC_SIGIL_INSET, SEMANTIC_SIGIL_SIZE, SEMANTIC_SIGIL_FOOTPRINT, SOURCE_BADGE_FOOTPRINT } from './utils.mjs';
// widthFactor: px of advance width per text unit, per px of font size.
// horizontalPadding: total px reserved inside the box so text never touches
// the border.
export const nodeTextFit = {
widthFactor: 0.6,
horizontalPadding: 8,
};
// Largest font size at or below `preferred` that fits `text` inside `width`,
// floored at `minimum` — below that the text is no longer legible and the
// caller should be reporting a problem instead.
export function fittedNodeFontSize(text, width, preferred, minimum) {
const units = Math.max(1, textUnits(text));
const available = Math.max(1, width - nodeTextFit.horizontalPadding);
const fitted = Math.min(preferred, available / (units * nodeTextFit.widthFactor));
return Math.max(minimum, Math.floor(fitted * 10) / 10);
}
// Width `text` occupies at its legible minimum. Compare against
// `width - nodeTextFit.horizontalPadding` to decide whether shrink-to-fit can
// rescue it.
export function minimumNodeTextWidth(text, minimum) {
return textUnits(text) * minimum * nodeTextFit.widthFactor;
}
// Available text width inside a box of `width`.
export function availableNodeTextWidth(width) {
return width - nodeTextFit.horizontalPadding;
}
// Adapted from Souptik Chakraborty's #220: shift only labels which reach a
// corner icon. Unlike the original hard gate, a narrow valid node uses a
// separate text row; its authored bounds and acceptance remain unchanged.
export function nodeLabelLayout({ width, height, rows, side = 'left', brand = false, source = false, step = '' }) {
const result = { x: width / 2, ys: rows.map(row => row.y), sigilY: SEMANTIC_SIGIL_INSET, sigilSize: SEMANTIC_SIGIL_SIZE };
const labelWidth = minimumNodeTextWidth(rows[0].text, rows[0].font);
const stepEnd = step ? (side === 'left' ? 23 : 10) + minimumNodeTextWidth(step, 8) + 3 : 0;
const left = Math.max(side === 'left' ? SEMANTIC_SIGIL_FOOTPRINT + 2 : 4, stepEnd);
const right = width - (brand ? 26 : side === 'right' ? SEMANTIC_SIGIL_FOOTPRINT + 2 : 4)
- (source ? SOURCE_BADGE_FOOTPRINT : 0);
if (result.x - labelWidth / 2 >= left && result.x + labelWidth / 2 <= right) return result;
if (labelWidth <= right - left) {
// Round away from the icon, retaining the node centre whenever possible.
result.x = Math.min(Math.floor((right - labelWidth / 2) * 10) / 10,
Math.max(result.x, Math.ceil((left + labelWidth / 2) * 10) / 10));
return result;
}
// Keep the existing font sizes and put the text below the decoration rail.
// Conservative ascent/descent bounds also protect CJK and fallback fonts.
let bottom = Math.max(brand ? 22 : SEMANTIC_SIGIL_FOOTPRINT, source ? 19 : 0);
const ys = rows.map(row => {
const y = Math.max(row.y, Math.ceil((bottom + 2 + row.font * 1.2) * 10) / 10);
bottom = y + row.font * 0.3;
return y;
});
if (bottom <= height - 2) {
result.ys = ys;
return result;
}
if (source) {
// A source badge adds a second decoration on the right. On short boxes,
// restoring the original rows would put the title back under that badge.
// Try compact leading before giving up the dedicated text rail. Retain
// every font size and the authored box; only this crowded fallback packs
// the rows, with a full em above each baseline and 0.3 em below it.
let compactBottom = Math.max(brand ? 22 : SEMANTIC_SIGIL_FOOTPRINT, 19) + 1;
const compactYs = rows.map(row => {
const y = Math.ceil((compactBottom + 1 + row.font) * 10) / 10;
compactBottom = y + row.font * 0.3;
return y;
});
// The compact fallback may also use the otherwise reserved bottom
// padding; the entire descent still stays inside the fixed box.
if (compactBottom <= height - 0.5) {
result.ys = compactYs;
return result;
}
}
// A deliberately short fixed box may have no spare row. Preserve its text
// and geometry, and fit only the decorative sigil in the space above it.
result.sigilY = 1;
result.sigilSize = Math.max(1, Math.min(SEMANTIC_SIGIL_SIZE,
Math.floor(rows[0].y - rows[0].font * 1.2 - 3)));
return result;
}
+254
View File
@@ -0,0 +1,254 @@
import {
escapeHtml as esc,
localizeTemplate,
resolveLocale,
translateMessage,
viewerCatalog,
} from './i18n.mjs';
export { esc };
export function renderDefinitions() {
return ` <!-- Definitions -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-default" />
</marker>
<marker id="arrowhead-emphasis" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-emphasis" />
</marker>
<marker id="arrowhead-security" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-security" />
</marker>
<marker id="arrowhead-dashed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="m-dashed" />
</marker>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" class="c-grid" stroke-width="0.5"/>
</pattern>
</defs>`;
}
const SIGIL_TONE = {
frontend: 'frontend',
start: 'frontend',
backend: 'backend',
active: 'frontend',
database: 'database',
success: 'backend',
cloud: 'cloud',
waiting: 'cloud',
security: 'security',
decision: 'database',
failure: 'security',
messagebus: 'messagebus',
external: 'external',
neutral: 'external',
};
const SIGIL_SHAPE = {
calendar: `<rect x="2" y="3.5" width="12" height="10.5" rx="2"/><path d="M5 2v3M11 2v3M2 7h12M5 10h2M9 10h2"/>`,
clock: `<circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/>`,
person: `<circle cx="8" cy="4.5" r="2.5"/><path d="M3 14v-2a5 5 0 0 1 10 0v2"/>`,
briefcase: `<rect x="2" y="5" width="12" height="9" rx="2"/><path d="M5 5V2h6v3M2 9h12M7 9v2h2V9"/>`,
flag: `<path d="M3 14V2h10l-2 3 2 3H3"/>`,
moon: `<path d="M13.5 10A6 6 0 0 1 6 2.5 6 6 0 1 0 13.5 10Z"/>`,
frontend: `<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M2 6.5h12"/>
<circle cx="4.1" cy="4.8" r=".7" class="sigil-fill"/>
<circle cx="6.3" cy="4.8" r=".7" class="sigil-fill"/>`,
backend: `<path d="M6 3 3 8l3 5M10 3l3 5-3 5"/>`,
database: `<ellipse cx="8" cy="4" rx="5" ry="2"/>
<path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>`,
cloud: `<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>`,
security: `<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/>
<path d="m5.8 8 1.5 1.5 3-3"/>`,
messagebus: `<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>
<circle cx="5" cy="4.5" r="1" class="sigil-fill"/>
<circle cx="10.5" cy="8" r="1" class="sigil-fill"/>
<circle cx="7" cy="11.5" r="1" class="sigil-fill"/>`,
external: `<rect x="2.5" y="5" width="8.5" height="8" rx="1.5"/>
<path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>`,
start: `<circle cx="8" cy="8" r="5"/>
<path d="m7 5.4 3.6 2.6L7 10.6Z" class="sigil-fill"/>`,
active: `<path d="M2 8h3l1.5-3.5L9 12l1.6-4H14"/>`,
waiting: `<path d="M4 2.5h8M4 13.5h8M5 3c0 2.8 2 3.2 3 5-1 1.8-3 2.2-3 5M11 3c0 2.8-2 3.2-3 5 1 1.8 3 2.2 3 5"/>`,
success: `<circle cx="8" cy="8" r="5.3"/>
<path d="m5.2 8 1.8 1.8 3.8-4"/>`,
failure: `<circle cx="8" cy="8" r="5.3"/>
<path d="m5.7 5.7 4.6 4.6m0-4.6-4.6 4.6"/>`,
neutral: `<rect x="3" y="3" width="10" height="10" rx="2"/>
<circle cx="8" cy="8" r="1.2" class="sigil-fill"/>`,
};
// A quiet, renderer-owned corner symbol (type default or authored icon). It is
// SVG content rather than a
// viewer overlay, so it survives canonical export while adding no focus target,
// accessible name, layout box, or interaction state of its own.
// Shared with label clearance so the reserved rail matches the actual icon.
export const SEMANTIC_SIGIL_INSET = 6;
export const SEMANTIC_SIGIL_SIZE = 11;
export const SEMANTIC_SIGIL_FOOTPRINT = SEMANTIC_SIGIL_INSET + SEMANTIC_SIGIL_SIZE;
// The viewer installs a runtime "sources" beacon on the node's top-right
// rail, just left of the brand mark. Layout must reserve the same footprint
// so labels never sit under the badge.
export const SOURCE_BADGE_FOOTPRINT = 38;
export function renderSemanticSigil(kind, { x, y, size = SEMANTIC_SIGIL_SIZE, icon } = {}) {
if (icon === 'none') return '';
const selected = icon ?? kind;
const normalized = Object.hasOwn(SIGIL_SHAPE, selected) ? selected : 'neutral';
const tone = SIGIL_TONE[kind] || 'external';
const scale = size / 16;
return `<g aria-hidden="true" data-semantic-sigil="${esc(normalized)}" class="semantic-sigil s-${tone}" transform="translate(${x} ${y}) scale(${scale})">
${SIGIL_SHAPE[normalized]}
</g>`;
}
export function renderCards(cards) {
const list = Array.isArray(cards) ? cards : [];
return ` <!-- Info Cards -->
<div class="cards">
${list.map((card) => ` <div class="card">
<div class="card-header">
<div class="card-dot ${esc(card.dot)}"></div>
<h3>${esc(card.title)}</h3>
</div>
<ul>
${card.items.map((item) => ` <li>${esc(item)}</li>`).join('\n')}
</ul>
</div>`).join('\n\n')}
</div>`;
}
const SVG_SLOT_RE = / <!-- ARCHIFY:SVG_SLOT_START -->[\s\S]*? <!-- ARCHIFY:SVG_SLOT_END -->/;
const CARDS_SLOT_RE = / <!-- ARCHIFY:CARDS_SLOT_START -->[\s\S]*? <!-- ARCHIFY:CARDS_SLOT_END -->/;
const SUBTITLE_SLOT_RE = /^([ \t]*)<p class="subtitle">\[Subtitle description\]<\/p>[ \t]*(\r?\n)?/m;
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
const I18N_PLACEHOLDER = ' <!-- ARCHIFY:I18N_DATA -->';
function serializeScriptJson(value) {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
}
const TEMPLATE_PLACEHOLDERS = [
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
'<title>[PROJECT NAME] Architecture Diagram</title>',
'<h1>[PROJECT NAME] Architecture</h1>',
I18N_PLACEHOLDER,
];
export function applyTemplate(template, {
title,
subtitle,
svg,
cards,
locale,
visualPreset = 'classic',
sourceEvidence = null,
}) {
if (!SVG_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing ARCHIFY:SVG_SLOT sentinel');
}
if (!CARDS_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing ARCHIFY:CARDS_SLOT sentinel');
}
if (!SUBTITLE_SLOT_RE.test(template)) {
throw new Error('applyTemplate: template missing subtitle placeholder');
}
for (const ph of TEMPLATE_PLACEHOLDERS) {
if (!template.includes(ph)) {
throw new Error(`applyTemplate: template missing placeholder ${JSON.stringify(ph)}`);
}
}
// Keep existing custom templates compatible when evidence is not requested.
// Silently dropping verified evidence would be misleading, so the new slot
// becomes mandatory only for the opt-in evidence path.
if (sourceEvidence && !template.includes(SOURCE_EVIDENCE_PLACEHOLDER)) {
throw new Error(`applyTemplate: repository evidence requires placeholder ${JSON.stringify(SOURCE_EVIDENCE_PLACEHOLDER)}`);
}
// Function replacers: a literal `$&`, `$'`, `$\`` or `$$` in titles, labels,
// or rendered SVG must not be interpreted as a replacement pattern.
const sourceEvidenceJson = serializeScriptJson(sourceEvidence);
const resolvedLocale = resolveLocale(locale);
const i18nJson = serializeScriptJson({ locale: resolvedLocale, messages: viewerCatalog(resolvedLocale) });
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
? `<p class="subtitle">${esc(subtitle)}</p>`
: '';
const i18nData = ` <script id="archify-i18n-data" type="application/json">${i18nJson}</script>`;
return localizeTemplate(template, resolvedLocale)
.replace(I18N_PLACEHOLDER, () => i18nData)
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="${esc(resolvedLocale)}" data-theme="dark" data-preset="${esc(visualPreset)}">`)
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(translateMessage(resolvedLocale, 'page.title', { title }))}</title>`)
.replace(TEMPLATE_PLACEHOLDERS[2], () => `<h1>${esc(title)}</h1>`)
.replace(SUBTITLE_SLOT_RE, (_match, indent, newline = '') => renderedSubtitle
? `${indent}${renderedSubtitle}${newline}`
: '')
.replace(SVG_SLOT_RE, () => svg)
.replace(CARDS_SLOT_RE, () => cards)
.replace(SOURCE_EVIDENCE_PLACEHOLDER, () => sourceEvidence
? ` <script id="archify-source-evidence-data" type="application/json">${sourceEvidenceJson}</script>`
: '');
}
// CJK and other wide/fullwidth glyphs render at roughly twice the advance
// width of ASCII in the monospace stacks the template uses. Keep halfwidth
// forms (notably U+FF61–U+FF9F Katakana) out of this set. The explicit ranges
// also cover vertical punctuation and supplementary East Asian scripts that
// literal glyph ranges made difficult to audit.
// Code points that take two columns of advance width: East Asian Wide and
// Fullwidth per UAX #11, tracking Unicode 17.0. That takes in the BMP symbols
// carrying emoji presentation (U+2705, U+2B50, U+26A1, U+231B, ...), which
// render at the same square advance as the supplementary-plane emoji already
// listed here, and Hangul Jamo Extended-A. Two boundary calls worth naming:
// Unicode 16.0 reclassified the trigrams (U+2630-U+2637) and the monogram /
// digram symbols (U+268A-U+268F) from Neutral to Wide, so both are in; and
// Hangul Jamo Extended-A stops at U+A97C, its last assigned jamo, because
// U+A97D-U+A97F are unassigned, and unassigned code points outside the CJK
// ranges UAX #11 names default to Neutral rather than Wide. Spelled out as
// ranges because V8 has no \p{East_Asian_Width=W} property escape.
const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
// Variation selectors contribute no separate unit. This is a conservative
// width estimate, not a measurement of the selected glyph: its actual advance
// depends on the font and presentation (Unicode UAX #11).
// VS16 requests emoji presentation, so reserve two units for the sequence.
// VS15 retains the base's width estimate; forcing every text-presentation
// sequence to one unit undercounts wide bases, including CJK characters whose
// font ignores that selector. Neutral bases remain one unit. Some selected
// text glyphs can be narrower than this estimate; prefer extra space to overflow.
const VARIATION_SELECTOR_FIRST = 0xfe00;
const VARIATION_SELECTOR_LAST = 0xfe0f;
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
// Width measurement is pure and the same labels are measured many times per
// compile, so the unit count is memoized by its input string.
const TEXT_UNITS_CACHE = new Map();
const MAX_TEXT_UNITS_CACHE_ENTRIES = 4096;
const MAX_CACHED_TEXT_LENGTH = 1024;
export function textUnits(text) {
const cacheKey = String(text ?? '');
const cachedUnits = TEXT_UNITS_CACHE.get(cacheKey);
if (cachedUnits !== undefined) return cachedUnits;
const chars = Array.from(cacheKey);
let units = 0;
for (let i = 0; i < chars.length; i += 1) {
const codePoint = chars[i].codePointAt(0);
if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue;
const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1;
if (next === VARIATION_SELECTOR_EMOJI) units += 2;
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
}
// Keep repeated in-process compiles bounded, including unusually long labels.
if (cacheKey.length <= MAX_CACHED_TEXT_LENGTH) {
if (TEXT_UNITS_CACHE.size >= MAX_TEXT_UNITS_CACHE_ENTRIES) {
TEXT_UNITS_CACHE.delete(TEXT_UNITS_CACHE.keys().next().value);
}
TEXT_UNITS_CACHE.set(cacheKey, units);
}
return units;
}
+86
View File
@@ -0,0 +1,86 @@
import * as validators from './generated-validators.mjs';
import { throwDiagnosticError } from './diagnostics.mjs';
// "/nodes/3/label" reads much better as "/nodes/3 (id: "router") /label" for the
// LLM fixing the JSON; resolve the nearest enclosing element's id or label.
function annotatedPath(instancePath, data) {
if (!instancePath) return { path: '/', identity: null };
let node = data;
let hint = null;
for (const seg of instancePath.split('/').slice(1)) {
if (node == null || typeof node !== 'object') break;
node = node[/^\d+$/.test(seg) ? Number(seg) : seg];
if (node && typeof node === 'object' && !Array.isArray(node)) {
const tag = node.id ?? node.label;
if (tag != null) hint = String(tag);
}
}
return { path: instancePath, identity: hint };
}
function annotatePath(instancePath, data) {
const annotated = annotatedPath(instancePath, data);
return annotated.identity != null
? `${annotated.path} (id/label: ${JSON.stringify(annotated.identity)})`
: annotated.path;
}
function formatErrors(errors, data) {
return errors.map((e) => {
const where = annotatePath(e.instancePath, data);
const detail = e.params && Object.keys(e.params).length
? ' ' + JSON.stringify(e.params)
: '';
return ` ${where} ${e.message}${detail}`;
}).join('\n');
}
export function validateSchema(diagramType, data) {
const validate = validators[diagramType];
if (!validate) {
throw new Error(`validateSchema: unknown diagram type "${diagramType}"`);
}
if (!validate(data)) {
const diagnostics = validate.errors.map((error) => {
const annotated = annotatedPath(error.instancePath, data);
const subject = {
diagramType,
path: annotated.path,
...(annotated.identity != null ? { identity: String(annotated.identity) } : {}),
};
const evidence = {
keyword: error.keyword,
expected: error.schema,
...error.params,
};
const supportedFixes = {
additionalProperties: [`remove unsupported property ${JSON.stringify(error.params?.additionalProperty)}`],
required: [`add required property ${JSON.stringify(error.params?.missingProperty)}`],
type: [`use ${JSON.stringify(error.params?.type)} at ${annotated.path}`],
enum: [`choose one of ${JSON.stringify(error.params?.allowedValues || [])}`],
pattern: [`match the required pattern ${JSON.stringify(error.params?.pattern)}`],
minimum: [`use a value ${error.params?.comparison || '>='} ${error.params?.limit}`],
maximum: [`use a value ${error.params?.comparison || '<='} ${error.params?.limit}`],
minItems: [`provide at least ${error.params?.limit} item(s)`],
maxItems: [`provide at most ${error.params?.limit} item(s)`],
minLength: [`provide at least ${error.params?.limit} character(s)`],
maxLength: [`provide at most ${error.params?.limit} character(s)`],
}[error.keyword] || [];
const detail = error.params && Object.keys(error.params).length
? ` ${JSON.stringify(error.params)}`
: '';
return {
code: `schema/${error.keyword}`,
severity: 'error',
message: `${annotatePath(error.instancePath, data)} ${error.message}${detail}`,
subject,
evidence,
supportedFixes,
};
});
throwDiagnosticError(
`${diagramType} schema validation failed:\n${formatErrors(validate.errors, data)}`,
diagnostics,
);
}
}