@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.
- package/README.md +149 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +13 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/cli.d.mts +24 -0
- package/dist/cli.d.mts.map +1 -0
- package/dist/cli.mjs +68 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +29 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/load-KUG-8Ug4.mjs +742 -0
- package/dist/load-KUG-8Ug4.mjs.map +1 -0
- package/dist/types-C_hmH1n3.d.mts +87 -0
- package/dist/types-C_hmH1n3.d.mts.map +1 -0
- package/package.json +55 -0
|
@@ -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
|