Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1982
File diff suppressed because it is too large
Load Diff
+670
@@ -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
@@ -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
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
+1941
File diff suppressed because it is too large
Load Diff
+603
@@ -0,0 +1,603 @@
|
||||
export const SUPPORTED_LOCALES = ['en', 'zh-CN'];
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
+1017
File diff suppressed because it is too large
Load Diff
+408
@@ -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}`;
|
||||
}
|
||||
+614
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user