@contentrain/verify 0.1.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,742 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ //#region src/types.ts
4
+ const CHECK_GROUPS = [
5
+ "identity",
6
+ "indexing",
7
+ "status",
8
+ "international",
9
+ "structured",
10
+ "navigation",
11
+ "assets"
12
+ ];
13
+ //#endregion
14
+ //#region src/html.ts
15
+ /** Everything before the first `</head>`, or '' when there is none. */
16
+ function head(html) {
17
+ const end = html.search(/<\/head\s*>/i);
18
+ return end === -1 ? "" : html.slice(0, end);
19
+ }
20
+ /** Everything after the first `</head>`, or the whole document when there is none. */
21
+ function body(html) {
22
+ const end = html.search(/<\/head\s*>/i);
23
+ return end === -1 ? html : html.slice(end);
24
+ }
25
+ const ENTITIES = {
26
+ amp: "&",
27
+ lt: "<",
28
+ gt: ">",
29
+ quot: "\"",
30
+ apos: "'",
31
+ "#39": "'"
32
+ };
33
+ function decodeEntities(value) {
34
+ return value.replace(/&(#39|amp|lt|gt|quot|apos);/g, (_m, name) => ENTITIES[name] ?? _m);
35
+ }
36
+ /**
37
+ * Read one attribute off a single tag's source. Quote-aware; unquoted allowed.
38
+ *
39
+ * The name must be preceded by whitespace, not merely a word boundary: `-` is
40
+ * not a word character, so `\bhref` matches inside `data-href` — and lazy-
41
+ * loading WordPress themes put the real URL in `data-src` and a placeholder in
42
+ * `src` on almost every image.
43
+ */
44
+ function attr(tag, name) {
45
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(tag);
46
+ if (!match) return void 0;
47
+ return decodeEntities(match[1] ?? match[2] ?? match[3] ?? "");
48
+ }
49
+ /** Every `<tag …>` opening tag in the source, as raw strings. */
50
+ function tags(source, name) {
51
+ return [...source.matchAll(new RegExp(`<${name}\\b[^>]*>`, "gi"))].map((m) => m[0]);
52
+ }
53
+ /**
54
+ * `<title>` texts from the head. `<svg>` blocks are removed first: a `<title>`
55
+ * inside one is an accessibility label, valid markup, and not the page title —
56
+ * counting it would report a duplicate on every page with an inline icon.
57
+ */
58
+ function titles(html) {
59
+ return [...head(html).replace(/<svg\b[\s\S]*?<\/svg\s*>/gi, "").matchAll(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/gi)].map((m) => decodeEntities(m[1] ?? "").trim());
60
+ }
61
+ /** `<meta name="…">` / `<meta property="…">` content, keyed by lowercased key. */
62
+ function metaMap(html) {
63
+ const found = /* @__PURE__ */ new Map();
64
+ for (const tag of tags(head(html), "meta")) {
65
+ const key = (attr(tag, "property") ?? attr(tag, "name"))?.toLowerCase();
66
+ if (!key) continue;
67
+ const content = attr(tag, "content") ?? "";
68
+ found.set(key, [...found.get(key) ?? [], content]);
69
+ }
70
+ return found;
71
+ }
72
+ /** `<link>` tags from the head, with `rel` lowercased. */
73
+ function links(html) {
74
+ const found = [];
75
+ for (const tag of tags(head(html), "link")) {
76
+ const rel = attr(tag, "rel")?.toLowerCase();
77
+ const href = attr(tag, "href");
78
+ if (!rel || href === void 0) continue;
79
+ found.push({
80
+ rel,
81
+ href,
82
+ hreflang: attr(tag, "hreflang"),
83
+ type: attr(tag, "type")
84
+ });
85
+ }
86
+ return found;
87
+ }
88
+ /** Links whose `rel` list contains the given token. */
89
+ function linksRel(html, token) {
90
+ return links(html).filter((link) => link.rel.split(/\s+/).includes(token));
91
+ }
92
+ /** Every `application/ld+json` block in the document, parsed where possible. */
93
+ function jsonLd(html) {
94
+ const blocks = [];
95
+ for (const match of html.matchAll(/<script\b[^>]*\btype\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script\s*>/gi)) {
96
+ const raw = (match[1] ?? "").trim();
97
+ try {
98
+ blocks.push({
99
+ raw,
100
+ value: JSON.parse(raw)
101
+ });
102
+ } catch {
103
+ blocks.push({ raw });
104
+ }
105
+ }
106
+ return blocks;
107
+ }
108
+ /**
109
+ * `@type` values in a JSON-LD block, including those nested in `@graph` —
110
+ * themes commonly ship one graph holding Organization, WebSite and WebPage,
111
+ * and reading only the root would see none of them.
112
+ */
113
+ function jsonLdTypes(value) {
114
+ const found = [];
115
+ const walk = (node) => {
116
+ if (Array.isArray(node)) {
117
+ for (const item of node) walk(item);
118
+ return;
119
+ }
120
+ if (!node || typeof node !== "object") return;
121
+ const record = node;
122
+ const type = record["@type"];
123
+ if (typeof type === "string") found.push(type);
124
+ else if (Array.isArray(type)) {
125
+ for (const t of type) if (typeof t === "string") found.push(t);
126
+ }
127
+ if ("@graph" in record) walk(record["@graph"]);
128
+ };
129
+ walk(value);
130
+ return found;
131
+ }
132
+ /** `href` of every `<a>` in the document body. */
133
+ function anchors(html) {
134
+ return tags(body(html), "a").map((tag) => attr(tag, "href")).filter((href) => href !== void 0);
135
+ }
136
+ /** Every `<img>` in the document body. */
137
+ function images(html) {
138
+ return tags(body(html), "img").map((tag) => ({
139
+ src: attr(tag, "src") ?? "",
140
+ alt: attr(tag, "alt")
141
+ })).filter((image) => image.src !== "");
142
+ }
143
+ /** Visible text, roughly: script/style stripped, tags removed, runs collapsed. */
144
+ function text(html) {
145
+ return body(html).replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
146
+ }
147
+ /** `<loc>` entries of a sitemap or sitemap index. */
148
+ function sitemapLocations(xml) {
149
+ return [...xml.matchAll(/<loc>\s*([\s\S]*?)\s*<\/loc>/gi)].map((m) => decodeEntities((m[1] ?? "").trim()));
150
+ }
151
+ //#endregion
152
+ //#region src/url.ts
153
+ const SITE_FALLBACK = "https://contentrain.invalid";
154
+ /** Absolute URL for `href` as seen from `base`, or undefined when unresolvable. */
155
+ function resolve(href, base, site) {
156
+ try {
157
+ const origin = site ?? SITE_FALLBACK;
158
+ const absoluteBase = base.startsWith("http") ? base : new URL(base, origin).href;
159
+ return new URL(href, absoluteBase).href;
160
+ } catch {
161
+ return;
162
+ }
163
+ }
164
+ /** Whether a resolved URL belongs to the site under test. */
165
+ function isInternal(url, site, extraHosts = []) {
166
+ try {
167
+ const parsed = new URL(url);
168
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
169
+ if (!site) return parsed.origin === SITE_FALLBACK || extraHosts.includes(parsed.host);
170
+ return parsed.host === new URL(site).host || extraHosts.includes(parsed.host);
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ /**
176
+ * The identity of a document: path only, with `index.html` and a trailing
177
+ * slash removed, query and fragment dropped. `/` stays `/`.
178
+ */
179
+ function identity(url, site) {
180
+ const full = url.startsWith("http") ? url : resolve(url, "/", site) ?? url;
181
+ let path;
182
+ try {
183
+ path = new URL(full).pathname;
184
+ } catch {
185
+ path = full.split(/[?#]/)[0] ?? full;
186
+ }
187
+ path = path.replace(/\/index\.html?$/i, "/");
188
+ if (path.length > 1) path = path.replace(/\/+$/, "");
189
+ return path === "" ? "/" : path;
190
+ }
191
+ /** Absolute form of a document's own address, when the site origin is known. */
192
+ function absolute(url, site) {
193
+ if (url.startsWith("http")) return url;
194
+ if (!site) return void 0;
195
+ try {
196
+ return new URL(url, site).href;
197
+ } catch {
198
+ return;
199
+ }
200
+ }
201
+ //#endregion
202
+ //#region src/checks.ts
203
+ const NOT_FOUND_TEXT = /\b(404|not found|page not found|sayfa bulunamad[ıi]|nicht gefunden|introuvable|no se encontr)/i;
204
+ function finding(f) {
205
+ return f;
206
+ }
207
+ function identityChecks(doc, ctx) {
208
+ const out = [];
209
+ const url = doc.url;
210
+ const group = "identity";
211
+ const found = titles(doc.html);
212
+ if (found.every((t) => t === "")) out.push(finding({
213
+ group,
214
+ check: "identity.title-missing",
215
+ severity: "error",
216
+ url,
217
+ message: "No non-empty <title> in the head."
218
+ }));
219
+ else if (found.length > 1) out.push(finding({
220
+ group,
221
+ check: "identity.title-duplicate",
222
+ severity: "error",
223
+ url,
224
+ message: `${found.length} <title> elements in the head — a crawler takes one and it is not defined which.`,
225
+ detail: found.join(" | ")
226
+ }));
227
+ const meta = metaMap(doc.html);
228
+ if (!meta.get("description")?.[0]?.trim()) out.push(finding({
229
+ group,
230
+ check: "identity.description-missing",
231
+ severity: "warning",
232
+ url,
233
+ message: "No meta description."
234
+ }));
235
+ const canonicals = linksRel(doc.html, "canonical");
236
+ if (canonicals.length === 0) out.push(finding({
237
+ group,
238
+ check: "identity.canonical-missing",
239
+ severity: "warning",
240
+ url,
241
+ message: "No canonical link."
242
+ }));
243
+ else if (canonicals.length > 1) out.push(finding({
244
+ group,
245
+ check: "identity.canonical-duplicate",
246
+ severity: "error",
247
+ url,
248
+ message: `${canonicals.length} canonical links — the page tells crawlers two different things about itself.`,
249
+ detail: canonicals.map((c) => c.href).join(" | ")
250
+ }));
251
+ else {
252
+ const declared = resolve(canonicals[0].href, url, ctx.input.site);
253
+ const self = absolute(url, ctx.input.site) ?? resolve(url, "/", ctx.input.site);
254
+ if (declared && self && identity(declared, ctx.input.site) !== identity(self, ctx.input.site)) out.push(finding({
255
+ group,
256
+ check: "identity.canonical-mismatch",
257
+ severity: "error",
258
+ url,
259
+ message: "Canonical points at a different page.",
260
+ detail: `${canonicals[0].href} ≠ ${url}`
261
+ }));
262
+ }
263
+ const missingOg = [
264
+ "og:title",
265
+ "og:type",
266
+ "og:url"
267
+ ].filter((key) => !meta.get(key)?.[0]);
268
+ if (missingOg.length) out.push(finding({
269
+ group,
270
+ check: "identity.open-graph-incomplete",
271
+ severity: "warning",
272
+ url,
273
+ message: `Open Graph incomplete — missing ${missingOg.join(", ")}. Shares render without a card.`
274
+ }));
275
+ if (!meta.get("twitter:card")?.[0]) out.push(finding({
276
+ group,
277
+ check: "identity.twitter-card-missing",
278
+ severity: "info",
279
+ url,
280
+ message: "No twitter:card."
281
+ }));
282
+ return out;
283
+ }
284
+ /** `noindex` as declared by a robots meta or an X-Robots-Tag header. */
285
+ function noindexReason(doc) {
286
+ const robots = metaMap(doc.html).get("robots")?.join(",") ?? "";
287
+ if (/\bnoindex\b/i.test(robots)) return `<meta name="robots" content="${robots}">`;
288
+ const header = doc.headers?.["x-robots-tag"] ?? "";
289
+ if (/\bnoindex\b/i.test(header)) return `X-Robots-Tag: ${header}`;
290
+ }
291
+ function indexingChecks(doc, ctx) {
292
+ const out = [];
293
+ const group = "indexing";
294
+ const url = doc.url;
295
+ const reason = noindexReason(doc);
296
+ if (reason) out.push(finding({
297
+ group,
298
+ check: "indexing.noindex",
299
+ severity: "error",
300
+ url,
301
+ message: "Page is marked noindex.",
302
+ detail: reason
303
+ }));
304
+ if (ctx.input.sitemap !== void 0) {
305
+ if (!new Set(sitemapLocations(ctx.input.sitemap).map((loc) => identity(loc, ctx.input.site))).has(identity(url, ctx.input.site)) && !reason) out.push(finding({
306
+ group,
307
+ check: "indexing.sitemap-missing-entry",
308
+ severity: "warning",
309
+ url,
310
+ message: "Indexable page is not in the sitemap."
311
+ }));
312
+ }
313
+ return out;
314
+ }
315
+ /** Sitemap entries pointing at pages the build does not serve. */
316
+ function sitemapStaleChecks(ctx) {
317
+ if (ctx.input.sitemap === void 0) return [];
318
+ const out = [];
319
+ for (const loc of sitemapLocations(ctx.input.sitemap)) {
320
+ const id = identity(loc, ctx.input.site);
321
+ if (ctx.byIdentity.has(id)) continue;
322
+ out.push(finding({
323
+ group: "indexing",
324
+ check: "indexing.sitemap-stale-entry",
325
+ severity: "warning",
326
+ url: loc,
327
+ message: "Sitemap lists a page the build does not serve."
328
+ }));
329
+ }
330
+ return out;
331
+ }
332
+ function statusChecks(doc, ctx) {
333
+ const out = [];
334
+ const group = "status";
335
+ const url = doc.url;
336
+ const status = doc.status ?? 200;
337
+ const before = ctx.baselineByIdentity.get(identity(url, ctx.input.site));
338
+ if (before) {
339
+ const was = before.status ?? 200;
340
+ if (was !== status) out.push(finding({
341
+ group,
342
+ check: "status.mismatch",
343
+ severity: "error",
344
+ url,
345
+ message: `Status changed: ${was} → ${status}.`
346
+ }));
347
+ }
348
+ if (status === 200) {
349
+ const bodyText = text(doc.html);
350
+ const title = titles(doc.html)[0] ?? "";
351
+ if ((NOT_FOUND_TEXT.test(title) || NOT_FOUND_TEXT.test(bodyText.slice(0, 400))) && bodyText.length <= ctx.options.soft404MaxTextLength) out.push(finding({
352
+ group,
353
+ check: "status.soft-404",
354
+ severity: "error",
355
+ url,
356
+ message: "Page says it was not found but answers 200.",
357
+ detail: title || bodyText.slice(0, 120)
358
+ }));
359
+ }
360
+ return out;
361
+ }
362
+ /** Redirect rules: chains, loops, and targets that do not exist. */
363
+ function redirectChecks(ctx) {
364
+ const rules = ctx.input.redirects;
365
+ if (!rules?.length) return [];
366
+ const out = [];
367
+ const group = "status";
368
+ const byFrom = new Map(rules.map((rule) => [identity(rule.from, ctx.input.site), rule]));
369
+ for (const rule of rules) {
370
+ const seen = new Set([identity(rule.from, ctx.input.site)]);
371
+ let hops = 1;
372
+ let target = identity(rule.to, ctx.input.site);
373
+ while (byFrom.has(target)) {
374
+ if (seen.has(target)) {
375
+ out.push(finding({
376
+ group,
377
+ check: "status.redirect-loop",
378
+ severity: "error",
379
+ url: rule.from,
380
+ message: "Redirect chain loops.",
381
+ detail: [...seen, target].join(" → ")
382
+ }));
383
+ break;
384
+ }
385
+ seen.add(target);
386
+ target = identity(byFrom.get(target).to, ctx.input.site);
387
+ hops += 1;
388
+ }
389
+ if (hops > ctx.options.maxRedirectHops) out.push(finding({
390
+ group,
391
+ check: "status.redirect-chain",
392
+ severity: "warning",
393
+ url: rule.from,
394
+ message: `Redirect chain is ${hops} hops.`,
395
+ detail: [...seen, target].join(" → ")
396
+ }));
397
+ if (!!isInternal(resolve(rule.to, rule.from, ctx.input.site) ?? "", ctx.input.site, ctx.options.internalHosts) && !ctx.served.has(target) && !byFrom.has(target)) out.push(finding({
398
+ group,
399
+ check: "status.redirect-target-missing",
400
+ severity: "error",
401
+ url: rule.from,
402
+ message: "Redirect points at a page the build does not serve.",
403
+ detail: rule.to
404
+ }));
405
+ }
406
+ return out;
407
+ }
408
+ /**
409
+ * hreflang has to be symmetric: if A offers B as its Turkish alternate, B must
410
+ * offer A back. A one-sided declaration is ignored by search engines entirely,
411
+ * so a half-migrated set of alternates is not a partial win — it is nothing.
412
+ */
413
+ function internationalChecks(ctx) {
414
+ const out = [];
415
+ const group = "international";
416
+ const declared = /* @__PURE__ */ new Map();
417
+ for (const doc of ctx.input.documents) {
418
+ const self = identity(doc.url, ctx.input.site);
419
+ const alternates = /* @__PURE__ */ new Map();
420
+ for (const link of linksRel(doc.html, "alternate")) {
421
+ if (!link.hreflang) continue;
422
+ const target = resolve(link.href, doc.url, ctx.input.site);
423
+ if (!target) continue;
424
+ alternates.set(link.hreflang.toLowerCase(), identity(target, ctx.input.site));
425
+ }
426
+ if (alternates.size) declared.set(self, alternates);
427
+ }
428
+ for (const [self, alternates] of declared) {
429
+ if (![...alternates.values()].includes(self)) out.push(finding({
430
+ group,
431
+ check: "international.hreflang-no-self",
432
+ severity: "warning",
433
+ url: self,
434
+ message: "Page declares hreflang alternates but does not list itself."
435
+ }));
436
+ for (const [lang, target] of alternates) {
437
+ if (target === self) continue;
438
+ if (!ctx.byIdentity.has(target)) {
439
+ out.push(finding({
440
+ group,
441
+ check: "international.hreflang-unknown-target",
442
+ severity: "warning",
443
+ url: self,
444
+ message: `hreflang="${lang}" points at a page the build does not serve.`,
445
+ detail: target
446
+ }));
447
+ continue;
448
+ }
449
+ const back = declared.get(target);
450
+ if (!back || ![...back.values()].includes(self)) out.push(finding({
451
+ group,
452
+ check: "international.hreflang-not-reciprocal",
453
+ severity: "error",
454
+ url: self,
455
+ message: `hreflang="${lang}" names ${target}, which does not name it back — search engines ignore one-sided alternates.`
456
+ }));
457
+ }
458
+ }
459
+ return out;
460
+ }
461
+ function structuredChecks(doc, ctx) {
462
+ const out = [];
463
+ const group = "structured";
464
+ const url = doc.url;
465
+ const blocks = jsonLd(doc.html);
466
+ for (const block of blocks) if (block.value === void 0) out.push(finding({
467
+ group,
468
+ check: "structured.jsonld-invalid",
469
+ severity: "error",
470
+ url,
471
+ message: "A JSON-LD block does not parse — the whole block is discarded by consumers.",
472
+ detail: block.raw.slice(0, 120)
473
+ }));
474
+ const before = ctx.baselineByIdentity.get(identity(url, ctx.input.site));
475
+ if (!before) return out;
476
+ const had = new Set(jsonLd(before.html).flatMap((block) => jsonLdTypes(block.value)));
477
+ const has = new Set(blocks.flatMap((block) => jsonLdTypes(block.value)));
478
+ const lost = [...had].filter((type) => !has.has(type));
479
+ if (lost.length) out.push(finding({
480
+ group,
481
+ check: "structured.type-lost",
482
+ severity: "warning",
483
+ url,
484
+ message: `Structured data types the old page had are gone: ${lost.join(", ")}.`
485
+ }));
486
+ return out;
487
+ }
488
+ function navigationChecks(doc, ctx) {
489
+ const out = [];
490
+ const group = "navigation";
491
+ const url = doc.url;
492
+ const redirected = new Set((ctx.input.redirects ?? []).map((rule) => identity(rule.from, ctx.input.site)));
493
+ const broken = /* @__PURE__ */ new Set();
494
+ for (const href of anchors(doc.html)) {
495
+ if (/^(#|mailto:|tel:|javascript:|data:)/i.test(href)) continue;
496
+ const target = resolve(href, url, ctx.input.site);
497
+ if (!target || !isInternal(target, ctx.input.site, ctx.options.internalHosts)) continue;
498
+ const id = identity(target, ctx.input.site);
499
+ if (ctx.served.has(id) || redirected.has(id)) continue;
500
+ broken.add(href);
501
+ }
502
+ for (const href of broken) out.push(finding({
503
+ group,
504
+ check: "navigation.broken-internal-link",
505
+ severity: "error",
506
+ url,
507
+ message: "Internal link goes nowhere.",
508
+ detail: href
509
+ }));
510
+ for (const token of ["next", "prev"]) for (const link of linksRel(doc.html, token)) {
511
+ const target = resolve(link.href, url, ctx.input.site);
512
+ if (!target || !isInternal(target, ctx.input.site, ctx.options.internalHosts)) continue;
513
+ if (ctx.served.has(identity(target, ctx.input.site))) continue;
514
+ out.push(finding({
515
+ group,
516
+ check: "navigation.pagination-broken",
517
+ severity: "warning",
518
+ url,
519
+ message: `rel="${token}" points at a page the build does not serve.`,
520
+ detail: link.href
521
+ }));
522
+ }
523
+ const before = ctx.baselineByIdentity.get(identity(url, ctx.input.site));
524
+ if (before) {
525
+ const feedRel = (html) => linksRel(html, "alternate").some((link) => /rss|atom|feed/i.test(link.type ?? ""));
526
+ if (feedRel(before.html) && !feedRel(doc.html)) out.push(finding({
527
+ group,
528
+ check: "navigation.feed-lost",
529
+ severity: "warning",
530
+ url,
531
+ message: "The old page advertised a feed; this one does not."
532
+ }));
533
+ }
534
+ return out;
535
+ }
536
+ function assetChecks(doc, ctx) {
537
+ const out = [];
538
+ const group = "assets";
539
+ const url = doc.url;
540
+ const refs = images(doc.html);
541
+ const broken = /* @__PURE__ */ new Set();
542
+ for (const image of refs) {
543
+ if (/^data:/i.test(image.src)) continue;
544
+ const target = resolve(image.src, url, ctx.input.site);
545
+ if (!target || !isInternal(target, ctx.input.site, ctx.options.internalHosts)) continue;
546
+ if (ctx.served.has(identity(target, ctx.input.site))) continue;
547
+ broken.add(image.src);
548
+ }
549
+ for (const src of broken) out.push(finding({
550
+ group,
551
+ check: "assets.broken-image",
552
+ severity: "error",
553
+ url,
554
+ message: "Image does not resolve to anything the build serves.",
555
+ detail: src
556
+ }));
557
+ const noAlt = refs.filter((image) => image.alt === void 0);
558
+ if (noAlt.length) out.push(finding({
559
+ group,
560
+ check: "assets.missing-alt",
561
+ severity: "warning",
562
+ url,
563
+ message: `${noAlt.length} image${noAlt.length === 1 ? "" : "s"} without an alt attribute.`,
564
+ detail: noAlt.slice(0, 5).map((image) => image.src).join(", ")
565
+ }));
566
+ const before = ctx.baselineByIdentity.get(identity(url, ctx.input.site));
567
+ if (before) {
568
+ const had = new Map(images(before.html).filter((i) => i.alt).map((i) => [identity(resolve(i.src, before.url, ctx.input.site) ?? i.src, ctx.input.site), i.alt]));
569
+ const now = new Map(refs.map((i) => [identity(resolve(i.src, url, ctx.input.site) ?? i.src, ctx.input.site), i.alt]));
570
+ const lost = [...had.keys()].filter((src) => now.has(src) && !now.get(src));
571
+ if (lost.length) out.push(finding({
572
+ group,
573
+ check: "assets.alt-lost",
574
+ severity: "warning",
575
+ url,
576
+ message: `${lost.length} image${lost.length === 1 ? "" : "s"} lost alt text the old page had.`,
577
+ detail: lost.slice(0, 5).join(", ")
578
+ }));
579
+ }
580
+ return out;
581
+ }
582
+ //#endregion
583
+ //#region src/verify.ts
584
+ const DEFAULTS = {
585
+ maxRedirectHops: 1,
586
+ soft404MaxTextLength: 600
587
+ };
588
+ /**
589
+ * Checks that need an input the caller did not supply. Reported rather than
590
+ * silently omitted: a green report over a run that never compared anything is
591
+ * the most dangerous output this package could produce.
592
+ */
593
+ function absentInputs(input, groups) {
594
+ const skipped = [];
595
+ if (!input.baseline) {
596
+ for (const group of [
597
+ "status",
598
+ "structured",
599
+ "navigation",
600
+ "assets"
601
+ ]) if (groups.includes(group)) skipped.push({
602
+ group,
603
+ reason: "no baseline — parity checks did not run"
604
+ });
605
+ }
606
+ if (input.sitemap === void 0 && groups.includes("indexing")) skipped.push({
607
+ group: "indexing",
608
+ reason: "no sitemap — membership checks did not run"
609
+ });
610
+ if (!input.redirects?.length && groups.includes("status")) skipped.push({
611
+ group: "status",
612
+ reason: "no redirect rules — chain and target checks did not run"
613
+ });
614
+ return skipped;
615
+ }
616
+ function buildContext(input) {
617
+ const options = {
618
+ ...DEFAULTS,
619
+ ...input.options
620
+ };
621
+ const byIdentity = new Map(input.documents.map((doc) => [identity(doc.url, input.site), doc]));
622
+ return {
623
+ input,
624
+ options,
625
+ byIdentity,
626
+ baselineByIdentity: new Map((input.baseline?.documents ?? []).map((doc) => [identity(doc.url, input.site), doc])),
627
+ served: new Set([...byIdentity.keys(), ...(input.assets ?? []).map((asset) => identity(asset, input.site))])
628
+ };
629
+ }
630
+ /**
631
+ * Run the gates over a built site.
632
+ *
633
+ * Nothing is fetched. `input.documents` is the built site as served, and
634
+ * `input.baseline` — when the caller has one — is the site it replaced. Without
635
+ * a baseline every self-check still runs; only the parity checks (status
636
+ * change, lost structured data, lost alt text, lost feed) go quiet, because
637
+ * there is nothing to compare against.
638
+ */
639
+ function verify(input) {
640
+ const groups = [...input.groups ?? CHECK_GROUPS];
641
+ const ctx = buildContext(input);
642
+ const findings = [];
643
+ const skipped = absentInputs(input, groups);
644
+ const run = (group, fn) => {
645
+ if (!groups.includes(group)) return;
646
+ findings.push(...fn());
647
+ };
648
+ for (const doc of input.documents) {
649
+ run("identity", () => identityChecks(doc, ctx));
650
+ run("indexing", () => indexingChecks(doc, ctx));
651
+ run("status", () => statusChecks(doc, ctx));
652
+ run("structured", () => structuredChecks(doc, ctx));
653
+ run("navigation", () => navigationChecks(doc, ctx));
654
+ run("assets", () => assetChecks(doc, ctx));
655
+ }
656
+ run("indexing", () => sitemapStaleChecks(ctx));
657
+ run("status", () => redirectChecks(ctx));
658
+ run("international", () => internationalChecks(ctx));
659
+ const counts = {
660
+ error: 0,
661
+ warning: 0,
662
+ info: 0
663
+ };
664
+ for (const item of findings) counts[item.severity] += 1;
665
+ return {
666
+ passed: counts.error === 0,
667
+ findings,
668
+ counts,
669
+ documents: input.documents.length,
670
+ groups,
671
+ skipped
672
+ };
673
+ }
674
+ /** A report as lines, most severe first. Used by the CLI and by consumers. */
675
+ function formatReport(report) {
676
+ const order = [
677
+ "error",
678
+ "warning",
679
+ "info"
680
+ ];
681
+ const lines = [];
682
+ for (const severity of order) for (const item of report.findings.filter((f) => f.severity === severity)) {
683
+ const where = item.url ? ` ${item.url}` : "";
684
+ const detail = item.detail ? `\n ${item.detail}` : "";
685
+ lines.push(`${severity.toUpperCase().padEnd(7)} ${item.check}${where}\n ${item.message}${detail}`);
686
+ }
687
+ for (const { group, reason } of report.skipped) lines.push(`SKIPPED ${group} — ${reason}`);
688
+ lines.push(`\n${report.documents} document${report.documents === 1 ? "" : "s"} · ${report.counts.error} error · ${report.counts.warning} warning · ${report.counts.info} info · ` + (report.passed ? "PASS" : "FAIL"));
689
+ return lines.join("\n");
690
+ }
691
+ //#endregion
692
+ //#region src/load.ts
693
+ const DOCUMENT_EXT = /\.html?$/i;
694
+ async function walk(dir, root, files = []) {
695
+ const entries = await readdir(dir, { withFileTypes: true });
696
+ for (const entry of entries) {
697
+ const full = join(dir, entry.name);
698
+ if (entry.isDirectory()) {
699
+ await walk(full, root, files);
700
+ continue;
701
+ }
702
+ files.push(relative(root, full));
703
+ }
704
+ return files;
705
+ }
706
+ /**
707
+ * Load a static build as a verification input: every `.html` becomes a
708
+ * document served at its path, everything else becomes an asset.
709
+ *
710
+ * `dist/about/index.html` is served at `/about/`, which `identity()` then
711
+ * reduces to `/about` — the same address a link to `/about/` or
712
+ * `/about/index.html` resolves to.
713
+ */
714
+ async function loadSiteDirectory(dir, site) {
715
+ const files = await walk(dir, dir);
716
+ const documents = [];
717
+ const assets = [];
718
+ let sitemap;
719
+ for (const file of files.toSorted()) {
720
+ const served = `/${file.split(sep).join("/")}`;
721
+ if (DOCUMENT_EXT.test(file)) {
722
+ documents.push({
723
+ url: served,
724
+ html: await readFile(join(dir, file), "utf8"),
725
+ status: 200
726
+ });
727
+ continue;
728
+ }
729
+ if (/^\/sitemap[^/]*\.xml$/i.test(served)) sitemap = await readFile(join(dir, file), "utf8");
730
+ assets.push(served);
731
+ }
732
+ return {
733
+ documents,
734
+ assets,
735
+ sitemap,
736
+ site
737
+ };
738
+ }
739
+ //#endregion
740
+ export { CHECK_GROUPS as i, formatReport as n, verify as r, loadSiteDirectory as t };
741
+
742
+ //# sourceMappingURL=load-KUG-8Ug4.mjs.map