pagetrace 0.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.
package/dist/cli.cjs ADDED
@@ -0,0 +1,1541 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_promises2 = require("fs/promises");
28
+ var import_cac = require("cac");
29
+ var import_picocolors2 = __toESM(require("picocolors"), 1);
30
+
31
+ // src/rules/rich-results.ts
32
+ var RICH_RESULT_RULES = {
33
+ Article: {
34
+ required: ["headline"],
35
+ recommended: ["author", "datePublished", "dateModified", "image"]
36
+ },
37
+ NewsArticle: {
38
+ required: ["headline"],
39
+ recommended: ["author", "datePublished", "dateModified", "image"]
40
+ },
41
+ BlogPosting: {
42
+ required: ["headline"],
43
+ recommended: ["author", "datePublished", "dateModified", "image"]
44
+ },
45
+ Product: {
46
+ required: ["name"],
47
+ recommended: ["image", "description", "brand"],
48
+ oneOf: [["offers", "review", "aggregateRating"]]
49
+ },
50
+ Offer: {
51
+ required: ["price", "priceCurrency"],
52
+ recommended: ["availability", "url"]
53
+ },
54
+ FAQPage: {
55
+ required: ["mainEntity"],
56
+ recommended: []
57
+ },
58
+ HowTo: {
59
+ required: ["name", "step"],
60
+ recommended: ["image", "totalTime", "supply", "tool"]
61
+ },
62
+ Recipe: {
63
+ required: ["name", "image"],
64
+ recommended: ["author", "datePublished", "description", "recipeIngredient", "recipeInstructions"]
65
+ },
66
+ Event: {
67
+ required: ["name", "startDate", "location"],
68
+ recommended: ["endDate", "description", "image", "offers"]
69
+ },
70
+ JobPosting: {
71
+ required: ["title", "description", "datePosted", "hiringOrganization"],
72
+ recommended: ["jobLocation", "baseSalary", "validThrough", "employmentType"]
73
+ },
74
+ Organization: {
75
+ required: ["name"],
76
+ recommended: ["url", "logo", "sameAs", "contactPoint"]
77
+ },
78
+ LocalBusiness: {
79
+ required: ["name", "address"],
80
+ recommended: ["telephone", "openingHoursSpecification", "geo", "priceRange", "image"]
81
+ },
82
+ BreadcrumbList: {
83
+ required: ["itemListElement"],
84
+ recommended: []
85
+ },
86
+ VideoObject: {
87
+ required: ["name", "description", "thumbnailUrl", "uploadDate"],
88
+ recommended: ["duration", "contentUrl", "embedUrl"]
89
+ },
90
+ Review: {
91
+ required: ["itemReviewed", "reviewRating", "author"],
92
+ recommended: ["datePublished", "reviewBody"]
93
+ },
94
+ AggregateRating: {
95
+ required: ["ratingValue"],
96
+ recommended: ["reviewCount", "ratingCount", "bestRating"]
97
+ },
98
+ Course: {
99
+ required: ["name", "description"],
100
+ recommended: ["provider", "offers", "hasCourseInstance"]
101
+ },
102
+ SoftwareApplication: {
103
+ required: ["name", "applicationCategory"],
104
+ recommended: ["operatingSystem", "offers", "aggregateRating"]
105
+ },
106
+ WebSite: {
107
+ required: ["name", "url"],
108
+ recommended: ["potentialAction"]
109
+ },
110
+ Person: {
111
+ required: ["name"],
112
+ recommended: ["url", "jobTitle", "sameAs", "image"]
113
+ }
114
+ };
115
+ var DEFAULT_AI_AGENTS = [
116
+ "GPTBot",
117
+ "OAI-SearchBot",
118
+ "ChatGPT-User",
119
+ "ClaudeBot",
120
+ "Claude-User",
121
+ "PerplexityBot",
122
+ "Perplexity-User",
123
+ "Google-Extended",
124
+ "Applebot-Extended",
125
+ "CCBot",
126
+ "Bytespider",
127
+ "meta-externalagent"
128
+ ];
129
+
130
+ // src/audit.ts
131
+ function auditPage(page, config = {}) {
132
+ const findings = [];
133
+ const at = (code, severity, message, extra = {}) => findings.push({ code, severity, route: page.route, message, ...extra });
134
+ if (!page.title) at("title.missing", "error", "Page has no <title>.");
135
+ else if (page.title.length > 65)
136
+ at("title.long", "info", "Title is long enough that it will likely be truncated in results.", {
137
+ after: page.title.length
138
+ });
139
+ if (!page.description) at("description.missing", "warn", "Page has no meta description.");
140
+ if (!page.canonical) at("canonical.missing", "error", "Page has no canonical URL.");
141
+ if (page.robots?.includes("noindex")) at("robots.noindex", "warn", "Page is marked noindex.");
142
+ if (page.h1.length === 0) at("h1.missing", "error", "Page has no <h1>.");
143
+ else if (page.h1.length > 1)
144
+ at("h1.multiple", "warn", "Page has more than one <h1>.", { after: page.h1 });
145
+ if (!page.og["og:title"]) at("og.title.missing", "warn", "Missing og:title.");
146
+ if (!page.og["og:image"]) at("og.image.missing", "warn", "Missing og:image.");
147
+ if (page.jsonLd.length === 0)
148
+ at("jsonld.missing", "warn", "Page has no JSON-LD structured data.");
149
+ for (const entity of page.jsonLd) {
150
+ if (entity.type === "__parse_error__") {
151
+ at("jsonld.invalid", "error", "A JSON-LD block failed to parse.");
152
+ continue;
153
+ }
154
+ const rule = RICH_RESULT_RULES[entity.type];
155
+ if (!rule) continue;
156
+ const present = new Set(entity.properties);
157
+ const missing = rule.required.filter((p) => !present.has(p));
158
+ if (missing.length > 0) {
159
+ at(
160
+ "jsonld.required.missing",
161
+ "error",
162
+ `${entity.type} is missing required ${missing.length === 1 ? "property" : "properties"}: ${missing.join(", ")}.`,
163
+ { after: entity.properties }
164
+ );
165
+ }
166
+ for (const group of rule.oneOf ?? []) {
167
+ if (!group.some((p) => present.has(p))) {
168
+ at(
169
+ "jsonld.oneof.missing",
170
+ "error",
171
+ `${entity.type} needs at least one of: ${group.join(", ")}.`
172
+ );
173
+ }
174
+ }
175
+ const missingRecommended = rule.recommended.filter((p) => !present.has(p));
176
+ if (missingRecommended.length > 0) {
177
+ at(
178
+ "jsonld.recommended.missing",
179
+ "info",
180
+ `${entity.type} is missing recommended: ${missingRecommended.join(", ")}.`
181
+ );
182
+ }
183
+ }
184
+ const minWords = config.minWordCount ?? 150;
185
+ if (page.wordCount < minWords)
186
+ at("content.thin", "warn", `Page has fewer than ${minWords} words.`, {
187
+ after: page.wordCount
188
+ });
189
+ if (page.leadAnswerWords === 0)
190
+ at("aeo.lead.missing", "warn", "No substantive opening paragraph for an answer engine to quote.");
191
+ else if (page.leadAnswerWords > 120)
192
+ at("aeo.lead.long", "info", "Opening paragraph is long; under ~80 words extracts better.", {
193
+ after: page.leadAnswerWords
194
+ });
195
+ if (page.images.missingAlt > 0)
196
+ at("images.alt.missing", "warn", "Page has images with no alt attribute.", {
197
+ after: { missingAlt: page.images.missingAlt, total: page.images.total }
198
+ });
199
+ return findings;
200
+ }
201
+ function auditSite(snapshot) {
202
+ const findings = [];
203
+ const { site } = snapshot;
204
+ if (!site.robotsTxt?.present) {
205
+ findings.push({ code: "robotstxt.missing", severity: "warn", route: null, message: "No robots.txt found." });
206
+ } else {
207
+ const blocked = Object.entries(site.robotsTxt.aiAgents).filter(([, state]) => state === "disallowed").map(([agent]) => agent);
208
+ if (blocked.length > 0) {
209
+ findings.push({
210
+ code: "aeo.crawler.blocked",
211
+ severity: "info",
212
+ route: null,
213
+ message: `robots.txt blocks ${blocked.length} AI crawler(s): ${blocked.join(", ")}.`,
214
+ after: blocked
215
+ });
216
+ }
217
+ if (site.robotsTxt.sitemaps.length === 0) {
218
+ findings.push({
219
+ code: "robotstxt.sitemap.missing",
220
+ severity: "warn",
221
+ route: null,
222
+ message: "robots.txt does not declare a sitemap."
223
+ });
224
+ }
225
+ }
226
+ if (!site.llmsTxt?.present) {
227
+ findings.push({
228
+ code: "aeo.llmstxt.missing",
229
+ severity: "info",
230
+ route: null,
231
+ message: "No /llms.txt found."
232
+ });
233
+ }
234
+ return findings;
235
+ }
236
+ function originOf(href) {
237
+ try {
238
+ return new URL(href).origin;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+ var VARIANT_SUFFIX = /(?:\/(?:page|p)\/\d+|\/amp)\/?$/i;
244
+ var AMP_PREFIX = /^\/amp(?=\/)/i;
245
+ function isVariantOf(route, target) {
246
+ return [route.replace(VARIANT_SUFFIX, ""), route.replace(AMP_PREFIX, "")].filter((stripped) => stripped !== route).some((stripped) => (stripped === "" ? "/" : stripped) === target);
247
+ }
248
+ function pathOf(href) {
249
+ try {
250
+ const path = new URL(href, "https://placeholder.invalid").pathname.replace(/\/+$/, "");
251
+ return path === "" ? "/" : path;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+ function auditCrossPage(snapshot) {
257
+ const findings = [];
258
+ const pages = Object.values(snapshot.pages);
259
+ const group = (key) => {
260
+ const map = /* @__PURE__ */ new Map();
261
+ for (const page of pages) {
262
+ const value = key(page);
263
+ if (value === null || value === void 0 || value === "") continue;
264
+ if (!map.has(value)) map.set(value, []);
265
+ map.get(value).push(page.route);
266
+ }
267
+ return map;
268
+ };
269
+ for (const [title, routes] of group((p) => p.title)) {
270
+ if (routes.length > 1) {
271
+ findings.push({
272
+ code: "duplicate.title",
273
+ severity: "warn",
274
+ route: null,
275
+ message: `${routes.length} pages share the title "${title}".`,
276
+ after: routes
277
+ });
278
+ }
279
+ }
280
+ for (const [, routes] of group((p) => p.description)) {
281
+ if (routes.length > 1) {
282
+ findings.push({
283
+ code: "duplicate.description",
284
+ severity: "warn",
285
+ route: null,
286
+ message: `${routes.length} pages share the same meta description.`,
287
+ after: routes
288
+ });
289
+ }
290
+ }
291
+ for (const [canonical, routes] of group((p) => p.canonical)) {
292
+ if (routes.length > 1) {
293
+ findings.push({
294
+ code: "duplicate.canonical",
295
+ severity: "error",
296
+ route: null,
297
+ message: `${routes.length} pages canonicalise to ${canonical}.`,
298
+ after: routes
299
+ });
300
+ }
301
+ }
302
+ const expectedOrigin = snapshot.site.origin ?? null;
303
+ for (const page of pages) {
304
+ if (!page.canonical) continue;
305
+ if (expectedOrigin !== null) {
306
+ const host = originOf(page.canonical);
307
+ if (host !== null && host !== expectedOrigin) {
308
+ findings.push({
309
+ code: "canonical.offsite",
310
+ severity: "error",
311
+ route: page.route,
312
+ message: "Canonical points at another host.",
313
+ before: expectedOrigin,
314
+ after: page.canonical
315
+ });
316
+ continue;
317
+ }
318
+ }
319
+ const normalized = pathOf(page.canonical);
320
+ if (normalized === null || normalized === page.route) continue;
321
+ if (isVariantOf(page.route, normalized)) continue;
322
+ findings.push({
323
+ code: "canonical.crosspath",
324
+ severity: "warn",
325
+ route: page.route,
326
+ message: "Canonical points to a different path.",
327
+ before: page.route,
328
+ after: page.canonical
329
+ });
330
+ }
331
+ return findings;
332
+ }
333
+ var LANG_TAG = /^[a-z]{2,3}(-[a-zA-Z0-9]{2,8})*$/i;
334
+ function auditHreflang(snapshot) {
335
+ const findings = [];
336
+ const pages = Object.values(snapshot.pages);
337
+ const annotated = pages.filter((p) => Object.keys(p.hreflang).length > 0);
338
+ if (annotated.length === 0) return findings;
339
+ const byRoute = new Map(pages.map((p) => [p.route, p]));
340
+ const claimed = /* @__PURE__ */ new Set();
341
+ for (const page of annotated) {
342
+ for (const href of Object.values(page.hreflang)) {
343
+ const target = pathOf(href);
344
+ if (target !== null && target !== page.route) claimed.add(target);
345
+ }
346
+ }
347
+ for (const page of pages) {
348
+ const entries = Object.entries(page.hreflang);
349
+ if (entries.length === 0) {
350
+ if (claimed.has(page.route)) {
351
+ findings.push({
352
+ code: "hreflang.missing",
353
+ severity: "warn",
354
+ route: page.route,
355
+ message: "Page is named as an hreflang alternate but declares none of its own."
356
+ });
357
+ }
358
+ continue;
359
+ }
360
+ const invalid = entries.map(([lang]) => lang).filter((lang) => lang !== "x-default" && !LANG_TAG.test(lang));
361
+ if (invalid.length > 0) {
362
+ findings.push({
363
+ code: "hreflang.invalid",
364
+ severity: "warn",
365
+ route: page.route,
366
+ message: "Page has malformed hreflang language codes.",
367
+ after: invalid
368
+ });
369
+ }
370
+ const targets = [
371
+ ...new Set(
372
+ entries.map(([, href]) => pathOf(href)).filter((p) => p !== null)
373
+ )
374
+ ];
375
+ if (!targets.includes(page.route)) {
376
+ findings.push({
377
+ code: "hreflang.self.missing",
378
+ severity: "warn",
379
+ route: page.route,
380
+ message: "Page does not include a self-referencing hreflang."
381
+ });
382
+ }
383
+ if (!entries.some(([lang]) => lang === "x-default")) {
384
+ findings.push({
385
+ code: "hreflang.xdefault.missing",
386
+ severity: "info",
387
+ route: page.route,
388
+ message: "Page has hreflang alternates but no x-default."
389
+ });
390
+ }
391
+ const broken = [];
392
+ for (const target of targets) {
393
+ if (target === page.route) continue;
394
+ const other = byRoute.get(target);
395
+ if (!other) continue;
396
+ const returns = Object.values(other.hreflang).map(pathOf).includes(page.route);
397
+ if (!returns) broken.push(target);
398
+ }
399
+ if (broken.length > 0) {
400
+ findings.push({
401
+ code: "hreflang.nonreciprocal",
402
+ severity: "error",
403
+ route: page.route,
404
+ message: "Page points at alternates that do not point back.",
405
+ after: broken
406
+ });
407
+ }
408
+ for (const target of targets) {
409
+ if (target === page.route) continue;
410
+ const other = byRoute.get(target);
411
+ if (other?.robots?.includes("noindex")) {
412
+ findings.push({
413
+ code: "hreflang.noindex.target",
414
+ severity: "error",
415
+ route: page.route,
416
+ message: "Page declares an hreflang alternate that is noindexed.",
417
+ after: target
418
+ });
419
+ }
420
+ }
421
+ }
422
+ return findings;
423
+ }
424
+ function auditSnapshot(snapshot, config = {}) {
425
+ return [
426
+ ...auditSite(snapshot),
427
+ ...auditCrossPage(snapshot),
428
+ ...auditHreflang(snapshot),
429
+ ...Object.values(snapshot.pages).flatMap((page) => auditPage(page, config))
430
+ ];
431
+ }
432
+
433
+ // src/diff.ts
434
+ function transition(before, after) {
435
+ if (before === after) return "unchanged";
436
+ if (before === null) return "added";
437
+ if (after === null) return "removed";
438
+ return "changed";
439
+ }
440
+ var SCALAR_FIELDS = [
441
+ { field: "title", label: "Title", code: "title", onRemoved: "error", onChanged: "info", onAdded: "info" },
442
+ { field: "description", label: "Meta description", code: "description", onRemoved: "warn", onChanged: "info", onAdded: "info" },
443
+ { field: "canonical", label: "Canonical", code: "canonical", onRemoved: "error", onChanged: "warn", onAdded: "info" }
444
+ ];
445
+ function indexEntities(entities) {
446
+ const map = /* @__PURE__ */ new Map();
447
+ const seen = /* @__PURE__ */ new Map();
448
+ for (const entity of entities) {
449
+ if (entity.id !== void 0 && !map.has(entity.id)) {
450
+ map.set(entity.id, entity);
451
+ continue;
452
+ }
453
+ const nth = (seen.get(entity.type) ?? 0) + 1;
454
+ seen.set(entity.type, nth);
455
+ map.set(`${entity.type}#${nth}`, entity);
456
+ }
457
+ return map;
458
+ }
459
+ function diffPage(before, after) {
460
+ const findings = [];
461
+ const route = after.route;
462
+ const push = (code, severity, message, extra = {}) => findings.push({ code, severity, route, message, ...extra });
463
+ for (const rule of SCALAR_FIELDS) {
464
+ const b = before[rule.field];
465
+ const a = after[rule.field];
466
+ switch (transition(b, a)) {
467
+ case "removed":
468
+ push(`${rule.code}.removed`, rule.onRemoved, `${rule.label} was removed.`, { before: b });
469
+ break;
470
+ case "added":
471
+ push(`${rule.code}.added`, rule.onAdded, `${rule.label} was added.`, { after: a });
472
+ break;
473
+ case "changed":
474
+ push(`${rule.code}.changed`, rule.onChanged, `${rule.label} changed.`, { before: b, after: a });
475
+ break;
476
+ }
477
+ }
478
+ const wasNoindex = before.robots?.includes("noindex") ?? false;
479
+ const isNoindex = after.robots?.includes("noindex") ?? false;
480
+ if (!wasNoindex && isNoindex)
481
+ push("robots.noindex.added", "error", "Page became noindex.", { before: before.robots, after: after.robots });
482
+ if (wasNoindex && !isNoindex)
483
+ push("robots.noindex.removed", "info", "Page is no longer noindex.", { before: before.robots });
484
+ const wasNofollow = before.robots?.includes("nofollow") ?? false;
485
+ const isNofollow = after.robots?.includes("nofollow") ?? false;
486
+ if (!wasNofollow && isNofollow)
487
+ push("robots.nofollow.added", "warn", "Page became nofollow.", { after: after.robots });
488
+ const beforeEntities = indexEntities(before.jsonLd);
489
+ const afterEntities = indexEntities(after.jsonLd);
490
+ for (const [key, entity] of beforeEntities) {
491
+ if (!afterEntities.has(key)) {
492
+ push("jsonld.entity.removed", "error", `Structured data entity ${entity.type} was removed.`, {
493
+ before: entity
494
+ });
495
+ }
496
+ }
497
+ for (const [key, entity] of afterEntities) {
498
+ if (!beforeEntities.has(key)) {
499
+ push("jsonld.entity.added", "info", `Structured data entity ${entity.type} was added.`, { after: entity });
500
+ continue;
501
+ }
502
+ const prev = beforeEntities.get(key);
503
+ const dropped = prev.properties.filter((p) => !entity.properties.includes(p));
504
+ if (dropped.length > 0) {
505
+ push("jsonld.property.removed", "error", `${entity.type} lost structured data properties.`, {
506
+ before: prev.properties,
507
+ after: entity.properties
508
+ });
509
+ }
510
+ }
511
+ for (const [group, label] of [
512
+ ["og", "Open Graph"],
513
+ ["twitter", "Twitter Card"]
514
+ ]) {
515
+ const b = before[group];
516
+ const a = after[group];
517
+ const dropped = Object.keys(b).filter((k) => !(k in a));
518
+ if (dropped.length > 0)
519
+ push(`${group}.removed`, "warn", `${label} tags were removed.`, { before: dropped });
520
+ }
521
+ const droppedHreflang = Object.keys(before.hreflang).filter((k) => !(k in after.hreflang));
522
+ if (droppedHreflang.length > 0)
523
+ push("hreflang.removed", "warn", "hreflang alternates were removed.", {
524
+ before: droppedHreflang
525
+ });
526
+ if (before.h1.length > 0 && after.h1.length === 0)
527
+ push("h1.removed", "error", "The <h1> was removed.", { before: before.h1 });
528
+ if (before.headingOutline.join(">") !== after.headingOutline.join(">"))
529
+ push("headings.changed", "info", "Heading outline changed.", {
530
+ before: before.headingOutline.length,
531
+ after: after.headingOutline.length
532
+ });
533
+ if (before.wordCount > 0) {
534
+ const ratio = after.wordCount / before.wordCount;
535
+ if (ratio < 0.5)
536
+ push("content.dropped", "error", "Word count fell by more than half.", {
537
+ before: before.wordCount,
538
+ after: after.wordCount
539
+ });
540
+ }
541
+ return findings;
542
+ }
543
+ function diffSite(before, after) {
544
+ const findings = [];
545
+ const push = (code, severity, message, extra = {}) => findings.push({ code, severity, route: null, message, ...extra });
546
+ if (before.robotsTxt?.present && !after.robotsTxt?.present)
547
+ push("robotstxt.removed", "error", "robots.txt disappeared.");
548
+ if (before.robotsTxt && after.robotsTxt) {
549
+ for (const [agent, state] of Object.entries(before.robotsTxt.aiAgents)) {
550
+ const next = after.robotsTxt.aiAgents[agent];
551
+ if (state === "allowed" && next === "disallowed")
552
+ push("aeo.crawler.newly_blocked", "error", `robots.txt now blocks ${agent}.`, { after: agent });
553
+ if (state === "disallowed" && next === "allowed")
554
+ push("aeo.crawler.unblocked", "info", `robots.txt now allows ${agent}.`, { after: agent });
555
+ }
556
+ const droppedSitemaps = before.robotsTxt.sitemaps.filter(
557
+ (s) => !after.robotsTxt.sitemaps.includes(s)
558
+ );
559
+ if (droppedSitemaps.length > 0)
560
+ push("robotstxt.sitemap.removed", "warn", `Sitemap declaration removed: ${droppedSitemaps.join(", ")}.`);
561
+ }
562
+ if (before.llmsTxt?.present && !after.llmsTxt?.present)
563
+ push("aeo.llmstxt.removed", "error", "/llms.txt disappeared.");
564
+ if (before.llmsTxt?.present && after.llmsTxt?.present) {
565
+ const dropped = before.llmsTxt.sections.filter((s) => !after.llmsTxt.sections.includes(s));
566
+ if (dropped.length > 0)
567
+ push("aeo.llmstxt.sections.removed", "warn", `llms.txt sections removed: ${dropped.join(", ")}.`);
568
+ if (after.llmsTxt.bytes < before.llmsTxt.bytes * 0.5)
569
+ push("aeo.llmstxt.truncated", "warn", "llms.txt shrank by more than half.", {
570
+ before: before.llmsTxt.bytes,
571
+ after: after.llmsTxt.bytes
572
+ });
573
+ }
574
+ return findings;
575
+ }
576
+ function diffSnapshots(before, after) {
577
+ const findings = diffSite(before.site, after.site);
578
+ for (const route of Object.keys(before.pages)) {
579
+ if (!(route in after.pages)) {
580
+ findings.push({
581
+ code: "page.removed",
582
+ severity: "warn",
583
+ route,
584
+ message: "Page is no longer present."
585
+ });
586
+ }
587
+ }
588
+ for (const [route, page] of Object.entries(after.pages)) {
589
+ const previous = before.pages[route];
590
+ if (!previous) {
591
+ findings.push({ code: "page.added", severity: "info", route, message: "New page." });
592
+ continue;
593
+ }
594
+ findings.push(...diffPage(previous, page));
595
+ }
596
+ return findings;
597
+ }
598
+
599
+ // src/rules/guidance.ts
600
+ var GUIDANCE = {
601
+ "title.missing": {
602
+ why: "The title is the strongest on-page ranking signal and the clickable line in results. Without one, search engines invent a title from page content, usually badly.",
603
+ fix: "Add a unique <title> of roughly 50-60 characters that leads with the primary term.",
604
+ byPlatform: {
605
+ wordpress: "Set the SEO title in Yoast or Rank Math for this post, or fix the title template under the plugin's Search Appearance settings.",
606
+ nextjs: "Export `metadata.title` from the route segment, or set a `title.template` in the root layout."
607
+ }
608
+ },
609
+ "title.long": {
610
+ why: "Titles beyond roughly 60 characters get truncated in results, so the tail of the title does no work.",
611
+ fix: "Trim to under 60 characters, keeping the distinguishing words at the front."
612
+ },
613
+ "description.missing": {
614
+ why: "Without a meta description the engine writes its own snippet from page text, which is often a nav menu or boilerplate.",
615
+ fix: "Write a 140-160 character description that states what the page offers.",
616
+ byPlatform: {
617
+ wordpress: "Fill the meta description field in the Yoast or Rank Math box below the editor, or set a template for this post type.",
618
+ nextjs: "Add `description` to the route's exported `metadata` object."
619
+ }
620
+ },
621
+ "canonical.missing": {
622
+ why: "Without a canonical, duplicate URLs (query strings, pagination, tracking parameters, trailing-slash variants) compete against each other and split ranking signals.",
623
+ fix: "Emit a self-referencing canonical link on every indexable page.",
624
+ byPlatform: {
625
+ wordpress: "Yoast and Rank Math both output canonicals by default \u2014 this usually means the SEO plugin is inactive on this template, or a theme is stripping wp_head().",
626
+ nextjs: "Set `alternates.canonical` in the route's metadata."
627
+ }
628
+ },
629
+ "h1.missing": {
630
+ why: "The h1 tells both crawlers and answer engines what the page is about, and it anchors the document outline used for passage extraction.",
631
+ fix: "Add exactly one h1 that matches the page topic.",
632
+ byPlatform: {
633
+ wordpress: "Many themes render the post title as h2 inside archive templates. Check single.php or the block template for this post type."
634
+ }
635
+ },
636
+ "h1.multiple": {
637
+ why: "Multiple h1 elements make the document outline ambiguous, which weakens passage extraction for AI answers.",
638
+ fix: "Keep one h1 and demote the rest to h2."
639
+ },
640
+ "robots.noindex": {
641
+ why: "This page is explicitly excluded from search results. If that is unintentional it is invisible traffic loss.",
642
+ fix: "Remove the noindex directive if the page should rank.",
643
+ byPlatform: {
644
+ wordpress: "Check Settings \u2192 Reading for the site-wide discourage option, and the per-post Advanced tab in your SEO plugin."
645
+ }
646
+ },
647
+ "og.title.missing": {
648
+ why: "Without Open Graph tags, shared links render with whatever the platform can scrape, which is usually wrong.",
649
+ fix: "Add og:title, og:description, og:image and og:url.",
650
+ byPlatform: {
651
+ wordpress: "Enable social meta in Yoast (Social tab) or Rank Math, and set a site-wide fallback image."
652
+ }
653
+ },
654
+ "og.image.missing": {
655
+ why: "Links without og:image get a plain text card in messaging apps and social feeds, which measurably lowers click-through.",
656
+ fix: "Add an og:image of at least 1200x630."
657
+ },
658
+ "jsonld.missing": {
659
+ why: "Structured data is how you become eligible for rich results, and it is the most reliable signal answer engines use to identify entities on a page.",
660
+ fix: "Add JSON-LD appropriate to the page type \u2014 Article for posts, Product for products, LocalBusiness and Organization site-wide.",
661
+ byPlatform: {
662
+ wordpress: "Rank Math and Yoast both emit a schema graph. If it is absent, the plugin is off for this template or the theme is not calling wp_head()."
663
+ }
664
+ },
665
+ "jsonld.invalid": {
666
+ why: "A JSON-LD block that fails to parse is ignored entirely, so any valid markup in the same script tag is lost with it.",
667
+ fix: "Fix the JSON syntax \u2014 usually an unescaped quote or a trailing comma injected by a template."
668
+ },
669
+ "jsonld.required.missing": {
670
+ why: "Google will not show a rich result when a required property is absent, even though the rest of the markup is valid.",
671
+ fix: "Add the named properties. Verify with the Rich Results Test before shipping."
672
+ },
673
+ "jsonld.oneof.missing": {
674
+ why: "Some types need at least one of a group of properties to qualify for a rich result.",
675
+ fix: "Add one of the listed properties."
676
+ },
677
+ "jsonld.recommended.missing": {
678
+ why: "Recommended properties are not required, but they widen the rich result and give answer engines more to work with.",
679
+ fix: "Add them where you have the data."
680
+ },
681
+ "content.thin": {
682
+ why: "Short pages rarely rank for competitive terms and are almost never cited by answer engines, which need enough context to quote.",
683
+ fix: "Either expand the page substantively or consolidate it into a stronger one.",
684
+ byPlatform: {
685
+ wordpress: "Tag and category archives commonly trip this. Consider noindexing thin archives rather than padding them."
686
+ }
687
+ },
688
+ "images.alt.missing": {
689
+ why: "Missing alt text is both an accessibility failure and lost context \u2014 image search and multimodal crawlers rely on it.",
690
+ fix: 'Describe the image in alt, or use alt="" for purely decorative images so it is explicitly marked.',
691
+ byPlatform: {
692
+ wordpress: "Set alt text in the Media Library so it applies everywhere the image is reused."
693
+ }
694
+ },
695
+ "aeo.lead.missing": {
696
+ why: "Answer engines extract and quote the opening passage. A page that starts with a hero image, a nav block or a one-line teaser gives them nothing to lift.",
697
+ fix: "Open with a self-contained paragraph of 40-80 words that directly answers the page's implied question."
698
+ },
699
+ "aeo.lead.long": {
700
+ why: "A very long opening block gets chunked awkwardly and the quotable part may be split across chunks.",
701
+ fix: "Front-load a short direct answer, then expand below it."
702
+ },
703
+ "robotstxt.missing": {
704
+ why: "Without robots.txt you have no control over crawler access and no place to declare your sitemap.",
705
+ fix: "Add a robots.txt at the site root with a Sitemap line.",
706
+ byPlatform: {
707
+ wordpress: "WordPress serves a virtual robots.txt; a missing one usually means a plugin or the server is intercepting the request."
708
+ }
709
+ },
710
+ "robotstxt.sitemap.missing": {
711
+ why: "The sitemap declaration in robots.txt is the primary discovery path for crawlers that did not arrive through Search Console.",
712
+ fix: "Add a Sitemap line pointing at your sitemap index.",
713
+ byPlatform: {
714
+ wordpress: "WordPress core exposes /wp-sitemap.xml; Yoast and Rank Math replace it with their own. Declare whichever is live."
715
+ }
716
+ },
717
+ "aeo.crawler.blocked": {
718
+ why: "A blocked AI crawler cannot fetch your pages, so your site cannot be cited in that assistant's answers. This is sometimes deliberate \u2014 worth confirming it is.",
719
+ fix: "Remove the Disallow for agents you want citing you, and keep it for the ones you do not.",
720
+ byPlatform: {
721
+ wordpress: "Some security and SEO plugins add AI crawler blocks by default. Check the plugin that manages your robots.txt."
722
+ }
723
+ },
724
+ "aeo.llmstxt.missing": {
725
+ why: "llms.txt is an emerging convention giving assistants a curated map of your site. Adoption is still early, so treat this as an opportunity rather than a defect.",
726
+ fix: "Publish /llms.txt with a short site summary and links to your most important pages."
727
+ },
728
+ "hreflang.missing": {
729
+ why: "The rest of the site declares language alternates but this page does not, so search engines treat it as having no localised counterparts and may serve the wrong language version.",
730
+ fix: "Add the full set of hreflang links, including a self-reference.",
731
+ byPlatform: {
732
+ wordpress: "Usually a template the translation plugin does not cover. Check that WPML or Polylang is active for this post type.",
733
+ nextjs: "Set `alternates.languages` in the route's metadata, or generate it in the shared layout."
734
+ }
735
+ },
736
+ "hreflang.nonreciprocal": {
737
+ why: "Google requires hreflang annotations to be reciprocal. If page A points at B but B does not point back at A, the entire annotation is discarded \u2014 not just the one link \u2014 so the whole language cluster stops working.",
738
+ fix: "Make every page in a language group list every other page in that group, including itself."
739
+ },
740
+ "hreflang.self.missing": {
741
+ why: "Each page in an hreflang set should reference itself. Without it, some engines will not associate the page with its own language.",
742
+ fix: "Add an hreflang link pointing at this page's own URL with its own language code."
743
+ },
744
+ "hreflang.xdefault.missing": {
745
+ why: "x-default tells engines which version to serve to users whose language matches none of your alternates. Without it, that choice is made for you.",
746
+ fix: "Add an x-default link pointing at your default or language-selection page."
747
+ },
748
+ "hreflang.invalid": {
749
+ why: "A malformed language code makes the annotation invalid and it is ignored.",
750
+ fix: "Use ISO 639-1 language codes, optionally with an ISO 3166-1 Alpha 2 region \u2014 `en`, `ml`, `en-IN` \u2014 or `x-default`."
751
+ },
752
+ "hreflang.noindex.target": {
753
+ why: "An hreflang alternate that is noindexed cannot be served as a language variant, which invalidates that link in the cluster.",
754
+ fix: "Either remove the noindex from the target or drop it from the hreflang set."
755
+ },
756
+ "duplicate.title": {
757
+ why: "Identical titles across pages make them compete for the same queries and signal thin or templated content.",
758
+ fix: "Make each title unique, usually by including the distinguishing attribute of the page.",
759
+ byPlatform: {
760
+ wordpress: "Almost always a title template problem \u2014 check Search Appearance for the affected post type or archive."
761
+ }
762
+ },
763
+ "duplicate.description": {
764
+ why: "Repeated descriptions get discarded by search engines, which then write their own snippet.",
765
+ fix: "Vary the description per page, or leave it off and let the engine choose rather than repeating boilerplate."
766
+ },
767
+ "duplicate.canonical": {
768
+ why: "Several pages pointing at one canonical means those pages are declaring themselves duplicates and will not rank independently. Correct for pagination and filters, a serious bug elsewhere.",
769
+ fix: "Confirm each canonical is self-referencing unless consolidation is intended.",
770
+ byPlatform: {
771
+ wordpress: "A common symptom of a plugin canonicalising every archive page to the parent."
772
+ }
773
+ },
774
+ "canonical.offsite": {
775
+ why: "The canonical points at a different host, which tells search engines to index that host instead of this one. A staging or CDN hostname leaking into canonicals removes the live site from results.",
776
+ fix: "Point canonicals at the production origin. If the content is deliberately syndicated from another domain, this is correct and the rule can be switched off in config.",
777
+ byPlatform: {
778
+ wordpress: "Check the Site Address (URL) setting, and any WP_HOME or WP_SITEURL override in wp-config.php, on the environment that built this.",
779
+ nextjs: "Check `metadataBase` \u2014 a wrong or missing value makes every relative canonical resolve against the wrong origin."
780
+ }
781
+ },
782
+ "canonical.crosspath": {
783
+ why: "The canonical points at a different path than the page itself, so this URL is asking not to be indexed in favour of another.",
784
+ fix: "Verify the target is correct. If this page should rank on its own, make the canonical self-referencing."
785
+ }
786
+ };
787
+ function detectPlatform(generators, urls = []) {
788
+ const gen = generators.filter(Boolean).join(" ").toLowerCase();
789
+ if (gen.includes("wordpress")) return "wordpress";
790
+ if (gen.includes("drupal")) return "drupal";
791
+ if (gen.includes("wix")) return "wix";
792
+ if (gen.includes("squarespace")) return "squarespace";
793
+ if (gen.includes("webflow")) return "webflow";
794
+ if (gen.includes("shopify")) return "shopify";
795
+ if (gen.includes("next.js")) return "nextjs";
796
+ const joined = urls.join(" ").toLowerCase();
797
+ if (joined.includes("/wp-content/") || joined.includes("/wp-json/")) return "wordpress";
798
+ if (joined.includes("/_next/")) return "nextjs";
799
+ if (joined.includes("cdn.shopify.com")) return "shopify";
800
+ return "unknown";
801
+ }
802
+ function withGuidance(finding, platform = "unknown") {
803
+ const guidance = GUIDANCE[finding.code];
804
+ if (!guidance) return finding;
805
+ return {
806
+ ...finding,
807
+ detail: guidance.why,
808
+ fix: guidance.byPlatform?.[platform] ?? guidance.fix
809
+ };
810
+ }
811
+
812
+ // src/report.ts
813
+ var import_picocolors = __toESM(require("picocolors"), 1);
814
+ var ORDER = { error: 0, warn: 1, info: 2 };
815
+ function applyConfig(findings, config = {}) {
816
+ const overrides = config.severity ?? {};
817
+ const out = [];
818
+ for (const finding of findings) {
819
+ const override = overrides[finding.code];
820
+ if (override === "off") continue;
821
+ out.push(override ? { ...finding, severity: override } : finding);
822
+ }
823
+ return out.sort(
824
+ (a, b) => ORDER[a.severity] - ORDER[b.severity] || (a.route ?? "").localeCompare(b.route ?? "")
825
+ );
826
+ }
827
+ function summarize(findings) {
828
+ return {
829
+ error: findings.filter((f) => f.severity === "error").length,
830
+ warn: findings.filter((f) => f.severity === "warn").length,
831
+ info: findings.filter((f) => f.severity === "info").length
832
+ };
833
+ }
834
+ function shouldFail(findings, failOn) {
835
+ const threshold = ORDER[failOn];
836
+ if (threshold === void 0) {
837
+ throw new Error(`Unknown severity "${failOn}". Expected one of: error, warn, info.`);
838
+ }
839
+ return findings.some((f) => ORDER[f.severity] <= threshold);
840
+ }
841
+ var BADGE = {
842
+ error: (s) => import_picocolors.default.red(s),
843
+ warn: (s) => import_picocolors.default.yellow(s),
844
+ info: (s) => import_picocolors.default.dim(s)
845
+ };
846
+ function formatPretty(findings) {
847
+ if (findings.length === 0) return import_picocolors.default.green("No SEO/AEO changes or issues found.");
848
+ const byRoute = /* @__PURE__ */ new Map();
849
+ for (const finding of findings) {
850
+ const key = finding.route ?? "(site-wide)";
851
+ if (!byRoute.has(key)) byRoute.set(key, []);
852
+ byRoute.get(key).push(finding);
853
+ }
854
+ const lines = [];
855
+ for (const [route, group] of byRoute) {
856
+ lines.push(import_picocolors.default.bold(route));
857
+ for (const f of group) {
858
+ lines.push(` ${BADGE[f.severity](f.severity.padEnd(5))} ${f.message} ${import_picocolors.default.dim(f.code)}`);
859
+ }
860
+ lines.push("");
861
+ }
862
+ const s = summarize(findings);
863
+ lines.push(`${s.error} error, ${s.warn} warning, ${s.info} info`);
864
+ return lines.join("\n");
865
+ }
866
+ function formatJson(findings) {
867
+ return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
868
+ }
869
+ var escapeCell = (value) => value.replace(/\|/g, "\\|");
870
+ function formatMarkdown(findings) {
871
+ const s = summarize(findings);
872
+ if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
873
+ const rows = findings.map(
874
+ (f) => `| ${f.severity} | \`${escapeCell(f.route ?? "\u2014")}\` | ${escapeCell(f.message)} | \`${f.code}\` |`
875
+ );
876
+ return [
877
+ "### pagetrace",
878
+ "",
879
+ `${s.error} error \xB7 ${s.warn} warning \xB7 ${s.info} info`,
880
+ "",
881
+ "| Severity | Route | Finding | Code |",
882
+ "| --- | --- | --- | --- |",
883
+ ...rows
884
+ ].join("\n");
885
+ }
886
+ function formatGithub(findings) {
887
+ return findings.filter((f) => f.severity !== "info").map((f) => {
888
+ const level = f.severity === "error" ? "error" : "warning";
889
+ return `::${level} title=${f.code}::${f.route ?? "site"} \u2014 ${f.message}`;
890
+ }).join("\n");
891
+ }
892
+ function aggregate(findings) {
893
+ const map = /* @__PURE__ */ new Map();
894
+ for (const finding of findings) {
895
+ const key = `${finding.code}::${finding.message}`;
896
+ const existing = map.get(key);
897
+ if (existing) {
898
+ existing.count += 1;
899
+ if (finding.route) existing.routes.push(finding.route);
900
+ continue;
901
+ }
902
+ map.set(key, {
903
+ code: finding.code,
904
+ severity: finding.severity,
905
+ count: 1,
906
+ routes: finding.route ? [finding.route] : [],
907
+ message: finding.message,
908
+ detail: finding.detail,
909
+ fix: finding.fix
910
+ });
911
+ }
912
+ return [...map.values()].sort(
913
+ (a, b) => ORDER[a.severity] - ORDER[b.severity] || b.count - a.count
914
+ );
915
+ }
916
+ var PLATFORM_LABEL = {
917
+ wordpress: "WordPress",
918
+ nextjs: "Next.js",
919
+ shopify: "Shopify",
920
+ webflow: "Webflow",
921
+ wix: "Wix",
922
+ squarespace: "Squarespace",
923
+ drupal: "Drupal",
924
+ unknown: "Unknown platform"
925
+ };
926
+ function isTemplateWide(group, pageCount) {
927
+ return pageCount >= 5 && group.routes.length >= Math.ceil(pageCount * 0.8);
928
+ }
929
+ function countIssues(groups) {
930
+ const blank = () => ({ error: 0, warn: 0, info: 0 });
931
+ const issues = blank();
932
+ const instances = blank();
933
+ for (const group of groups) {
934
+ issues[group.severity] += 1;
935
+ instances[group.severity] += group.count;
936
+ }
937
+ return { issues, instances, total: groups.length };
938
+ }
939
+ function sampleRoutes(routes, limit = 5) {
940
+ if (routes.length === 0) return "site-wide";
941
+ const shown = routes.slice(0, limit).join(", ");
942
+ return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
943
+ }
944
+ function formatAuditPretty(groups, meta) {
945
+ const lines = [
946
+ import_picocolors.default.bold(meta.target),
947
+ import_picocolors.default.dim(`${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages \xB7 ${meta.generatedAt}`),
948
+ ""
949
+ ];
950
+ if (groups.length === 0) {
951
+ lines.push(import_picocolors.default.green("No issues found."));
952
+ return lines.join("\n");
953
+ }
954
+ for (const group of groups) {
955
+ const scope = isTemplateWide(group, meta.pageCount) ? import_picocolors.default.dim(`(${group.count} pages \u2014 one template fix)`) : import_picocolors.default.dim(`(${group.count})`);
956
+ lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${import_picocolors.default.bold(group.message)} ${scope}`);
957
+ if (group.detail) lines.push(` ${group.detail}`);
958
+ if (group.fix) lines.push(` ${import_picocolors.default.cyan("Fix:")} ${group.fix}`);
959
+ if (group.routes.length > 0) lines.push(` ${import_picocolors.default.dim(sampleRoutes(group.routes))}`);
960
+ lines.push("");
961
+ }
962
+ const { issues, instances, total } = countIssues(groups);
963
+ lines.push(
964
+ `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
965
+ );
966
+ lines.push(
967
+ import_picocolors.default.dim(
968
+ `across ${instances.error + instances.warn + instances.info} page findings on ${meta.pageCount} pages`
969
+ )
970
+ );
971
+ return lines.join("\n");
972
+ }
973
+ function formatAuditMarkdown(groups, meta) {
974
+ const lines = [
975
+ `# SEO & AEO audit \u2014 ${meta.target}`,
976
+ "",
977
+ `${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages crawled \xB7 ${meta.generatedAt}`,
978
+ ""
979
+ ];
980
+ if (groups.length === 0) {
981
+ lines.push("No issues found.");
982
+ return lines.join("\n");
983
+ }
984
+ for (const group of groups) {
985
+ lines.push(`## ${group.message}`, "");
986
+ const scope = isTemplateWide(group, meta.pageCount) ? `affects ${group.count} pages \u2014 one template fix` : `affects ${group.count} page${group.count === 1 ? "" : "s"}`;
987
+ lines.push(`**${group.severity.toUpperCase()}** \xB7 ${scope} \xB7 \`${group.code}\``, "");
988
+ if (group.detail) lines.push(group.detail, "");
989
+ if (group.fix) lines.push(`**Fix.** ${group.fix}`, "");
990
+ if (group.routes.length > 0) {
991
+ lines.push("<details><summary>Affected pages</summary>", "");
992
+ for (const route of group.routes.slice(0, 50)) lines.push(`- \`${route}\``);
993
+ if (group.routes.length > 50) lines.push(`- \u2026and ${group.routes.length - 50} more`);
994
+ lines.push("", "</details>", "");
995
+ }
996
+ }
997
+ return lines.join("\n");
998
+ }
999
+ var escapeHtml = (value) => value.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
1000
+ function formatAuditHtml(groups, meta) {
1001
+ const { issues } = countIssues(groups);
1002
+ const cards = groups.map((group) => {
1003
+ const scope = isTemplateWide(group, meta.pageCount) ? `<span class="tmpl">template-wide</span>` : "";
1004
+ const routes = group.routes.length > 0 ? `<details><summary>${group.routes.length} affected page${group.routes.length === 1 ? "" : "s"}</summary><ul>${group.routes.slice(0, 100).map((r) => `<li><code>${escapeHtml(r)}</code></li>`).join("")}</ul></details>` : "";
1005
+ return `<article class="f ${group.severity}">
1006
+ <header><span class="sev">${group.severity}</span><h2>${escapeHtml(group.message)}</h2>${scope}<span class="count">${group.count}</span></header>
1007
+ ${group.detail ? `<p>${escapeHtml(group.detail)}</p>` : ""}
1008
+ ${group.fix ? `<p class="fix"><strong>Fix.</strong> ${escapeHtml(group.fix)}</p>` : ""}
1009
+ ${routes}
1010
+ <code class="code">${escapeHtml(group.code)}</code>
1011
+ </article>`;
1012
+ }).join("\n");
1013
+ return `<!doctype html>
1014
+ <html lang="en"><head><meta charset="utf-8">
1015
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1016
+ <title>SEO &amp; AEO audit \u2014 ${escapeHtml(meta.target)}</title>
1017
+ <style>
1018
+ :root{--fg:#16181d;--muted:#6b7280;--line:#e5e7eb;--err:#b42318;--warn:#b54708;--info:#475467;--bg:#fff}
1019
+ *{box-sizing:border-box}
1020
+ body{margin:0;padding:48px 24px;font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--fg);background:var(--bg)}
1021
+ main{max-width:820px;margin:0 auto}
1022
+ h1{font-size:28px;margin:0 0 6px;letter-spacing:-.02em}
1023
+ .meta{color:var(--muted);font-size:14px;margin:0 0 28px}
1024
+ .totals{display:flex;gap:12px;margin:0 0 36px;padding:0;list-style:none}
1025
+ .totals li{flex:1;border:1px solid var(--line);border-radius:10px;padding:14px 16px}
1026
+ .totals b{display:block;font-size:26px;line-height:1.2}
1027
+ .totals span{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.06em}
1028
+ .f{border:1px solid var(--line);border-left-width:4px;border-radius:10px;padding:18px 20px;margin:0 0 16px}
1029
+ .f.error{border-left-color:var(--err)} .f.warn{border-left-color:var(--warn)} .f.info{border-left-color:var(--info)}
1030
+ .f header{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
1031
+ .f h2{font-size:17px;margin:0;flex:1;letter-spacing:-.01em}
1032
+ .sev{font-size:11px;text-transform:uppercase;letter-spacing:.08em;font-weight:700}
1033
+ .error .sev{color:var(--err)} .warn .sev{color:var(--warn)} .info .sev{color:var(--info)}
1034
+ .count{font-variant-numeric:tabular-nums;color:var(--muted);font-size:14px}
1035
+ .tmpl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);border:1px solid var(--line);border-radius:99px;padding:2px 8px}
1036
+ .f p{margin:0 0 10px;font-size:15px}
1037
+ .fix{color:#065f46}
1038
+ details{font-size:14px;margin:10px 0}
1039
+ summary{cursor:pointer;color:var(--muted)}
1040
+ details ul{margin:8px 0 0;padding-left:20px;max-height:260px;overflow:auto}
1041
+ .code{font-size:12px;color:var(--muted)}
1042
+ footer{margin-top:40px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);padding-top:16px}
1043
+ </style></head>
1044
+ <body><main>
1045
+ <h1>SEO &amp; AEO audit</h1>
1046
+ <p class="meta">${escapeHtml(meta.target)} \xB7 ${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages crawled \xB7 ${escapeHtml(meta.generatedAt)}</p>
1047
+ <ul class="totals">
1048
+ <li><b>${issues.error}</b><span>Errors</span></li>
1049
+ <li><b>${issues.warn}</b><span>Warnings</span></li>
1050
+ <li><b>${issues.info}</b><span>Notes</span></li>
1051
+ </ul>
1052
+ ${cards || "<p>No issues found.</p>"}
1053
+ <footer>Generated by pagetrace. Findings are heuristic; verify structured data with Google&rsquo;s Rich Results Test before shipping fixes.</footer>
1054
+ </main></body></html>`;
1055
+ }
1056
+
1057
+ // src/snapshot.ts
1058
+ var import_promises = require("fs/promises");
1059
+ var import_node_path = require("path");
1060
+
1061
+ // src/extract.ts
1062
+ var import_node_html_parser = require("node-html-parser");
1063
+ var HEADING_TAGS = /* @__PURE__ */ new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
1064
+ var NON_CONTENT = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
1065
+ function text(el) {
1066
+ if (!el) return null;
1067
+ const value = el.textContent.replace(/\s+/g, " ").trim();
1068
+ return value.length > 0 ? value : null;
1069
+ }
1070
+ function metaContent(root, name) {
1071
+ for (const el of root.querySelectorAll("meta")) {
1072
+ if (el.getAttribute("name")?.trim().toLowerCase() !== name) continue;
1073
+ const value = el.getAttribute("content")?.trim();
1074
+ if (value) return value;
1075
+ }
1076
+ return null;
1077
+ }
1078
+ function hasRel(el, rel) {
1079
+ const value = el.getAttribute("rel");
1080
+ if (!value) return false;
1081
+ return value.trim().toLowerCase().split(/\s+/).includes(rel);
1082
+ }
1083
+ function linkHref(root, rel) {
1084
+ for (const el of root.querySelectorAll("link")) {
1085
+ if (!hasRel(el, rel)) continue;
1086
+ const href = el.getAttribute("href")?.trim();
1087
+ if (href) return href;
1088
+ }
1089
+ return null;
1090
+ }
1091
+ function metaGroup(root, prefix) {
1092
+ const out = {};
1093
+ for (const el of root.querySelectorAll("meta")) {
1094
+ const key = el.getAttribute("property") ?? el.getAttribute("name");
1095
+ if (!key || !key.toLowerCase().startsWith(`${prefix}:`)) continue;
1096
+ const content = el.getAttribute("content")?.trim();
1097
+ if (!content) continue;
1098
+ out[key.toLowerCase()] = content;
1099
+ }
1100
+ return out;
1101
+ }
1102
+ function hreflangMap(root) {
1103
+ const out = {};
1104
+ for (const el of root.querySelectorAll("link")) {
1105
+ if (!hasRel(el, "alternate")) continue;
1106
+ const lang = el.getAttribute("hreflang");
1107
+ const href = el.getAttribute("href");
1108
+ if (lang && href) out[lang.toLowerCase()] = href.trim();
1109
+ }
1110
+ return out;
1111
+ }
1112
+ function flattenJsonLd(node, out) {
1113
+ if (Array.isArray(node)) {
1114
+ for (const item of node) flattenJsonLd(item, out);
1115
+ return;
1116
+ }
1117
+ if (typeof node !== "object" || node === null) return;
1118
+ const obj = node;
1119
+ if ("@graph" in obj) {
1120
+ flattenJsonLd(obj["@graph"], out);
1121
+ const rest = Object.keys(obj).filter((k) => k !== "@graph" && k !== "@context");
1122
+ if (rest.length === 0) return;
1123
+ }
1124
+ const rawType = obj["@type"];
1125
+ const type = Array.isArray(rawType) ? String(rawType[0]) : rawType ? String(rawType) : null;
1126
+ if (!type) return;
1127
+ out.push({
1128
+ type,
1129
+ ...typeof obj["@id"] === "string" ? { id: obj["@id"] } : {},
1130
+ properties: Object.keys(obj).filter((k) => !k.startsWith("@")).sort()
1131
+ });
1132
+ }
1133
+ function extractJsonLd(root) {
1134
+ const entities = [];
1135
+ for (const script of root.querySelectorAll("script")) {
1136
+ if (script.getAttribute("type")?.trim().toLowerCase() !== "application/ld+json") continue;
1137
+ try {
1138
+ flattenJsonLd(JSON.parse(script.textContent), entities);
1139
+ } catch {
1140
+ entities.push({ type: "__parse_error__", properties: [] });
1141
+ }
1142
+ }
1143
+ return entities;
1144
+ }
1145
+ function countWords(root) {
1146
+ const body = root.querySelector("body") ?? root;
1147
+ const clone = (0, import_node_html_parser.parse)(body.outerHTML);
1148
+ for (const tag of NON_CONTENT) {
1149
+ for (const el of clone.querySelectorAll(tag.toLowerCase())) el.remove();
1150
+ }
1151
+ const words = clone.textContent.replace(/\s+/g, " ").trim();
1152
+ return words.length === 0 ? 0 : words.split(" ").length;
1153
+ }
1154
+ function leadAnswer(root) {
1155
+ const scope = root.querySelector("main") ?? root.querySelector("article") ?? root.querySelector("body") ?? root;
1156
+ const candidates = scope.querySelectorAll("h1, p").filter((el) => !el.closest("header, nav, footer, aside"));
1157
+ const firstH1 = candidates.findIndex((el) => el.tagName?.toUpperCase() === "H1");
1158
+ for (const el of candidates.slice(firstH1 + 1)) {
1159
+ if (el.tagName?.toUpperCase() !== "P") continue;
1160
+ const value = text(el);
1161
+ if (!value) continue;
1162
+ const words = value.split(" ").length;
1163
+ if (words >= 8) return words;
1164
+ }
1165
+ return 0;
1166
+ }
1167
+ function extractPage(html, route) {
1168
+ const root = (0, import_node_html_parser.parse)(html, { blockTextElements: { script: true, style: true } });
1169
+ const headings = [];
1170
+ const h1 = [];
1171
+ for (const el of root.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
1172
+ const tag = el.tagName?.toUpperCase();
1173
+ if (!tag || !HEADING_TAGS.has(tag)) continue;
1174
+ headings.push(tag.toLowerCase());
1175
+ if (tag === "H1") {
1176
+ const value = text(el);
1177
+ if (value) h1.push(value);
1178
+ }
1179
+ }
1180
+ const imgs = root.querySelectorAll("img");
1181
+ const missingAlt = imgs.filter((img) => {
1182
+ const alt = img.getAttribute("alt");
1183
+ return alt === void 0 || alt === null;
1184
+ }).length;
1185
+ return {
1186
+ route,
1187
+ title: text(root.querySelector("title")),
1188
+ description: metaContent(root, "description"),
1189
+ canonical: linkHref(root, "canonical"),
1190
+ robots: metaContent(root, "robots")?.toLowerCase() ?? null,
1191
+ og: metaGroup(root, "og"),
1192
+ twitter: metaGroup(root, "twitter"),
1193
+ hreflang: hreflangMap(root),
1194
+ h1,
1195
+ headingOutline: headings,
1196
+ jsonLd: extractJsonLd(root).sort((a, b) => a.type.localeCompare(b.type)),
1197
+ wordCount: countWords(root),
1198
+ images: { total: imgs.length, missingAlt },
1199
+ leadAnswerWords: leadAnswer(root),
1200
+ generator: metaContent(root, "generator")
1201
+ };
1202
+ }
1203
+ function extractRobotsTxt(body, agents) {
1204
+ const sitemaps = [];
1205
+ const groups = [];
1206
+ let current = null;
1207
+ let lastWasAgent = false;
1208
+ for (const rawLine of body.split(/\r?\n/)) {
1209
+ const line = rawLine.split("#")[0].trim();
1210
+ if (!line) continue;
1211
+ const idx = line.indexOf(":");
1212
+ if (idx === -1) continue;
1213
+ const field = line.slice(0, idx).trim().toLowerCase();
1214
+ const value = line.slice(idx + 1).trim();
1215
+ if (field === "sitemap") {
1216
+ sitemaps.push(value);
1217
+ continue;
1218
+ }
1219
+ if (field === "user-agent") {
1220
+ if (!current || !lastWasAgent) {
1221
+ current = { agents: [], disallowAll: false };
1222
+ groups.push(current);
1223
+ }
1224
+ current.agents.push(value.toLowerCase());
1225
+ lastWasAgent = true;
1226
+ continue;
1227
+ }
1228
+ lastWasAgent = false;
1229
+ if (field === "disallow" && current && value === "/") current.disallowAll = true;
1230
+ if (field === "allow" && current && value === "/") current.disallowAll = false;
1231
+ }
1232
+ const aiAgents = {};
1233
+ for (const agent of agents) {
1234
+ const lower = agent.toLowerCase();
1235
+ const specific = groups.find((g) => g.agents.includes(lower));
1236
+ const wildcard = groups.find((g) => g.agents.includes("*"));
1237
+ const group = specific ?? wildcard;
1238
+ aiAgents[agent] = group?.disallowAll ? "disallowed" : "allowed";
1239
+ }
1240
+ return { present: true, aiAgents, sitemaps };
1241
+ }
1242
+ function extractLlmsTxt(body) {
1243
+ const sections = body.split(/\r?\n/).filter((line) => line.startsWith("## ")).map((line) => line.slice(3).trim());
1244
+ return { present: true, sections, bytes: Buffer.byteLength(body, "utf8") };
1245
+ }
1246
+ var XML_ENTITIES = {
1247
+ amp: "&",
1248
+ lt: "<",
1249
+ gt: ">",
1250
+ quot: '"',
1251
+ apos: "'"
1252
+ };
1253
+ function decodeXml(value) {
1254
+ return value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (match, dec, hex, name) => {
1255
+ if (dec) return String.fromCodePoint(Number(dec));
1256
+ if (hex) return String.fromCodePoint(parseInt(hex, 16));
1257
+ return XML_ENTITIES[String(name).toLowerCase()] ?? match;
1258
+ });
1259
+ }
1260
+ function extractSitemapUrls(xml) {
1261
+ const pattern = /<loc>\s*(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*?))\s*<\/loc>/gi;
1262
+ return [...xml.matchAll(pattern)].map((m) => (m[1] !== void 0 ? m[1] : decodeXml(m[2] ?? "")).trim()).filter((url) => url.length > 0);
1263
+ }
1264
+
1265
+ // src/snapshot.ts
1266
+ function routeFromFilePath(root, filePath) {
1267
+ const rel = (0, import_node_path.relative)(root, filePath).split(import_node_path.sep).join("/");
1268
+ const withoutExt = rel.replace(/\.html?$/i, "");
1269
+ const route = withoutExt === "index" ? "/" : `/${withoutExt.replace(/\/index$/, "")}`;
1270
+ return route === "//" ? "/" : route;
1271
+ }
1272
+ function routeFromUrl(url) {
1273
+ try {
1274
+ const parsed = new URL(url);
1275
+ const path = parsed.pathname.replace(/\/+$/, "");
1276
+ return path === "" ? "/" : path;
1277
+ } catch {
1278
+ return url;
1279
+ }
1280
+ }
1281
+ function shouldIgnore(route, patterns = []) {
1282
+ return patterns.some(
1283
+ (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
1284
+ );
1285
+ }
1286
+ async function walkHtml(dir, acc = []) {
1287
+ for (const entry of await (0, import_promises.readdir)(dir, { withFileTypes: true })) {
1288
+ const full = (0, import_node_path.join)(dir, entry.name);
1289
+ if (entry.isDirectory()) {
1290
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
1291
+ await walkHtml(full, acc);
1292
+ } else if (/\.html?$/i.test(entry.name)) {
1293
+ acc.push(full);
1294
+ }
1295
+ }
1296
+ return acc;
1297
+ }
1298
+ var DEFAULT_TIMEOUT_MS = 15e3;
1299
+ async function fetchText(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
1300
+ let response;
1301
+ try {
1302
+ response = await fetch(url, {
1303
+ signal: AbortSignal.timeout(timeoutMs),
1304
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1305
+ });
1306
+ } catch (cause) {
1307
+ throw new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1308
+ }
1309
+ if (response.status === 404 || response.status === 410) return null;
1310
+ if (!response.ok) throw new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1311
+ return await response.text();
1312
+ }
1313
+ function tryFetchText(url, timeoutMs) {
1314
+ return fetchText(url, timeoutMs).catch(() => null);
1315
+ }
1316
+ async function snapshotFromDir(dir, config = {}) {
1317
+ const files = await walkHtml(dir);
1318
+ const pages = {};
1319
+ for (const file of files.sort()) {
1320
+ const route = routeFromFilePath(dir, file);
1321
+ if (shouldIgnore(route, config.ignoreRoutes)) continue;
1322
+ if (route in pages) {
1323
+ console.error(`pagetrace: ${file} maps to ${route}, already taken. Skipping.`);
1324
+ continue;
1325
+ }
1326
+ pages[route] = extractPage(await (0, import_promises.readFile)(file, "utf8"), route);
1327
+ }
1328
+ const agents = config.aiAgents ?? DEFAULT_AI_AGENTS;
1329
+ const site = {
1330
+ origin: config.siteUrl ? new URL(config.siteUrl).origin : null,
1331
+ robotsTxt: null,
1332
+ llmsTxt: null
1333
+ };
1334
+ const robots = await (0, import_promises.readFile)((0, import_node_path.join)(dir, "robots.txt"), "utf8").catch(() => null);
1335
+ if (robots !== null) site.robotsTxt = extractRobotsTxt(robots, agents);
1336
+ const llms = await (0, import_promises.readFile)((0, import_node_path.join)(dir, "llms.txt"), "utf8").catch(() => null);
1337
+ if (llms !== null) site.llmsTxt = extractLlmsTxt(llms);
1338
+ return { schemaVersion: 1, createdAt: (/* @__PURE__ */ new Date()).toISOString(), site, pages };
1339
+ }
1340
+ var MAX_NESTED_SITEMAPS = 50;
1341
+ async function snapshotFromOrigin(origin, options = {}) {
1342
+ const base = new URL(origin);
1343
+ const agents = options.aiAgents ?? DEFAULT_AI_AGENTS;
1344
+ const timeout = options.timeout;
1345
+ const site = { origin: base.origin, robotsTxt: null, llmsTxt: null };
1346
+ const limit = options.limit ?? 200;
1347
+ const robots = await fetchText(new URL("/robots.txt", base).href, timeout);
1348
+ if (robots !== null) site.robotsTxt = extractRobotsTxt(robots, agents);
1349
+ const llms = await fetchText(new URL("/llms.txt", base).href, timeout);
1350
+ if (llms !== null) site.llmsTxt = extractLlmsTxt(llms);
1351
+ const sitemapUrls = site.robotsTxt?.sitemaps.length ? site.robotsTxt.sitemaps : [
1352
+ new URL("/sitemap.xml", base).href,
1353
+ new URL("/sitemap_index.xml", base).href,
1354
+ new URL("/wp-sitemap.xml", base).href
1355
+ ];
1356
+ const discovered = /* @__PURE__ */ new Set();
1357
+ const fetched = /* @__PURE__ */ new Set();
1358
+ let nestedFetches = 0;
1359
+ for (const sitemapUrl of sitemapUrls) {
1360
+ if (discovered.size >= limit) break;
1361
+ if (discovered.size > 0 && !site.robotsTxt?.sitemaps.length) break;
1362
+ if (fetched.has(sitemapUrl)) continue;
1363
+ fetched.add(sitemapUrl);
1364
+ const xml = site.robotsTxt?.sitemaps.length ? await fetchText(sitemapUrl, timeout) : await tryFetchText(sitemapUrl, timeout);
1365
+ if (!xml) continue;
1366
+ for (const loc of extractSitemapUrls(xml)) {
1367
+ if (discovered.size >= limit) break;
1368
+ if (!/\.xml(\.gz)?($|\?)/i.test(loc)) {
1369
+ discovered.add(loc);
1370
+ continue;
1371
+ }
1372
+ if (/\.gz($|\?)/i.test(loc)) continue;
1373
+ if (nestedFetches >= MAX_NESTED_SITEMAPS || fetched.has(loc)) continue;
1374
+ fetched.add(loc);
1375
+ nestedFetches += 1;
1376
+ const nested = await tryFetchText(loc, timeout);
1377
+ if (nested) for (const url of extractSitemapUrls(nested)) discovered.add(url);
1378
+ }
1379
+ }
1380
+ if (discovered.size === 0) discovered.add(base.href);
1381
+ const targets = [...discovered].filter((url) => {
1382
+ try {
1383
+ return new URL(url).origin === base.origin;
1384
+ } catch {
1385
+ return false;
1386
+ }
1387
+ }).filter((url) => !shouldIgnore(routeFromUrl(url), options.ignoreRoutes)).slice(0, limit);
1388
+ const pages = {};
1389
+ const concurrency = Math.max(1, options.concurrency ?? 5);
1390
+ const queue = [...targets];
1391
+ await Promise.all(
1392
+ Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
1393
+ while (queue.length > 0) {
1394
+ const url = queue.shift();
1395
+ const html = await fetchText(url, timeout);
1396
+ if (html === null) continue;
1397
+ const route = routeFromUrl(url);
1398
+ pages[route] = extractPage(html, route);
1399
+ }
1400
+ })
1401
+ );
1402
+ return { schemaVersion: 1, createdAt: (/* @__PURE__ */ new Date()).toISOString(), site, pages };
1403
+ }
1404
+
1405
+ // src/cli.ts
1406
+ var DEFAULT_LOCKFILE = "pagetrace.lock.json";
1407
+ var DEFAULT_CONFIG = "pagetrace.config.json";
1408
+ var SEVERITIES = ["error", "warn", "info"];
1409
+ function parseFailOn(value, allowNever) {
1410
+ const expected = [...SEVERITIES, ...allowNever ? ["never"] : []].join(" | ");
1411
+ if (typeof value !== "string") throw new Error(`--fail-on needs a value. Expected ${expected}.`);
1412
+ if (allowNever && value === "never") return "never";
1413
+ if (SEVERITIES.includes(value)) return value;
1414
+ throw new Error(`Invalid --fail-on "${value}". Expected ${expected}.`);
1415
+ }
1416
+ async function loadConfig(path = DEFAULT_CONFIG) {
1417
+ try {
1418
+ return JSON.parse(await (0, import_promises2.readFile)(path, "utf8"));
1419
+ } catch {
1420
+ return {};
1421
+ }
1422
+ }
1423
+ async function build(flags, config) {
1424
+ if (flags.dir) return snapshotFromDir(flags.dir, config);
1425
+ if (flags.url)
1426
+ return snapshotFromOrigin(flags.url, {
1427
+ ...config,
1428
+ limit: flags.limit,
1429
+ concurrency: flags.concurrency
1430
+ });
1431
+ throw new Error("Provide a source: --dir <build directory> or --url <origin>.");
1432
+ }
1433
+ function render(findings, format) {
1434
+ switch (format) {
1435
+ case "json":
1436
+ return formatJson(findings);
1437
+ case "markdown":
1438
+ return formatMarkdown(findings);
1439
+ case "github":
1440
+ return formatGithub(findings);
1441
+ default:
1442
+ return formatPretty(findings);
1443
+ }
1444
+ }
1445
+ var cli = (0, import_cac.cac)("pagetrace");
1446
+ cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--out <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).action(async (flags) => {
1447
+ const config = await loadConfig(flags.config);
1448
+ const snapshot = await build(flags, config);
1449
+ await (0, import_promises2.writeFile)(flags.out, `${JSON.stringify(snapshot, null, 2)}
1450
+ `, "utf8");
1451
+ const count = Object.keys(snapshot.pages).length;
1452
+ console.log(import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`));
1453
+ });
1454
+ cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
1455
+ const failOn = parseFailOn(flags.failOn, false);
1456
+ const config = await loadConfig(flags.config);
1457
+ const next = await build(flags, config);
1458
+ let previous = null;
1459
+ try {
1460
+ previous = JSON.parse(await (0, import_promises2.readFile)(flags.lockfile, "utf8"));
1461
+ } catch {
1462
+ previous = null;
1463
+ }
1464
+ if (!previous) {
1465
+ console.error(
1466
+ import_picocolors2.default.yellow(`No lockfile at ${flags.lockfile}. Run \`pagetrace snapshot\` first to set a baseline.`)
1467
+ );
1468
+ }
1469
+ const raw = [
1470
+ ...previous ? diffSnapshots(previous, next) : [],
1471
+ ...flags.audit ? auditSnapshot(next, config) : []
1472
+ ];
1473
+ const findings = applyConfig(raw, config);
1474
+ console.log(render(findings, flags.format));
1475
+ if (flags.update) {
1476
+ await (0, import_promises2.writeFile)(flags.lockfile, `${JSON.stringify(next, null, 2)}
1477
+ `, "utf8");
1478
+ console.error(import_picocolors2.default.dim(`Updated ${flags.lockfile}.`));
1479
+ }
1480
+ const summary = summarize(findings);
1481
+ if (previous && shouldFail(findings, failOn)) {
1482
+ console.error(
1483
+ import_picocolors2.default.red(`
1484
+ Failing: ${summary.error} error, ${summary.warn} warning (--fail-on ${failOn}).`)
1485
+ );
1486
+ process.exitCode = 1;
1487
+ }
1488
+ });
1489
+ cli.command("audit", "Audit a site as it stands, with explanations and fixes").option("--url <origin>", "Live origin to crawl").option("--dir <dir>", "Directory of built HTML").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | html", { default: "pretty" }).option("--out <file>", "Write the report to a file instead of stdout").option("--fail-on <severity>", "error | warn | info | never", { default: "never" }).action(async (flags) => {
1490
+ const failOn = parseFailOn(flags.failOn, true);
1491
+ const config = await loadConfig(flags.config);
1492
+ const snapshot = await build(flags, config);
1493
+ const pages = Object.values(snapshot.pages);
1494
+ if (pages.length === 0) {
1495
+ console.error(
1496
+ import_picocolors2.default.yellow(
1497
+ "No pages found. Check that the sitemap is reachable, or pass --dir with pre-rendered HTML."
1498
+ )
1499
+ );
1500
+ process.exitCode = 1;
1501
+ return;
1502
+ }
1503
+ const platform = detectPlatform(
1504
+ pages.map((p) => p.generator),
1505
+ pages.flatMap((p) => Object.values(p.og))
1506
+ );
1507
+ const findings = applyConfig(auditSnapshot(snapshot, config), config).map(
1508
+ (f) => withGuidance(f, platform)
1509
+ );
1510
+ const groups = aggregate(findings);
1511
+ const meta = {
1512
+ target: flags.url ?? flags.dir ?? "site",
1513
+ platform,
1514
+ pageCount: pages.length,
1515
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1516
+ };
1517
+ const output = flags.format === "json" ? JSON.stringify({ schemaVersion: 1, meta, summary: summarize(findings), groups }, null, 2) : flags.format === "markdown" ? formatAuditMarkdown(groups, meta) : flags.format === "html" ? formatAuditHtml(groups, meta) : formatAuditPretty(groups, meta);
1518
+ if (flags.out) {
1519
+ await (0, import_promises2.writeFile)(flags.out, `${output}
1520
+ `, "utf8");
1521
+ console.log(import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${groups.length} issue types across ${pages.length} pages.`));
1522
+ } else {
1523
+ console.log(output);
1524
+ }
1525
+ if (failOn !== "never" && shouldFail(findings, failOn)) {
1526
+ process.exitCode = 1;
1527
+ }
1528
+ });
1529
+ cli.help();
1530
+ cli.version("0.2.0");
1531
+ async function main() {
1532
+ try {
1533
+ cli.parse(process.argv, { run: false });
1534
+ await cli.runMatchedCommand();
1535
+ } catch (error) {
1536
+ console.error(import_picocolors2.default.red(error.message));
1537
+ process.exitCode = 1;
1538
+ }
1539
+ }
1540
+ void main();
1541
+ //# sourceMappingURL=cli.cjs.map