@rivi-software/agentic-seo-check 0.3.0 → 0.5.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.
Files changed (128) hide show
  1. package/README.md +29 -5
  2. package/dist/cli.js +20 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/aeo/audit.d.ts +8 -0
  5. package/dist/core/aeo/audit.js +101 -17
  6. package/dist/core/aeo/audit.js.map +1 -1
  7. package/dist/core/aeo/bots.d.ts +48 -0
  8. package/dist/core/aeo/bots.js +151 -5
  9. package/dist/core/aeo/bots.js.map +1 -1
  10. package/dist/core/aeo/fixes.d.ts +38 -0
  11. package/dist/core/aeo/fixes.js +111 -0
  12. package/dist/core/aeo/fixes.js.map +1 -0
  13. package/dist/core/aeo/site-rules.d.ts +5 -0
  14. package/dist/core/aeo/site-rules.js +293 -10
  15. package/dist/core/aeo/site-rules.js.map +1 -1
  16. package/dist/core/audit.d.ts +8 -0
  17. package/dist/core/audit.js +144 -25
  18. package/dist/core/audit.js.map +1 -1
  19. package/dist/core/crawl.js +43 -4
  20. package/dist/core/crawl.js.map +1 -1
  21. package/dist/core/html/answer.d.ts +5 -2
  22. package/dist/core/html/answer.js +25 -7
  23. package/dist/core/html/answer.js.map +1 -1
  24. package/dist/core/html/dom.d.ts +13 -1
  25. package/dist/core/html/dom.js +30 -2
  26. package/dist/core/html/dom.js.map +1 -1
  27. package/dist/core/html/extract.js +122 -30
  28. package/dist/core/html/extract.js.map +1 -1
  29. package/dist/core/html/head.js +114 -19
  30. package/dist/core/html/head.js.map +1 -1
  31. package/dist/core/html/resources.js +15 -7
  32. package/dist/core/html/resources.js.map +1 -1
  33. package/dist/core/html/structured-data.js +21 -5
  34. package/dist/core/html/structured-data.js.map +1 -1
  35. package/dist/core/lighthouse.d.ts +2 -5
  36. package/dist/core/lighthouse.js +290 -5
  37. package/dist/core/lighthouse.js.map +1 -1
  38. package/dist/core/performance-fixes.d.ts +25 -0
  39. package/dist/core/performance-fixes.js +446 -0
  40. package/dist/core/performance-fixes.js.map +1 -0
  41. package/dist/core/performance-rules.js +56 -8
  42. package/dist/core/performance-rules.js.map +1 -1
  43. package/dist/core/recheck.d.ts +68 -0
  44. package/dist/core/recheck.js +247 -0
  45. package/dist/core/recheck.js.map +1 -0
  46. package/dist/core/render-fixes.d.ts +27 -0
  47. package/dist/core/render-fixes.js +164 -0
  48. package/dist/core/render-fixes.js.map +1 -0
  49. package/dist/core/render-rules.js +60 -21
  50. package/dist/core/render-rules.js.map +1 -1
  51. package/dist/core/report/markdown.js +68 -1
  52. package/dist/core/report/markdown.js.map +1 -1
  53. package/dist/core/robots.d.ts +5 -0
  54. package/dist/core/robots.js +8 -0
  55. package/dist/core/robots.js.map +1 -1
  56. package/dist/core/rules/aeo.js +339 -25
  57. package/dist/core/rules/aeo.js.map +1 -1
  58. package/dist/core/rules/canonical.js +107 -11
  59. package/dist/core/rules/canonical.js.map +1 -1
  60. package/dist/core/rules/content.js +65 -7
  61. package/dist/core/rules/content.js.map +1 -1
  62. package/dist/core/rules/document.js +73 -8
  63. package/dist/core/rules/document.js.map +1 -1
  64. package/dist/core/rules/fix.d.ts +55 -0
  65. package/dist/core/rules/fix.js +159 -0
  66. package/dist/core/rules/fix.js.map +1 -0
  67. package/dist/core/rules/head.js +81 -4
  68. package/dist/core/rules/head.js.map +1 -1
  69. package/dist/core/rules/headings.js +92 -9
  70. package/dist/core/rules/headings.js.map +1 -1
  71. package/dist/core/rules/hreflang.js +79 -9
  72. package/dist/core/rules/hreflang.js.map +1 -1
  73. package/dist/core/rules/images.js +49 -0
  74. package/dist/core/rules/images.js.map +1 -1
  75. package/dist/core/rules/index.d.ts +11 -0
  76. package/dist/core/rules/index.js +21 -1
  77. package/dist/core/rules/index.js.map +1 -1
  78. package/dist/core/rules/indexing.js +108 -1
  79. package/dist/core/rules/indexing.js.map +1 -1
  80. package/dist/core/rules/keyword.js +92 -2
  81. package/dist/core/rules/keyword.js.map +1 -1
  82. package/dist/core/rules/links.js +42 -0
  83. package/dist/core/rules/links.js.map +1 -1
  84. package/dist/core/rules/loading.js +54 -1
  85. package/dist/core/rules/loading.js.map +1 -1
  86. package/dist/core/rules/meta.js +40 -6
  87. package/dist/core/rules/meta.js.map +1 -1
  88. package/dist/core/rules/page-fixes.d.ts +39 -0
  89. package/dist/core/rules/page-fixes.js +107 -0
  90. package/dist/core/rules/page-fixes.js.map +1 -0
  91. package/dist/core/rules/quoted-tags.d.ts +23 -0
  92. package/dist/core/rules/quoted-tags.js +79 -0
  93. package/dist/core/rules/quoted-tags.js.map +1 -0
  94. package/dist/core/rules/response.js +71 -7
  95. package/dist/core/rules/response.js.map +1 -1
  96. package/dist/core/rules/rule.d.ts +17 -4
  97. package/dist/core/rules/rule.js +11 -2
  98. package/dist/core/rules/rule.js.map +1 -1
  99. package/dist/core/rules/social.js +101 -10
  100. package/dist/core/rules/social.js.map +1 -1
  101. package/dist/core/rules/structured-data-fixes.d.ts +33 -0
  102. package/dist/core/rules/structured-data-fixes.js +469 -0
  103. package/dist/core/rules/structured-data-fixes.js.map +1 -0
  104. package/dist/core/rules/structured-data-values.js +242 -31
  105. package/dist/core/rules/structured-data-values.js.map +1 -1
  106. package/dist/core/rules/structured-data.js +171 -4
  107. package/dist/core/rules/structured-data.js.map +1 -1
  108. package/dist/core/rules/title.js +39 -5
  109. package/dist/core/rules/title.js.map +1 -1
  110. package/dist/core/rules/urls.js +166 -0
  111. package/dist/core/rules/urls.js.map +1 -1
  112. package/dist/core/site-fixes.d.ts +111 -0
  113. package/dist/core/site-fixes.js +650 -0
  114. package/dist/core/site-fixes.js.map +1 -0
  115. package/dist/core/site-rules.js +481 -54
  116. package/dist/core/site-rules.js.map +1 -1
  117. package/dist/core/sitemap.d.ts +16 -0
  118. package/dist/core/sitemap.js +34 -0
  119. package/dist/core/sitemap.js.map +1 -1
  120. package/dist/core/types.d.ts +223 -3
  121. package/dist/core/types.js.map +1 -1
  122. package/dist/index.d.ts +2 -0
  123. package/dist/index.js +1 -0
  124. package/dist/index.js.map +1 -1
  125. package/dist/mcp.js +16 -2
  126. package/dist/mcp.js.map +1 -1
  127. package/package.json +1 -1
  128. package/skills/seo-audit/SKILL.md +35 -5
@@ -0,0 +1,650 @@
1
+ /**
2
+ * The edits that fix site-wide findings: robots.txt lines, sitemap entries, server responses and
3
+ * the links, canonicals and titles of the pages a site rule names. Each edit says which file or
4
+ * response it changes with `url`, since a site finding belongs to no single page.
5
+ */
6
+ import { DEFAULT_USER_AGENT } from "./fetch.js";
7
+ import { quote } from "./html/dom.js";
8
+ import { robotsUserAgentToken, ruleLineMatcher } from "./robots.js";
9
+ import { addEdit, escapeAttribute, removeEdit, replaceEdit, setAttributeEdit, slot, withAttribute, withContent, } from "./rules/fix.js";
10
+ import { isoDate } from "./rules/structured-data-fixes.js";
11
+ import { resolveUrl } from "./url.js";
12
+ /**
13
+ * The most edits a site fix builds. `finding()` keeps the first 10 of any longer list, but a fix
14
+ * that reads a site's robots.txt or every crawled page could build thousands first.
15
+ */
16
+ export const MAX_EDITS = 10;
17
+ /** `e` with `url` set, in the key order an edit reads in. */
18
+ export const onUrl = (e, url) => {
19
+ const { in: target, action, ...rest } = e;
20
+ return { in: target, action, url, ...rest };
21
+ };
22
+ const REASONS = new Map([
23
+ [200, "OK"],
24
+ [301, "Moved Permanently"],
25
+ [302, "Found"],
26
+ [303, "See Other"],
27
+ [307, "Temporary Redirect"],
28
+ [308, "Permanent Redirect"],
29
+ [400, "Bad Request"],
30
+ [401, "Unauthorized"],
31
+ [403, "Forbidden"],
32
+ [404, "Not Found"],
33
+ [410, "Gone"],
34
+ [429, "Too Many Requests"],
35
+ [500, "Internal Server Error"],
36
+ [502, "Bad Gateway"],
37
+ [503, "Service Unavailable"],
38
+ [504, "Gateway Timeout"],
39
+ ]);
40
+ /** An HTTP status line, or what happened when there was no response. */
41
+ export const statusLine = (status, error) => {
42
+ if (status === null) {
43
+ return `no response (${error ?? "the request failed"})`;
44
+ }
45
+ const reason = REASONS.get(status);
46
+ return `HTTP/1.1 ${status}${reason ? ` ${reason}` : ""}`;
47
+ };
48
+ /** A permanent redirect to `location`; 308 keeps the method, for a redirect that was 307 or 308. */
49
+ export const redirectTo = (location, keepMethod = false) => `${statusLine(keepMethod ? 308 : 301)}\nLocation: ${location}`;
50
+ /** Text for an XML element, such as a sitemap <loc>. */
51
+ export const xmlText = (value) => value
52
+ .replaceAll("&", "&amp;")
53
+ .replaceAll("<", "&lt;")
54
+ .replaceAll(">", "&gt;");
55
+ /** A sitemap <loc> element, as a generator writes it. */
56
+ export const locOf = (url) => `<loc>${xmlText(url)}</loc>`;
57
+ /** A value for a double-quoted CSS attribute selector. */
58
+ const cssString = (value) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
59
+ const parse = (url) => {
60
+ try {
61
+ return new URL(url);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ };
67
+ /** The path, query and fragment of `url`: how a page links to its own site. */
68
+ export const pathOf = (url) => {
69
+ const u = parse(url);
70
+ return u ? `${u.pathname}${u.search}${u.hash}` : url;
71
+ };
72
+ /**
73
+ * `target` written the way `original` was: absolute when the page wrote an absolute URL, else from
74
+ * the root. A fragment the page wrote is kept.
75
+ */
76
+ export const hrefLike = (original, target, pageUrl) => {
77
+ const to = parse(target);
78
+ const from = parse(pageUrl);
79
+ const hash = original.includes("#")
80
+ ? original.slice(original.indexOf("#"))
81
+ : "";
82
+ if (!to) {
83
+ return target;
84
+ }
85
+ if (!to.hash && hash) {
86
+ to.hash = hash;
87
+ }
88
+ const absolute = /^[a-z][\d+.a-z-]*:/iu.test(original) || original.startsWith("//");
89
+ return absolute || !from || from.origin !== to.origin
90
+ ? to.href
91
+ : pathOf(to.href);
92
+ };
93
+ /** A CSS selector for a link, by the href the page wrote. */
94
+ export const linkSelector = (link) => `a[href="${cssString(link.href)}"]`;
95
+ /** The edit that points the link `link` on page `pageUrl` at `href` (exact, or a `{{…}}` slot). */
96
+ export const relinkEdit = (pageUrl, link, href, write) => onUrl(setAttributeEdit(link.tag, linkSelector(link), "href", href.startsWith("{{") ? href : escapeAttribute(href), write), pageUrl);
97
+ /** The edit that adds a link to `target` on page `from`, in its navigation or footer. */
98
+ export const addLinkEdit = (from, target, write) => addEdit("html", "nav, footer", `<a href="${escapeAttribute(pathOf(target))}">${slot("link text")}</a>`, { url: from, write });
99
+ /* robots.txt */
100
+ const USER_AGENT_LINE = /^\s*user-agent\s*:/iu;
101
+ /**
102
+ * robots.txt split into lines, each without its line ending. A lone CR ends a line too, as in
103
+ * robots-parser, so line numbers match the ones it reports.
104
+ */
105
+ export const linesOf = (text) => text.split(/\r\n|\r|\n/u);
106
+ /**
107
+ * The first `User-agent:` line of the group that holds line `index` (0-based): the line an agent
108
+ * finds the group by.
109
+ */
110
+ export const groupHeader = (lines, index) => {
111
+ let i = index;
112
+ while (i >= 0 && !USER_AGENT_LINE.test(lines[i] ?? "")) {
113
+ i -= 1;
114
+ }
115
+ if (i < 0) {
116
+ return null;
117
+ }
118
+ while (i > 0 && USER_AGENT_LINE.test(lines[i - 1] ?? "")) {
119
+ i -= 1;
120
+ }
121
+ return quote((lines[i] ?? "").trim());
122
+ };
123
+ /** The crawler token this audit's requests send, which robots.txt groups match on. */
124
+ const CRAWLER = robotsUserAgentToken(DEFAULT_USER_AGENT);
125
+ /** The last robots.txt parsed, so the fixes for a site's many blocked URLs parse it once. */
126
+ let parsed = null;
127
+ /** The robots.txt line (0-based) whose rule blocks `target` for `agent`, or -1. */
128
+ const blockingLine = (robotsUrl, text, target, agent) => {
129
+ if (parsed?.url !== robotsUrl || parsed.text !== text) {
130
+ parsed = { url: robotsUrl, text, match: ruleLineMatcher(robotsUrl, text) };
131
+ }
132
+ return parsed.match(target, agent) - 1;
133
+ };
134
+ export const robotsMissingFix = (robotsUrl, sitemapUrl) => [
135
+ addEdit("robots.txt", "new file", `User-agent: *\nDisallow:\n\nSitemap: ${sitemapUrl}`, {
136
+ url: robotsUrl,
137
+ write: "Serve it as a plain-text file at the site root (public/robots.txt, or app/robots.ts in Next.js).",
138
+ }),
139
+ ];
140
+ export const robotsErrorFix = (robotsUrl, status, error) => [
141
+ replaceEdit("http", statusLine(status, error), `${statusLine(200)}\nContent-Type: text/plain`, {
142
+ url: robotsUrl,
143
+ write: "Serve robots.txt as a static file. A site without one should answer 404, which Google reads as permission to crawl everything.",
144
+ }),
145
+ ];
146
+ /** Replaces the rule that blocks the start URL with an empty Disallow, which allows everything. */
147
+ export const robotsBlocksAllFix = (robotsUrl, text, startUrl) => {
148
+ const lines = linesOf(text);
149
+ const write = "Keep a Disallow that blocks the whole site only on a staging or preview host that must stay out of search.";
150
+ for (const agent of [CRAWLER, "Googlebot"]) {
151
+ const index = blockingLine(robotsUrl, text, startUrl, agent);
152
+ const line = lines[index]?.trim();
153
+ if (index >= 0 && line && /^disallow\s*:/iu.test(line)) {
154
+ return [
155
+ replaceEdit("robots.txt", quote(line), "Disallow:", {
156
+ url: robotsUrl,
157
+ at: groupHeader(lines, index) ?? undefined,
158
+ write,
159
+ }),
160
+ ];
161
+ }
162
+ }
163
+ return [
164
+ addEdit("robots.txt", "User-agent: *", "Allow: /", {
165
+ url: robotsUrl,
166
+ write: `Add it to the group that blocks the site and remove that group's Disallow lines that match /. ${write}`,
167
+ }),
168
+ ];
169
+ };
170
+ /** The Disallow line that blocks `target` for this crawler, quoted for a `write`. */
171
+ export const blockingRule = (robotsUrl, text, target) => {
172
+ const index = blockingLine(robotsUrl, text, target, CRAWLER);
173
+ // The rule without its comment: the site wrote the line, and a padded comment could be huge.
174
+ const line = linesOf(text)[index]?.replace(/#.*$/u, "").trim();
175
+ return index >= 0 && line ? quote(line) : null;
176
+ };
177
+ /**
178
+ * Cuts robots.txt at the last line Google reads: the rules after it are ignored, so the file should
179
+ * end there once the ones that matter are folded in above.
180
+ */
181
+ export const robotsTooLargeFix = (robotsUrl, text, bytes) => {
182
+ const lines = linesOf(text);
183
+ // `text` stops at 500 KiB, so its last line may be cut: the one before it is whole.
184
+ const last = lines.length > 1 ? lines.length - 2 : 0;
185
+ const line = (lines[last] ?? "").trim();
186
+ return [
187
+ removeEdit("robots.txt", quote(line), {
188
+ url: robotsUrl,
189
+ at: `line ${last + 1} to the end of the file`,
190
+ write: `Google stops reading at 500 KiB, near this line (line ${last + 1}); the file is ${Math.round(bytes / 1024)} KiB. Delete this line and every line after it once the rules that matter are moved above it, folded into patterns (Disallow: /*?sort= instead of one line per URL); delete rules for paths that no longer exist.`,
191
+ }),
192
+ ];
193
+ };
194
+ const RETIRED_FIELD = /^(?:noindex|nofollow)\s*:/iu;
195
+ const CRAWL_DELAY = /^crawl-delay\s*:/iu;
196
+ /** Removes noindex:, nofollow: and crawl-delay: lines; a noindex path gets an X-Robots-Tag header instead. */
197
+ export const robotsUnsupportedFix = (robotsUrl, text) => {
198
+ const lines = linesOf(text);
199
+ const origin = parse(robotsUrl)?.origin ?? "";
200
+ const edits = [];
201
+ let header;
202
+ for (const [index, raw] of lines.slice(0, 50_000).entries()) {
203
+ // A robots.txt of nothing but noindex: lines would otherwise build two edits per line.
204
+ if (edits.length >= MAX_EDITS) {
205
+ break;
206
+ }
207
+ const line = raw.replace(/#.*$/u, "").trim();
208
+ // The group's first User-agent line, kept in this one pass: groupHeader per line scans back to
209
+ // it, which on a 500 KiB robots.txt is quadratic.
210
+ if (USER_AGENT_LINE.test(raw) &&
211
+ !USER_AGENT_LINE.test(lines[index - 1] ?? "")) {
212
+ header = quote(raw.trim());
213
+ }
214
+ const at = header;
215
+ if (RETIRED_FIELD.test(line)) {
216
+ edits.push(removeEdit("robots.txt", quote(raw.trim()), {
217
+ url: robotsUrl,
218
+ at,
219
+ write: "Google has not read noindex: or nofollow: lines since 2019.",
220
+ }));
221
+ const path = quote(line.slice(line.indexOf(":") + 1).trim());
222
+ if (/^noindex/iu.test(line) && path.startsWith("/")) {
223
+ edits.push(addEdit("http", "X-Robots-Tag", "X-Robots-Tag: noindex", {
224
+ url: `${origin}${path.replace(/\*.*$/u, "")}`,
225
+ write: `On every URL under ${path}, in the server or CDN config. Google sees the header only on URLs robots.txt lets it fetch.`,
226
+ }));
227
+ }
228
+ }
229
+ else if (CRAWL_DELAY.test(line)) {
230
+ edits.push(removeEdit("robots.txt", quote(raw.trim()), {
231
+ url: robotsUrl,
232
+ at,
233
+ write: "Only if it was meant for Google, which ignores it. Bing and Yandex read crawl-delay: keep it if their crawl rate matters.",
234
+ }));
235
+ }
236
+ }
237
+ return edits.slice(0, MAX_EDITS);
238
+ };
239
+ /** The directory of a URL's path and its file extension: /assets/app.js → ["/assets/", ".js"]. */
240
+ const folderAndType = (url) => {
241
+ const u = parse(url);
242
+ if (!u) {
243
+ return null;
244
+ }
245
+ const folder = u.pathname.slice(0, u.pathname.lastIndexOf("/") + 1);
246
+ const match = /\.[\da-z]+$/iu.exec(u.pathname);
247
+ return [folder, match ? match[0].toLowerCase() : ""];
248
+ };
249
+ /**
250
+ * Allow lines for the scripts and stylesheets robots.txt keeps from Googlebot, added to the group
251
+ * that blocks them: a longer Allow pattern wins over the Disallow that matched.
252
+ */
253
+ export const blockedResourcesFix = (robotsUrl, text, resources) => {
254
+ const lines = linesOf(text);
255
+ const byGroup = new Map();
256
+ for (const url of resources) {
257
+ const index = blockingLine(robotsUrl, text, url, "Googlebot");
258
+ const header = groupHeader(lines, index) ?? "User-agent: *";
259
+ const parts = folderAndType(url);
260
+ if (!parts) {
261
+ continue;
262
+ }
263
+ const [folder, type] = parts;
264
+ const allow = type ? `Allow: ${folder}*${type}` : `Allow: ${pathOf(url)}`;
265
+ const set = byGroup.get(header) ?? new Set();
266
+ set.add(allow);
267
+ byGroup.set(header, set);
268
+ }
269
+ return [...byGroup].map(([header, allows]) => addEdit("robots.txt", header, [...allows].join("\n"), {
270
+ url: robotsUrl,
271
+ write: "Next to the group's Disallow lines.",
272
+ }));
273
+ };
274
+ /* sitemaps */
275
+ /** The sitemap file that lists `loc`, or the only one there is. */
276
+ export const sitemapFileOf = (layout, loc) => layout.sitemapOf.get(loc) ??
277
+ (layout.urlsets.length === 1 ? layout.urlsets[0] : undefined);
278
+ /** Removes the sitemap entry of `loc`. */
279
+ export const removeFromSitemap = (layout, loc, why) => removeEdit("sitemap", locOf(loc), {
280
+ url: sitemapFileOf(layout, loc),
281
+ write: `Remove the whole <url> element that holds this <loc>: ${why}`,
282
+ });
283
+ /** Points the sitemap entry of `loc` at `to`. */
284
+ export const replaceInSitemap = (layout, loc, to) => replaceEdit("sitemap", locOf(loc), locOf(to), {
285
+ url: sitemapFileOf(layout, loc),
286
+ });
287
+ const URLSET_OPEN = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
288
+ /** The most URLs a fix writes out; the rest go in a slot. */
289
+ const MAX_LISTED_URLS = 50;
290
+ /** One `<url>` line per URL, up to `MAX_LISTED_URLS`, then a slot for the rest. */
291
+ const urlLines = (urls) => {
292
+ const lines = urls
293
+ .slice(0, MAX_LISTED_URLS)
294
+ .map((u) => ` <url>${locOf(u)}</url>`);
295
+ if (urls.length > MAX_LISTED_URLS) {
296
+ lines.push(` ${slot(`a <url> for each of the other ${urls.length - MAX_LISTED_URLS} pages`)}`);
297
+ }
298
+ return lines;
299
+ };
300
+ const moreUrlsNote = (urls) => urls.length > MAX_LISTED_URLS
301
+ ? ` The slot stands for the other ${urls.length - MAX_LISTED_URLS} pages this audit found; generate the list from the router or CMS.`
302
+ : "";
303
+ /** A sitemap of the indexable pages, and the robots.txt line that names it. */
304
+ export const sitemapMissingFix = (sitemapUrl, pages, robotsUrl) => {
305
+ const edits = [
306
+ addEdit("sitemap", "new file", [URLSET_OPEN, ...urlLines(pages), "</urlset>"].join("\n"), {
307
+ url: sitemapUrl,
308
+ write: `Generate it from the routes (app/sitemap.ts in Next.js, @astrojs/sitemap, @nuxtjs/sitemap) so new pages are listed as they are added.${moreUrlsNote(pages)}`,
309
+ }),
310
+ ];
311
+ if (robotsUrl) {
312
+ edits.push(addEdit("robots.txt", "end of file", `Sitemap: ${sitemapUrl}`, {
313
+ url: robotsUrl,
314
+ }));
315
+ }
316
+ return edits;
317
+ };
318
+ /** Adds the pages a sitemap misses to the sitemap that lists the site's pages. */
319
+ export const addToSitemapFix = (layout, urls) => {
320
+ const [file] = layout.urlsets;
321
+ const edit = addEdit("sitemap", "before </urlset>", urlLines(urls).join("\n"), {
322
+ url: file,
323
+ write: `Better still, generate the sitemap from the routes so it stays complete.${moreUrlsNote(urls)}`,
324
+ });
325
+ return [edit];
326
+ };
327
+ /** The URL and detail of a sitemap error message: "https://…/sitemap.xml: HTTP 500". */
328
+ const splitError = (message) => {
329
+ const at = message.indexOf(": ");
330
+ return at === -1
331
+ ? [message, ""]
332
+ : [message.slice(0, at), message.slice(at + 2)];
333
+ };
334
+ /** What serves a sitemap that answers with an error, HTML or something that is not XML. */
335
+ export const sitemapErrorFix = (errors, origin) => errors.map((message) => {
336
+ const [where, detail] = splitError(message);
337
+ const url = where.startsWith("/") && origin ? `${origin}${where}` : where;
338
+ const status = /^HTTP (?<code>\d{3})$/u.exec(detail)?.groups?.code;
339
+ if (status) {
340
+ return replaceEdit("http", statusLine(Number(status)), `${statusLine(200)}\nContent-Type: application/xml`, {
341
+ url,
342
+ write: "Serve the sitemap at this URL. If it should not exist, remove the Sitemap: line that names it from robots.txt instead.",
343
+ });
344
+ }
345
+ if (/HTML page/u.test(detail)) {
346
+ return replaceEdit("http", "Content-Type: text/html", "Content-Type: application/xml", {
347
+ url,
348
+ write: "Serve the sitemap XML at this URL; the app's HTML fallback page answers it now. Add the sitemap route, or a rewrite that comes before the fallback.",
349
+ });
350
+ }
351
+ if (/declared in robots\.txt|outside the output/u.test(detail)) {
352
+ return addEdit("sitemap", "new file", `${URLSET_OPEN}\n <url><loc>${slot("page URL")}</loc></url>\n</urlset>`, {
353
+ url,
354
+ write: "Write the sitemap robots.txt names into the build output, one <url> per indexable page; or remove the Sitemap: line from robots.txt.",
355
+ });
356
+ }
357
+ if (/^no response|ETIMEDOUT|ECONN|ENOTFOUND|timed out|fetch failed/iu.test(detail)) {
358
+ return replaceEdit("http", statusLine(null, detail), `${statusLine(200)}\nContent-Type: application/xml`, { url, write: "Make the sitemap answer quickly from this host." });
359
+ }
360
+ return addEdit("sitemap", "whole file", `${URLSET_OPEN}\n <url><loc>${slot("page URL")}</loc></url>\n</urlset>`, {
361
+ url,
362
+ write: `Replace the file with valid sitemap XML (it ${detail || "could not be read"}): one <url> per indexable page, & written as &amp;. Serve it uncompressed or as valid gzip.`,
363
+ });
364
+ });
365
+ /** sitemap.xml → sitemap-1.xml, the name of part `n` of a sitemap that is split. */
366
+ const partName = (sitemap, n) => {
367
+ const u = parse(sitemap);
368
+ if (!u) {
369
+ return `${sitemap}-${n}`;
370
+ }
371
+ const name = u.pathname.replace(/(?:\.xml)?(?:\.gz)?$/iu, "");
372
+ u.pathname = `${name}-${n}.xml`;
373
+ u.search = "";
374
+ return u.href;
375
+ };
376
+ /**
377
+ * A sitemap index of `parts` files, served where the one large sitemap was. `parts` can come from
378
+ * the Content-Length the site declares, so only the first ones are written out.
379
+ */
380
+ export const splitSitemapFix = (sitemap, parts, write) => {
381
+ const listed = Math.min(parts, MAX_LISTED_URLS);
382
+ const rest = parts - listed;
383
+ return addEdit("sitemap", "whole file", [
384
+ '<?xml version="1.0" encoding="UTF-8"?>',
385
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
386
+ ...Array.from({ length: listed }, (_, i) => ` <sitemap>${locOf(partName(sitemap, i + 1))}</sitemap>`),
387
+ ...(rest > 0
388
+ ? [` ${slot(`a <sitemap> for each of the other ${rest} parts`)}`]
389
+ : []),
390
+ "</sitemapindex>",
391
+ ].join("\n"), {
392
+ url: sitemap,
393
+ write: rest > 0
394
+ ? `${write} The slot stands for the other ${rest} parts, named the same way.`
395
+ : write,
396
+ });
397
+ };
398
+ /** The longest URL the sitemap protocol allows. */
399
+ const MAX_LOC_LENGTH = 2048;
400
+ /** Lists a nested index's sitemaps in the index above it, in place of the nested index. */
401
+ export const nestedIndexFix = (layout, nested) => {
402
+ const listed = layout.children.get(nested) ?? [];
403
+ // The nested index is the site's own file and may list thousands of sitemaps, or URLs of any
404
+ // length: past 50, or with one longer than the protocol allows, the slot says what to write.
405
+ const children = listed.length <= MAX_LISTED_URLS &&
406
+ listed.every((c) => c.length <= MAX_LOC_LENGTH)
407
+ ? listed
408
+ : [];
409
+ return replaceEdit("sitemap", locOf(nested), children.length > 0
410
+ ? children.map((c) => `<sitemap>${locOf(c)}</sitemap>`).join("\n")
411
+ : `<sitemap><loc>${slot("URL of each sitemap the nested index lists")}</loc></sitemap>`, {
412
+ url: layout.parentOf.get(nested),
413
+ write: children.length > 0
414
+ ? "Replace the whole <sitemap> element that holds this <loc> with these, one per sitemap the nested index lists."
415
+ : "Replace the whole <sitemap> element that holds this <loc> with one <sitemap> per sitemap the nested index lists.",
416
+ });
417
+ };
418
+ /** "https://…/a (2026-13-01)" → the URL and the value in brackets. */
419
+ const exampleParts = (example) => {
420
+ const open = example.indexOf(" (");
421
+ return open === -1 || !example.endsWith(")")
422
+ ? null
423
+ : [example.slice(0, open), example.slice(open + 2, -1)];
424
+ };
425
+ const ISO_LIKE = /^(?<date>\d{4}-\d{2}-\d{2})[\sT]+(?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)\s*(?<zone>Z|[+-]\d{2}:?\d{2})?$/u;
426
+ /** A time of day in a date written out: the "10:00" of "Mon, 09 Nov 2026 10:00:00 GMT". */
427
+ const TIME_OF_DAY = /\b\d{1,2}:\d{2}/u;
428
+ /** The zone after a time of day: GMT, UTC, Z or an offset. */
429
+ const ZONE_AFTER = /\b(?:gmt|utc|z)\b|[+-]\d{2}:?\d{2}\b/iu;
430
+ /**
431
+ * A lastmod value in W3C Datetime, when it says unambiguously which moment it means: a date and
432
+ * time with a space instead of the T, a zone without its colon, a date written another way
433
+ * (14 March 2026, 14/03/2026), or a date and time with a month name and a zone. Null for the rest:
434
+ * 03/04/2026 could be March or April, and a time without a zone is a moment in the site's time
435
+ * zone, which the audit does not know.
436
+ */
437
+ export const toW3cDatetime = (value) => {
438
+ const v = value.trim();
439
+ const iso = ISO_LIKE.exec(v)?.groups;
440
+ if (iso?.date && iso.time) {
441
+ const zone = iso.zone
442
+ ? iso.zone.replace(/^(?<sign>[+-]\d{2})(?<minutes>\d{2})$/u, "$<sign>:$<minutes>")
443
+ : "";
444
+ return `${iso.date}T${iso.time}${zone}`;
445
+ }
446
+ // A date alone is a calendar date: read it without this machine's time zone.
447
+ const date = isoDate(v);
448
+ if (date) {
449
+ return date;
450
+ }
451
+ const time = TIME_OF_DAY.exec(v);
452
+ if (time &&
453
+ /[a-z]{3}/iu.test(v) &&
454
+ /\b\d{4}\b/u.test(v) &&
455
+ ZONE_AFTER.test(v.slice(time.index + time[0].length))) {
456
+ const moment = Date.parse(v);
457
+ if (Number.isFinite(moment)) {
458
+ return new Date(moment).toISOString().replace(/\.000Z$/u, "Z");
459
+ }
460
+ }
461
+ return null;
462
+ };
463
+ /** Rewrites <lastmod> values that are not W3C Datetime, or lie in the future. */
464
+ export const lastmodFix = (layout, examples, kind, today) => examples.flatMap((example) => {
465
+ const parts = exampleParts(example);
466
+ if (!parts) {
467
+ return [];
468
+ }
469
+ const [loc, value] = parts;
470
+ const fixed = kind === "invalid" ? toW3cDatetime(value) : null;
471
+ return [
472
+ replaceEdit("sitemap", `<lastmod>${xmlText(value)}</lastmod>`, `<lastmod>${fixed ?? slot("date the page last changed")}</lastmod>`, {
473
+ url: sitemapFileOf(layout, loc),
474
+ at: locOf(loc),
475
+ write: fixed
476
+ ? undefined
477
+ : `When ${loc} last changed in substance, as 2026-11-09 or 2026-11-09T10:00:00+01:00, no later than today (${today}). Write the same in the generator so every entry gets it.`,
478
+ }),
479
+ ];
480
+ });
481
+ const bareHost = (host) => host.replace(/^www\./iu, "");
482
+ /**
483
+ * URLs on another host: the www or bare-domain spelling of the site's own host is a slip, written
484
+ * as the site's host; another host may list them when its own robots.txt names the sitemap.
485
+ */
486
+ export const otherHostFix = (layout, examples, siteOrigin) => {
487
+ const site = parse(siteOrigin);
488
+ const robotsFor = new Map();
489
+ const edits = [];
490
+ for (const example of examples) {
491
+ const parts = exampleParts(example);
492
+ const loc = parts ? parse(parts[0]) : null;
493
+ const sitemap = parts?.[1].replace(/^in /u, "");
494
+ if (!parts || !loc || !sitemap) {
495
+ continue;
496
+ }
497
+ if (site && bareHost(loc.hostname) === bareHost(site.hostname)) {
498
+ // Appended, not resolved: a "//blog/post" path resolved against the origin names a host.
499
+ const fixed = new URL(`${site.origin}${loc.pathname}${loc.search}`).href;
500
+ edits.push(replaceEdit("sitemap", locOf(loc.href), locOf(fixed), { url: sitemap }));
501
+ }
502
+ else if (!robotsFor.has(loc.origin)) {
503
+ robotsFor.set(loc.origin, sitemap);
504
+ }
505
+ }
506
+ for (const [origin, sitemap] of robotsFor) {
507
+ edits.push(addEdit("robots.txt", "end of file", `Sitemap: ${sitemap}`, {
508
+ url: `${origin}/robots.txt`,
509
+ write: `So Google accepts the ${origin} URLs this sitemap lists. If they are a staging or old domain by mistake, generate the sitemap from the production URL (${siteOrigin}) instead.`,
510
+ }));
511
+ }
512
+ return edits;
513
+ };
514
+ /* pages a site rule names */
515
+ /** Where a page lives for an agent: its file in a directory audit, else its URL. */
516
+ export const locationOf = (record) => record.file ?? record.url;
517
+ /** The links on `record` whose target has the key `targetKey`. */
518
+ export const linksTo = (record, targetKey, key) => {
519
+ const seen = new Set();
520
+ return record.page.links.filter((l) => {
521
+ if (!l.url || key(l.url) !== targetKey || seen.has(l.tag)) {
522
+ return false;
523
+ }
524
+ seen.add(l.tag);
525
+ return true;
526
+ });
527
+ };
528
+ const SLOT_TITLE = "a title for this page";
529
+ /** Gives a page that shares its title with others a title of its own. */
530
+ export const retitleEdit = (record) => {
531
+ const { page } = record;
532
+ const tag = page.title === null
533
+ ? undefined
534
+ : page.markup.titles[page.titles.indexOf(page.title)];
535
+ if (tag === undefined) {
536
+ return null;
537
+ }
538
+ return onUrl(replaceEdit("html", tag, withContent(tag, slot(SLOT_TITLE)) ??
539
+ `<title>${slot(SLOT_TITLE)}</title>`, {
540
+ at: "head > title",
541
+ write: "A title that says what this page has and the others do not: the product, place, topic or item name, then the site name if it fits (30-60 characters).",
542
+ }), locationOf(record));
543
+ };
544
+ /** Gives a page that shares its meta description with others one of its own. */
545
+ export const redescribeEdit = (record) => {
546
+ const { page } = record;
547
+ const tag = page.metaDescription === null
548
+ ? undefined
549
+ : page.markup.metaDescriptions[page.metaDescriptions.indexOf(page.metaDescription)];
550
+ if (tag === undefined) {
551
+ return null;
552
+ }
553
+ return onUrl(setAttributeEdit(tag, 'head > meta[name="description"]', "content", slot("a description of this page"), "A summary of what this page offers that no other page's description says, in 70-160 characters."), locationOf(record));
554
+ };
555
+ /** Gives a page that shares its <h1> with others one of its own. */
556
+ export const reheadEdit = (record) => {
557
+ const { page } = record;
558
+ const index = page.headings.findIndex((h) => h.level === 1);
559
+ const tag = page.markup.headings[index];
560
+ if (index === -1 || tag === undefined) {
561
+ return null;
562
+ }
563
+ return onUrl(replaceEdit("html", tag, withContent(tag, slot("heading for this page")) ??
564
+ `<h1>${slot("heading for this page")}</h1>`, {
565
+ at: "h1",
566
+ write: "The main topic of this page, in words that set it apart from the others.",
567
+ }), locationOf(record));
568
+ };
569
+ /** Sets the canonical of `record` to `href`: rewrites its canonical link, or adds one. */
570
+ export const canonicalEdit = (record, href, write) => {
571
+ const [tag] = record.page.markup.canonicals;
572
+ const edit = tag
573
+ ? setAttributeEdit(tag, 'link[rel="canonical"]', "href", escapeAttribute(href), write)
574
+ : addEdit("html", "head", `<link rel="canonical" href="${escapeAttribute(href)}">`, {
575
+ write,
576
+ });
577
+ return onUrl(edit, locationOf(record));
578
+ };
579
+ /** The hreflang link that lists `alternate` on `record`, on the page that does not link back. */
580
+ export const returnLinkEdit = (target, page, key) => {
581
+ const selves = page.page.hreflang.filter((h) => h.url && key(h.url) === key(page.url));
582
+ // A real language code before x-default, which some platforms write first: the return link
583
+ // should say which language the page is, not only that it is the fallback.
584
+ const self = selves.find((h) => h.lang.toLowerCase() !== "x-default") ?? selves[0];
585
+ const lang = self?.lang ?? page.page.lang;
586
+ return addEdit("html", "head", `<link rel="alternate" hreflang="${lang ? escapeAttribute(lang) : slot("language code of the page")}" href="${escapeAttribute(page.url)}">`, {
587
+ url: locationOf(target),
588
+ write: lang
589
+ ? undefined
590
+ : `The hreflang code ${page.url} stands for, such as en or nl-nl; that page should list itself with the same code.`,
591
+ });
592
+ };
593
+ /** The `content` of a meta tag as written, or null. */
594
+ const contentOf = (tag) => {
595
+ const match = /\scontent\s*=\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|(?<bare>[^\s"'>]+))/iu.exec(tag);
596
+ return match?.groups?.dq ?? match?.groups?.sq ?? match?.groups?.bare ?? null;
597
+ };
598
+ /** Robots directives without noindex: "none" leaves nofollow. */
599
+ const withoutNoindex = (value) => value
600
+ .split(",")
601
+ .map((d) => d.trim())
602
+ .flatMap((d) => {
603
+ const lower = d.toLowerCase();
604
+ if (lower === "noindex") {
605
+ return [];
606
+ }
607
+ return lower === "none" ? ["nofollow"] : [d];
608
+ })
609
+ .filter(Boolean)
610
+ .join(", ");
611
+ const isNoindex = (value) => value
612
+ .toLowerCase()
613
+ .split(",")
614
+ .some((d) => ["noindex", "none"].includes(d.trim()));
615
+ /** The edits that take noindex off a page: its robots meta tags, or its X-Robots-Tag header. */
616
+ export const removeNoindexEdits = (record, write) => {
617
+ const location = locationOf(record);
618
+ const edits = [];
619
+ for (const tag of record.page.markup.metaRobots) {
620
+ const content = contentOf(tag);
621
+ if (content === null || !isNoindex(content)) {
622
+ continue;
623
+ }
624
+ const rest = withoutNoindex(content);
625
+ const rewritten = rest
626
+ ? withAttribute(tag, "content", escapeAttribute(rest))
627
+ : null;
628
+ edits.push(onUrl(rewritten
629
+ ? replaceEdit("html", tag, rewritten, {
630
+ at: 'meta[name="robots"]',
631
+ write,
632
+ })
633
+ : removeEdit("html", tag, { at: 'meta[name="robots"]', write }), location));
634
+ }
635
+ const header = record.response?.headers["x-robots-tag"];
636
+ if (header && isNoindex(header)) {
637
+ const rest = withoutNoindex(header);
638
+ const current = `X-Robots-Tag: ${header}`;
639
+ edits.push(rest
640
+ ? replaceEdit("http", current, `X-Robots-Tag: ${rest}`, {
641
+ url: record.url,
642
+ write,
643
+ })
644
+ : removeEdit("http", current, { url: record.url, write }));
645
+ }
646
+ return edits;
647
+ };
648
+ /** The `<img>` tags on `record` that show the image at `url`. */
649
+ export const imagesOf = (record, url) => record.page.images.filter((img) => img.src !== null && resolveUrl(img.src, record.page.url) === url);
650
+ //# sourceMappingURL=site-fixes.js.map