freestruct 1.0.0 → 1.2.0

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.
@@ -0,0 +1,487 @@
1
+ // engine/audit.js - the `freestruct audit` subcommand (issue #18)
2
+ // Post-build verification that the SEO injection ACTUALLY landed, page by
3
+ // page, plus site-level artifact checks - with an exit code for CI.
4
+ // Ported from the donated zero-dep audit script (prestruct#34, MIT-compatible)
5
+ // and remapped onto freestruct's own contract:
6
+ // - meta extraction is attribute-order tolerant (per the donor's notes)
7
+ // - og:image / twitter:image / twitter:site/creator are config-gated in
8
+ // this engine (missing-only), so their absence is a WARN, not a FAIL.
9
+ // The og:image / twitter:image absences are CONFIG-shaped (identical on
10
+ // every page), so they collapse to one line each with a page count
11
+ // (issue #28); twitter:site/creator are graded only when a handle is set.
12
+ // - the FAIL og/twitter class is the SHARED core (og:title/og:description,
13
+ // twitter:card/twitter:title); the rest of the injected set rides WARN
14
+ // because missing-only means SSG-tagged pages legitimately keep a
15
+ // partial set (post-injection the audit cannot tell SSG-owned heads
16
+ // apart, so partial-set coherence is report-grade)
17
+ // - the SVG og:image trap stays a FAIL here (audit-grade; the run-warning
18
+ // ladder from issue #20 is the pre-build enforcement point)
19
+ // - the 404 check is the noindex contract only (issue #11's shape)
20
+ // - external http(s) links: opt-in via --external (SEO-gap pass) - the
21
+ // baked tree is audited for the network, not the default audit; HEAD
22
+ // with a GET retry (some CDNs refuse HEAD), resolution failures and
23
+ // HTTP >= 400 come back as WARN rows (report-grade: bots get 403/429)
24
+
25
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
26
+ import path from 'path';
27
+ import { decodeEntities } from './scan.js';
28
+
29
+ // FAIL class = the tags core to the engine's own contract. The REST of the
30
+ // injected set (og:url/type/site_name/locale, twitter:description) rides as
31
+ // WARN because missing-only means SSG-tagged pages LEGITIMATELY keep a
32
+ // partial set - fail-failing those would punish contract-abiding output.
33
+ const OG_CORE = ['og:title', 'og:description'];
34
+ const OG_SET = ['og:url', 'og:type', 'og:site_name', 'og:locale'];
35
+ const TW_CORE = ['twitter:card', 'twitter:title'];
36
+ const TW_SET = ['twitter:description', 'twitter:image', 'twitter:site', 'twitter:creator'];
37
+ // LocalBusiness recommended fields per the donated script (schema.org).
38
+ const LB_RECOMMENDED = ['name', 'url', 'image', 'logo', 'telephone', 'priceRange', 'openingHoursSpecification', 'geo', 'sameAs'];
39
+
40
+ // ---------- scans ----------
41
+
42
+ /** Attribute-order tolerant meta extraction: hits aligned multi-space output. */
43
+ function grabMeta(head, attrHasKey, name) {
44
+ // `<meta\b[^>]*` (not `<meta[^>]`) so a leading attribute - `<meta charset=...
45
+ // name="description">` - is found too. The single-char class made the audit
46
+ // false-FAIL "missing meta description" on any page whose description meta
47
+ // carried another attribute first.
48
+ const keyRe = new RegExp(`<meta\\b[^>]*\\b${attrHasKey}=["']${name}["'][^>]*>`, 'i');
49
+ const tag = head.match(keyRe);
50
+ if (!tag) return null;
51
+ const content = attrValue(tag[0], 'content');
52
+ return content;
53
+ }
54
+
55
+ // Quoted-attribute reader. The naive `["']([^"']*)["']` stopped at the FIRST
56
+ // quote of either kind, so any value holding the other quote (a description
57
+ // with an apostrophe - "this repo's docs") was truncated at the apostrophe.
58
+ // The backreference forces the closing quote to match the opening one, so the
59
+ // value survives intact. Unquoted values fall through to a bare read.
60
+ function attrValue(tag, attr) {
61
+ const quoted = tag.match(new RegExp(`\\b${attr}=(["'])([\\s\\S]*?)\\1`, 'i'));
62
+ if (quoted) return quoted[2];
63
+ const bare = tag.match(new RegExp(`\\b${attr}=([^\\s>]+)`, 'i'));
64
+ return bare ? bare[1] : null;
65
+ }
66
+
67
+ function grabCanonical(head) {
68
+ const link = head.match(/<link[^>]+rel=["']canonical["'][^>]*>/i)
69
+ || head.match(/<link[^>]+href=["'][^"']+["'][^>]+rel=["']canonical["'][^>]*>/i);
70
+ if (!link) return null;
71
+ return attrValue(link[0], 'href');
72
+ }
73
+
74
+ /** Count every occurrence of a head shape - the duplicate-class probe. */
75
+ function countHead(head, re) {
76
+ return (head.match(re) || []).length;
77
+ }
78
+
79
+ function walkHtml(dir) {
80
+ let out = [];
81
+ for (const name of readdirSync(dir)) {
82
+ const p = path.join(dir, name);
83
+ if (statSync(p).isDirectory()) out = out.concat(walkHtml(p));
84
+ else if (/\.html?$/i.test(name)) out.push(p);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function stripTags(s) {
90
+ return String(s).replace(/<[^>]+>/g, '').trim();
91
+ }
92
+
93
+ // Title/description length is graded against what a SEARCH ENGINE sees: the
94
+ // DECODED text, not the raw markup. An entity-heavy title ('&amp;', '&#39;',
95
+ // '&#65;') inflated the raw length and tripped the > 65 / > 165 warnings on
96
+ // text that renders well inside the band.
97
+ function decodedLength(s) {
98
+ return decodeEntities(stripTags(s)).length;
99
+ }
100
+
101
+ /**
102
+ * Head-referenced site-local URL -> { resolved, inside } | null.
103
+ *
104
+ * null means "not a site-local path" (external host, protocol-relative,
105
+ * relative, empty) - nothing on disk to check. `inside` is false when the
106
+ * path resolves OUTSIDE the output tree: a traversal shape like
107
+ * '/../../etc/hostname', which must never be probed as if it were an
108
+ * artifact. (Before Pass 26.21 urlToDisk resolved and existsSync'd such a
109
+ * path, so a value pointing at any file on the host read back as "exists".)
110
+ */
111
+ function sitePathInfo(urlValue, { outputDir, config }) {
112
+ if (!urlValue) return null;
113
+ let pathName = urlValue.split(/[?#]/)[0];
114
+ if (/^https?:\/\//i.test(pathName)) {
115
+ try {
116
+ const u = new URL(pathName);
117
+ const site = config?.site?.url ? new URL(config.site.url) : null;
118
+ // Only site-local files can exist in the output tree.
119
+ if (site && u.host !== site.host) return null;
120
+ pathName = u.pathname + u.search;
121
+ } catch (e) { return null; }
122
+ } else if (/^\/\//.test(pathName)) {
123
+ return null;
124
+ }
125
+ if (!pathName.startsWith('/')) return null;
126
+ // basePath normalization mirrors urls.js (issue #32 contract): the
127
+ // configured prefix may lack its leading slash, and a prefix must match a
128
+ // whole path segment ('/myproject' never strips '/myprojectx/...').
129
+ const bp = config?.basePath
130
+ ? ('/' + String(config.basePath).replace(/^\/+/, '')).replace(/\/+$/, '')
131
+ : '';
132
+ if (bp && (pathName === bp || pathName.startsWith(bp + '/'))) {
133
+ pathName = pathName.slice(bp.length);
134
+ }
135
+ const resolved = path.resolve(outputDir, pathName.replace(/^\//, ''));
136
+ const root = path.resolve(outputDir);
137
+ const inside = resolved === root || resolved.startsWith(root + path.sep);
138
+ return { resolved, inside };
139
+ }
140
+
141
+ // ---------- the audit ----------
142
+
143
+ /**
144
+ * Audit a baked output directory. Returns { fails, warns, pages, sitemapLocs }.
145
+ * Prints a human report by default, or a JSON digest with `json: true`;
146
+ * `capture: true` (used when external checks ride along in json mode) skips
147
+ * printing and hands the caller the raw result.
148
+ * NEVER exits: the caller owns the exit code (0 clean / 1 findings).
149
+ */
150
+ export function auditOutput({ outputDir, config, json = false, capture = false }) {
151
+ const fails = [];
152
+ const warns = [];
153
+ const fail = (page, msg) => fails.push({ page, msg });
154
+ const warn = (page, msg) => warns.push({ page, msg });
155
+
156
+ // Config-gated absences (issue #28): a condition that can only ever be
157
+ // true/false at the CONFIG level repeats identically on every page
158
+ // (og:image / twitter:image absent because no og.image is configured).
159
+ // Collect affected pages here and flush ONE line each with a count at the
160
+ // end, instead of N page-suffixed rows that bury the real findings.
161
+ // Page-shaped classes (title/description length, JSON-LD absence, h1
162
+ // sanity) still repeat - their findings genuinely differ per page.
163
+ const configGated = new Map(); // key -> Set(page labels)
164
+ const configGate = (key, label) => {
165
+ if (!configGated.has(key)) configGated.set(key, new Set());
166
+ configGated.get(key).add(label);
167
+ };
168
+
169
+ // File-at-path hardening (Pass 26.13): a file at the outputDir path passes
170
+ // existsSync, and walkHtml's readdirSync would arrive here uncaught.
171
+ if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) {
172
+ if (json) console.log(JSON.stringify({ ok: false, error: 'output dir not found: ' + outputDir }));
173
+ else console.error('[freestruct] audit: no "' + outputDir + '" directory - run the build first.');
174
+ return { fails: [{ page: 'outputDir', msg: 'missing' }], warns, pages: 0, sitemapLocs: 0 };
175
+ }
176
+
177
+ const pages = walkHtml(outputDir);
178
+ let sitemapLocs = 0;
179
+
180
+ for (const filePath of pages) {
181
+ let rel;
182
+ try { rel = path.relative(outputDir, filePath); } catch (e) { rel = filePath; }
183
+ const html = readFileSync(filePath, 'utf8');
184
+ const head = html.split('</head>')[0];
185
+ const label = rel.replace(/\\/g, '/');
186
+
187
+ if (/(^|\/)404\.html?$/i.test(label)) {
188
+ const robots = grabMeta(head, 'name', 'robots') || '';
189
+ if (!/noindex/i.test(robots)) fail(label, '404 page missing its noindex robots directive');
190
+ continue;
191
+ }
192
+
193
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
194
+ if (!titleMatch || !titleMatch[1].trim()) fail(label, 'missing <title>');
195
+ else {
196
+ const t = decodedLength(titleMatch[1]);
197
+ if (t > 65) warn(label, 'title ' + t + ' chars (> 65, may truncate)');
198
+ if (t < 10) warn(label, 'title ' + t + ' chars (suspiciously short)');
199
+ }
200
+
201
+ // A page that declares `robots noindex` is deliberately out of the index
202
+ // (a redirect stub, a thank-you page): the content-quality classes below
203
+ // - description length band, h1 shape, JSON-LD absence - grade indexable
204
+ // content, so they would only emit cosmetic noise on a page whose whole
205
+ // point is to not rank. The structural classes (title/canonical presence,
206
+ // duplicate tags, absolute canonical) still apply.
207
+ const noindex = /noindex/i.test(grabMeta(head, 'name', 'robots') || '');
208
+
209
+ const desc = grabMeta(head, 'name', 'description');
210
+ if (!desc) fail(label, 'missing meta description');
211
+ else if (!noindex) {
212
+ const d = decodeEntities(desc).length;
213
+ if (d < 70 || d > 165) warn(label, 'description ' + d + ' chars (outside 70-165, may truncate)');
214
+ }
215
+
216
+ const canonical = grabCanonical(head);
217
+ if (!canonical || canonical === '') fail(label, 'missing canonical link');
218
+ else if (!/^https?:\/\//i.test(canonical)) fail(label, 'canonical not absolute: ' + canonical);
219
+
220
+ // Duplicate tags ride the FAIL class: missing-only injection never
221
+ // adds a tag the page already carries, so any duplicate in a baked
222
+ // head is a page/SSG edit gone wrong (Pass 26.11). Attribute-order
223
+ // tolerant, same as the missing checks.
224
+ for (const dup of [
225
+ ['duplicate canonical link (a page ships exactly one)', /<link[^>]+rel=["']canonical["']/gi],
226
+ ['duplicate meta description', /<meta[^>]+name=["']description["']/gi],
227
+ ['multiple <title> elements', /<title[^>]*>/gi],
228
+ ['duplicate og:title', /<meta[^>]+property=["']og:title["']/gi],
229
+ ['duplicate og:description', /<meta[^>]+property=["']og:description["']/gi],
230
+ ['duplicate twitter:title', /<meta[^>]+name=["']twitter:title["']/gi],
231
+ ['duplicate twitter:card', /<meta[^>]+name=["']twitter:card["']/gi],
232
+ ]) {
233
+ if (countHead(head, dup[1]) > 1) fail(label, dup[0]);
234
+ }
235
+
236
+ // og/twitter CORE class. FAIL only when the page carries NO tag of the
237
+ // set at all: that is the "the engine failed to inject on a bare page"
238
+ // case the class exists to catch. A page shipping a PARTIAL set of its
239
+ // own (an SSG theme emitting only og:title, say) makes the engine's
240
+ // group-level missing-only gate back off the whole group BY DESIGN, so
241
+ // its missing companions are report-grade - failing them punished the
242
+ // very contract (SSG-owned partial sets) the injection rule preserves,
243
+ // and the audit cannot tell an SSG-owned partial head from an engine one.
244
+ const ogAny = countHead(head, /<meta[^>]+property=["']og:[^"']*["']/gi);
245
+ for (const prop of OG_CORE) {
246
+ if (grabMeta(head, 'property', prop)) continue;
247
+ if (ogAny === 0) fail(label, 'missing ' + prop);
248
+ else warn(label, prop + ' absent (page ships a partial og set; SSG-owned partial sets keep contract)');
249
+ }
250
+ for (const prop of OG_SET) {
251
+ if (!grabMeta(head, 'property', prop)) {
252
+ warn(label, prop + ' absent (part of the engine-injected set; SSG-owned partial sets keep contract)');
253
+ }
254
+ }
255
+ if (!grabMeta(head, 'property', 'og:image')) {
256
+ configGate('og:image', label);
257
+ }
258
+ const twAny = countHead(head, /<meta[^>]+name=["']twitter:[^"']*["']/gi);
259
+ for (const name of TW_CORE) {
260
+ if (grabMeta(head, 'name', name)) continue;
261
+ if (twAny === 0) fail(label, 'missing ' + name);
262
+ else warn(label, name + ' absent (page ships a partial twitter set; SSG-owned partial sets keep contract)');
263
+ }
264
+ for (const name of TW_SET) {
265
+ // Symmetry with the engine's own late pass (seo.js): without a
266
+ // configured handle the injector ships neither twitter:site nor
267
+ // twitter:creator, so the audit grades those absences only when the
268
+ // handle exists. The 'config-gated' wording in the warning now holds.
269
+ if ((name === 'twitter:site' || name === 'twitter:creator') && !config?.twitter?.username) continue;
270
+ if (!grabMeta(head, 'name', name)) {
271
+ // twitter:image is config-gated (no og.image -> the engine ships no
272
+ // twitter:image either): collapse it like og:image. The rest of the
273
+ // twitter set is SSG-owned-partial-set shaped and stays per-page.
274
+ if (name === 'twitter:image') configGate('twitter:image', label);
275
+ else warn(label, name + ' absent (config-gated or SSG-owned partial set; add a twitter username / og.image as needed)');
276
+ }
277
+ }
278
+
279
+ const ogImage = grabMeta(head, 'property', 'og:image');
280
+ if (ogImage && /\.svg(\?|#|$)/i.test(ogImage)) {
281
+ fail(label, 'og:image is SVG (' + ogImage + ') - most social scrapers cannot render it; ship a raster PNG/JPG');
282
+ }
283
+ if (ogImage && !grabMeta(head, 'property', 'og:image:width')) {
284
+ warn(label, 'og:image:width/height/alt absent (the og.image object form supplies them)');
285
+ }
286
+ if (ogImage) {
287
+ const info = sitePathInfo(ogImage, { outputDir, config });
288
+ if (info && !info.inside) {
289
+ // A site-local og:image that escapes the output tree can never be a
290
+ // served artifact; report it (same report-grade posture as missing).
291
+ warn(label, 'og:image points at ' + ogImage + ' which escapes the output directory');
292
+ } else if (info && !existsSync(info.resolved)) {
293
+ // Report-grade on purpose (issue #18 sketch: 'worth having'): an og
294
+ // image can legitimately be emitted on an SSG-side pass or a same-host
295
+ // CDN, so a hard FAIL would punish valid setups; the JSON digest
296
+ // carries it for teams who want to harden it in CI.
297
+ warn(label, 'og:image points at ' + ogImage + ' which does not exist on disk');
298
+ }
299
+ }
300
+
301
+ const ldBlocks = [...html.matchAll(/<script type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
302
+ if (!ldBlocks.length && !noindex) warn(label, 'no JSON-LD structured data');
303
+ for (const block of ldBlocks) {
304
+ let parsed;
305
+ try { parsed = JSON.parse(block[1]); } catch (e) {
306
+ fail(label, 'invalid JSON-LD: ' + e.message);
307
+ continue;
308
+ }
309
+ const nodes = Array.isArray(parsed) ? parsed : (parsed['@graph'] ? parsed['@graph'] : [parsed]);
310
+ for (const node of nodes) {
311
+ if (!node || typeof node !== 'object') continue;
312
+ if (!node['@type']) { warn(label, 'JSON-LD node without @type'); continue; }
313
+ const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
314
+ if (types.includes('LocalBusiness')) {
315
+ const missing = LB_RECOMMENDED.filter(f => !(f in node));
316
+ if (missing.length) warn(label, 'LocalBusiness missing recommended: ' + missing.join(', '));
317
+ }
318
+ // schema-carried logo/image files: same exists-on-disk check as og:image
319
+ for (const key of ['logo', 'image']) {
320
+ const v = node[key];
321
+ const info = sitePathInfo(typeof v === 'string' ? v : null, { outputDir, config });
322
+ if (info && !info.inside) {
323
+ warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which escapes the output directory');
324
+ } else if (info && !existsSync(info.resolved)) {
325
+ warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which does not exist on disk');
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ const h1s = [...html.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/gi)];
332
+ const h1Texts = h1s.map(m => stripTags(m[1])).filter(Boolean);
333
+ if (!h1Texts.length && !noindex) warn(label, 'no non-empty <h1>');
334
+ else if (h1Texts.length > 1) warn(label, h1Texts.length + ' <h1> elements (prefer one)');
335
+ }
336
+
337
+ // ---------- site-level artifacts ----------
338
+
339
+ // Honor the config's own generate switches: with `generateSitemap: false`
340
+ // (or robots), the run never writes the artifact, so its absence is the
341
+ // documented behavior, not a failure. The audit previously FAILed the
342
+ // missing file the config explicitly asked not to produce, so a
343
+ // legitimately-configured site could never pass its own audit. Configless
344
+ // audits (config === null) keep the strict check - absence is then a real
345
+ // gap. A DISABLED artifact that is present is still checked for shape.
346
+ const sitemapDisabled = config?.generateSitemap === false;
347
+ const sitemapPath = path.join(outputDir, 'sitemap.xml');
348
+ if (!existsSync(sitemapPath)) {
349
+ if (!sitemapDisabled) fail('sitemap.xml', 'missing');
350
+ } else {
351
+ const s = readFileSync(sitemapPath, 'utf8');
352
+ sitemapLocs = (s.match(/<loc>/g) || []).length;
353
+ if (sitemapLocs === 0) fail('sitemap.xml', 'no <loc> entries');
354
+ if (!s.includes('<lastmod>')) warn('sitemap.xml', 'no <lastmod> dates (engine runs always add them; hand-stripped?)');
355
+ }
356
+
357
+ const robotsDisabled = config?.generateRobots === false;
358
+ const robotsPath = path.join(outputDir, 'robots.txt');
359
+ if (!existsSync(robotsPath)) {
360
+ if (!robotsDisabled) fail('robots.txt', 'missing');
361
+ } else {
362
+ const r = readFileSync(robotsPath, 'utf8');
363
+ if (!/Sitemap:/i.test(r)) fail('robots.txt', 'no Sitemap: pointer');
364
+ }
365
+
366
+ // ---------- report ----------
367
+
368
+ // Flush config-gated absences as ONE line each (issue #28), with the
369
+ // affected-page count. Deterministic order (og:image before twitter:image).
370
+ // The og:image wording stays truthful when the config DOES supply an image
371
+ // but not for every page (og.scoped, ogImageGeneration): the line then
372
+ // reads "absent on N page(s)" rather than the false "not configured".
373
+ const ogScopedCfg = config?.og?.scoped;
374
+ const ogImageConfigured = Boolean(config?.og?.image)
375
+ || Boolean(config?.ogImageGeneration)
376
+ || Boolean(ogScopedCfg && typeof ogScopedCfg === 'object' && !Array.isArray(ogScopedCfg)
377
+ && Object.keys(ogScopedCfg).length > 0);
378
+ for (const [key, pages] of [...configGated.entries()].sort()) {
379
+ const n = pages.size;
380
+ let msg;
381
+ if (key === 'og:image') {
382
+ msg = ogImageConfigured
383
+ ? 'og:image absent on ' + n + ' page(s) - no og.image covers them (add one, or extend og.scoped)'
384
+ : 'og:image not configured - ' + n + ' page(s) affected (add one for share previews)';
385
+ } else {
386
+ // twitter:image ships alongside og:image (the injector derives it), so
387
+ // its absence tracks the same config state: "not configured" only when
388
+ // NO og image source exists at all, else "absent on N page(s)".
389
+ msg = ogImageConfigured
390
+ ? key + ' absent on ' + n + ' page(s) - no og.image covers them (add one, or extend og.scoped)'
391
+ : key + ' not configured - ' + n + ' page(s) affected (add a twitter username / og.image as needed)';
392
+ }
393
+ warn('config', msg);
394
+ }
395
+
396
+ if (capture) {
397
+ return { fails, warns, pages: pages.length, sitemapLocs };
398
+ }
399
+ if (json) {
400
+ console.log(JSON.stringify({ ok: fails.length === 0, pages: pages.length, fails, warns }));
401
+ } else {
402
+ console.log('[freestruct] SEO audit: ' + pages.length + ' baked page(s) checked, sitemap.xml has ' + sitemapLocs + ' <loc> entries');
403
+ for (const f of fails) console.log(' FAIL ' + f.page + ': ' + f.msg);
404
+ for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg);
405
+ if (fails.length === 0) {
406
+ console.log('[freestruct] audit: 0 failures, ' + warns.length + ' warning(s) - clean');
407
+ } else {
408
+ console.log('[freestruct] audit: ' + fails.length + ' failure(s), ' + warns.length + ' warning(s) - exiting 1');
409
+ }
410
+ }
411
+ return { fails, warns, pages: pages.length, sitemapLocs };
412
+ }
413
+
414
+ // ---------- external links (opt-in, SEO-gap pass) ----------
415
+
416
+ const EXTERNAL_LIMIT = 50;
417
+
418
+ /** Collect external http(s) href URLs from the baked pages, deduped, with referencing pages. */
419
+ function collectExternalLinks({ outputDir, pages }) {
420
+ const byUrl = new Map();
421
+ for (const filePath of pages) {
422
+ let rel;
423
+ try { rel = path.relative(outputDir, filePath).replace(/\\/g, '/'); } catch (e) { rel = filePath; }
424
+ const html = readFileSync(filePath, 'utf8');
425
+ for (const m of html.matchAll(/href\s*=\s*["'](https?:\/\/[^"'\s]+)["']/gi)) {
426
+ const url = m[1].replace(/&amp;/g, '&');
427
+ if (!byUrl.has(url)) byUrl.set(url, []);
428
+ byUrl.get(url).push(rel);
429
+ }
430
+ }
431
+ return byUrl;
432
+ }
433
+
434
+ /** HEAD with a GET retry (some CDNs refuse HEAD); returns the problem string or null. */
435
+ async function headCheck(url) {
436
+ const tryOnce = async (method) => {
437
+ const res = await fetch(url, {
438
+ method,
439
+ redirect: 'follow',
440
+ signal: AbortSignal.timeout(8000),
441
+ headers: { 'user-agent': 'freestruct-audit (+https://github.com/dhaupin/freestruct)' },
442
+ });
443
+ return res.status >= 400 ? 'HTTP ' + res.status + (res.statusText ? ' ' + res.statusText : '') : null;
444
+ };
445
+ try {
446
+ return await tryOnce('HEAD');
447
+ } catch (headErr) {
448
+ try {
449
+ return await tryOnce('GET');
450
+ } catch (getErr) {
451
+ const cause = (getErr && getErr.cause && getErr.cause.code) ? getErr.cause.code : getErr.message;
452
+ return 'unreachable (' + cause + ')';
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * External-link audit (opt-in): dedupe the tree's external hrefs, HEAD-check
459
+ * up to EXTERNAL_LIMIT unique URLs, report every failure as a WARN row
460
+ * (report-grade by design: bots get 403/429 legitimately). NEVER exits.
461
+ */
462
+ export async function auditExternalLinks({ outputDir, json = false }) {
463
+ const empty = { warns: [], checked: 0 };
464
+ if (!existsSync(outputDir)) return empty;
465
+ const pages = walkHtml(outputDir);
466
+ const byUrl = collectExternalLinks({ outputDir, pages });
467
+ if (byUrl.size === 0) return empty;
468
+ const capped = [...byUrl.keys()].slice(0, EXTERNAL_LIMIT);
469
+ const results = await Promise.all(capped.map(async (url) => [url, await headCheck(url)]));
470
+ const warns = [];
471
+ for (const [url, problem] of results) {
472
+ if (!problem) continue;
473
+ const referencing = byUrl.get(url);
474
+ const label = referencing.length > 1
475
+ ? referencing[0] + ' (+' + (referencing.length - 1) + ' more page(s))'
476
+ : referencing[0];
477
+ warns.push({ page: label, msg: 'external link ' + url + ': ' + problem });
478
+ }
479
+ if (byUrl.size > EXTERNAL_LIMIT) {
480
+ warns.push({ page: 'external', msg: 'checked only the first ' + EXTERNAL_LIMIT + ' unique external urls of ' + byUrl.size });
481
+ }
482
+ if (!json && warns.length > 0) {
483
+ console.log('[freestruct] external link check: ' + capped.length + ' unique url(s) probed');
484
+ for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg);
485
+ }
486
+ return { warns, checked: capped.length };
487
+ }