astro-archify 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/astro-archify-integration.d.ts +100 -0
- package/astro-archify-integration.js +0 -0
- package/package.json +64 -0
- package/vendor/archify/LICENSE +22 -0
- package/vendor/archify/NOTICE.md +48 -0
- package/vendor/archify/assets/template.html +14787 -0
- package/vendor/archify/renderers/architecture/grid.mjs +62 -0
- package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
- package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
- package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
- package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
- package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- package/vendor/archify/renderers/shared/cli.mjs +220 -0
- package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
- package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
- package/vendor/archify/renderers/shared/i18n.mjs +594 -0
- package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
- package/vendor/archify/renderers/shared/legend.mjs +217 -0
- package/vendor/archify/renderers/shared/output-path.mjs +321 -0
- package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
- package/vendor/archify/renderers/shared/utils.mjs +232 -0
- package/vendor/archify/renderers/shared/validator.mjs +86 -0
- package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { BRAND_MARKS } from './generated-brand-marks.mjs';
|
|
7
|
+
import { throwDiagnosticError } from './diagnostics.mjs';
|
|
8
|
+
import { esc, textUnits } from './utils.mjs';
|
|
9
|
+
|
|
10
|
+
const COLLECTIONS = Object.freeze({
|
|
11
|
+
architecture: 'components',
|
|
12
|
+
workflow: 'nodes',
|
|
13
|
+
sequence: 'participants',
|
|
14
|
+
dataflow: 'nodes',
|
|
15
|
+
lifecycle: 'states',
|
|
16
|
+
});
|
|
17
|
+
const MARK_BY_LOOKUP = new Map();
|
|
18
|
+
const MARK_BY_DOMAIN = new Map();
|
|
19
|
+
const RESOLVED_BY_NODE = new WeakMap();
|
|
20
|
+
const RESOLVED_MARK = Symbol('archify.brandMark');
|
|
21
|
+
const MAX_HTML_BYTES = 256 * 1024;
|
|
22
|
+
const MAX_IMAGE_BYTES = 1024 * 1024;
|
|
23
|
+
const MAX_CAPTURE_CONCURRENCY = 3;
|
|
24
|
+
const DEFAULT_CAPTURE_TIMEOUT_MS = 8000;
|
|
25
|
+
const USER_AGENT = 'Archify/2.15 brand-preview';
|
|
26
|
+
|
|
27
|
+
function 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])];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (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);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function 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
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function domainMark(hostname) {
|
|
54
|
+
const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, '');
|
|
55
|
+
const candidates = [...MARK_BY_DOMAIN.entries()]
|
|
56
|
+
.filter(([domain]) => host === domain || host.endsWith(`.${domain}`))
|
|
57
|
+
.sort(([left], [right]) => right.length - left.length);
|
|
58
|
+
return candidates[0]?.[1] || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function findBrandMark(value) {
|
|
62
|
+
const url = asUrl(value);
|
|
63
|
+
if (url) return domainMark(url.hostname);
|
|
64
|
+
for (const form of lookupForms(value)) {
|
|
65
|
+
const mark = MARK_BY_LOOKUP.get(form);
|
|
66
|
+
if (mark) return mark;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listBrandMarks(query = '') {
|
|
72
|
+
const needle = String(query).trim().toLocaleLowerCase('en-US');
|
|
73
|
+
return BRAND_MARKS.filter((mark) => {
|
|
74
|
+
if (!needle) return true;
|
|
75
|
+
return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains]
|
|
76
|
+
.some((value) => String(value).toLocaleLowerCase('en-US').includes(needle));
|
|
77
|
+
}).map(({ path, ...mark }) => mark);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ipv4Private(address) {
|
|
81
|
+
const parts = address.split('.').map(Number);
|
|
82
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
|
83
|
+
const [a, b, c] = parts;
|
|
84
|
+
return a === 0 || a === 10 || a === 127 || a >= 224
|
|
85
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
86
|
+
|| (a === 169 && b === 254)
|
|
87
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
88
|
+
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|
|
89
|
+
|| (a === 192 && b === 88 && c === 99)
|
|
90
|
+
|| (a === 192 && b === 168)
|
|
91
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
92
|
+
|| (a === 198 && b === 51 && c === 100)
|
|
93
|
+
|| (a === 203 && b === 0 && c === 113);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ipv6Private(address) {
|
|
97
|
+
const normalized = address.toLocaleLowerCase('en-US').split('%')[0];
|
|
98
|
+
if (normalized === '::' || normalized === '::1') return true;
|
|
99
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true;
|
|
100
|
+
if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:')
|
|
101
|
+
|| normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true;
|
|
102
|
+
const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
103
|
+
if (mappedDotted) return ipv4Private(mappedDotted[1]);
|
|
104
|
+
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
105
|
+
if (mappedHex) {
|
|
106
|
+
const high = Number.parseInt(mappedHex[1], 16);
|
|
107
|
+
const low = Number.parseInt(mappedHex[2], 16);
|
|
108
|
+
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
|
109
|
+
}
|
|
110
|
+
const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
111
|
+
if (compatibleHex) {
|
|
112
|
+
const high = Number.parseInt(compatibleHex[1], 16);
|
|
113
|
+
const low = Number.parseInt(compatibleHex[2], 16);
|
|
114
|
+
return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isPrivateBrandAddress(address) {
|
|
120
|
+
const family = net.isIP(address);
|
|
121
|
+
return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') {
|
|
125
|
+
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported');
|
|
126
|
+
if (url.username || url.password) throw new Error('brand links cannot contain credentials');
|
|
127
|
+
const expectedPort = url.protocol === 'https:' ? '443' : '80';
|
|
128
|
+
if (!allowPrivate && url.port && url.port !== expectedPort) {
|
|
129
|
+
throw new Error('brand links must use a standard web port');
|
|
130
|
+
}
|
|
131
|
+
const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, '');
|
|
132
|
+
if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) {
|
|
133
|
+
throw new Error('private brand links are not fetched');
|
|
134
|
+
}
|
|
135
|
+
return host;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function beforeDeadline(promise, deadline) {
|
|
139
|
+
const remaining = deadline - Date.now();
|
|
140
|
+
if (remaining <= 0) return Promise.reject(new Error('brand capture timed out'));
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
promise.then(
|
|
145
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
146
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resolveRequestTarget(url, deadline) {
|
|
152
|
+
const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1';
|
|
153
|
+
const host = validateUrlShape(url, allowPrivate);
|
|
154
|
+
const directFamily = net.isIP(host);
|
|
155
|
+
const addresses = directFamily
|
|
156
|
+
? [{ address: host, family: directFamily }]
|
|
157
|
+
: await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline);
|
|
158
|
+
if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) {
|
|
159
|
+
throw new Error('private brand links are not fetched');
|
|
160
|
+
}
|
|
161
|
+
return addresses[0];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function timeoutSignal(milliseconds) {
|
|
165
|
+
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds);
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const timer = setTimeout(() => controller.abort(), milliseconds);
|
|
168
|
+
timer.unref?.();
|
|
169
|
+
return controller.signal;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function captureTimeoutMilliseconds() {
|
|
173
|
+
const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS);
|
|
174
|
+
if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS;
|
|
175
|
+
return Math.max(100, Math.min(30000, Math.round(configured)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requestPinned(url, accept, target, deadline) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
181
|
+
const request = transport.request(url, {
|
|
182
|
+
method: 'GET',
|
|
183
|
+
signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))),
|
|
184
|
+
headers: { accept, 'user-agent': USER_AGENT },
|
|
185
|
+
// Reuse the exact public address that passed validation. This closes the
|
|
186
|
+
// DNS-rebinding gap between checking a hostname and opening its socket.
|
|
187
|
+
lookup(_hostname, options, callback) {
|
|
188
|
+
if (options?.all) callback(null, [target]);
|
|
189
|
+
else callback(null, target.address, target.family);
|
|
190
|
+
},
|
|
191
|
+
}, (response) => {
|
|
192
|
+
const status = response.statusCode || 0;
|
|
193
|
+
resolve({
|
|
194
|
+
status,
|
|
195
|
+
ok: status >= 200 && status < 300,
|
|
196
|
+
headers: {
|
|
197
|
+
get(name) {
|
|
198
|
+
const value = response.headers[String(name).toLocaleLowerCase('en-US')];
|
|
199
|
+
return Array.isArray(value) ? value.join(', ') : (value ?? null);
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
body: response,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
request.on('error', reject);
|
|
206
|
+
request.end();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function checkedFetch(input, accept, deadline) {
|
|
211
|
+
let current = new URL(input);
|
|
212
|
+
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
|
213
|
+
if (Date.now() >= deadline) throw new Error('brand capture timed out');
|
|
214
|
+
const target = await resolveRequestTarget(current, deadline);
|
|
215
|
+
const response = await requestPinned(current, accept, target, deadline);
|
|
216
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
217
|
+
const location = response.headers.get('location');
|
|
218
|
+
response.body.resume();
|
|
219
|
+
if (!location || redirects === 3) throw new Error('brand link redirected too many times');
|
|
220
|
+
current = new URL(location, current);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
response.body.resume();
|
|
225
|
+
throw new Error(`brand link returned HTTP ${response.status}`);
|
|
226
|
+
}
|
|
227
|
+
return { response, finalUrl: current };
|
|
228
|
+
}
|
|
229
|
+
throw new Error('brand link redirected too many times');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readLimited(response, maximum) {
|
|
233
|
+
const declared = Number(response.headers.get('content-length'));
|
|
234
|
+
if (Number.isFinite(declared) && declared > maximum) {
|
|
235
|
+
response.body?.destroy?.();
|
|
236
|
+
throw new Error('brand asset is too large');
|
|
237
|
+
}
|
|
238
|
+
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
|
|
239
|
+
const chunks = [];
|
|
240
|
+
let total = 0;
|
|
241
|
+
for await (const value of response.body) {
|
|
242
|
+
total += value.byteLength;
|
|
243
|
+
if (total > maximum) {
|
|
244
|
+
response.body.destroy?.();
|
|
245
|
+
throw new Error('brand asset is too large');
|
|
246
|
+
}
|
|
247
|
+
chunks.push(Buffer.from(value));
|
|
248
|
+
}
|
|
249
|
+
return Buffer.concat(chunks, total);
|
|
250
|
+
}
|
|
251
|
+
if (!response.body?.getReader) {
|
|
252
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
253
|
+
if (buffer.length > maximum) throw new Error('brand asset is too large');
|
|
254
|
+
return buffer;
|
|
255
|
+
}
|
|
256
|
+
const reader = response.body.getReader();
|
|
257
|
+
const chunks = [];
|
|
258
|
+
let total = 0;
|
|
259
|
+
while (true) {
|
|
260
|
+
const { done, value } = await reader.read();
|
|
261
|
+
if (done) break;
|
|
262
|
+
total += value.byteLength;
|
|
263
|
+
if (total > maximum) {
|
|
264
|
+
await reader.cancel();
|
|
265
|
+
throw new Error('brand asset is too large');
|
|
266
|
+
}
|
|
267
|
+
chunks.push(Buffer.from(value));
|
|
268
|
+
}
|
|
269
|
+
return Buffer.concat(chunks, total);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function attribute(tag, name) {
|
|
273
|
+
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
|
|
274
|
+
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : '';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function iconCandidates(html, pageUrl) {
|
|
278
|
+
const candidates = [];
|
|
279
|
+
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
280
|
+
const tag = match[0];
|
|
281
|
+
const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/);
|
|
282
|
+
if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue;
|
|
283
|
+
const href = attribute(tag, 'href');
|
|
284
|
+
if (!href) continue;
|
|
285
|
+
try {
|
|
286
|
+
const url = new URL(href, pageUrl);
|
|
287
|
+
if (!['https:', 'http:'].includes(url.protocol)) continue;
|
|
288
|
+
const type = attribute(tag, 'type').toLocaleLowerCase('en-US');
|
|
289
|
+
const sizes = attribute(tag, 'sizes');
|
|
290
|
+
const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)]
|
|
291
|
+
.reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0);
|
|
292
|
+
const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0)
|
|
293
|
+
+ (rel.includes('apple-touch-icon') ? 500000 : 0)
|
|
294
|
+
+ area;
|
|
295
|
+
candidates.push({ url, score });
|
|
296
|
+
} catch {
|
|
297
|
+
// A malformed icon candidate is ignored; the deterministic fallback remains available.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
301
|
+
const fallback = new URL('/favicon.ico', pageUrl);
|
|
302
|
+
const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate]));
|
|
303
|
+
unique.delete(fallback.href);
|
|
304
|
+
return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function imageData(response) {
|
|
308
|
+
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US');
|
|
309
|
+
const allowed = new Set([
|
|
310
|
+
'image/png',
|
|
311
|
+
'image/jpeg',
|
|
312
|
+
'image/webp',
|
|
313
|
+
'image/x-icon',
|
|
314
|
+
'image/vnd.microsoft.icon',
|
|
315
|
+
]);
|
|
316
|
+
if (!allowed.has(contentType)) {
|
|
317
|
+
response.body?.destroy?.();
|
|
318
|
+
throw new Error(`unsupported brand image type ${contentType || 'unknown'}`);
|
|
319
|
+
}
|
|
320
|
+
const buffer = await readLimited(response, MAX_IMAGE_BYTES);
|
|
321
|
+
const signatureMatches = contentType === 'image/png'
|
|
322
|
+
? buffer.length >= 45
|
|
323
|
+
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
324
|
+
&& buffer.readUInt32BE(8) === 13
|
|
325
|
+
&& buffer.toString('ascii', 12, 16) === 'IHDR'
|
|
326
|
+
&& buffer.readUInt32BE(16) > 0
|
|
327
|
+
&& buffer.readUInt32BE(20) > 0
|
|
328
|
+
&& buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND'
|
|
329
|
+
: (contentType === 'image/jpeg'
|
|
330
|
+
? buffer.length >= 20
|
|
331
|
+
&& buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff
|
|
332
|
+
&& buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9
|
|
333
|
+
: (contentType === 'image/webp'
|
|
334
|
+
? buffer.length >= 16
|
|
335
|
+
&& buffer.toString('ascii', 0, 4) === 'RIFF'
|
|
336
|
+
&& buffer.toString('ascii', 8, 12) === 'WEBP'
|
|
337
|
+
&& buffer.readUInt32LE(4) + 8 <= buffer.length
|
|
338
|
+
: buffer.length >= 22
|
|
339
|
+
&& buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0
|
|
340
|
+
&& buffer.readUInt16LE(4) > 0
|
|
341
|
+
&& 6 + buffer.readUInt16LE(4) * 16 <= buffer.length));
|
|
342
|
+
if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`);
|
|
343
|
+
return {
|
|
344
|
+
dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`,
|
|
345
|
+
sha256: createHash('sha256').update(buffer).digest('hex'),
|
|
346
|
+
contentType,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) {
|
|
351
|
+
const sourceUrl = new URL(value);
|
|
352
|
+
const fallback = (reason) => ({
|
|
353
|
+
id: sourceUrl.hostname,
|
|
354
|
+
title: sourceUrl.hostname,
|
|
355
|
+
category: 'link',
|
|
356
|
+
kind: 'fallback',
|
|
357
|
+
status: 'unavailable',
|
|
358
|
+
sourceUrl: sourceUrl.href,
|
|
359
|
+
reason,
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline);
|
|
363
|
+
const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US');
|
|
364
|
+
if (pageType.startsWith('image/')) {
|
|
365
|
+
const image = await imageData(page.response);
|
|
366
|
+
return {
|
|
367
|
+
id: sourceUrl.hostname,
|
|
368
|
+
title: sourceUrl.hostname,
|
|
369
|
+
category: 'link',
|
|
370
|
+
kind: 'remote',
|
|
371
|
+
status: 'captured',
|
|
372
|
+
sourceUrl: sourceUrl.href,
|
|
373
|
+
resolvedUrl: page.finalUrl.href,
|
|
374
|
+
...image,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) {
|
|
378
|
+
page.response.body?.destroy?.();
|
|
379
|
+
return fallback('linked page is not HTML');
|
|
380
|
+
}
|
|
381
|
+
const html = (await readLimited(page.response, MAX_HTML_BYTES)).toString('utf8');
|
|
382
|
+
const iconErrors = [];
|
|
383
|
+
for (const candidate of iconCandidates(html, page.finalUrl)) {
|
|
384
|
+
try {
|
|
385
|
+
const fetched = await checkedFetch(candidate.url, 'image/*', deadline);
|
|
386
|
+
const image = await imageData(fetched.response);
|
|
387
|
+
return {
|
|
388
|
+
id: sourceUrl.hostname,
|
|
389
|
+
title: sourceUrl.hostname,
|
|
390
|
+
category: 'link',
|
|
391
|
+
kind: 'remote',
|
|
392
|
+
status: 'captured',
|
|
393
|
+
sourceUrl: sourceUrl.href,
|
|
394
|
+
resolvedUrl: fetched.finalUrl.href,
|
|
395
|
+
...image,
|
|
396
|
+
};
|
|
397
|
+
} catch (error) {
|
|
398
|
+
iconErrors.push(error);
|
|
399
|
+
// Try the next declared favicon before using the generic link mark.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const usefulError = iconErrors.find((error) => /unsupported brand image type/i.test(error?.message))
|
|
403
|
+
|| iconErrors.at(-1);
|
|
404
|
+
return fallback(usefulError?.message || 'no usable site icon was found');
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return fallback(error.message);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function captureBrandReference(value) {
|
|
411
|
+
const url = asUrl(value);
|
|
412
|
+
if (!url) throw new Error('brand capture requires one HTTP(S) URL');
|
|
413
|
+
validateUrlShape(url);
|
|
414
|
+
const preset = findBrandMark(url.href);
|
|
415
|
+
if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } };
|
|
416
|
+
const resolved = await captureRemoteBrand(url.href);
|
|
417
|
+
if (resolved.status !== 'captured' || !resolved.sha256) {
|
|
418
|
+
throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
brand: { url: url.href, sha256: resolved.sha256 },
|
|
422
|
+
resolved,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function remoteBrand(value, cache, deadline) {
|
|
427
|
+
const key = new URL(value).href;
|
|
428
|
+
if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline));
|
|
429
|
+
return cache.get(key);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function suggestions(value) {
|
|
433
|
+
const needle = lookupForms(value)[0] || '';
|
|
434
|
+
return BRAND_MARKS.map((mark) => ({
|
|
435
|
+
id: mark.id,
|
|
436
|
+
score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1,
|
|
437
|
+
})).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
|
|
438
|
+
.slice(0, 5)
|
|
439
|
+
.map((entry) => entry.id);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function mapConcurrent(values, limit, visit) {
|
|
443
|
+
let cursor = 0;
|
|
444
|
+
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
445
|
+
while (cursor < values.length) {
|
|
446
|
+
const index = cursor;
|
|
447
|
+
cursor += 1;
|
|
448
|
+
await visit(values[index], index);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
await Promise.all(workers);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function prepareDiagramBrandMarks(diagramType, diagram) {
|
|
455
|
+
const collection = COLLECTIONS[diagramType];
|
|
456
|
+
const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
|
|
457
|
+
const unknown = [];
|
|
458
|
+
const remoteByUrl = new Map();
|
|
459
|
+
const deadline = Date.now() + captureTimeoutMilliseconds();
|
|
460
|
+
await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => {
|
|
461
|
+
if (!node.brand) return;
|
|
462
|
+
if (typeof node.brand === 'object') {
|
|
463
|
+
const url = asUrl(node.brand.url);
|
|
464
|
+
const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null;
|
|
465
|
+
if (!resolved || resolved.status !== 'captured') {
|
|
466
|
+
unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (resolved.sha256 !== node.brand.sha256) {
|
|
470
|
+
unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
node[RESOLVED_MARK] = resolved;
|
|
474
|
+
RESOLVED_BY_NODE.set(node, resolved);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const preset = findBrandMark(node.brand);
|
|
478
|
+
if (preset) {
|
|
479
|
+
const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source };
|
|
480
|
+
node[RESOLVED_MARK] = resolved;
|
|
481
|
+
RESOLVED_BY_NODE.set(node, resolved);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const url = asUrl(node.brand);
|
|
485
|
+
if (url) {
|
|
486
|
+
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`);
|
|
490
|
+
});
|
|
491
|
+
if (unknown.length) {
|
|
492
|
+
throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({
|
|
493
|
+
code: message.includes('is an unpinned URL') ? 'brand/unpinned-url'
|
|
494
|
+
: (message.includes('digest changed') ? 'brand/digest-mismatch'
|
|
495
|
+
: (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')),
|
|
496
|
+
severity: 'error',
|
|
497
|
+
message,
|
|
498
|
+
subject: { diagramType, collection },
|
|
499
|
+
evidence: {},
|
|
500
|
+
supportedFixes: message.includes('is an unpinned URL')
|
|
501
|
+
? ['run `archify brands capture <url> --json` and author the returned digest-pinned brand object']
|
|
502
|
+
: ['choose an ID from `archify brands`', 'run `archify brands capture <url> --json` for an unknown official site'],
|
|
503
|
+
})));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function brandMarkFor(node) {
|
|
508
|
+
return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function brandMetadataFor(node) {
|
|
512
|
+
const mark = brandMarkFor(node);
|
|
513
|
+
return mark ? {
|
|
514
|
+
brand: mark.title,
|
|
515
|
+
brandId: mark.id,
|
|
516
|
+
brandStatus: mark.status,
|
|
517
|
+
brandSource: mark.sourceUrl,
|
|
518
|
+
} : {};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function brandLabelFitWidth(node, width) {
|
|
522
|
+
return brandMarkFor(node) ? Math.max(1, width - 48) : width;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') {
|
|
526
|
+
if (!brandMarkFor(node)) return null;
|
|
527
|
+
const available = width - 48;
|
|
528
|
+
const required = textUnits(node.label) * minimumFontSize * 0.6;
|
|
529
|
+
if (available >= required) return null;
|
|
530
|
+
return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but `
|
|
531
|
+
+ `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function markAttrs(mark) {
|
|
535
|
+
return [
|
|
536
|
+
`data-brand-mark="${esc(mark.id)}"`,
|
|
537
|
+
`data-brand-title="${esc(mark.title)}"`,
|
|
538
|
+
`data-brand-status="${esc(mark.status)}"`,
|
|
539
|
+
mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '',
|
|
540
|
+
mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '',
|
|
541
|
+
].filter(Boolean).join(' ');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function renderBrandMark(node, { x, y, size = 16 } = {}) {
|
|
545
|
+
const mark = brandMarkFor(node);
|
|
546
|
+
if (!mark) return '';
|
|
547
|
+
const inset = 3;
|
|
548
|
+
let content;
|
|
549
|
+
if (mark.kind === 'preset') {
|
|
550
|
+
const scale = (size - inset * 2) / mark.viewBox;
|
|
551
|
+
content = `<path d="${esc(mark.path)}" transform="translate(${inset} ${inset}) scale(${scale})" fill="#${esc(mark.hex)}"/>`;
|
|
552
|
+
} else if (mark.kind === 'remote') {
|
|
553
|
+
content = `<image href="${esc(mark.dataUrl)}" x="${inset}" y="${inset}" width="${size - inset * 2}" height="${size - inset * 2}" preserveAspectRatio="xMidYMid meet"/>`;
|
|
554
|
+
} else {
|
|
555
|
+
const scale = size / 20;
|
|
556
|
+
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>`;
|
|
557
|
+
}
|
|
558
|
+
return `<g aria-hidden="true" ${markAttrs(mark)} class="brand-mark" transform="translate(${x} ${y})">
|
|
559
|
+
<rect width="${size}" height="${size}" rx="4" class="brand-mark-badge"/>
|
|
560
|
+
${content}
|
|
561
|
+
<rect width="${size}" height="${size}" rx="4" class="brand-mark-frame"/>
|
|
562
|
+
</g>`;
|
|
563
|
+
}
|