Skill Tracer
Skills › Archify › archify › brand-marks.mjs

brand-marks.mjs

706 lines · archify/renderers/shared/brand-marks.mjs at a406594

1import { createHash } from 'node:crypto';
2import { lookup } from 'node:dns/promises';
3import http from 'node:http';
4import https from 'node:https';
5import net from 'node:net';
6import { BRAND_MARKS } from './generated-brand-marks.mjs';
7import { throwDiagnosticError } from './diagnostics.mjs';
8import { esc, textUnits } from './utils.mjs';
9
10const COLLECTIONS = Object.freeze({
11 architecture: 'components',
12 workflow: 'nodes',
13 sequence: 'participants',
14 dataflow: 'nodes',
15 lifecycle: 'states',
16});
17const MARK_BY_LOOKUP = new Map();
18const MARK_BY_DOMAIN = new Map();
19const RESOLVED_BY_NODE = new WeakMap();
20const RESOLVED_MARK = Symbol('archify.brandMark');
21const MAX_HTML_BYTES = 256 * 1024;
22const MAX_IMAGE_BYTES = 1024 * 1024;
23const MAX_CAPTURE_CONCURRENCY = 3;
24const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
25const USER_AGENT = 'Archify/2.15 brand-preview';
27function lookupForms(value) {
28 const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
29 if (!raw) return [];
30 const dashed = raw.replace(/[\s_]+/g, '-');
31 const compact = raw.replace(/[\s_.-]+/g, '');
32 return [...new Set([raw, dashed, compact])];
35for (const mark of BRAND_MARKS) {
36 for (const value of [mark.id, mark.title, ...mark.aliases]) {
37 for (const form of lookupForms(value)) {
38 if (!MARK_BY_LOOKUP.has(form)) MARK_BY_LOOKUP.set(form, mark);
39 }
40 }
41 for (const domain of mark.domains) MARK_BY_DOMAIN.set(domain, mark);
44function asUrl(value) {
45 try {
46 const url = new URL(String(value));
47 return ['https:', 'http:'].includes(url.protocol) ? url : null;
48 } catch {
49 return null;
50 }
53// Public provenance is separate from the URL used to reproduce a pinned asset.
54export function normalizeBrandSourceUrl(value) {
55 const url = asUrl(value);
56 if (!url) return null;
57 url.username = '';
58 url.password = '';
59 url.search = '';
60 url.hash = '';
61 return url.href;
64function domainMark(hostname) {
65 const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
66 const candidates = [...MARK_BY_DOMAIN.entries()]
67 .filter(([domain]) => host === domain || host.endsWith(`.${domain}`))
68 .sort(([left], [right]) => right.length - left.length);
69 return candidates[0]?.[1] || null;
72export function findBrandMark(value) {
73 const url = asUrl(value);
74 if (url) return domainMark(url.hostname);
75 for (const form of lookupForms(value)) {
76 const mark = MARK_BY_LOOKUP.get(form);
77 if (mark) return mark;
78 }
79 return null;
82export function listBrandMarks(query = '') {
83 const needle = String(query).trim().toLocaleLowerCase('en-US');
84 return BRAND_MARKS.filter((mark) => {
85 if (!needle) return true;
86 return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains]
87 .some((value) => String(value).toLocaleLowerCase('en-US').includes(needle));
88 }).map(({ path, ...mark }) => mark);
91function ipv4Private(address) {
92 const parts = address.split('.').map(Number);
93 if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
94 const [a, b, c] = parts;
95 return a === 0 || a === 10 || a === 127 || a >= 224
96 || (a === 100 && b >= 64 && b <= 127)
97 || (a === 169 && b === 254)
98 || (a === 172 && b >= 16 && b <= 31)
99 || (a === 192 && b === 0 && (c === 0 || c === 2))
100 || (a === 192 && b === 88 && c === 99)
101 || (a === 192 && b === 168)
102 || (a === 198 && (b === 18 || b === 19))
103 || (a === 198 && b === 51 && c === 100)
104 || (a === 203 && b === 0 && c === 113);
107function ipv6Private(address) {
108 const normalized = address.toLocaleLowerCase('en-US').split('%')[0];
109 if (normalized === '::' || normalized === '::1') return true;
110 if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true;
111 if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:')
112 || normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true;
113 const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
114 if (mappedDotted) return ipv4Private(mappedDotted[1]);
115 const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
116 if (mappedHex) {
117 const high = Number.parseInt(mappedHex[1], 16);
118 const low = Number.parseInt(mappedHex[2], 16);
119 return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
120 }
121 const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
122 if (compatibleHex) {
123 const high = Number.parseInt(compatibleHex[1], 16);
124 const low = Number.parseInt(compatibleHex[2], 16);
125 return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
126 }
127 return false;
130export function isPrivateBrandAddress(address) {
131 const family = net.isIP(address);
132 return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true);
135function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') {
136 if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported');
137 if (url.username || url.password) throw new Error('brand links cannot contain credentials');
138 const expectedPort = url.protocol === 'https:' ? '443' : '80';
139 if (!allowPrivate && url.port && url.port !== expectedPort) {
140 throw new Error('brand links must use a standard web port');
141 }
142 const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, '');
143 if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) {
144 throw new Error('private brand links are not fetched');
145 }
146 return host;
149function beforeDeadline(promise, deadline) {
150 const remaining = deadline - Date.now();
151 if (remaining <= 0) return Promise.reject(new Error('brand capture timed out'));
152 return new Promise((resolve, reject) => {
153 const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining);
154 timer.unref?.();
155 promise.then(
156 (value) => { clearTimeout(timer); resolve(value); },
157 (error) => { clearTimeout(timer); reject(error); },
158 );
159 });
162async function resolveRequestTarget(url, deadline) {
163 const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1';
164 const host = validateUrlShape(url, allowPrivate);
165 const directFamily = net.isIP(host);
166 const addresses = directFamily
167 ? [{ address: host, family: directFamily }]
168 : await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline);
169 if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) {
170 throw new Error('private brand links are not fetched');
171 }
172 return addresses[0];
175function timeoutSignal(milliseconds) {
176 if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds);
177 const controller = new AbortController();
178 const timer = setTimeout(() => controller.abort(), milliseconds);
179 timer.unref?.();
180 return controller.signal;
183function captureTimeoutMilliseconds() {
184 const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS);
185 if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS;
186 return Math.max(100, Math.min(30000, Math.round(configured)));
189function requestPinned(url, accept, target, deadline) {
190 return new Promise((resolve, reject) => {
191 const transport = url.protocol === 'https:' ? https : http;
192 const request = transport.request(url, {
193 method: 'GET',
194 signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
195 headers: { accept, 'accept-encoding': 'identity', 'user-agent': USER_AGENT },
196 // Reuse the exact public address that passed validation. This closes the
197 // DNS-rebinding gap between checking a hostname and opening its socket.
198 lookup(_hostname, options, callback) {
199 if (options?.all) callback(null, [target]);
200 else callback(null, target.address, target.family);
201 },
202 }, (response) => {
203 const status = response.statusCode || 0;
204 resolve({
205 status,
206 ok: status >= 200 && status < 300,
207 headers: {
208 get(name) {
209 const value = response.headers[String(name).toLocaleLowerCase('en-US')];
210 return Array.isArray(value) ? value.join(', ') : (value ?? null);
211 },
212 },
213 body: response,
214 });
215 });
216 request.on('error', reject);
217 request.end();
218 });
221async function checkedFetch(input, accept, deadline) {
222 let current = new URL(input);
223 for (let redirects = 0; redirects <= 3; redirects += 1) {
224 if (Date.now() >= deadline) throw new Error('brand capture timed out');
225 const target = await resolveRequestTarget(current, deadline);
226 const response = await requestPinned(current, accept, target, deadline);
227 if ([301, 302, 303, 307, 308].includes(response.status)) {
228 const location = response.headers.get('location');
229 response.body.resume();
230 if (!location || redirects === 3) throw new Error('brand link redirected too many times');
231 current = new URL(location, current);
232 continue;
233 }
234 if (!response.ok) {
235 response.body.resume();
236 throw new Error(`brand link returned HTTP ${response.status}`);
237 }
238 // Raw HTTP responses are not decompressed. Check successful bodies before
239 // HTML discovery or image validation so byte limits and digests stay valid.
240 const contentEncoding = (response.headers.get('content-encoding') || '').trim().toLowerCase();
241 if (contentEncoding && contentEncoding !== 'identity') {
242 response.body.destroy();
243 throw new Error(`unsupported brand content encoding ${contentEncoding}`);
244 }
245 return { response, finalUrl: current };
246 }
247 throw new Error('brand link redirected too many times');
250async function readLimited(response, maximum) {
251 const declared = Number(response.headers.get('content-length'));
252 if (Number.isFinite(declared) && declared > maximum) {
253 response.body?.destroy?.();
254 throw new Error('brand asset is too large');
255 }
256 if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
257 const chunks = [];
258 let total = 0;
259 for await (const value of response.body) {
260 total += value.byteLength;
261 if (total > maximum) {
262 response.body.destroy?.();
263 throw new Error('brand asset is too large');
264 }
265 chunks.push(Buffer.from(value));
266 }
267 return Buffer.concat(chunks, total);
268 }
269 if (!response.body?.getReader) {
270 const buffer = Buffer.from(await response.arrayBuffer());
271 if (buffer.length > maximum) throw new Error('brand asset is too large');
272 return buffer;
273 }
274 const reader = response.body.getReader();
275 const chunks = [];
276 let total = 0;
277 while (true) {
278 const { done, value } = await reader.read();
279 if (done) break;
280 total += value.byteLength;
281 if (total > maximum) {
282 await reader.cancel();
283 throw new Error('brand asset is too large');
284 }
285 chunks.push(Buffer.from(value));
286 }
287 return Buffer.concat(chunks, total);
290// Read only the bounded head, independent of network chunk boundaries. Scan
291// bytes once so many tiny chunks cannot cause repeated concatenation/rescanning.
292// Collect link tags in the same pass: markup in comments, raw text, attributes
293// or templates must not become an icon candidate or an early head ending.
294async function readHtmlHeadLinks(response, maximum, mediaType) {
295 const chunks = response.body && typeof response.body[Symbol.asyncIterator] === 'function'
296 ? response.body : [await readLimited(response, maximum)];
297 const buffer = Buffer.alloc(maximum);
298 const links = [];
299 let total = 0;
300 let tagStart = -1;
301 let quote = 0;
302 let comment = false;
303 let rawClosing = '';
304 let matched = 0;
305 let templateDepth = 0;
306 for await (const value of chunks) {
307 const chunk = Buffer.from(value);
308 const length = Math.min(chunk.length, maximum - total);
309 chunk.copy(buffer, total, 0, length);
310 for (let offset = 0; offset < length; offset++) {
311 const byte = chunk[offset];
312 const position = total + offset;
313 if (comment) {
314 if (byte === 0x3e && buffer[position - 1] === 0x2d && buffer[position - 2] === 0x2d) comment = false;
315 continue;
316 }
317 if (rawClosing) {
318 const lower = byte >= 65 && byte <= 90 ? byte + 32 : byte;
319 if (matched === rawClosing.length && [9, 10, 12, 13, 32, 47, 62].includes(byte)) {
320 tagStart = position - matched;
321 rawClosing = '';
322 matched = 0;
323 } else {
324 matched = lower === rawClosing.charCodeAt(matched) ? matched + 1 : (byte === 0x3c ? 1 : 0);
325 continue;
326 }
327 }
328 if (tagStart < 0) {
329 if (byte === 0x3c) tagStart = position;
330 continue;
331 }
332 if (position === tagStart + 1 && !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || [33, 47, 63].includes(byte))) {
333 tagStart = byte === 0x3c ? position : -1;
334 continue;
335 }
336 if (position === tagStart + 3 && buffer[tagStart + 1] === 0x21 && buffer[tagStart + 2] === 0x2d && byte === 0x2d) {
337 comment = true;
338 tagStart = -1;
339 continue;
340 }
341 if (quote) {
342 if (byte === quote) quote = 0;
343 continue;
344 }
345 if (byte === 0x22 || byte === 0x27) {
346 quote = byte;
347 continue;
348 }
349 if (byte === 0x3e) {
350 const tag = buffer.toString('utf8', tagStart, position + 1);
351 if (!templateDepth && /^<\/head[\t\n\f\r ]*>$/i.test(tag)) {
352 response.body?.destroy?.();
353 return links;
354 }
355 const emptyXhtmlElement = mediaType === 'application/xhtml+xml' && tag.endsWith('/>');
356 if (!emptyXhtmlElement && /^<template(?=[\t\n\f\r />])/i.test(tag)) templateDepth += 1;
357 else if (/^<\/template(?=[\t\n\f\r />])/i.test(tag)) templateDepth = Math.max(0, templateDepth - 1);
358 else if (!templateDepth && /^<link(?=[\t\n\f\r />])/i.test(tag)) links.push(tag);
359 const raw = /^<(script|style|title|textarea|xmp|iframe|noembed|noframes)(?=[\t\n\f\r />])/i.exec(tag)
360 || (mediaType === 'text/html' && /^<(noscript)(?=[\t\n\f\r />])/i.exec(tag));
361 if (raw && !emptyXhtmlElement) rawClosing = `</${raw[1].toLowerCase()}`;
362 tagStart = -1;
363 }
364 }
365 total += length;
366 if (chunk.length > length) {
367 response.body?.destroy?.();
368 throw new Error('brand asset is too large');
369 }
370 }
371 return links;
374function linkAttribute(tag, name) {
375 // Consume entire attributes so data-href and quoted examples cannot supply
376 // a real href. As in HTML, the first occurrence wins, even when valueless.
377 const attributes = tag.slice(5, -1).matchAll(/([^\t\n\f\r />=]+)(?:[\t\n\f\r ]*=[\t\n\f\r ]*(?:"([^"]*)"|'([^']*)'|([^\t\n\f\r >]+)))?/g);
378 for (const match of attributes) {
379 if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? '';
380 }
381 return '';
384// HTML numeric references in the C1 range use the legacy Windows-1252 mapping.
385// https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
386const HTML_C1_REFERENCES = [
387 0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021,
388 0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f,
389 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
390 0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
391];
392const BASIC_HTML_REFERENCES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' };
394function decodeIconHref(value) {
395 // Decode only after extracting the attribute, in one pass. Leave percent
396 // escapes to URL parsing and do not reinterpret decoded quotes as markup.
397 return value.replace(/&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?|&(amp|AMP|quot|QUOT|lt|LT|gt|GT)(?:;|(?![A-Za-z0-9=]))|&(apos);/g,
398 (_match, hex, decimal, named, apostrophe) => {
399 if (named || apostrophe) return BASIC_HTML_REFERENCES[(named || apostrophe).toLowerCase()];
400 let point = Number.parseInt(hex || decimal, hex ? 16 : 10);
401 if (point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return '\uFFFD';
402 if (point >= 0x80 && point <= 0x9f) point = HTML_C1_REFERENCES[point - 0x80];
403 return String.fromCodePoint(point);
404 });
407function iconCandidates(links, pageUrl) {
408 const candidates = [];
409 for (const tag of links) {
410 const rel = linkAttribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
411 if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
412 const href = decodeIconHref(linkAttribute(tag, 'href'));
413 if (!href) continue;
414 try {
415 const url = new URL(href, pageUrl);
416 if (!['https:', 'http:'].includes(url.protocol)) continue;
417 const type = linkAttribute(tag, 'type').toLocaleLowerCase('en-US');
418 const sizes = linkAttribute(tag, 'sizes');
419 const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
420 .reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
421 const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
422 + (rel.includes('apple-touch-icon') ? 500000 : 0)
423 + area;
424 candidates.push({ url, score });
425 } catch {
426 // A malformed icon candidate is ignored; the deterministic fallback remains available.
427 }
428 }
429 candidates.sort((left, right) => right.score - left.score);
430 const fallback = new URL('/favicon.ico', pageUrl);
431 const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate]));
432 unique.delete(fallback.href);
433 return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
436function validIcoImageRanges(buffer) {
437 if (buffer.length < 6 || buffer.readUInt16LE(0) !== 0 || buffer.readUInt16LE(2) !== 1) return false;
438 const count = buffer.readUInt16LE(4);
439 const directoryEnd = 6 + count * 16;
440 if (count === 0 || directoryEnd > buffer.length) return false;
441 for (let index = 0; index < count; index += 1) {
442 const entry = 6 + index * 16;
443 const size = buffer.readUInt32LE(entry + 8);
444 const offset = buffer.readUInt32LE(entry + 12);
445 if (size === 0 || offset < directoryEnd || offset + size > buffer.length) return false;
446 }
447 return true;
450async function imageData(response) {
451 const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
452 const allowed = new Set([
453 'image/png',
454 'image/jpeg',
455 'image/webp',
456 'image/x-icon',
457 'image/vnd.microsoft.icon',
458 ]);
459 if (!allowed.has(contentType)) {
460 response.body?.destroy?.();
461 throw new Error(`unsupported brand image type ${contentType || 'unknown'}`);
462 }
463 const buffer = await readLimited(response, MAX_IMAGE_BYTES);
464 const signatureMatches = contentType === 'image/png'
465 ? buffer.length >= 45
466 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
467 && buffer.readUInt32BE(8) === 13
468 && buffer.toString('ascii', 12, 16) === 'IHDR'
469 && buffer.readUInt32BE(16) > 0
470 && buffer.readUInt32BE(20) > 0
471 && buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND'
472 : (contentType === 'image/jpeg'
473 ? buffer.length >= 20
474 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
475 && buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9
476 : (contentType === 'image/webp'
477 ? buffer.length >= 16
478 && buffer.toString('ascii', 0, 4) === 'RIFF'
479 && buffer.toString('ascii', 8, 12) === 'WEBP'
480 && buffer.readUInt32LE(4) + 8 <= buffer.length
481 : validIcoImageRanges(buffer)));
482 if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
483 return {
484 dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
485 sha256: createHash('sha256').update(buffer).digest('hex'),
486 contentType,
487 };
490async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
491 const sourceUrl = new URL(value);
492 const publicSourceUrl = normalizeBrandSourceUrl(sourceUrl);
493 const fallback = (reason) => ({
494 id: sourceUrl.hostname,
495 title: sourceUrl.hostname,
496 category: 'link',
497 kind: 'fallback',
498 status: 'unavailable',
499 sourceUrl: publicSourceUrl,
500 reason,
501 });
502 try {
503 const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline);
504 const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US');
505 if (pageType.startsWith('image/')) {
506 const image = await imageData(page.response);
507 return {
508 id: sourceUrl.hostname,
509 title: sourceUrl.hostname,
510 category: 'link',
511 kind: 'remote',
512 status: 'captured',
513 sourceUrl: publicSourceUrl,
514 resolvedUrl: page.finalUrl.href,
515 ...image,
516 };
517 }
518 if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) {
519 page.response.body?.destroy?.();
520 return fallback('linked page is not HTML');
521 }
522 // Distinguish HTML noscript and XHTML empty elements without executing scripts.
523 const mediaType = pageType.split(';')[0].trim();
524 const links = await readHtmlHeadLinks(page.response, MAX_HTML_BYTES, mediaType);
525 const iconErrors = [];
526 for (const candidate of iconCandidates(links, page.finalUrl)) {
527 try {
528 const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
529 const image = await imageData(fetched.response);
530 return {
531 id: sourceUrl.hostname,
532 title: sourceUrl.hostname,
533 category: 'link',
534 kind: 'remote',
535 status: 'captured',
536 sourceUrl: publicSourceUrl,
537 resolvedUrl: fetched.finalUrl.href,
538 ...image,
539 };
540 } catch (error) {
541 iconErrors.push(error);
542 // Try the next declared favicon before using the generic link mark.
543 }
544 }
545 const usefulError = iconErrors.find((error) => /unsupported brand (?:image type|content encoding)/i.test(error?.message))
546 || iconErrors.at(-1);
547 return fallback(usefulError?.message || 'no usable site icon was found');
548 } catch (error) {
549 return fallback(error.message);
550 }
553export async function captureBrandReference(value) {
554 const url = asUrl(value);
555 if (!url) throw new Error('brand capture requires one HTTP(S) URL');
556 validateUrlShape(url);
557 const preset = findBrandMark(url.href);
558 if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } };
559 const resolved = await captureRemoteBrand(url.href);
560 if (resolved.status !== 'captured' || !resolved.sha256) {
561 throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`);
562 }
563 return {
564 brand: { url: url.href, sha256: resolved.sha256 },
565 resolved,
566 };
569function remoteBrand(value, cache, deadline) {
570 const key = new URL(value).href;
571 if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline));
572 return cache.get(key);
575function suggestions(value) {
576 const needle = lookupForms(value)[0] || '';
577 return BRAND_MARKS.map((mark) => ({
578 id: mark.id,
579 score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1,
580 })).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
581 .slice(0, 5)
582 .map((entry) => entry.id);
585async function mapConcurrent(values, limit, visit) {
586 let cursor = 0;
587 const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
588 while (cursor < values.length) {
589 const index = cursor;
590 cursor += 1;
591 await visit(values[index], index);
592 }
593 });
594 await Promise.all(workers);
597export async function prepareDiagramBrandMarks(diagramType, diagram) {
598 const collection = COLLECTIONS[diagramType];
599 const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
600 const unknown = [];
601 const remoteByUrl = new Map();
602 const deadline = Date.now() + captureTimeoutMilliseconds();
603 await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => {
604 if (!node.brand) return;
605 if (typeof node.brand === 'object') {
606 const url = asUrl(node.brand.url);
607 const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null;
608 if (!resolved || resolved.status !== 'captured') {
609 unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`);
610 return;
611 }
612 if (resolved.sha256 !== node.brand.sha256) {
613 unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`);
614 return;
615 }
616 node[RESOLVED_MARK] = resolved;
617 RESOLVED_BY_NODE.set(node, resolved);
618 return;
619 }
620 const preset = findBrandMark(node.brand);
621 if (preset) {
622 const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source };
623 node[RESOLVED_MARK] = resolved;
624 RESOLVED_BY_NODE.set(node, resolved);
625 return;
626 }
627 const url = asUrl(node.brand);
628 if (url) {
629 unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
630 return;
631 }
632 unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
633 });
634 if (unknown.length) {
635 throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({
636 code: message.includes('is an unpinned URL') ? 'brand/unpinned-url'
637 : (message.includes('digest changed') ? 'brand/digest-mismatch'
638 : (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')),
639 severity: 'error',
640 message,
641 subject: { diagramType, collection },
642 evidence: {},
643 supportedFixes: message.includes('is an unpinned URL')
644 ? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
645 : ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
646 })));
647 }
650export function brandMarkFor(node) {
651 return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null;
654export function brandMetadataFor(node) {
655 const mark = brandMarkFor(node);
656 return mark ? {
657 brand: mark.title,
658 brandId: mark.id,
659 brandStatus: mark.status,
660 brandSource: mark.sourceUrl,
661 } : {};
664export function brandLabelFitWidth(node, width) {
665 return brandMarkFor(node) ? Math.max(1, width - 48) : width;
668export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') {
669 if (!brandMarkFor(node)) return null;
670 const available = width - 48;
671 const required = textUnits(node.label) * minimumFontSize * 0.6;
672 if (available >= required) return null;
673 return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
674 + `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
677function markAttrs(mark) {
678 return [
679 `data-brand-mark="${esc(mark.id)}"`,
680 `data-brand-title="${esc(mark.title)}"`,
681 `data-brand-status="${esc(mark.status)}"`,
682 mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '',
683 mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '',
684 ].filter(Boolean).join(' ');
687export function renderBrandMark(node, { x, y, size = 16 } = {}) {
688 const mark = brandMarkFor(node);
689 if (!mark) return '';
690 const inset = 3;
691 let content;
692 if (mark.kind === 'preset') {
693 const scale = (size - inset * 2) / mark.viewBox;
694 content = `<path d="${esc(mark.path)}" transform="translate(${inset} ${inset}) scale(${scale})" fill="#${esc(mark.hex)}"/>`;
695 } else if (mark.kind === 'remote') {
696 content = `<image href="${esc(mark.dataUrl)}" x="${inset}" y="${inset}" width="${size - inset * 2}" height="${size - inset * 2}" preserveAspectRatio="xMidYMid meet"/>`;
697 } else {
698 const scale = size / 20;
699 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>`;
700 }
701 return `<g aria-hidden="true" ${markAttrs(mark)} class="brand-mark" transform="translate(${x} ${y})">
702 <rect width="${size}" height="${size}" rx="4" class="brand-mark-badge"/>
703 ${content}
704 <rect width="${size}" height="${size}" rx="4" class="brand-mark-frame"/>
705 </g>`;