docs: archify
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
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, '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}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function attribute(tag, name) {
|
||||
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
||||
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
|
||||
}
|
||||
|
||||
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 = 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 readLimited(page.response, MAX_HTML_BYTES)).toString('utf8');
|
||||
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/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>`;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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, throwDiagnosticProblems } from './diagnostics.mjs';
|
||||
import { validateEngineeringProfile } from './engineering-profiles.mjs';
|
||||
import { resolveOutputPath } from './output-path.mjs';
|
||||
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
|
||||
|
||||
installRendererDiagnosticBoundary();
|
||||
|
||||
const outputPathGuards = new Map();
|
||||
|
||||
// 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 }) {
|
||||
const skillRoot = path.resolve(rendererDir, '../..');
|
||||
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
|
||||
const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
validateSchema(diagramType, diagram);
|
||||
validateGuidedViews(diagramType, diagram);
|
||||
validateRelationshipIds(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');
|
||||
// Optional chaining: in degraded mode (no ajv) malformed input must still
|
||||
// reach the renderer's friendly layout checks instead of crashing here.
|
||||
const outputRequest = {
|
||||
requestedOutput: argv[3],
|
||||
authoredOutput: diagram.meta?.output,
|
||||
defaultOutput: `${diagramType}.html`,
|
||||
inputPaths: [inputPath],
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
const { outputPath: outPath } = resolveOutputPath(outputRequest);
|
||||
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']);
|
||||
|
||||
// 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);
|
||||
if (outputGuard) resolveOutputPath(outputGuard);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, applyTemplate(template, {
|
||||
title: meta.title,
|
||||
subtitle: meta.subtitle,
|
||||
svg,
|
||||
cards: renderCards(cards),
|
||||
visualPreset: meta.visual_preset || 'classic',
|
||||
guidedViews: meta.views || [],
|
||||
sourceEvidence,
|
||||
}));
|
||||
outputPathGuards.delete(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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Schema keeps the view object bounded; this pass checks facts that span
|
||||
// collections. Keeping it here makes the same contract apply to all five
|
||||
// renderers, including the zero-install standalone-validator path.
|
||||
export function validateGuidedViews(diagramType, diagram) {
|
||||
const views = diagram.meta?.views;
|
||||
if (!Array.isArray(views) || views.length === 0) return;
|
||||
const collection = SEMANTIC_COLLECTIONS[diagramType];
|
||||
const semanticIds = new Set((diagram[collection] || []).map((item) => item.id));
|
||||
const seen = new Set();
|
||||
const problems = [];
|
||||
|
||||
views.forEach((view, index) => {
|
||||
if (seen.has(view.id)) problems.push(`/meta/views/${index}/id duplicates view id ${JSON.stringify(view.id)}`);
|
||||
seen.add(view.id);
|
||||
const seenFocus = new Set();
|
||||
(view.focus || []).forEach((id, focusIndex) => {
|
||||
if (seenFocus.has(id)) {
|
||||
problems.push(`/meta/views/${index}/focus/${focusIndex} duplicates semantic id ${JSON.stringify(id)}`);
|
||||
}
|
||||
seenFocus.add(id);
|
||||
if (!semanticIds.has(id)) {
|
||||
problems.push(`/meta/views/${index}/focus/${focusIndex} references unknown semantic id ${JSON.stringify(id)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (problems.length) {
|
||||
throwDiagnosticProblems('Guided view validation failed', problems, {
|
||||
code: 'guided-view/invalid',
|
||||
subject: { diagramType, collection: 'meta.views' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Accessible name for the generated diagram SVG.
|
||||
export function svgRootAttrs(meta, kind) {
|
||||
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 = process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
|
||||
const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
|
||||
const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
|
||||
return `role="img" 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 || `A ${kind} generated by Archify.`;
|
||||
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, Story, 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 = {}) {
|
||||
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 ? `Focus ${label}, ${detail}` : `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,26 @@
|
||||
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 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);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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');
|
||||
|
||||
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))]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDiagnostic(diagnostic) {
|
||||
if (!DIAGNOSTIC_MODE) return;
|
||||
const normalized = normalizedDiagnostic(diagnostic);
|
||||
if (recordedMessages.has(normalized.message)) return;
|
||||
recordedMessages.add(normalized.message);
|
||||
recorded.push(normalized);
|
||||
}
|
||||
|
||||
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 = {} } = {}) {
|
||||
const messages = (problems || []).map((problem) => String(problem));
|
||||
for (const message of messages) {
|
||||
recordDiagnostic({
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject,
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
throw new Error(`${prefix}:\n- ${messages.join('\n- ')}`);
|
||||
}
|
||||
|
||||
function fallbackDiagnostic(error) {
|
||||
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
|
||||
if (error instanceof SyntaxError) {
|
||||
return normalizedDiagnostic({
|
||||
code: 'input/json-parse',
|
||||
severity: 'error',
|
||||
message: `Input JSON could not be parsed: ${error.message}`,
|
||||
subject: { input },
|
||||
evidence: { reason: error.message },
|
||||
supportedFixes: ['repair the JSON syntax and run validation again'],
|
||||
});
|
||||
}
|
||||
if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') {
|
||||
return normalizedDiagnostic({
|
||||
code: 'input/read',
|
||||
severity: 'error',
|
||||
message: `Input could not be read: ${error.message}`,
|
||||
subject: { input },
|
||||
evidence: { systemCode: error.code, reason: error.message },
|
||||
supportedFixes: ['provide one readable JSON input file'],
|
||||
});
|
||||
}
|
||||
return normalizedDiagnostic({
|
||||
code: 'internal/unclassified',
|
||||
severity: 'error',
|
||||
message: error?.message || 'Renderer failed without a diagnostic.',
|
||||
subject: { input },
|
||||
evidence: { errorName: error?.name || 'Error' },
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
function rendererFailure(error) {
|
||||
const attached = Array.isArray(error?.archifyDiagnostics)
|
||||
? error.archifyDiagnostics.map(normalizedDiagnostic)
|
||||
: [];
|
||||
const diagnostics = recorded.length ? recorded : (attached.length ? attached : [fallbackDiagnostic(error)]);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
source: 'renderer',
|
||||
error: error?.message || 'Renderer failed without a diagnostic.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function installRendererDiagnosticBoundary() {
|
||||
if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return;
|
||||
globalThis[boundaryKey] = true;
|
||||
process.on('uncaughtException', (error) => {
|
||||
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
|
||||
try {
|
||||
fs.writeSync(process.stderr.fd, payload);
|
||||
} 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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
/** 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',
|
||||
points: routed.points.map(([x, y]) => [Math.round(x), Math.round(y)]),
|
||||
...(labelAt ? { labelAt: labelAt.map(Math.round) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
|
||||
import { esc, textUnits } from './utils.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: Math.max(...measured.map((entry) => entry.width)),
|
||||
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 }) {
|
||||
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">Legend</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');
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAX_SYMLINK_DEPTH = 64;
|
||||
const directorySemanticsCache = new Map();
|
||||
let semanticsProbeSequence = 0;
|
||||
|
||||
function splitAbsolute(absolutePath) {
|
||||
const root = path.parse(absolutePath).root;
|
||||
return {
|
||||
root,
|
||||
segments: absolutePath.slice(root.length).split(path.sep).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalize(targetPath, depth) {
|
||||
const absolutePath = path.resolve(targetPath);
|
||||
const { root, segments } = splitAbsolute(absolutePath);
|
||||
let current = root;
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const candidate = path.join(current, segments[index]);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(candidate);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return path.resolve(current, ...segments.slice(index));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (depth >= MAX_SYMLINK_DEPTH) {
|
||||
const error = new Error(`Could not resolve path because a symbolic-link cycle includes "${candidate}".`);
|
||||
error.code = 'ELOOP';
|
||||
error.path = candidate;
|
||||
throw error;
|
||||
}
|
||||
const link = fs.readlinkSync(candidate);
|
||||
const linkTarget = path.isAbsolute(link) ? link : path.resolve(path.dirname(candidate), link);
|
||||
return canonicalize(path.join(linkTarget, ...segments.slice(index + 1)), depth + 1);
|
||||
}
|
||||
|
||||
current = fs.realpathSync.native(candidate);
|
||||
}
|
||||
|
||||
return path.normalize(current);
|
||||
}
|
||||
|
||||
export function canonicalFuturePath(targetPath) {
|
||||
try {
|
||||
return canonicalize(targetPath, 0);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ELOOP') throw error;
|
||||
const output = path.resolve(targetPath);
|
||||
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: {
|
||||
systemCode: 'ELOOP',
|
||||
...(error.path ? { cycleAt: path.resolve(error.path) } : {}),
|
||||
},
|
||||
supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasFileIdentity(stat) {
|
||||
return stat.ino !== 0 && stat.ino !== 0n;
|
||||
}
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
return hasFileIdentity(left)
|
||||
&& hasFileIdentity(right)
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino;
|
||||
}
|
||||
|
||||
function nearestExistingDirectory(targetPath) {
|
||||
let directory = path.dirname(targetPath);
|
||||
while (true) {
|
||||
try {
|
||||
const stat = fs.statSync(directory);
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
path: fs.realpathSync.native(directory),
|
||||
stat,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return null;
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) return null;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function directoryIdentityKey(directory) {
|
||||
if (!hasFileIdentity(directory.stat)) return null;
|
||||
return `${directory.stat.dev}:${directory.stat.ino}`;
|
||||
}
|
||||
|
||||
function probeNamesAlias(directoryPath, authoredName, lookupName) {
|
||||
let fileDescriptor;
|
||||
let created = false;
|
||||
let result = null;
|
||||
let cleaned = true;
|
||||
const authoredPath = path.join(directoryPath, authoredName);
|
||||
const lookupPath = path.join(directoryPath, lookupName);
|
||||
try {
|
||||
fileDescriptor = fs.openSync(authoredPath, 'wx', 0o600);
|
||||
created = true;
|
||||
fs.closeSync(fileDescriptor);
|
||||
fileDescriptor = undefined;
|
||||
|
||||
let authored;
|
||||
let lookup;
|
||||
try {
|
||||
authored = fs.statSync(authoredPath);
|
||||
lookup = fs.statSync(lookupPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') result = false;
|
||||
}
|
||||
if (authored && lookup) {
|
||||
if (sameFileIdentity(authored, lookup)) {
|
||||
result = true;
|
||||
} else {
|
||||
try {
|
||||
result = fs.realpathSync.native(authoredPath) === fs.realpathSync.native(lookupPath);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = null;
|
||||
} finally {
|
||||
if (fileDescriptor !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fileDescriptor);
|
||||
} catch {
|
||||
cleaned = false;
|
||||
}
|
||||
}
|
||||
if (created) {
|
||||
try {
|
||||
fs.unlinkSync(authoredPath);
|
||||
} catch {
|
||||
cleaned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned ? result : null;
|
||||
}
|
||||
|
||||
function probeDirectorySemantics(directory) {
|
||||
const cacheKey = directoryIdentityKey(directory);
|
||||
if (cacheKey && directorySemanticsCache.has(cacheKey)) {
|
||||
return directorySemanticsCache.get(cacheKey);
|
||||
}
|
||||
|
||||
semanticsProbeSequence += 1;
|
||||
const suffix = `${process.pid}-${Date.now().toString(36)}-${semanticsProbeSequence}`;
|
||||
const caseAuthored = `.archify-Case-Probe-${suffix}`;
|
||||
const normalizationAuthored = `.archify-norm-\u00e9-probe-${suffix}`;
|
||||
const semantics = {
|
||||
caseInsensitive: probeNamesAlias(
|
||||
directory.path,
|
||||
caseAuthored,
|
||||
caseAuthored.toLowerCase(),
|
||||
),
|
||||
normalizationInsensitive: probeNamesAlias(
|
||||
directory.path,
|
||||
normalizationAuthored,
|
||||
normalizationAuthored.normalize('NFD'),
|
||||
),
|
||||
};
|
||||
if (
|
||||
cacheKey
|
||||
&& semantics.caseInsensitive !== null
|
||||
&& semantics.normalizationInsensitive !== null
|
||||
) {
|
||||
directorySemanticsCache.set(cacheKey, semantics);
|
||||
}
|
||||
return semantics;
|
||||
}
|
||||
|
||||
function sameDirectory(left, right) {
|
||||
return left.path === right.path || sameFileIdentity(left.stat, right.stat);
|
||||
}
|
||||
|
||||
function futurePathsAlias(leftPath, rightPath) {
|
||||
const left = canonicalFuturePath(leftPath);
|
||||
const right = canonicalFuturePath(rightPath);
|
||||
if (left === right) return true;
|
||||
|
||||
const leftDirectory = nearestExistingDirectory(left);
|
||||
const rightDirectory = nearestExistingDirectory(right);
|
||||
if (!leftDirectory || !rightDirectory || !sameDirectory(leftDirectory, rightDirectory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const semantics = probeDirectorySemantics(leftDirectory);
|
||||
let comparableLeft = path.relative(leftDirectory.path, left);
|
||||
let comparableRight = path.relative(rightDirectory.path, right);
|
||||
if (semantics.normalizationInsensitive !== false) {
|
||||
comparableLeft = comparableLeft.normalize('NFC');
|
||||
comparableRight = comparableRight.normalize('NFC');
|
||||
}
|
||||
if (semantics.caseInsensitive !== false) {
|
||||
comparableLeft = comparableLeft.toLowerCase();
|
||||
comparableRight = comparableRight.toLowerCase();
|
||||
}
|
||||
return comparableLeft === comparableRight;
|
||||
}
|
||||
|
||||
export function pathsAlias(leftPath, rightPath) {
|
||||
if (futurePathsAlias(leftPath, rightPath)) return true;
|
||||
try {
|
||||
const left = fs.statSync(leftPath);
|
||||
const right = fs.statSync(rightPath);
|
||||
return sameFileIdentity(left, right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathIsInside(directoryPath, targetPath) {
|
||||
const relative = path.relative(canonicalFuturePath(directoryPath), canonicalFuturePath(targetPath));
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
||||
}
|
||||
|
||||
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(),
|
||||
}) {
|
||||
const rawOutput = requestedOutput || authoredOutput || defaultOutput;
|
||||
const source = requestedOutput ? 'cli' : (authoredOutput ? 'meta' : 'default');
|
||||
if (
|
||||
source === 'meta'
|
||||
&& (path.isAbsolute(rawOutput) || path.posix.isAbsolute(rawOutput) || path.win32.isAbsolute(rawOutput))
|
||||
) {
|
||||
throw new OutputPathError('meta.output must be a relative path.', {
|
||||
code: 'output/meta-absolute',
|
||||
message: 'meta.output must be a relative path resolved from the current working directory.',
|
||||
subject: { output: rawOutput },
|
||||
supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
|
||||
});
|
||||
}
|
||||
if (source === 'meta' && path.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 },
|
||||
supportedFixes: ['change meta.output to a path ending in .html'],
|
||||
});
|
||||
}
|
||||
const outputPath = path.resolve(cwd, rawOutput);
|
||||
if (source === 'meta' && 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 (source === 'meta' && !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'],
|
||||
});
|
||||
}
|
||||
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { throwDiagnosticError } from './diagnostics.mjs';
|
||||
|
||||
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
||||
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
||||
throwDiagnosticError(message, [{
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject: { surface: 'repository-evidence', ...subject },
|
||||
evidence,
|
||||
supportedFixes,
|
||||
}]);
|
||||
}
|
||||
|
||||
function runGit(repoRoot, args) {
|
||||
const result = spawnSync('git', ['-C', repoRoot, ...args], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 githubSlug(value) {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
||||
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function verifiedSourcePath(value, where) {
|
||||
const sourcePath = String(value || '');
|
||||
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 sourceHref(repositoryUrl, revision, source) {
|
||||
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
||||
const lineFragment = source.line
|
||||
? `#L${source.line}${source.endLine && source.endLine !== source.line ? `-L${source.endLine}` : ''}`
|
||||
: '';
|
||||
return `${repositoryUrl}/blob/${revision}/${encodedPath}${lineFragment}`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function hasRepositoryEvidence(diagramType, diagram) {
|
||||
if (diagramType !== 'architecture') return false;
|
||||
const components = Array.isArray(diagram?.components) ? diagram.components : [];
|
||||
return Boolean(diagram?.meta?.repository) || components.some((component) => Array.isArray(component?.sources) && component.sources.length);
|
||||
}
|
||||
|
||||
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
||||
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
||||
if (diagramType !== 'architecture') evidenceFailure('repository-evidence/type-unsupported', 'Repository evidence is currently supported for architecture diagrams only.', {
|
||||
subject: { diagramType },
|
||||
supportedFixes: ['use architecture mode or remove repository evidence'],
|
||||
});
|
||||
|
||||
const repository = diagram.meta?.repository;
|
||||
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['add the pinned public repository metadata or remove component 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 authoredSlug = githubSlug(repository.url);
|
||||
if (!authoredSlug || !String(repository.url).startsWith('https://github.com/')) {
|
||||
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a public https://github.com owner/repository URL.', {
|
||||
subject: { path: '/meta/repository/url' },
|
||||
evidence: { repositoryUrl: repository.url },
|
||||
supportedFixes: ['use the canonical public GitHub HTTPS repository URL'],
|
||||
});
|
||||
}
|
||||
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.`);
|
||||
if (fs.realpathSync(gitRoot) !== realRoot) {
|
||||
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 (githubSlug(origin) !== authoredSlug) {
|
||||
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(origin)} does not match ${JSON.stringify(repository.url)}.`, {
|
||||
subject: { repoRoot: realRoot },
|
||||
evidence: { localOrigin: origin, 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'],
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = Object.create(null);
|
||||
let referenceCount = 0;
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : [];
|
||||
for (const [componentIndex, component] of components.entries()) {
|
||||
if (!Array.isArray(component.sources) || component.sources.length === 0) continue;
|
||||
const verified = [];
|
||||
for (const [sourceIndex, authored] of component.sources.entries()) {
|
||||
const where = `/components/${componentIndex}/sources/${sourceIndex}/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', `/components/${componentIndex}/sources/${sourceIndex}/end_line requires line.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}/end_line`, componentId: component.id },
|
||||
supportedFixes: ['add line or remove end_line'],
|
||||
});
|
||||
}
|
||||
if (source.endLine && source.endLine < source.line) {
|
||||
evidenceFailure('repository-evidence/line-range-invalid', `/components/${componentIndex}/sources/${sourceIndex}/end_line must be greater than or equal to line.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
||||
evidence: { line: source.line, endLine: source.endLine },
|
||||
supportedFixes: ['use an end_line greater than or equal to line'],
|
||||
});
|
||||
}
|
||||
const object = `${revision}:${source.path}`;
|
||||
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
||||
if (type.status !== 0 || type.stdout.trim() !== 'blob') {
|
||||
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
||||
subject: { path: where, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, revision },
|
||||
supportedFixes: ['use a file path that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
if (source.line) {
|
||||
const 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, componentId: component.id },
|
||||
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', `/components/${componentIndex}/sources/${sourceIndex} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
||||
subject: { path: `/components/${componentIndex}/sources/${sourceIndex}`, componentId: component.id },
|
||||
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
||||
supportedFixes: ['use a line range that exists at the pinned revision'],
|
||||
});
|
||||
}
|
||||
}
|
||||
verified.push({ ...source, href: sourceHref(repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''), revision, source) });
|
||||
referenceCount += 1;
|
||||
}
|
||||
nodes[component.id] = verified;
|
||||
}
|
||||
if (referenceCount === 0) {
|
||||
evidenceFailure('repository-evidence/source-required', '/meta/repository requires at least one component source reference.', {
|
||||
subject: { path: '/meta/repository' },
|
||||
supportedFixes: ['add at least one verified component source or remove repository metadata'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
verified: true,
|
||||
repository: {
|
||||
url: repository.url.replace(/\.git\/?$/i, '').replace(/\/$/, ''),
|
||||
revision,
|
||||
shortRevision: revision.slice(0, 7),
|
||||
},
|
||||
referenceCount,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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 } 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;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
export function esc(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
|
||||
}
|
||||
|
||||
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: 'backend',
|
||||
database: 'database',
|
||||
success: 'database',
|
||||
cloud: 'cloud',
|
||||
waiting: 'cloud',
|
||||
security: 'security',
|
||||
failure: 'security',
|
||||
messagebus: 'messagebus',
|
||||
external: 'external',
|
||||
neutral: 'external',
|
||||
};
|
||||
|
||||
const SIGIL_SHAPE = {
|
||||
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 role stamp. It is authored 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.
|
||||
export function renderSemanticSigil(kind, { x, y, size = 11 } = {}) {
|
||||
const normalized = Object.hasOwn(SIGIL_SHAPE, kind) ? kind : 'neutral';
|
||||
const tone = SIGIL_TONE[normalized] || '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 GUIDED_VIEWS_PLACEHOLDER = '<!-- ARCHIFY:GUIDED_VIEWS_DATA -->';
|
||||
const SOURCE_EVIDENCE_PLACEHOLDER = ' <!-- ARCHIFY:SOURCE_EVIDENCE_DATA -->';
|
||||
|
||||
const TEMPLATE_PLACEHOLDERS = [
|
||||
'<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">',
|
||||
'<title>[PROJECT NAME] Architecture Diagram</title>',
|
||||
'<h1>[PROJECT NAME] Architecture</h1>',
|
||||
GUIDED_VIEWS_PLACEHOLDER,
|
||||
];
|
||||
|
||||
export function applyTemplate(template, { title, subtitle, svg, cards, visualPreset = 'classic', guidedViews = [], 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 guidedViewsJson = JSON.stringify(guidedViews)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026');
|
||||
const sourceEvidenceJson = JSON.stringify(sourceEvidence)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026');
|
||||
const renderedSubtitle = typeof subtitle === 'string' && subtitle.trim()
|
||||
? `<p class="subtitle">${esc(subtitle)}</p>`
|
||||
: '';
|
||||
return template
|
||||
.replace(TEMPLATE_PLACEHOLDERS[0], () => `<html lang="en" data-theme="dark" data-preset="${esc(visualPreset)}">`)
|
||||
.replace(TEMPLATE_PLACEHOLDERS[1], () => `<title>${esc(title)} Diagram</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(GUIDED_VIEWS_PLACEHOLDER, () => `<script id="archify-guided-views-data" type="application/json">${guidedViewsJson}</script>`)
|
||||
.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;
|
||||
|
||||
// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it
|
||||
// re-presents the character before it. VS15 (U+FE0E) asks for text
|
||||
// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji
|
||||
// presentation, which renders at the square emoji advance. So a base plus a
|
||||
// selector is measured from the selector, not from the base -- otherwise
|
||||
// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two
|
||||
// units into three while the glyph on screen stays one square, and leaves
|
||||
// U+2708 U+FE0F at two only because its base happens to be narrow.
|
||||
//
|
||||
// A selector following a base that cannot take emoji presentation is
|
||||
// malformed input; measuring it wide is the safe direction here, since
|
||||
// over-measuring pads a box while under-measuring spills the label out of it.
|
||||
const VARIATION_SELECTOR_FIRST = 0xfe00;
|
||||
const VARIATION_SELECTOR_LAST = 0xfe0f;
|
||||
const VARIATION_SELECTOR_TEXT = 0xfe0e;
|
||||
const VARIATION_SELECTOR_EMOJI = 0xfe0f;
|
||||
|
||||
export function textUnits(text) {
|
||||
const chars = Array.from(String(text ?? ''));
|
||||
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 if (next === VARIATION_SELECTOR_TEXT) units += 1;
|
||||
else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1;
|
||||
}
|
||||
return units;
|
||||
}
|
||||
@@ -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