@rivi-software/agentic-seo-check 0.4.0 → 0.6.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 (131) hide show
  1. package/README.md +49 -7
  2. package/dist/cli.js +28 -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 +5 -6
  36. package/dist/core/lighthouse.js +297 -7
  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/render.d.ts +9 -1
  52. package/dist/core/render.js +36 -2
  53. package/dist/core/render.js.map +1 -1
  54. package/dist/core/report/markdown.js +68 -1
  55. package/dist/core/report/markdown.js.map +1 -1
  56. package/dist/core/robots.d.ts +5 -0
  57. package/dist/core/robots.js +8 -0
  58. package/dist/core/robots.js.map +1 -1
  59. package/dist/core/rules/aeo.js +339 -25
  60. package/dist/core/rules/aeo.js.map +1 -1
  61. package/dist/core/rules/canonical.js +107 -11
  62. package/dist/core/rules/canonical.js.map +1 -1
  63. package/dist/core/rules/content.js +65 -7
  64. package/dist/core/rules/content.js.map +1 -1
  65. package/dist/core/rules/document.js +73 -8
  66. package/dist/core/rules/document.js.map +1 -1
  67. package/dist/core/rules/fix.d.ts +55 -0
  68. package/dist/core/rules/fix.js +159 -0
  69. package/dist/core/rules/fix.js.map +1 -0
  70. package/dist/core/rules/head.js +81 -4
  71. package/dist/core/rules/head.js.map +1 -1
  72. package/dist/core/rules/headings.js +92 -9
  73. package/dist/core/rules/headings.js.map +1 -1
  74. package/dist/core/rules/hreflang.js +79 -9
  75. package/dist/core/rules/hreflang.js.map +1 -1
  76. package/dist/core/rules/images.js +49 -0
  77. package/dist/core/rules/images.js.map +1 -1
  78. package/dist/core/rules/index.d.ts +11 -0
  79. package/dist/core/rules/index.js +21 -1
  80. package/dist/core/rules/index.js.map +1 -1
  81. package/dist/core/rules/indexing.js +108 -1
  82. package/dist/core/rules/indexing.js.map +1 -1
  83. package/dist/core/rules/keyword.js +91 -1
  84. package/dist/core/rules/keyword.js.map +1 -1
  85. package/dist/core/rules/links.js +42 -0
  86. package/dist/core/rules/links.js.map +1 -1
  87. package/dist/core/rules/loading.js +54 -1
  88. package/dist/core/rules/loading.js.map +1 -1
  89. package/dist/core/rules/meta.js +36 -5
  90. package/dist/core/rules/meta.js.map +1 -1
  91. package/dist/core/rules/page-fixes.d.ts +39 -0
  92. package/dist/core/rules/page-fixes.js +107 -0
  93. package/dist/core/rules/page-fixes.js.map +1 -0
  94. package/dist/core/rules/quoted-tags.d.ts +23 -0
  95. package/dist/core/rules/quoted-tags.js +79 -0
  96. package/dist/core/rules/quoted-tags.js.map +1 -0
  97. package/dist/core/rules/response.js +71 -7
  98. package/dist/core/rules/response.js.map +1 -1
  99. package/dist/core/rules/rule.d.ts +17 -4
  100. package/dist/core/rules/rule.js +11 -2
  101. package/dist/core/rules/rule.js.map +1 -1
  102. package/dist/core/rules/social.js +101 -10
  103. package/dist/core/rules/social.js.map +1 -1
  104. package/dist/core/rules/structured-data-fixes.d.ts +33 -0
  105. package/dist/core/rules/structured-data-fixes.js +469 -0
  106. package/dist/core/rules/structured-data-fixes.js.map +1 -0
  107. package/dist/core/rules/structured-data-values.js +242 -31
  108. package/dist/core/rules/structured-data-values.js.map +1 -1
  109. package/dist/core/rules/structured-data.js +171 -4
  110. package/dist/core/rules/structured-data.js.map +1 -1
  111. package/dist/core/rules/title.js +39 -5
  112. package/dist/core/rules/title.js.map +1 -1
  113. package/dist/core/rules/urls.js +166 -0
  114. package/dist/core/rules/urls.js.map +1 -1
  115. package/dist/core/site-fixes.d.ts +111 -0
  116. package/dist/core/site-fixes.js +650 -0
  117. package/dist/core/site-fixes.js.map +1 -0
  118. package/dist/core/site-rules.js +474 -53
  119. package/dist/core/site-rules.js.map +1 -1
  120. package/dist/core/sitemap.d.ts +16 -0
  121. package/dist/core/sitemap.js +34 -0
  122. package/dist/core/sitemap.js.map +1 -1
  123. package/dist/core/types.d.ts +223 -3
  124. package/dist/core/types.js.map +1 -1
  125. package/dist/index.d.ts +2 -0
  126. package/dist/index.js +1 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/mcp.js +16 -2
  129. package/dist/mcp.js.map +1 -1
  130. package/package.json +5 -7
  131. package/skills/seo-audit/SKILL.md +23 -6
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Helpers for the fixes of the response, social, structured data, URL, loading, content and
3
+ * keyword rules: status lines, JSON-LD fragments quoted from the script that holds them, and URLs
4
+ * with a parameter or an origin swapped.
5
+ */
6
+ import { STATUS_CODES } from "node:http";
7
+ import { resolveUrl } from "../url.js";
8
+ import { isLocalHost } from "./canonical.js";
9
+ import { replaceHtml, slot, withContent } from "./fix.js";
10
+ /** An HTTP/1.1 status line: "HTTP/1.1 301 Moved Permanently". */
11
+ export const statusLine = (status) => {
12
+ const reason = STATUS_CODES[status];
13
+ return reason ? `HTTP/1.1 ${status} ${reason}` : `HTTP/1.1 ${status}`;
14
+ };
15
+ /** The status and Location of a permanent redirect to `location`. */
16
+ export const permanentRedirect = (location) => `${statusLine(301)}\nLocation: ${location}`;
17
+ /** Where a JSON-LD object sits, for an edit's `at`: its script, its type and its path inside the script. */
18
+ export const jsonLdAt = (type, path = "") => `script[type="application/ld+json"] > ${type}${path && path !== "(root)" ? ` at ${path}` : ""}`;
19
+ /** `text` as a regular expression that matches it literally. */
20
+ const literal = (text) => text.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw `\$&`);
21
+ /**
22
+ * `"key": value` as the script writes it (any spacing around the colon), or null when it is not
23
+ * there verbatim, for instance because the script escapes a character the value holds.
24
+ */
25
+ export const jsonProperty = (scriptText, key, value) => {
26
+ const json = JSON.stringify(value);
27
+ if (!scriptText || json === undefined) {
28
+ return null;
29
+ }
30
+ // Both parts are escaped literals joined by \s*, so the pattern cannot backtrack. The lookahead
31
+ // keeps `"position": 1` from matching the start of `"position": 10`.
32
+ const match = new RegExp(`${literal(JSON.stringify(key))}\\s*:\\s*${literal(json)}(?![\\w.+-])`, "u").exec(scriptText);
33
+ return match ? match[0] : null;
34
+ };
35
+ /** A JSON value for a JSON-LD fix, safe inside a <script>: "<" is escaped so "</script>" cannot end it. */
36
+ export const json = (value) => JSON.stringify(value).replaceAll("<", String.raw `\u003c`);
37
+ /**
38
+ * The origin to build absolute URLs from: the page's own, or a slot when the page is on a
39
+ * development host or is HTML without a URL, where only the author knows the production origin.
40
+ */
41
+ export const productionOrigin = (page, source) => {
42
+ if (source !== "html" && !isLocalHost(page.url)) {
43
+ try {
44
+ return new URL(page.url).origin;
45
+ }
46
+ catch {
47
+ // Falls through to the slot.
48
+ }
49
+ }
50
+ return slot("https://www.example.com");
51
+ };
52
+ /** What a production-origin slot needs, for `write`. */
53
+ export const ORIGIN_SLOT_WRITE = "The site's production origin (scheme and host, such as https://www.example.com), from the site URL setting rather than the development server.";
54
+ /** Whether a `productionOrigin` is a slot the author fills in. */
55
+ export const isSlot = (text) => text.includes("{{");
56
+ /**
57
+ * `raw`, a URL as an attribute writes it, without the query parameters `drop` selects (and a
58
+ * `;jsessionid=` path parameter when `drop` selects "jsessionid"), written the way `raw` was:
59
+ * absolute stays absolute, a path stays a path.
60
+ */
61
+ export const withoutParameters = (raw, base, drop) => {
62
+ const absolute = resolveUrl(raw, base);
63
+ if (!absolute) {
64
+ return raw;
65
+ }
66
+ const url = new URL(absolute);
67
+ if (drop("jsessionid")) {
68
+ url.pathname = url.pathname.replaceAll(/;jsessionid=[^/;]*/giu, "");
69
+ }
70
+ // A copy: deleting while the live iterator runs would skip the parameter after each one deleted.
71
+ const names = [...url.searchParams.keys()];
72
+ for (const name of names) {
73
+ if (drop(name)) {
74
+ url.searchParams.delete(name);
75
+ }
76
+ }
77
+ const search = url.searchParams.size > 0 ? url.search : "";
78
+ if (/^[a-z][\d+.a-z-]*:/iu.test(raw) || raw.startsWith("//")) {
79
+ return `${raw.startsWith("//") ? "//" : `${url.protocol}//`}${url.host}${url.pathname}${search}${url.hash}`;
80
+ }
81
+ return `${url.pathname}${search}${url.hash}`;
82
+ };
83
+ /** Sentences of visible text that end in a full stop, question or exclamation mark. */
84
+ export const sentences = (text) =>
85
+ // Text after the last end mark matches too (`|$`), so a run without one is read once instead of
86
+ // once from every position in it, which took seconds on a long paragraph; it is dropped after.
87
+ [...text.matchAll(/[^.!?]+(?:[.!?]|$)/gu)].flatMap((m) => /[.!?]$/u.test(m[0]) ? [m[0].trim()] : []);
88
+ /**
89
+ * The edit that rewrites one text block: the element as written, with a slot for its content. A
90
+ * block quoted cut short is rebuilt from its start tag, so the code is still whole.
91
+ */
92
+ export const rewriteBlock = (block, hint, write) => replaceHtml(block.outer, withContent(block.outer, slot(hint)) ??
93
+ `${block.tag}${slot(hint)}</${block.name}>`, { at: block.name, write });
94
+ /** The block of `blocks` that scores highest, or null when none scores above 0. */
95
+ export const bestBlock = (blocks, score) => {
96
+ let best = null;
97
+ let top = 0;
98
+ for (const block of blocks) {
99
+ const value = score(block);
100
+ if (value > top) {
101
+ best = block;
102
+ top = value;
103
+ }
104
+ }
105
+ return best;
106
+ };
107
+ //# sourceMappingURL=page-fixes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-fixes.js","sourceRoot":"","sources":["../../../src/core/rules/page-fixes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The attributes of a quoted start tag, names lower-cased and values as written (character
3
+ * references not decoded). The first of a repeated attribute wins, as in a browser. Null for a
4
+ * quote cut short.
5
+ */
6
+ export declare const readAttributes: (tag: string) => Map<string, string> | null;
7
+ /** `element[name="value"]`, the selector of the element with that attribute value. */
8
+ export declare const withAttributeSelector: (element: string, name: string, value: string) => string;
9
+ /** The slot for the production origin, when the audit ran on a local or file URL. */
10
+ export declare const ORIGIN_SLOT: string;
11
+ export declare const ORIGIN_WRITE = "The site's production origin, such as https://www.example.com: this audit ran on a local URL or a file, so it cannot know it.";
12
+ /**
13
+ * `url` as the live site writes it: as it is, or with its origin left as a slot when the audit ran
14
+ * on a local host or a file, whose origin is not the site's.
15
+ */
16
+ export declare const liveUrl: (url: string, local: boolean) => string;
17
+ /**
18
+ * `outer`, a whole element as quoted (`<h4 class="x">Text</h4>`), with its tag name changed in
19
+ * the start and end tag; null when the quote was cut or does not end in that element's end tag.
20
+ */
21
+ export declare const renamed: (outer: string, from: string, to: string) => string | null;
22
+ /** What sits between the start tag and the end tag of a whole quoted element, or null. */
23
+ export declare const innerOf: (outer: string) => string | null;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reading the tags a page quotes (`PageMarkup`, `ImageInfo.tag`) for the fixes of the page rules:
3
+ * attribute values, CSS selectors that find one element, and URLs as the live site should write
4
+ * them.
5
+ */
6
+ import { slot } from "./fix.js";
7
+ /*
8
+ * One attribute of a start tag: optional whitespace or a stray "/", a name, then maybe "=" and a
9
+ * value. The name and the separators share no character, so matching stays linear.
10
+ */
11
+ const ATTRIBUTE = /[\s/]*(?<name>[^\s"'>/=]+)(?:\s*=\s*(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)'|(?<uq>[^\s>]+)))?/uy;
12
+ /**
13
+ * The attributes of a quoted start tag, names lower-cased and values as written (character
14
+ * references not decoded). The first of a repeated attribute wins, as in a browser. Null for a
15
+ * quote cut short.
16
+ */
17
+ export const readAttributes = (tag) => {
18
+ const name = /^<[^\s/>]+/u.exec(tag);
19
+ if (!name || tag.endsWith("…")) {
20
+ return null;
21
+ }
22
+ const out = new Map();
23
+ ATTRIBUTE.lastIndex = name[0].length;
24
+ for (let m = ATTRIBUTE.exec(tag); m?.groups?.name; m = ATTRIBUTE.exec(tag)) {
25
+ const key = m.groups.name.toLowerCase();
26
+ if (!out.has(key)) {
27
+ out.set(key, m.groups.dq ?? m.groups.sq ?? m.groups.uq ?? "");
28
+ }
29
+ }
30
+ return out;
31
+ };
32
+ /** A value inside a double-quoted CSS attribute selector. */
33
+ const cssString = (value) => value
34
+ .replaceAll("\\", "\\\\")
35
+ .replaceAll('"', '\\"')
36
+ .replaceAll(/[\n\r\f]/gu, " ");
37
+ /** `element[name="value"]`, the selector of the element with that attribute value. */
38
+ export const withAttributeSelector = (element, name, value) => `${element}[${name}="${cssString(value.slice(0, 200))}"]`;
39
+ /** The slot for the production origin, when the audit ran on a local or file URL. */
40
+ export const ORIGIN_SLOT = slot("production origin");
41
+ export const ORIGIN_WRITE = "The site's production origin, such as https://www.example.com: this audit ran on a local URL or a file, so it cannot know it.";
42
+ /**
43
+ * `url` as the live site writes it: as it is, or with its origin left as a slot when the audit ran
44
+ * on a local host or a file, whose origin is not the site's.
45
+ */
46
+ export const liveUrl = (url, local) => {
47
+ if (!local) {
48
+ return url;
49
+ }
50
+ try {
51
+ const u = new URL(url);
52
+ return `${ORIGIN_SLOT}${u.pathname}${u.search}`;
53
+ }
54
+ catch {
55
+ return url;
56
+ }
57
+ };
58
+ /**
59
+ * `outer`, a whole element as quoted (`<h4 class="x">Text</h4>`), with its tag name changed in
60
+ * the start and end tag; null when the quote was cut or does not end in that element's end tag.
61
+ */
62
+ export const renamed = (outer, from, to) => {
63
+ const start = new RegExp(`^<${from}(?=[\\s/>])`, "iu");
64
+ const end = new RegExp(`</${from}\\s*>$`, "iu");
65
+ if (outer.endsWith("…") || !start.test(outer) || !end.test(outer)) {
66
+ return null;
67
+ }
68
+ return outer.replace(start, `<${to}`).replace(end, `</${to}>`);
69
+ };
70
+ /** What sits between the start tag and the end tag of a whole quoted element, or null. */
71
+ export const innerOf = (outer) => {
72
+ const open = outer.indexOf(">");
73
+ const close = outer.lastIndexOf("</");
74
+ if (outer.endsWith("…") || open === -1 || close < open) {
75
+ return null;
76
+ }
77
+ return outer.slice(open + 1, close);
78
+ };
79
+ //# sourceMappingURL=quoted-tags.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quoted-tags.js","sourceRoot":"","sources":["../../../src/core/rules/quoted-tags.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,26 @@
1
1
  import { isLocalHost } from "./canonical.js";
2
+ import { addEdit, removeEdit, replaceEdit } from "./fix.js";
3
+ import { permanentRedirect, statusLine } from "./page-fixes.js";
2
4
  import { defineRule, GOOGLE_DOCS } from "./rule.js";
5
+ /** Where a redirect hop sends the request: the next hop's URL, or the final URL after the last. */
6
+ const nextUrl = (response, index) => response.redirectChain[index + 1]?.url ?? response.finalUrl;
7
+ /** A hop's status line and Location, as the server answered. */
8
+ const hopResponse = (response, index, hop) => `${statusLine(hop.status)}\nLocation: ${nextUrl(response, index)}`;
9
+ /** A redirect that keeps its kind (permanent or temporary) but sends the request to `location`. */
10
+ const redirectTo = (status, location) => `${statusLine(status)}\nLocation: ${location}`;
11
+ /** Edits that point the links to `from` at `to`: they are on other pages, so the edit names them. */
12
+ const linkEdit = (from, to) => replaceEdit("html", from, to, {
13
+ at: 'a[href], link[rel="canonical"], link[rel="alternate"]',
14
+ write: "In every internal link, canonical, hreflang link and sitemap entry across the site that names the redirecting URL (also when written as a relative path).",
15
+ });
16
+ /** The page has markup of its own, so it is HTML served with the wrong type rather than a file. */
17
+ const looksLikeHtml = (page) => page.markup.html !== null ||
18
+ page.markup.head !== null ||
19
+ page.titles.length > 0;
20
+ /** Headers that make a response unsafe to share from a CDN cache: it is set up per visitor. */
21
+ const isPersonal = (response) => "set-cookie" in response.headers ||
22
+ /\b(?:private|no-store)\b/iu.test(response.headers["cache-control"] ?? "") ||
23
+ /\b(?:cookie|authorization)\b/iu.test(response.headers["vary"] ?? "");
3
24
  export const responseRules = [
4
25
  defineRule({
5
26
  id: "response.error-status",
@@ -9,6 +30,12 @@ export const responseRules = [
9
30
  recommendation: "Fix the route or server error. If the page was removed on purpose, return 410 (or 404) and remove internal links and sitemap entries pointing to it.",
10
31
  }, ({ response }, make) => response?.status && response.status >= 400
11
32
  ? make(`URL returned HTTP ${response.status}.`, {
33
+ fix: [
34
+ replaceEdit("http", statusLine(response.status), statusLine(200), {
35
+ at: "status line",
36
+ write: "Fix the route or the server error so the page answers 200 with its content. If the page was removed on purpose, answer 410 Gone instead and remove the links and sitemap entries that point to it.",
37
+ }),
38
+ ],
12
39
  evidence: { status: response.status, finalUrl: response.finalUrl },
13
40
  })
14
41
  : null),
@@ -23,6 +50,7 @@ export const responseRules = [
23
50
  const [hop] = response?.redirectChain ?? [];
24
51
  return response && hop && response.redirectChain.length === 1
25
52
  ? make(`Request redirected (${hop.status}) to ${response.finalUrl}.`, {
53
+ fix: [linkEdit(hop.url, response.finalUrl)],
26
54
  evidence: {
27
55
  chain: response.redirectChain,
28
56
  finalUrl: response.finalUrl,
@@ -39,6 +67,15 @@ export const responseRules = [
39
67
  docs: GOOGLE_DOCS.redirects,
40
68
  }, ({ response }, make) => response && response.redirectChain.length >= 2
41
69
  ? make(`Redirect chain of ${response.redirectChain.length} hops ends at ${response.finalUrl}.`, {
70
+ // Every hop redirects straight to the end, keeping its kind; the last already does.
71
+ fix: [
72
+ ...response.redirectChain
73
+ .slice(0, -1)
74
+ .map((hop, i) => replaceEdit("http", hopResponse(response, i, hop), redirectTo(hop.status, response.finalUrl), { url: hop.url, at: "status line and Location" })),
75
+ ...(response.redirectChain[0]
76
+ ? [linkEdit(response.redirectChain[0].url, response.finalUrl)]
77
+ : []),
78
+ ],
42
79
  evidence: {
43
80
  chain: response.redirectChain,
44
81
  finalUrl: response.finalUrl,
@@ -55,7 +92,20 @@ export const responseRules = [
55
92
  }, ({ response, thresholds }, make) => response?.timeMs !== null &&
56
93
  response?.timeMs !== undefined &&
57
94
  response.timeMs > thresholds.slowResponseMs
58
- ? make(`HTML response took ${response.timeMs} ms (single measurement; threshold ${thresholds.slowResponseMs} ms).`, { evidence: { timeMs: response.timeMs } })
95
+ ? make(`HTML response took ${response.timeMs} ms (single measurement; threshold ${thresholds.slowResponseMs} ms).`, {
96
+ // A CDN may cache only a page that is the same for every visitor.
97
+ fix: [
98
+ isPersonal(response)
99
+ ? replaceEdit("http", `${response.timeMs} ms`, "under 800 ms", {
100
+ at: "time to first byte",
101
+ write: "The response is set up per visitor (cookies or private caching), so it must not come from a shared cache: speed up the server work for this URL instead (database queries, API calls during render, a cache of the data it fetches).",
102
+ })
103
+ : addEdit("http", "response headers", "Cache-Control: public, s-maxage=300, stale-while-revalidate=86400", {
104
+ write: "Lets the CDN serve this HTML from its cache for 5 minutes and refresh it in the background. Set it only when the page is the same for every visitor; otherwise speed up the server work for this URL. Aim for a time to first byte under 800 ms.",
105
+ }),
106
+ ],
107
+ evidence: { timeMs: response.timeMs },
108
+ })
59
109
  : null),
60
110
  defineRule({
61
111
  id: "response.not-html",
@@ -63,17 +113,25 @@ export const responseRules = [
63
113
  severity: "warning",
64
114
  description: "The response Content-Type is not HTML.",
65
115
  recommendation: "Serve pages with Content-Type: text/html; charset=utf-8.",
66
- }, ({ response }, make) => {
116
+ }, ({ page, response }, make) => {
67
117
  const contentType = response?.contentType;
68
118
  if (!contentType) {
69
119
  return null;
70
120
  }
71
121
  const ct = contentType.toLowerCase();
72
- return !ct.includes("html") && !ct.includes("xhtml")
73
- ? make(`Content-Type is "${contentType}", not HTML.`, {
74
- evidence: { contentType },
75
- })
76
- : null;
122
+ if (ct.includes("html") || ct.includes("xhtml")) {
123
+ return null;
124
+ }
125
+ // A file such as a PDF keeps its type; HTML sent with the wrong one gets text/html.
126
+ const fix = looksLikeHtml(page)
127
+ ? replaceEdit("http", `Content-Type: ${contentType}`, "Content-Type: text/html; charset=utf-8", { at: "Content-Type" })
128
+ : removeEdit("sitemap", `<loc>${response.finalUrl}</loc>`, {
129
+ write: "This URL serves a file, not a page, so its type is right: if a sitemap lists it, remove its <url> entry, and audit an HTML page instead.",
130
+ });
131
+ return make(`Content-Type is "${contentType}", not HTML.`, {
132
+ fix: [fix],
133
+ evidence: { contentType },
134
+ });
77
135
  }),
78
136
  defineRule({
79
137
  id: "response.not-https",
@@ -86,6 +144,12 @@ export const responseRules = [
86
144
  response.finalUrl.startsWith("http://") &&
87
145
  !isLocalHost(response.finalUrl)
88
146
  ? make("Page is served over http:// instead of https://.", {
147
+ fix: [
148
+ replaceEdit("http", statusLine(response.status ?? 200), permanentRedirect(response.finalUrl.replace(/^http:/iu, "https:")), {
149
+ at: "status line and Location",
150
+ write: "Serve the site over https first (a TLS certificate for this host, which most hosts and CDNs issue for free), then redirect every http URL to its https twin. Build canonicals, sitemaps and links from the https origin.",
151
+ }),
152
+ ],
89
153
  evidence: { finalUrl: response.finalUrl },
90
154
  })
91
155
  : null),
@@ -1 +1 @@
1
- {"version":3,"file":"response.js","sourceRoot":"","sources":["../../../src/core/rules/response.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"response.js","sourceRoot":"","sources":["../../../src/core/rules/response.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAEpD,mGAAmG;AACnG,MAAM,OAAO,GAAG,CAAC,QAAsB,EAAE,KAAa,EAAU,EAAE,CAChE,QAAQ,CAAC,aAAa,CAAC,KAAK,GAAG,CAAC,CAAC,EAAE,GAAG,IAAI,QAAQ,CAAC,QAAQ,CAAC;AAE9D,gEAAgE;AAChE,MAAM,WAAW,GAAG,CAAC,QAAsB,EAAE,KAAa,EAAE,GAAgB,EAAE,EAAE,CAC9E,GAAG,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,OAAO,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,CAAC;AAErE,mGAAmG;AACnG,MAAM,UAAU,GAAG,CAAC,MAAc,EAAE,QAAgB,EAAU,EAAE,CAC9D,GAAG,UAAU,CAAC,MAAM,CAAC,eAAe,QAAQ,EAAE,CAAC;AAEjD,qGAAqG;AACrG,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAE,EAAU,EAAW,EAAE,CACrD,WAAW,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE;IAC5B,EAAE,EAAE,uDAAuD;IAC3D,KAAK,EACH,2JAA2J;CAC9J,CAAC,CAAC;AAEL,mGAAmG;AACnG,MAAM,aAAa,GAAG,CAAC,IAAmB,EAAW,EAAE,CACrD,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,IAAI;IACzB,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,IAAI;IACzB,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;AAEzB,+FAA+F;AAC/F,MAAM,UAAU,GAAG,CAAC,QAAsB,EAAW,EAAE,CACrD,YAAY,IAAI,QAAQ,CAAC,OAAO;IAChC,4BAA4B,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;IAC1E,gCAAgC,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;AAExE,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,UAAU,CACR;QACE,EAAE,EAAE,uBAAuB;QAC3B,QAAQ,EAAE,UAAU;QACpB,QAAQ,EAAE,OAAO;QACjB,WAAW,EAAE,4CAA4C;QACzD,cAAc,EACZ,sJAAsJ;KACzJ,EACD,CAAC,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE,CACrB,QAAQ,EAAE,MAAM,IAAI,QAAQ,CAAC,MAAM,IAAI,GAAG;QACxC,CAAC,CAAC,IAAI,CAAC,qBAAqB,QAAQ,CAAC,MAAM,GAAG,EAAE;YAC5C,GAAG,EAAE;gBACH,WAAW,CACT,MAAM,EACN,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC,EAC3B,UAAU,CAAC,GAAG,CAAC,EACf;oBACE,EAAE,EAAE,aAAa;oBACjB,KAAK,EACH,oMAAoM;iBACvM,CACF;aACF;YACD,QAAQ,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE;SACnE,CAAC;QACJ,CAAC,CAAC,IAAI,CACX;IACD,UAAU,CACR;QACE,EAAE,EAAE,qBAAqB;QACzB,QAAQ,EAAE,UAAU;QACpB,QAAQ,EAAE,MAAM;QAChB,WAAW,EAAE,8CAA8C;QAC3D,cAAc,EACZ,8GAA8G;QAChH,IAAI,EAAE,WAAW,CAAC,SAAS;KAC5B,EACD,CAAC,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE;QACrB,MAAM,CAAC,GAAG,CAAC,GAAG,QAAQ,EAAE,aAAa,IAAI,EAAE,CAAC;QAE5C,OAAO,QAAQ,IAAI,GAAG,IAAI,QAAQ,CAAC,aAAa,CAAC,MAAM,KAAK,CAAC;YAC3D,CAAC,CAAC,IAAI,CAAC,uBAAuB,GAAG,CAAC,MAAM,QAAQ,QAAQ,CAAC,QAAQ,GAAG,EAAE;gBAClE,GAAG,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;gBAC3C,QAAQ,EAAE;oBACR,KAAK,EAAE,QAAQ,CAAC,aAAa;oBAC7B,QAAQ,EAAE,QAAQ,CAAC,QAAQ;iBAC5B;aACF,CAAC;YACJ,CAAC,CAAC,IAAI,CAAC;IACX,CAAC,CACF;IACD,UAAU,CACR;QACE,EAAE,EAAE,yBAAyB;QAC7B,QAAQ,EAAE,UAAU;QACpB,QAAQ,EAAE,SAAS;QACnB,WAAW,EACT,4EAA4E;QAC9E,cAAc,EACZ,qIAAqI;QACvI,IAAI,EAAE,WAAW,CAAC,SAAS;KAC5B,EACD,CAAC,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE,CACrB,QAAQ,IAAI,QAAQ,CAAC,aAAa,CAAC,MAAM,IAAI,CAAC;QAC5C,CAAC,CAAC,IAAI,CACF,qBAAqB,QAAQ,CAAC,aAAa,CAAC,MAAM,iBAAiB,QAAQ,CAAC,QAAQ,GAAG,EACvF;YACE,oFAAoF;YACpF,GAAG,EAAE;gBACH,GAAG,QAAQ,CAAC,aAAa;qBACtB,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;qBACZ,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CACd,WAAW,CACT,MAAM,EACN,WAAW,CAAC,QAAQ,EAAE,CAAC,EAAE,GAAG,CAAC,EAC7B,UAAU,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC,EACzC,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,EAAE,EAAE,0BAA0B,EAAE,CACjD,CACF;gBACH,GAAG,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC;oBAC3B,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;oBAC9D,CAAC,CAAC,EAAE,CAAC;aACR;YACD,QAAQ,EAAE;gBACR,KAAK,EAAE,QAAQ,CAAC,aAAa;gBAC7B,QAAQ,EAAE,QAAQ,CAAC,QAAQ;aAC5B;SACF,CACF;QACH,CAAC,CAAC,IAAI,CACX;IACD,UAAU,CACR;QACE,EAAE,EAAE,eAAe;QACnB,QAAQ,EAAE,UAAU;QACpB,QAAQ,EAAE,SAAS;QACnB,WAAW,EACT,gFAAgF;QAClF,cAAc,EACZ,yIAAyI;QAC3I,IAAI,EAAE,WAAW,CAAC,GAAG;KACtB,EACD,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,EAAE,CACjC,QAAQ,EAAE,MAAM,KAAK,IAAI;QACzB,QAAQ,EAAE,MAAM,KAAK,SAAS;QAC9B,QAAQ,CAAC,MAAM,GAAG,UAAU,CAAC,cAAc;QACzC,CAAC,CAAC,IAAI,CACF,sBAAsB,QAAQ,CAAC,MAAM,sCAAsC,UAAU,CAAC,cAAc,OAAO,EAC3G;YACE,kEAAkE;YAClE,GAAG,EAAE;gBACH,UAAU,CAAC,QAAQ,CAAC;oBAClB,CAAC,CAAC,WAAW,CACT,MAAM,EACN,GAAG,QAAQ,CAAC,MAAM,KAAK,EACvB,cAAc,EACd;wBACE,EAAE,EAAE,oBAAoB;wBACxB,KAAK,EACH,sOAAsO;qBACzO,CACF;oBACH,CAAC,CAAC,OAAO,CACL,MAAM,EACN,kBAAkB,EAClB,mEAAmE,EACnE;wBACE,KAAK,EACH,kPAAkP;qBACrP,CACF;aACN;YACD,QAAQ,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE;SACtC,CACF;QACH,CAAC,CAAC,IAAI,CACX;IACD,UAAU,CACR;QACE,EAAE,EAAE,mBAAmB;QACvB,QAAQ,EAAE,UAAU;QACpB,QAAQ,EAAE,SAAS;QACnB,WAAW,EAAE,wCAAwC;QACrD,cAAc,EACZ,0DAA0D;KAC7D,EACD,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE;QAC3B,MAAM,WAAW,GAAG,QAAQ,EAAE,WAAW,CAAC;QAE1C,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,EAAE,GAAG,WAAW,CAAC,WAAW,EAAE,CAAC;QAErC,IAAI,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;YAChD,OAAO,IAAI,CAAC;QACd,CAAC;QAED,oFAAoF;QACpF,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,CAAC;YAC7B,CAAC,CAAC,WAAW,CACT,MAAM,EACN,iBAAiB,WAAW,EAAE,EAC9B,wCAAwC,EACxC,EAAE,EAAE,EAAE,cAAc,EAAE,CACvB;YACH,CAAC,CAAC,UAAU,CAAC,SAAS,EAAE,QAAQ,QAAQ,CAAC,QAAQ,QAAQ,EAAE;gBACvD,KAAK,EACH,0IAA0I;aAC7I,CAAC,CAAC;QAEP,OAAO,IAAI,CAAC,oBAAoB,WAAW,cAAc,EAAE;YACzD,GAAG,EAAE,CAAC,GAAG,CAAC;YACV,QAAQ,EAAE,EAAE,WAAW,EAAE;SAC1B,CAAC,CAAC;IACL,CAAC,CACF;IACD,UAAU,CACR;QACE,EAAE,EAAE,oBAAoB;QACxB,QAAQ,EAAE,UAAU;QACpB,QAAQ,EAAE,SAAS;QACnB,WAAW,EAAE,0CAA0C;QACvD,cAAc,EACZ,iIAAiI;KACpI,EACD,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,CAC7B,MAAM,KAAK,KAAK;QAChB,QAAQ;QACR,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC,SAAS,CAAC;QACvC,CAAC,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC7B,CAAC,CAAC,IAAI,CAAC,kDAAkD,EAAE;YACvD,GAAG,EAAE;gBACH,WAAW,CACT,MAAM,EACN,UAAU,CAAC,QAAQ,CAAC,MAAM,IAAI,GAAG,CAAC,EAClC,iBAAiB,CACf,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,EAAE,QAAQ,CAAC,CAChD,EACD;oBACE,EAAE,EAAE,0BAA0B;oBAC9B,KAAK,EACH,0NAA0N;iBAC7N,CACF;aACF;YACD,QAAQ,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE;SAC1C,CAAC;QACJ,CAAC,CAAC,IAAI,CACX;CACF,CAAC"}
@@ -1,4 +1,4 @@
1
- import type { ExtractedPage, Finding, PageSource, ResponseInfo, RuleMeta, Severity, Thresholds } from "../types.js";
1
+ import type { ExtractedPage, Finding, FixEdit, PageSource, ResponseInfo, RuleMeta, Severity, Thresholds } from "../types.js";
2
2
  export interface PageContext {
3
3
  page: ExtractedPage;
4
4
  source: PageSource;
@@ -14,13 +14,26 @@ export interface Rule {
14
14
  export interface FindingExtras {
15
15
  severity?: Severity;
16
16
  recommendation?: string;
17
+ /**
18
+ * The edits that resolve this finding here. Required, so no rule can report a problem without
19
+ * its fix; an empty list only for a finding about the audit itself rather than the site.
20
+ */
21
+ fix: FixEdit[];
22
+ /**
23
+ * What this finding is about, when a rule can report more than one per page (a JSON-LD type, a
24
+ * sitemap URL). It goes into the finding's id only, so two findings of one rule keep apart. It
25
+ * stays with the object `finding()` returns: set `url` on that object rather than copying it.
26
+ */
27
+ key?: string;
17
28
  selector?: string;
18
29
  evidence?: Finding["evidence"];
19
30
  }
20
- /** Build a finding from rule metadata, overriding only what differs for this instance. */
21
- export declare const finding: (meta: RuleMeta, message: string, extras?: FindingExtras) => Finding;
22
- export declare const defineRule: (meta: RuleMeta, check: (ctx: PageContext, make: (message: string, extras?: FindingExtras) => Finding) => Finding[] | Finding | null | undefined) => Rule;
23
31
  export declare const cap: <T>(items: T[], max?: number) => T[];
32
+ /** The `key` a finding was made with, for its id. */
33
+ export declare const findingKey: (f: Finding) => string | undefined;
34
+ /** Build a finding from rule metadata, overriding only what differs for this instance. */
35
+ export declare const finding: (meta: RuleMeta, message: string, extras: FindingExtras) => Finding;
36
+ export declare const defineRule: (meta: RuleMeta, check: (ctx: PageContext, make: (message: string, extras: FindingExtras) => Finding) => Finding[] | Finding | null | undefined) => Rule;
24
37
  export declare const GOOGLE_DOCS: {
25
38
  readonly titles: "https://developers.google.com/search/docs/appearance/title-link";
26
39
  readonly snippets: "https://developers.google.com/search/docs/appearance/snippet";
@@ -1,5 +1,9 @@
1
+ export const cap = (items, max = 10) => items.length > max ? items.slice(0, max) : items;
2
+ const findingKeys = new WeakMap();
3
+ /** The `key` a finding was made with, for its id. */
4
+ export const findingKey = (f) => findingKeys.get(f);
1
5
  /** Build a finding from rule metadata, overriding only what differs for this instance. */
2
- export const finding = (meta, message, extras = {}) => {
6
+ export const finding = (meta, message, extras) => {
3
7
  const f = {
4
8
  rule: meta.id,
5
9
  severity: extras.severity ?? meta.severity,
@@ -7,6 +11,12 @@ export const finding = (meta, message, extras = {}) => {
7
11
  message,
8
12
  recommendation: extras.recommendation ?? meta.recommendation,
9
13
  };
14
+ if (extras.fix.length) {
15
+ f.fix = cap(extras.fix);
16
+ }
17
+ if (extras.key) {
18
+ findingKeys.set(f, extras.key);
19
+ }
10
20
  if (extras.selector) {
11
21
  f.selector = extras.selector;
12
22
  }
@@ -28,7 +38,6 @@ export const defineRule = (meta, check) => ({
28
38
  return Array.isArray(result) ? result : [result];
29
39
  },
30
40
  });
31
- export const cap = (items, max = 10) => items.length > max ? items.slice(0, max) : items;
32
41
  export const GOOGLE_DOCS = {
33
42
  titles: "https://developers.google.com/search/docs/appearance/title-link",
34
43
  snippets: "https://developers.google.com/search/docs/appearance/snippet",
@@ -1 +1 @@
1
- {"version":3,"file":"rule.js","sourceRoot":"","sources":["../../../src/core/rules/rule.ts"],"names":[],"mappings":"AA+BA,0FAA0F;AAC1F,MAAM,CAAC,MAAM,OAAO,GAAG,CACrB,IAAc,EACd,OAAe,EACf,MAAM,GAAkB,EAAE,EACjB,EAAE;IACX,MAAM,CAAC,GAAY;QACjB,IAAI,EAAE,IAAI,CAAC,EAAE;QACb,QAAQ,EAAE,MAAM,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ;QAC1C,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,OAAO;QACP,cAAc,EAAE,MAAM,CAAC,cAAc,IAAI,IAAI,CAAC,cAAc;KAC7D,CAAC;IAEF,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACpB,CAAC,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;IAC/B,CAAC;IAED,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACpB,CAAC,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;IAC/B,CAAC;IAED,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;QACd,CAAC,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;IACrB,CAAC;IAED,OAAO,CAAC,CAAC;AACX,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,CACxB,IAAc,EACd,KAG2C,EACrC,EAAE,CAAC,CAAC;IACV,IAAI;IACJ,KAAK,CAAC,GAAG;QACP,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,CAC5C,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,CAC/B,CAAC;QAEF,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,EAAE,CAAC;QACZ,CAAC;QAED,OAAO,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACnD,CAAC;CACF,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,GAAG,GAAG,CAAI,KAAU,EAAE,GAAG,GAAG,EAAE,EAAO,EAAE,CAClD,KAAK,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AAEnD,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,MAAM,EAAE,iEAAiE;IACzE,QAAQ,EAAE,8DAA8D;IACxE,SAAS,EACP,wFAAwF;IAC1F,UAAU,EACR,6EAA6E;IAC/E,SAAS,EACP,0EAA0E;IAC5E,QAAQ,EACN,+EAA+E;IACjF,QAAQ,EACN,sFAAsF;IACxF,MAAM,EAAE,oEAAoE;IAC5E,KAAK,EACH,6EAA6E;IAC/E,YAAY,EACV,2EAA2E;IAC7E,aAAa,EACX,iFAAiF;IACnF,cAAc,EACZ,4FAA4F;IAC9F,UAAU,EACR,8FAA8F;IAChG,GAAG,EAAE,iCAAiC;IACtC,MAAM,EACJ,uGAAuG;IACzG,SAAS,EACP,2EAA2E;IAC7E,QAAQ,EACN,iGAAiG;IACnG,EAAE,EAAE,iBAAiB;IACrB,OAAO,EACL,sEAAsE;CAChE,CAAC"}
1
+ {"version":3,"file":"rule.js","sourceRoot":"","sources":["../../../src/core/rules/rule.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,72 @@
1
- import { normalizeUrl } from "../url.js";
1
+ import { normalizeUrl, resolveUrl } from "../url.js";
2
+ import { addHtml, escapeAttribute, setAttributeEdit, slot } from "./fix.js";
3
+ import { isSlot, ORIGIN_SLOT_WRITE, productionOrigin } from "./page-fixes.js";
2
4
  import { defineRule, GOOGLE_DOCS } from "./rule.js";
5
+ /** The URL og:url should name: the canonical, or the page itself. */
6
+ const pageUrlFor = (page, source) => {
7
+ if (page.canonical) {
8
+ return page.canonical;
9
+ }
10
+ const origin = productionOrigin(page, source);
11
+ if (!isSlot(origin)) {
12
+ return page.url;
13
+ }
14
+ try {
15
+ return `${origin}${new URL(page.url).pathname}`;
16
+ }
17
+ catch {
18
+ return `${origin}/`;
19
+ }
20
+ };
21
+ /**
22
+ * The <meta> tag for one Open Graph property, with the value the page already states elsewhere
23
+ * (its title, description, canonical) or a slot for what only the author knows (the image).
24
+ */
25
+ const ogTag = (page, source, property) => {
26
+ const meta = (value) => `<meta property="og:${property}" content="${value}">`;
27
+ switch (property) {
28
+ case "title": {
29
+ return page.title
30
+ ? { code: meta(escapeAttribute(page.title)) }
31
+ : {
32
+ code: meta(slot("page title")),
33
+ write: "og:title: the page's title as it should read in a share.",
34
+ };
35
+ }
36
+ case "description": {
37
+ return page.metaDescription
38
+ ? { code: meta(escapeAttribute(page.metaDescription)) }
39
+ : {
40
+ code: meta(slot("share description")),
41
+ write: "og:description: one or two sentences on what the page offers.",
42
+ };
43
+ }
44
+ case "image": {
45
+ return {
46
+ code: meta(`${productionOrigin(page, source)}${slot("/path/to/share-image.jpg")}`),
47
+ write: "og:image: the absolute URL of an image for share previews, at least 1200x630 pixels; the origin is the site's production origin.",
48
+ };
49
+ }
50
+ case "url": {
51
+ const url = pageUrlFor(page, source);
52
+ return {
53
+ code: meta(isSlot(url) ? url : escapeAttribute(url)),
54
+ write: isSlot(url) ? ORIGIN_SLOT_WRITE : undefined,
55
+ };
56
+ }
57
+ default: {
58
+ return { code: meta("website") };
59
+ }
60
+ }
61
+ };
62
+ /** One edit adding the given Open Graph tags to <head>, with what the slots in them need. */
63
+ const addOgTags = (page, source, properties) => {
64
+ const tags = properties.map((p) => ogTag(page, source, p));
65
+ const writes = tags.flatMap((t) => (t.write ? [t.write] : []));
66
+ return addHtml("head", tags.map((t) => t.code).join("\n"), writes.length > 0
67
+ ? `${writes.join(" ")} og:type is "website"; use "article" for an article.`
68
+ : undefined);
69
+ };
3
70
  const OG_CORE = ["title", "description", "image"];
4
71
  const OG_RECOMMENDED = [
5
72
  "title",
@@ -14,6 +81,8 @@ const TWITTER_CARDS = new Set([
14
81
  "app",
15
82
  "player",
16
83
  ]);
84
+ /** A large image card when the page has an image to show in it, else the small one. */
85
+ const cardFor = (page) => page.og["image"] || page.twitter["image"] ? "summary_large_image" : "summary";
17
86
  export const socialRules = [
18
87
  defineRule({
19
88
  id: "og.missing",
@@ -22,9 +91,10 @@ export const socialRules = [
22
91
  description: "No Open Graph tags (og:title, og:description, og:image) are present.",
23
92
  recommendation: "Add og:title, og:description, og:image (absolute URL, at least 1200x630), og:url and og:type so shares on social platforms and chat apps render a rich preview.",
24
93
  docs: GOOGLE_DOCS.og,
25
- }, ({ page }, make) => OG_CORE.some((k) => page.og[k])
94
+ }, ({ page, source }, make) => OG_CORE.some((k) => page.og[k])
26
95
  ? null
27
96
  : make("Page has no Open Graph tags.", {
97
+ fix: [addOgTags(page, source, OG_RECOMMENDED)],
28
98
  selector: 'head > meta[property^="og:"]',
29
99
  })),
30
100
  defineRule({
@@ -34,13 +104,14 @@ export const socialRules = [
34
104
  description: "Some Open Graph tags are present but one or more of og:title, og:description, og:image, og:url, og:type is missing.",
35
105
  recommendation: "Fill in the missing Open Graph properties. og:image and og:description have the biggest effect on share previews.",
36
106
  docs: GOOGLE_DOCS.og,
37
- }, ({ page }, make) => {
107
+ }, ({ page, source }, make) => {
38
108
  if (!OG_CORE.some((k) => page.og[k])) {
39
109
  return null;
40
110
  }
41
111
  const missing = OG_RECOMMENDED.filter((k) => !page.og[k]);
42
112
  return missing.length
43
113
  ? make(`Open Graph is missing: ${missing.map((m) => `og:${m}`).join(", ")}.`, {
114
+ fix: [addOgTags(page, source, missing)],
44
115
  selector: 'head > meta[property^="og:"]',
45
116
  evidence: { missing, present: Object.keys(page.og) },
46
117
  })
@@ -53,14 +124,25 @@ export const socialRules = [
53
124
  description: "og:image is not an absolute URL.",
54
125
  recommendation: "Use a full https:// URL for og:image; most platforms ignore relative image paths.",
55
126
  docs: GOOGLE_DOCS.og,
56
- }, ({ page }, make) => {
127
+ }, ({ page, source }, make) => {
57
128
  const img = page.og["image"];
58
- return img && !/^https?:\/\//iu.test(img)
59
- ? make(`og:image "${img}" is not an absolute URL.`, {
60
- selector: 'head > meta[property="og:image"]',
61
- evidence: { image: img },
62
- })
63
- : null;
129
+ if (!img || /^https?:\/\//iu.test(img)) {
130
+ return null;
131
+ }
132
+ // The path the page meant, on the production origin (a slot when the page is on a dev host).
133
+ const origin = productionOrigin(page, source);
134
+ const resolved = resolveUrl(img, page.url);
135
+ const absolute = isSlot(origin)
136
+ ? `${origin}${resolved ? new URL(resolved).pathname + new URL(resolved).search : `/${img.replace(/^\/+/u, "")}`}`
137
+ : (resolved ?? img);
138
+ const selector = 'head > meta[property="og:image"]';
139
+ return make(`og:image "${img}" is not an absolute URL.`, {
140
+ fix: [
141
+ setAttributeEdit(page.markup.social["og:image"] ?? null, selector, "content", isSlot(absolute) ? absolute : escapeAttribute(absolute), isSlot(absolute) ? ORIGIN_SLOT_WRITE : undefined),
142
+ ],
143
+ selector,
144
+ evidence: { image: img },
145
+ });
64
146
  }),
65
147
  defineRule({
66
148
  id: "og.url-mismatch",
@@ -78,6 +160,9 @@ export const socialRules = [
78
160
  const b = normalizeUrl(page.canonical);
79
161
  return a && b && a !== b
80
162
  ? make("og:url does not match the canonical URL.", {
163
+ fix: [
164
+ setAttributeEdit(page.markup.social["og:url"] ?? null, 'head > meta[property="og:url"]', "content", escapeAttribute(page.canonical)),
165
+ ],
81
166
  selector: 'head > meta[property="og:url"]',
82
167
  evidence: { ogUrl, canonical: page.canonical },
83
168
  })
@@ -93,6 +178,9 @@ export const socialRules = [
93
178
  }, ({ page }, make) => page.twitter["card"]
94
179
  ? null
95
180
  : make("Page has no twitter:card tag.", {
181
+ fix: [
182
+ addHtml("head", `<meta name="twitter:card" content="${cardFor(page)}">`),
183
+ ],
96
184
  selector: 'head > meta[name="twitter:card"]',
97
185
  })),
98
186
  defineRule({
@@ -106,6 +194,9 @@ export const socialRules = [
106
194
  const { card } = page.twitter;
107
195
  return card && !TWITTER_CARDS.has(card.toLowerCase())
108
196
  ? make(`twitter:card "${card}" is not a valid card type.`, {
197
+ fix: [
198
+ setAttributeEdit(page.markup.social["twitter:card"] ?? null, 'head > meta[name="twitter:card"]', "content", cardFor(page)),
199
+ ],
109
200
  selector: 'head > meta[name="twitter:card"]',
110
201
  evidence: { card },
111
202
  })