framewatch-mcp-server 0.1.0 → 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.
Files changed (133) hide show
  1. package/README.md +895 -15
  2. package/dist/constants.d.ts +274 -0
  3. package/dist/constants.js +279 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.d.ts +20 -4
  6. package/dist/engine/browser.js +26 -9
  7. package/dist/engine/browser.js.map +1 -1
  8. package/dist/engine/clicks.d.ts +221 -0
  9. package/dist/engine/clicks.js +801 -0
  10. package/dist/engine/clicks.js.map +1 -0
  11. package/dist/engine/forms.d.ts +137 -0
  12. package/dist/engine/forms.js +474 -0
  13. package/dist/engine/forms.js.map +1 -0
  14. package/dist/engine/hmr.d.ts +41 -0
  15. package/dist/engine/hmr.js +91 -0
  16. package/dist/engine/hmr.js.map +1 -0
  17. package/dist/engine/inspect.d.ts +31 -0
  18. package/dist/engine/inspect.js +383 -0
  19. package/dist/engine/inspect.js.map +1 -0
  20. package/dist/engine/interaction.d.ts +12 -7
  21. package/dist/engine/interaction.js +110 -18
  22. package/dist/engine/interaction.js.map +1 -1
  23. package/dist/engine/links.d.ts +134 -0
  24. package/dist/engine/links.js +384 -0
  25. package/dist/engine/links.js.map +1 -0
  26. package/dist/engine/mocks.d.ts +53 -0
  27. package/dist/engine/mocks.js +148 -0
  28. package/dist/engine/mocks.js.map +1 -0
  29. package/dist/engine/rtl.d.ts +129 -0
  30. package/dist/engine/rtl.js +540 -0
  31. package/dist/engine/rtl.js.map +1 -0
  32. package/dist/engine/seo.d.ts +189 -0
  33. package/dist/engine/seo.js +398 -0
  34. package/dist/engine/seo.js.map +1 -0
  35. package/dist/engine/snapshot.d.ts +29 -0
  36. package/dist/engine/snapshot.js +10 -0
  37. package/dist/engine/snapshot.js.map +1 -0
  38. package/dist/engine/vue.d.ts +54 -0
  39. package/dist/engine/vue.js +419 -0
  40. package/dist/engine/vue.js.map +1 -0
  41. package/dist/index.js +45 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/tools/accessibility.d.ts +4 -0
  44. package/dist/tools/accessibility.js +9 -2
  45. package/dist/tools/accessibility.js.map +1 -1
  46. package/dist/tools/api-mock.d.ts +405 -0
  47. package/dist/tools/api-mock.js +186 -0
  48. package/dist/tools/api-mock.js.map +1 -0
  49. package/dist/tools/capture.d.ts +90 -26
  50. package/dist/tools/capture.js +109 -58
  51. package/dist/tools/capture.js.map +1 -1
  52. package/dist/tools/compare.d.ts +4 -0
  53. package/dist/tools/compare.js +16 -5
  54. package/dist/tools/compare.js.map +1 -1
  55. package/dist/tools/dead-clicks.d.ts +128 -0
  56. package/dist/tools/dead-clicks.js +570 -0
  57. package/dist/tools/dead-clicks.js.map +1 -0
  58. package/dist/tools/form-test.d.ts +112 -0
  59. package/dist/tools/form-test.js +477 -0
  60. package/dist/tools/form-test.js.map +1 -0
  61. package/dist/tools/index.d.ts +17 -1
  62. package/dist/tools/index.js +45 -1
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/inspect.d.ts +78 -0
  65. package/dist/tools/inspect.js +136 -0
  66. package/dist/tools/inspect.js.map +1 -0
  67. package/dist/tools/interact.d.ts +37 -18
  68. package/dist/tools/interact.js +113 -13
  69. package/dist/tools/interact.js.map +1 -1
  70. package/dist/tools/links.d.ts +129 -0
  71. package/dist/tools/links.js +640 -0
  72. package/dist/tools/links.js.map +1 -0
  73. package/dist/tools/responsive.d.ts +10 -6
  74. package/dist/tools/responsive.js +21 -4
  75. package/dist/tools/responsive.js.map +1 -1
  76. package/dist/tools/rtl.d.ts +241 -0
  77. package/dist/tools/rtl.js +410 -0
  78. package/dist/tools/rtl.js.map +1 -0
  79. package/dist/tools/save-auth.d.ts +263 -0
  80. package/dist/tools/save-auth.js +253 -0
  81. package/dist/tools/save-auth.js.map +1 -0
  82. package/dist/tools/screenshot.d.ts +4 -0
  83. package/dist/tools/screenshot.js +15 -4
  84. package/dist/tools/screenshot.js.map +1 -1
  85. package/dist/tools/seo.d.ts +113 -0
  86. package/dist/tools/seo.js +281 -0
  87. package/dist/tools/seo.js.map +1 -0
  88. package/dist/tools/snapshot.d.ts +122 -0
  89. package/dist/tools/snapshot.js +183 -0
  90. package/dist/tools/snapshot.js.map +1 -0
  91. package/dist/tools/wait-for.d.ts +107 -0
  92. package/dist/tools/wait-for.js +167 -0
  93. package/dist/tools/wait-for.js.map +1 -0
  94. package/dist/utils/arabic-text.d.ts +14 -0
  95. package/dist/utils/arabic-text.js +193 -0
  96. package/dist/utils/arabic-text.js.map +1 -0
  97. package/dist/utils/budget.d.ts +41 -0
  98. package/dist/utils/budget.js +182 -0
  99. package/dist/utils/budget.js.map +1 -0
  100. package/dist/utils/format.d.ts +11 -1
  101. package/dist/utils/format.js +27 -4
  102. package/dist/utils/format.js.map +1 -1
  103. package/dist/utils/highlight.d.ts +69 -0
  104. package/dist/utils/highlight.js +181 -0
  105. package/dist/utils/highlight.js.map +1 -0
  106. package/dist/utils/link-rules.d.ts +100 -0
  107. package/dist/utils/link-rules.js +284 -0
  108. package/dist/utils/link-rules.js.map +1 -0
  109. package/dist/utils/mock-rules.d.ts +144 -0
  110. package/dist/utils/mock-rules.js +224 -0
  111. package/dist/utils/mock-rules.js.map +1 -0
  112. package/dist/utils/rtl-rules.d.ts +142 -0
  113. package/dist/utils/rtl-rules.js +296 -0
  114. package/dist/utils/rtl-rules.js.map +1 -0
  115. package/dist/utils/seo-rules.d.ts +129 -0
  116. package/dist/utils/seo-rules.js +726 -0
  117. package/dist/utils/seo-rules.js.map +1 -0
  118. package/dist/utils/snapshot-rules.d.ts +33 -0
  119. package/dist/utils/snapshot-rules.js +111 -0
  120. package/dist/utils/snapshot-rules.js.map +1 -0
  121. package/dist/utils/storage-state.d.ts +76 -0
  122. package/dist/utils/storage-state.js +195 -0
  123. package/dist/utils/storage-state.js.map +1 -0
  124. package/dist/utils/style-rules.d.ts +107 -0
  125. package/dist/utils/style-rules.js +223 -0
  126. package/dist/utils/style-rules.js.map +1 -0
  127. package/dist/utils/test-data.d.ts +75 -0
  128. package/dist/utils/test-data.js +294 -0
  129. package/dist/utils/test-data.js.map +1 -0
  130. package/dist/utils/vue-rules.d.ts +72 -0
  131. package/dist/utils/vue-rules.js +108 -0
  132. package/dist/utils/vue-rules.js.map +1 -0
  133. package/package.json +6 -4
@@ -0,0 +1,726 @@
1
+ import { MAX_SEO_TEXT_LENGTH, SEO_CLS_GOOD, SEO_CLS_POOR, SEO_DESCRIPTION_MAX, SEO_DESCRIPTION_MIN, SEO_DOM_NODES_POOR, SEO_DOM_NODES_WARN, SEO_LCP_GOOD_MS, SEO_LCP_POOR_MS, SEO_OG_IMAGE_IDEAL, SEO_OG_IMAGE_MIN, SEO_TITLE_MAX, SEO_TITLE_MIN, SEO_TTFB_GOOD_MS, SEO_TTFB_POOR_MS, } from "../constants.js";
2
+ /**
3
+ * What counts as an SEO problem.
4
+ *
5
+ * Everything here is pure: it takes what `engine/seo.ts` read off the page and
6
+ * returns findings. Nothing in this file opens a browser, which is the point —
7
+ * "is this canonical wrong?", "does this robots.txt block that path?" and
8
+ * "which properties is this Product missing?" are all judgements with edge
9
+ * cases, and judgements need unit tests that run in milliseconds.
10
+ *
11
+ * The type import above is type-only, so there is no runtime dependency on the
12
+ * engine and no cycle: the engine reads, this judges, the tool prints.
13
+ */
14
+ /* ── Findings ─────────────────────────────────────────────────────────── */
15
+ export const SEO_AREAS = ["indexing", "content", "headings", "social", "images", "structured_data", "performance"];
16
+ /* ── The audit ────────────────────────────────────────────────────────── */
17
+ /**
18
+ * Judge a page. The order of the checks is the order they are printed in, and
19
+ * it runs from "will this page be in the index at all" down to "how fast is
20
+ * it" — a `noindex` makes every other finding academic, so it goes first.
21
+ */
22
+ export function judgeSeo(input) {
23
+ const findings = [];
24
+ const add = (area, level, label, detail, fix) => {
25
+ findings.push({ area, level, label, detail, ...(fix ? { fix } : {}) });
26
+ };
27
+ auditIndexing(input, add);
28
+ auditContent(input, add);
29
+ auditHeadings(input, add);
30
+ auditSocial(input, add);
31
+ auditImages(input, add);
32
+ auditStructuredData(input, add);
33
+ auditPerformance(input, add);
34
+ return {
35
+ findings,
36
+ problems: findings.filter((f) => f.level === "problem").length,
37
+ warnings: findings.filter((f) => f.level === "warning").length,
38
+ passes: findings.filter((f) => f.level === "pass").length,
39
+ };
40
+ }
41
+ /** Can this page be crawled and indexed, and under which address? */
42
+ function auditIndexing(input, add) {
43
+ const { page, response } = input;
44
+ if (response && response.status >= 400) {
45
+ add("indexing", "problem", "HTTP status", `the page answered ${response.status} — an error page is not indexed, whatever is rendered on it`, "Serve a 200 for pages that should be in the index.");
46
+ }
47
+ else if (response && response.status >= 300) {
48
+ add("indexing", "info", "HTTP status", `${response.status}`);
49
+ }
50
+ if (!sameAddress(input.requested_url, page.url)) {
51
+ add("indexing", "info", "Final URL", `${input.requested_url} redirected to ${page.url} — everything below describes the page that answered`);
52
+ }
53
+ // The single most expensive one-line mistake there is: a staging noindex
54
+ // that shipped. Checked before anything else about the page's content.
55
+ const robotsMeta = [...metaValues(page, "robots"), ...metaValues(page, "googlebot")].join(", ");
56
+ const headerRobots = response ? (response.headers["x-robots-tag"] ?? "") : "";
57
+ const directives = `${robotsMeta} ${headerRobots}`.toLowerCase();
58
+ if (/\bnoindex\b|\bnone\b/.test(directives)) {
59
+ const source = /\bnoindex\b|\bnone\b/.test(headerRobots.toLowerCase()) ? "X-Robots-Tag header" : "<meta name=robots>";
60
+ add("indexing", "problem", "noindex", `this page tells search engines not to index it (${source}: "${(headerRobots || robotsMeta).trim()}")`, "Remove the noindex if this page is meant to be found — it is the usual reason a live site has no search presence.");
61
+ }
62
+ else if (/\bnofollow\b/.test(directives)) {
63
+ add("indexing", "warning", "nofollow", `robots directives say "${(robotsMeta || headerRobots).trim()}" — links on this page pass no signal`);
64
+ }
65
+ else {
66
+ add("indexing", "pass", "Robots directives", robotsMeta ? `"${robotsMeta}" — indexable` : "none — indexable by default");
67
+ }
68
+ auditRobotsTxt(input, add);
69
+ auditCanonical(input, add);
70
+ if (page.lang) {
71
+ add("indexing", "pass", "Language", `<html lang="${page.lang}">${page.dir ? ` dir="${page.dir}"` : ""}`);
72
+ }
73
+ else {
74
+ add("indexing", "warning", "Language", "<html> has no lang attribute", 'Add lang (e.g. <html lang="en"> or lang="ar" dir="rtl") — it drives translation offers, hyphenation and screen-reader pronunciation.');
75
+ }
76
+ const viewport = metaValues(page, "viewport")[0];
77
+ if (viewport) {
78
+ add("indexing", "pass", "Viewport", `"${viewport}"`);
79
+ }
80
+ else {
81
+ add("indexing", "warning", "Viewport", "no <meta name=viewport>", 'Add <meta name="viewport" content="width=device-width, initial-scale=1"> — indexing is mobile-first, and without it a phone renders the page at 980px wide.');
82
+ }
83
+ if (!page.charset) {
84
+ add("indexing", "warning", "Charset", "the document declares no character encoding", 'Add <meta charset="utf-8"> as the first thing in <head>.');
85
+ }
86
+ const alternates = page.links.alternate ?? [];
87
+ const hreflangs = alternates.filter((link) => link.hreflang);
88
+ if (hreflangs.length > 0) {
89
+ const missingDefault = !hreflangs.some((link) => link.hreflang?.toLowerCase() === "x-default");
90
+ const detail = `${hreflangs.length} alternate${hreflangs.length === 1 ? "" : "s"}: ${hreflangs
91
+ .map((link) => link.hreflang)
92
+ .join(", ")}`;
93
+ if (missingDefault) {
94
+ add("indexing", "info", "hreflang", `${detail} — no x-default, so unmatched locales fall back to nothing in particular`);
95
+ }
96
+ else {
97
+ add("indexing", "pass", "hreflang", detail);
98
+ }
99
+ }
100
+ }
101
+ function auditRobotsTxt(input, add) {
102
+ const robots = input.robots;
103
+ if (!robots)
104
+ return;
105
+ if (robots.error !== undefined) {
106
+ add("indexing", "info", "robots.txt", `not checked — ${robots.error}`);
107
+ return;
108
+ }
109
+ if (robots.text === undefined) {
110
+ add("indexing", "pass", "robots.txt", `${robots.url} answered ${robots.status ?? "no content"} — no rules, so everything is crawlable`);
111
+ return;
112
+ }
113
+ const verdict = evaluateRobots(robots.text, input.page.url, input.robots_user_agent);
114
+ if (!verdict.allowed) {
115
+ add("indexing", "problem", "robots.txt", `blocked for ${input.robots_user_agent} by "${verdict.rule}"${verdict.group ? ` in the "${verdict.group}" group` : ""} — ` +
116
+ "the page will not be crawled, and nothing on it (title, description, structured data) will ever be read", `Remove or narrow that rule in ${robots.url}.`);
117
+ }
118
+ else {
119
+ add("indexing", "pass", "robots.txt", `${input.robots_user_agent} may crawl this path — ${verdict.reason}`);
120
+ }
121
+ if (verdict.sitemaps.length > 0) {
122
+ add("indexing", "info", "Sitemap", verdict.sitemaps.join(", "));
123
+ }
124
+ }
125
+ function auditCanonical(input, add) {
126
+ const { page } = input;
127
+ const canonicals = page.links.canonical ?? [];
128
+ if (canonicals.length === 0) {
129
+ add("indexing", "warning", "Canonical", "no <link rel=canonical>", "Add a self-referencing canonical — without one, the same page reached with a query string or a trailing " +
130
+ "slash is a separate, competing URL.");
131
+ return;
132
+ }
133
+ if (canonicals.length > 1) {
134
+ add("indexing", "problem", "Canonical", `${canonicals.length} canonical links (${canonicals.map((link) => link.href).join(", ")}) — ` +
135
+ "search engines ignore all of them when they disagree", "Leave exactly one.");
136
+ return;
137
+ }
138
+ const canonical = canonicals[0];
139
+ if (!canonical.href) {
140
+ add("indexing", "problem", "Canonical", "the canonical link has an empty href", "Point it at this page's own URL.");
141
+ return;
142
+ }
143
+ if (!/^https?:\/\//i.test(canonical.href)) {
144
+ add("indexing", "warning", "Canonical", `"${canonical.href}" is relative (resolves to ${canonical.resolved ?? "?"})`, "Use the absolute URL — relative canonicals are honoured inconsistently.");
145
+ return;
146
+ }
147
+ if (canonical.resolved && !sameAddress(canonical.resolved, page.url)) {
148
+ add("indexing", "warning", "Canonical", `points at ${canonical.resolved}, not at this page (${page.url}) — this URL will not be indexed on its own`, "Deliberate for a duplicate; a mistake if this page is meant to rank.");
149
+ return;
150
+ }
151
+ add("indexing", "pass", "Canonical", canonical.resolved ?? canonical.href);
152
+ }
153
+ /** The two strings that are the page in a search result. */
154
+ function auditContent(input, add) {
155
+ const { page } = input;
156
+ const title = page.title ?? "";
157
+ if (page.title_count === 0 || title === "") {
158
+ add("content", "problem", "Title", page.title_count === 0 ? "no <title> element" : "the <title> is empty", "Write one — it is the headline of every search result and browser tab, and the strongest on-page signal there is.");
159
+ }
160
+ else {
161
+ if (page.title_count > 1) {
162
+ add("content", "warning", "Title", `${page.title_count} <title> elements — only the first counts`);
163
+ }
164
+ const length = [...title].length;
165
+ if (length > SEO_TITLE_MAX) {
166
+ add("content", "warning", "Title", `"${title}" (${length} characters) — past about ${SEO_TITLE_MAX} the end is cut off in results`, "Put the distinguishing words first.");
167
+ }
168
+ else if (length < SEO_TITLE_MIN) {
169
+ add("content", "warning", "Title", `"${title}" (${length} characters) — short; there is room for about ${SEO_TITLE_MAX}`, "Say what the page is for, not only what it is called.");
170
+ }
171
+ else {
172
+ add("content", "pass", "Title", `"${title}" (${length} characters)`);
173
+ }
174
+ }
175
+ const descriptions = metaValues(page, "description");
176
+ const description = descriptions[0] ?? "";
177
+ if (descriptions.length === 0) {
178
+ add("content", "problem", "Meta description", "missing", 'Add <meta name="description" content="…"> — without one the snippet is whatever text the engine scrapes off the page.');
179
+ }
180
+ else if (description === "") {
181
+ add("content", "problem", "Meta description", "present but empty", "Write the snippet, or drop the tag.");
182
+ }
183
+ else {
184
+ if (descriptions.length > 1) {
185
+ add("content", "warning", "Meta description", `${descriptions.length} description tags — only the first counts`);
186
+ }
187
+ const length = [...description].length;
188
+ if (length > SEO_DESCRIPTION_MAX) {
189
+ add("content", "warning", "Meta description", `${length} characters — cut off around ${SEO_DESCRIPTION_MAX}: "${elide(description, MAX_SEO_TEXT_LENGTH)}"`);
190
+ }
191
+ else if (length < SEO_DESCRIPTION_MIN) {
192
+ add("content", "warning", "Meta description", `${length} characters — short: "${description}"`, `About ${SEO_DESCRIPTION_MIN}–${SEO_DESCRIPTION_MAX} characters uses the whole snippet.`);
193
+ }
194
+ else {
195
+ add("content", "pass", "Meta description", `"${elide(description, MAX_SEO_TEXT_LENGTH)}" (${length} characters)`);
196
+ }
197
+ }
198
+ add("content", "info", "Body", `${plural(page.word_count, "word")}, ${plural(page.anchors.total, "link")} ` +
199
+ `(${page.anchors.internal} internal, ${page.anchors.external} external` +
200
+ `${page.anchors.nofollow > 0 ? `, ${page.anchors.nofollow} nofollow` : ""})`);
201
+ if (page.anchors.empty > 0) {
202
+ add("content", "warning", "Link text", `${page.anchors.empty} link${page.anchors.empty === 1 ? "" : "s"} with no text and no accessible name`, "A crawler follows the link but learns nothing about what is on the other side.");
203
+ }
204
+ }
205
+ /** The outline: one h1, and no levels skipped on the way down. */
206
+ function auditHeadings(input, add) {
207
+ const { page } = input;
208
+ const h1s = page.headings.filter((heading) => heading.level === 1);
209
+ const h1Count = page.heading_counts.h1 ?? 0;
210
+ if (h1Count === 0) {
211
+ add("headings", "problem", "H1", page.heading_total === 0 ? "the page has no headings at all" : "no <h1>", "Give the page one h1 that says what it is about.");
212
+ }
213
+ else if (h1Count > 1) {
214
+ add("headings", "warning", "H1", `${h1Count} h1 elements: ${h1s.map((heading) => `"${heading.text}"`).join(", ")}`, "One h1 per page; the rest are h2s.");
215
+ }
216
+ else if (h1s[0]?.empty) {
217
+ add("headings", "problem", "H1", "the h1 is empty", "An h1 containing only an image or an icon says nothing to a crawler.");
218
+ }
219
+ else {
220
+ const only = h1s[0];
221
+ add("headings", "pass", "H1", only ? `"${only.text}"${only.hidden ? " (not visible, but still crawled)" : ""}` : "one, further down the page than the outline below reaches");
222
+ }
223
+ const first = page.headings[0];
224
+ if (first && first.level !== 1 && h1Count > 0) {
225
+ add("headings", "info", "Outline", `the first heading is an h${first.level}, not the h1`);
226
+ }
227
+ const skips = [];
228
+ let previous = 0;
229
+ for (const heading of page.headings) {
230
+ if (previous > 0 && heading.level > previous + 1) {
231
+ skips.push(`h${previous} → h${heading.level} ("${heading.text}")`);
232
+ }
233
+ previous = heading.level;
234
+ }
235
+ if (skips.length > 0) {
236
+ add("headings", "warning", "Outline", `${skips.length} skipped level${skips.length === 1 ? "" : "s"}: ${skips.slice(0, 3).join("; ")}` +
237
+ (skips.length > 3 ? `; … and ${skips.length - 3} more` : ""), "Headings are the page's table of contents; a jumped level breaks it for crawlers and screen readers alike.");
238
+ }
239
+ else if (page.heading_total > 1) {
240
+ add("headings", "pass", "Outline", `${describeCounts(page.heading_counts)} — no skipped levels`);
241
+ }
242
+ const empties = page.headings.filter((heading) => heading.empty && heading.level > 1).length;
243
+ if (empties > 0) {
244
+ add("headings", "warning", "Empty headings", `${empties} heading${empties === 1 ? "" : "s"} with no text`);
245
+ }
246
+ }
247
+ /** What the link looks like when someone shares it. */
248
+ function auditSocial(input, add) {
249
+ const { page } = input;
250
+ const og = (key) => metaValues(page, key)[0] ?? "";
251
+ const required = [
252
+ ["og:title", "warning", "the headline on the share card"],
253
+ ["og:description", "warning", "the text under it"],
254
+ ["og:image", "warning", "the picture — a card without one is a grey box"],
255
+ ["og:url", "info", "the canonical address of the shared page"],
256
+ ["og:type", "info", 'usually "website" or "article"'],
257
+ ];
258
+ const missing = [];
259
+ for (const [key, level, why] of required) {
260
+ const value = og(key);
261
+ if (value) {
262
+ add("social", "pass", key, elide(value, MAX_SEO_TEXT_LENGTH));
263
+ }
264
+ else {
265
+ missing.push(key);
266
+ add("social", level, key, `missing — ${why}`);
267
+ }
268
+ }
269
+ if (missing.length === required.length) {
270
+ add("social", "warning", "Open Graph", "no Open Graph tags at all — every share of this link renders as a bare URL", "Add og:title, og:description, og:image and og:url.");
271
+ }
272
+ const card = og("twitter:card");
273
+ if (card) {
274
+ add("social", "pass", "twitter:card", card);
275
+ }
276
+ else if (og("og:image")) {
277
+ add("social", "info", "twitter:card", 'absent — X falls back to Open Graph, but "summary_large_image" is what makes the image full width');
278
+ }
279
+ auditOgImage(input, add);
280
+ }
281
+ function auditOgImage(input, add) {
282
+ const probe = input.og_image;
283
+ if (!probe)
284
+ return;
285
+ const raw = metaValues(input.page, "og:image")[0] ?? "";
286
+ if (raw && !/^https?:\/\//i.test(raw)) {
287
+ add("social", "warning", "Share image", `"${raw}" is not an absolute URL (fetched as ${probe.url})`, "Open Graph requires an absolute URL — most networks will not resolve a relative one.");
288
+ }
289
+ if (!probe.ok) {
290
+ add("social", "problem", "Share image", `${probe.url} could not be fetched${probe.status ? ` (HTTP ${probe.status})` : ` — ${probe.error ?? "no response"}`} — ` +
291
+ "the share card will be blank", "Point og:image at an image that resolves publicly, with no auth in front of it.");
292
+ return;
293
+ }
294
+ if (probe.width === undefined || probe.height === undefined) {
295
+ add("social", "warning", "Share image", `${probe.url} answered ${probe.status ?? 200}${probe.content_type ? ` as ${probe.content_type}` : ""} but could not be ` +
296
+ `read as an image${probe.error ? ` (${probe.error})` : ""}`);
297
+ return;
298
+ }
299
+ const size = `${probe.width}x${probe.height}${probe.bytes ? `, ${kb(probe.bytes)}` : ""}`;
300
+ if (probe.width < SEO_OG_IMAGE_MIN.width || probe.height < SEO_OG_IMAGE_MIN.height) {
301
+ add("social", "problem", "Share image", `${size} — below the ${SEO_OG_IMAGE_MIN.width}x${SEO_OG_IMAGE_MIN.height} floor, so the networks will not render a card at all`, `Use ${SEO_OG_IMAGE_IDEAL.width}x${SEO_OG_IMAGE_IDEAL.height}.`);
302
+ }
303
+ else if (probe.width < SEO_OG_IMAGE_IDEAL.width || probe.height < SEO_OG_IMAGE_IDEAL.height) {
304
+ add("social", "warning", "Share image", `${size} — smaller than the ${SEO_OG_IMAGE_IDEAL.width}x${SEO_OG_IMAGE_IDEAL.height} card, so it renders as a small thumbnail beside the text`);
305
+ }
306
+ else {
307
+ add("social", "pass", "Share image", `${size} — fetched and readable`);
308
+ }
309
+ }
310
+ /** Alt text: what a crawler and a screen reader read instead of the picture. */
311
+ function auditImages(input, add) {
312
+ const { images } = input.page;
313
+ if (images.total === 0) {
314
+ add("images", "info", "Images", "no <img> elements on the page");
315
+ return;
316
+ }
317
+ if (images.missing_alt_total > 0) {
318
+ const listed = images.missing_alt.map((image) => image.description || image.src || "?");
319
+ add("images", "problem", "Alt text", `${images.missing_alt_total} of ${images.total} images have no alt attribute: ${listed.join(", ")}` +
320
+ (images.missing_alt_total > listed.length ? `, … and ${images.missing_alt_total - listed.length} more` : ""), 'Describe what the image shows, or mark it decorative with alt="" — a missing attribute says neither.');
321
+ }
322
+ else {
323
+ add("images", "pass", "Alt text", `all ${images.total} images have an alt attribute` + (images.empty_alt > 0 ? ` (${images.empty_alt} decorative, alt="")` : ""));
324
+ }
325
+ if (images.no_dimensions > 0) {
326
+ add("images", "info", "Dimensions", `${images.no_dimensions} of ${images.total} images have no width/height attributes — the browser cannot reserve space, which is where layout shift comes from`);
327
+ }
328
+ }
329
+ /** JSON-LD: the difference between a blue link and a rich result. */
330
+ function auditStructuredData(input, add) {
331
+ const { page } = input;
332
+ if (page.jsonld_total === 0) {
333
+ const elsewhere = page.microdata > 0
334
+ ? ` (there are ${page.microdata} microdata elements — Google reads those too, but JSON-LD is what it recommends)`
335
+ : "";
336
+ add("structured_data", "warning", "JSON-LD", `none on the page${elsewhere}`, "Add a <script type=\"application/ld+json\"> block — it is what makes a page eligible for rich results.");
337
+ return;
338
+ }
339
+ const parsed = parseJsonLd(page.jsonld);
340
+ for (const block of parsed) {
341
+ if (!block.ok) {
342
+ add("structured_data", "problem", `JSON-LD block ${block.index}`, `is not valid JSON — ${block.error}`, "A block that does not parse is ignored entirely, so the page has that much less structured data than it looks like.");
343
+ continue;
344
+ }
345
+ if (block.nodes.length === 0) {
346
+ add("structured_data", "warning", `JSON-LD block ${block.index}`, "parses, but declares no @type", "Without @type there is nothing to match against.");
347
+ continue;
348
+ }
349
+ for (const node of block.nodes) {
350
+ const label = `${node.type}${node.name ? ` "${elide(node.name, 60)}"` : ""}`;
351
+ if (node.missing_required.length > 0) {
352
+ add("structured_data", "problem", label, `missing required ${node.missing_required.join(", ")} (has ${node.keys.join(", ")})`, "Google drops the whole item when a required property is absent.");
353
+ }
354
+ else if (node.missing_recommended.length > 0) {
355
+ add("structured_data", "warning", label, `missing recommended ${node.missing_recommended.join(", ")}`);
356
+ }
357
+ else if (node.known) {
358
+ add("structured_data", "pass", label, `has ${node.keys.join(", ")}`);
359
+ }
360
+ else {
361
+ add("structured_data", "info", label, `${node.keys.length} properties: ${node.keys.slice(0, 8).join(", ")}`);
362
+ }
363
+ }
364
+ }
365
+ const truncated = page.jsonld.filter((block) => block.truncated).length;
366
+ if (truncated > 0) {
367
+ add("structured_data", "info", "JSON-LD", `${truncated} block${truncated === 1 ? "" : "s"} too large to read in full — only the start was parsed`);
368
+ }
369
+ if (page.jsonld_total > page.jsonld.length) {
370
+ add("structured_data", "info", "JSON-LD", `${page.jsonld_total} blocks on the page, ${page.jsonld.length} read`);
371
+ }
372
+ }
373
+ /**
374
+ * Speed, measured rather than scored.
375
+ *
376
+ * These are lab numbers from one load of a headless browser on this machine:
377
+ * useful for "the LCP element is a 4MB hero image", worthless as a prediction
378
+ * of what Chrome will report from real visitors. The wording says so.
379
+ */
380
+ function auditPerformance(input, add) {
381
+ const perf = input.performance;
382
+ if (!perf)
383
+ return;
384
+ if (perf.lcp_ms !== undefined) {
385
+ const level = perf.lcp_ms <= SEO_LCP_GOOD_MS ? "pass" : perf.lcp_ms > SEO_LCP_POOR_MS ? "problem" : "warning";
386
+ add("performance", level, "LCP", `${perf.lcp_ms}ms${perf.lcp_element ? ` — largest element: ${perf.lcp_element}` : ""} ` +
387
+ `(good ≤ ${SEO_LCP_GOOD_MS}ms, poor > ${SEO_LCP_POOR_MS}ms)`, level === "pass" ? undefined : "Largest Contentful Paint is a ranking signal; the named element is what to make faster.");
388
+ }
389
+ if (perf.cls !== undefined) {
390
+ const level = perf.cls <= SEO_CLS_GOOD ? "pass" : perf.cls > SEO_CLS_POOR ? "problem" : "warning";
391
+ add("performance", level, "CLS", `${perf.cls} (good ≤ ${SEO_CLS_GOOD}, poor > ${SEO_CLS_POOR})`, level === "pass" ? undefined : "Reserve space for images, ads and late-loading fonts.");
392
+ }
393
+ if (perf.ttfb_ms !== undefined) {
394
+ const level = perf.ttfb_ms <= SEO_TTFB_GOOD_MS ? "pass" : perf.ttfb_ms > SEO_TTFB_POOR_MS ? "warning" : "info";
395
+ add("performance", level, "TTFB", `${perf.ttfb_ms}ms to the first byte (good ≤ ${SEO_TTFB_GOOD_MS}ms)`);
396
+ }
397
+ if (perf.fcp_ms !== undefined)
398
+ add("performance", "info", "FCP", `${perf.fcp_ms}ms to the first paint`);
399
+ if (perf.load_ms !== undefined)
400
+ add("performance", "info", "Load", `${perf.load_ms}ms to the load event`);
401
+ const weight = `${perf.requests} requests, ${kb(perf.transfer_bytes)} transferred` +
402
+ (perf.transfer_incomplete ? " (a floor: cross-origin resources without Timing-Allow-Origin report 0)" : "");
403
+ const top = perf.resources
404
+ .slice(0, 3)
405
+ .map((group) => `${group.type} ${group.count}×${group.bytes > 0 ? ` ${kb(group.bytes)}` : ""}`)
406
+ .join(", ");
407
+ add("performance", "info", "Page weight", top ? `${weight} — ${top}` : weight);
408
+ if (perf.long_tasks > 0) {
409
+ add("performance", "info", "Long tasks", `${perf.long_tasks} tasks over 50ms blocked the main thread during load`);
410
+ }
411
+ const nodes = input.page.dom_nodes;
412
+ if (nodes > SEO_DOM_NODES_POOR) {
413
+ add("performance", "warning", "DOM size", `${nodes} elements — past ${SEO_DOM_NODES_POOR} the page costs memory and style recalculation on every interaction`);
414
+ }
415
+ else if (nodes > SEO_DOM_NODES_WARN) {
416
+ add("performance", "info", "DOM size", `${nodes} elements (Lighthouse starts warning at ${SEO_DOM_NODES_WARN})`);
417
+ }
418
+ }
419
+ /**
420
+ * Decide whether `userAgent` may crawl `url`, per RFC 9309.
421
+ *
422
+ * Two rules do the work. Group selection: the most specific user-agent line
423
+ * that matches wins, and `*` is the fallback — so a `Disallow: /` under
424
+ * `User-agent: *` does not apply to Googlebot if Googlebot has a group of its
425
+ * own. Rule selection: the *longest* matching path wins regardless of order,
426
+ * and Allow beats Disallow on a tie. That is why `Disallow: /` followed by
427
+ * `Allow: /public/` permits `/public/page` — reading top to bottom would get
428
+ * it exactly wrong.
429
+ *
430
+ * `*` matches any run of characters and a trailing `$` anchors to the end.
431
+ */
432
+ export function evaluateRobots(text, url, userAgent) {
433
+ const { groups, sitemaps } = parseRobots(text);
434
+ const path = pathOf(url);
435
+ const agent = userAgent.toLowerCase();
436
+ let best;
437
+ let bestToken = "";
438
+ for (const group of groups) {
439
+ for (const candidate of group.agents) {
440
+ const matches = candidate === "*" ? true : agent === candidate || agent.startsWith(candidate);
441
+ if (!matches)
442
+ continue;
443
+ // A named group always beats "*", and a longer name beats a shorter one.
444
+ const better = candidate !== "*" && (bestToken === "*" || candidate.length > bestToken.length);
445
+ if (best === undefined || better) {
446
+ best = group;
447
+ bestToken = candidate;
448
+ }
449
+ }
450
+ }
451
+ if (!best) {
452
+ return { allowed: true, sitemaps, reason: `no group matches ${userAgent}` };
453
+ }
454
+ // Groups naming the same crawler are one group, however many times the file
455
+ // repeats the header — two `User-agent: *` blocks is what a concatenated
456
+ // config produces, and honouring only the first would call a disallowed path
457
+ // crawlable.
458
+ const rules = groups.filter((group) => group.agents.includes(bestToken)).flatMap((group) => group.rules);
459
+ let winner;
460
+ for (const rule of rules) {
461
+ if (!matchesRobotsPath(rule.path, path))
462
+ continue;
463
+ if (winner === undefined ||
464
+ rule.path.length > winner.path.length ||
465
+ (rule.path.length === winner.path.length && rule.allow && !winner.allow)) {
466
+ winner = rule;
467
+ }
468
+ }
469
+ if (!winner) {
470
+ return { allowed: true, sitemaps, group: bestToken, reason: `no rule in the "${bestToken}" group matches ${path}` };
471
+ }
472
+ return {
473
+ allowed: winner.allow,
474
+ rule: `${winner.allow ? "Allow" : "Disallow"}: ${winner.path}`,
475
+ group: bestToken,
476
+ sitemaps,
477
+ reason: `"${winner.allow ? "Allow" : "Disallow"}: ${winner.path}" in the "${bestToken}" group`,
478
+ };
479
+ }
480
+ function parseRobots(text) {
481
+ const groups = [];
482
+ const sitemaps = [];
483
+ let current;
484
+ // Consecutive user-agent lines head the same group; the first rule line
485
+ // closes the header, so the next user-agent starts a new group.
486
+ let heading = false;
487
+ for (const raw of text.split(/\r?\n/)) {
488
+ const line = raw.split("#")[0].trim();
489
+ if (!line)
490
+ continue;
491
+ const at = line.indexOf(":");
492
+ if (at === -1)
493
+ continue;
494
+ const field = line.slice(0, at).trim().toLowerCase();
495
+ const value = line.slice(at + 1).trim();
496
+ if (field === "user-agent") {
497
+ if (!current || !heading) {
498
+ current = { agents: [], rules: [] };
499
+ groups.push(current);
500
+ heading = true;
501
+ }
502
+ current.agents.push(value.toLowerCase());
503
+ continue;
504
+ }
505
+ if (field === "sitemap") {
506
+ if (value)
507
+ sitemaps.push(value);
508
+ continue;
509
+ }
510
+ if (field !== "allow" && field !== "disallow")
511
+ continue;
512
+ if (!current)
513
+ continue;
514
+ heading = false;
515
+ // `Disallow:` with nothing after it means "nothing is disallowed" — it is
516
+ // the absence of a rule, not a rule matching everything.
517
+ if (value === "")
518
+ continue;
519
+ current.rules.push({ allow: field === "allow", path: value });
520
+ }
521
+ return { groups, sitemaps };
522
+ }
523
+ /** A robots path pattern against a request path. `*` is any run, `$` anchors the end. */
524
+ export function matchesRobotsPath(pattern, path) {
525
+ const anchored = pattern.endsWith("$");
526
+ const body = anchored ? pattern.slice(0, -1) : pattern;
527
+ const source = body
528
+ .split("*")
529
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
530
+ .join(".*");
531
+ return new RegExp(`^${source}${anchored ? "$" : ""}`).test(path);
532
+ }
533
+ function pathOf(url) {
534
+ try {
535
+ const parsed = new URL(url);
536
+ return `${parsed.pathname}${parsed.search}`;
537
+ }
538
+ catch {
539
+ return url.startsWith("/") ? url : `/${url}`;
540
+ }
541
+ }
542
+ /**
543
+ * What Google's rich-result documentation asks for, per type.
544
+ *
545
+ * Deliberately short. Only types worth a rich result are listed, and only the
546
+ * properties whose absence actually costs one — a validator that reports every
547
+ * optional property of every schema.org type produces noise nobody reads, and
548
+ * schema.org itself requires almost nothing.
549
+ */
550
+ export const SCHEMA_EXPECTATIONS = {
551
+ Organization: { required: ["name", "url"], recommended: ["logo"] },
552
+ LocalBusiness: { required: ["name", "address"], recommended: ["telephone", "openingHours", "image"] },
553
+ Product: { required: ["name", "image"], recommended: ["offers", "description", "brand"] },
554
+ Offer: { required: ["price", "priceCurrency"], recommended: ["availability"] },
555
+ Article: { required: ["headline", "image", "datePublished"], recommended: ["author", "dateModified"] },
556
+ BreadcrumbList: { required: ["itemListElement"], recommended: [] },
557
+ WebSite: { required: ["name", "url"], recommended: ["potentialAction"] },
558
+ WebPage: { required: ["name"], recommended: ["description"] },
559
+ Event: { required: ["name", "startDate", "location"], recommended: ["endDate", "offers", "image"] },
560
+ Recipe: { required: ["name", "image", "recipeIngredient", "recipeInstructions"], recommended: ["author", "totalTime"] },
561
+ FAQPage: { required: ["mainEntity"], recommended: [] },
562
+ VideoObject: { required: ["name", "description", "thumbnailUrl", "uploadDate"], recommended: ["duration"] },
563
+ Person: { required: ["name"], recommended: ["url"] },
564
+ SoftwareApplication: { required: ["name", "offers", "applicationCategory"], recommended: ["aggregateRating"] },
565
+ };
566
+ /** Subtypes that inherit another type's expectations. */
567
+ const SCHEMA_ALIASES = {
568
+ Restaurant: "LocalBusiness",
569
+ Store: "LocalBusiness",
570
+ CafeOrCoffeeShop: "LocalBusiness",
571
+ Bakery: "LocalBusiness",
572
+ FoodEstablishment: "LocalBusiness",
573
+ ProfessionalService: "LocalBusiness",
574
+ MedicalBusiness: "LocalBusiness",
575
+ NewsArticle: "Article",
576
+ BlogPosting: "Article",
577
+ TechArticle: "Article",
578
+ ScholarlyArticle: "Article",
579
+ Blog: "WebPage",
580
+ CollectionPage: "WebPage",
581
+ ItemPage: "WebPage",
582
+ AboutPage: "WebPage",
583
+ ContactPage: "WebPage",
584
+ Corporation: "Organization",
585
+ NGO: "Organization",
586
+ EducationalOrganization: "Organization",
587
+ OnlineStore: "Organization",
588
+ IndividualProduct: "Product",
589
+ ProductModel: "Product",
590
+ AggregateOffer: "Offer",
591
+ BusinessEvent: "Event",
592
+ MusicEvent: "Event",
593
+ SportsEvent: "Event",
594
+ MobileApplication: "SoftwareApplication",
595
+ WebApplication: "SoftwareApplication",
596
+ };
597
+ export function expectationsFor(type) {
598
+ return SCHEMA_EXPECTATIONS[type] ?? SCHEMA_EXPECTATIONS[SCHEMA_ALIASES[type] ?? ""];
599
+ }
600
+ /**
601
+ * Parse the JSON-LD blocks and check each node against its type.
602
+ *
603
+ * `@graph` is flattened and a top-level array is iterated, because both are
604
+ * ordinary ways to put several entities in one block. Nesting below that is
605
+ * left alone: a Product's `offers` is checked as part of the Product's own
606
+ * expectations, and walking arbitrarily deep would report the same missing
607
+ * property from three directions.
608
+ */
609
+ export function parseJsonLd(blocks) {
610
+ return blocks.map((block, at) => {
611
+ const index = at + 1;
612
+ let value;
613
+ try {
614
+ value = JSON.parse(block.text);
615
+ }
616
+ catch (error) {
617
+ const reason = error instanceof Error ? error.message : String(error);
618
+ return {
619
+ index,
620
+ ok: false,
621
+ error: block.truncated ? `${reason} (the block was too large to read in full)` : reason,
622
+ nodes: [],
623
+ };
624
+ }
625
+ const nodes = [];
626
+ for (const entity of flattenEntities(value)) {
627
+ for (const type of typesOf(entity)) {
628
+ nodes.push(describeNode(type, entity));
629
+ }
630
+ }
631
+ return { index, ok: true, nodes };
632
+ });
633
+ }
634
+ function flattenEntities(value) {
635
+ if (Array.isArray(value))
636
+ return value.flatMap(flattenEntities);
637
+ if (typeof value !== "object" || value === null)
638
+ return [];
639
+ const entity = value;
640
+ const graph = entity["@graph"];
641
+ if (graph !== undefined) {
642
+ const rest = Object.keys(entity).some((key) => key !== "@graph" && key !== "@context") ? [entity] : [];
643
+ return [...rest, ...flattenEntities(graph)];
644
+ }
645
+ return [entity];
646
+ }
647
+ function typesOf(entity) {
648
+ const raw = entity["@type"];
649
+ const types = (Array.isArray(raw) ? raw : [raw])
650
+ .filter((type) => typeof type === "string" && type.trim() !== "")
651
+ // schema.org URLs are a legal way to spell a type.
652
+ .map((type) => type.trim().replace(/^https?:\/\/schema\.org\//i, ""));
653
+ return types.length > 0 ? types : [];
654
+ }
655
+ function describeNode(type, entity) {
656
+ const keys = Object.keys(entity).filter((key) => !key.startsWith("@"));
657
+ const present = new Set(keys);
658
+ const expectation = expectationsFor(type);
659
+ const node = {
660
+ type,
661
+ keys,
662
+ missing_required: expectation ? expectation.required.filter((key) => !hasValue(entity, present, key)) : [],
663
+ missing_recommended: expectation ? expectation.recommended.filter((key) => !hasValue(entity, present, key)) : [],
664
+ known: expectation !== undefined,
665
+ };
666
+ const name = entity.name ?? entity.headline;
667
+ if (typeof name === "string" && name.trim() !== "")
668
+ node.name = name.trim();
669
+ return node;
670
+ }
671
+ /** Present *and* non-empty: `"name": ""` is the same problem as no name at all. */
672
+ function hasValue(entity, present, key) {
673
+ if (!present.has(key))
674
+ return false;
675
+ const value = entity[key];
676
+ if (value === null || value === undefined)
677
+ return false;
678
+ if (typeof value === "string")
679
+ return value.trim() !== "";
680
+ if (Array.isArray(value))
681
+ return value.length > 0;
682
+ return true;
683
+ }
684
+ /* ── Small shared helpers ─────────────────────────────────────────────── */
685
+ /** Read a meta value however the page spelled it: `property` first, then `name`. */
686
+ export function metaValues(page, key) {
687
+ const lower = key.toLowerCase();
688
+ const values = [...(page.properties[lower] ?? []), ...(page.named[lower] ?? [])];
689
+ return values.filter((value) => value !== undefined);
690
+ }
691
+ /** Two URLs that address the same page. The fragment is not part of the address. */
692
+ export function sameAddress(a, b) {
693
+ if (a === b)
694
+ return true;
695
+ try {
696
+ const left = new URL(a);
697
+ const right = new URL(b);
698
+ left.hash = "";
699
+ right.hash = "";
700
+ if (left.href === right.href)
701
+ return true;
702
+ // A trailing slash on the root is the same address with or without it.
703
+ return left.href.replace(/\/$/, "") === right.href.replace(/\/$/, "");
704
+ }
705
+ catch {
706
+ return false;
707
+ }
708
+ }
709
+ function describeCounts(counts) {
710
+ return Object.entries(counts)
711
+ .filter(([, count]) => count > 0)
712
+ .map(([level, count]) => `${count} ${level}`)
713
+ .join(", ");
714
+ }
715
+ function plural(n, noun) {
716
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
717
+ }
718
+ function elide(text, max) {
719
+ return text.length > max ? `${text.slice(0, max)}…` : text;
720
+ }
721
+ function kb(bytes) {
722
+ if (bytes >= 1_000_000)
723
+ return `${(bytes / 1_000_000).toFixed(1)}MB`;
724
+ return `${Math.max(1, Math.round(bytes / 1000))}KB`;
725
+ }
726
+ //# sourceMappingURL=seo-rules.js.map