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,69 @@
1
+ import type { Page } from "playwright";
2
+ import type { BoundingBox } from "../types.js";
3
+ /**
4
+ * Element highlight overlay.
5
+ *
6
+ * A selector like `.nav > button:nth-of-type(3)` says nothing about where the
7
+ * problem is on screen. This draws a labelled box over each named element and
8
+ * leaves it there until the screenshot has been taken, so a report about
9
+ * elements comes with a picture of those elements.
10
+ *
11
+ * The boxes are drawn in *document* coordinates and appended to `<body>`, so
12
+ * one full-page screenshot shows every highlight, including the ones below the
13
+ * fold. They are `pointer-events: none` and are removed again by
14
+ * `clearHighlights`, so nothing here changes what the page does — but the
15
+ * overlay is still a DOM mutation, which is why callers that watch for
16
+ * mutations (framewatch_dead_clicks) draw it only once everything has been
17
+ * measured.
18
+ *
19
+ * Every style is set through the CSSOM rather than as a `style` attribute or a
20
+ * `<style>` block: a page with a strict Content-Security-Policy blocks both of
21
+ * those, and the pages most worth auditing are the ones that lock themselves
22
+ * down.
23
+ */
24
+ /** The id of the container every highlight lives in, so `clearHighlights` can find it. */
25
+ export declare const HIGHLIGHT_CONTAINER_ID = "__framewatch_highlights";
26
+ export interface Highlight {
27
+ /**
28
+ * CSS selector for the element to draw over — or, when `box` is given, just
29
+ * the name this highlight is reported under in `drawn` / `missing`.
30
+ */
31
+ selector: string;
32
+ /**
33
+ * Draw here instead of looking `selector` up, in viewport pixels. For an
34
+ * element that was resolved some other way (an aria ref, a handle) and has
35
+ * already been measured.
36
+ */
37
+ box?: BoundingBox;
38
+ /**
39
+ * Which match of `selector` to draw over, when the selector is not unique.
40
+ * Defaults to the first.
41
+ */
42
+ match_index?: number;
43
+ /** Drawn in the corner of the box — a number, or a short phrase. */
44
+ label?: string;
45
+ /** Any CSS colour. The box is outlined in it, and the label badge is painted with it. */
46
+ colour?: string;
47
+ /**
48
+ * The translucent fill inside the box. Give it a low alpha: what is under
49
+ * the box is the thing being reported on, and it has to stay readable.
50
+ */
51
+ wash?: string;
52
+ }
53
+ export interface HighlightResult {
54
+ /** Selectors that were drawn. */
55
+ drawn: string[];
56
+ /** Selectors that matched nothing, or matched something with no box to draw. */
57
+ missing: string[];
58
+ }
59
+ /**
60
+ * Draw a box over each element in `highlights` and return which ones landed.
61
+ *
62
+ * An element that is gone, or that has no size, is reported in `missing`
63
+ * rather than being an error: by the time a report is drawn the page may have
64
+ * re-rendered, and a screenshot missing one box is far better than no
65
+ * screenshot at all.
66
+ */
67
+ export declare function highlightElements(page: Page, highlights: readonly Highlight[], max?: number): Promise<HighlightResult>;
68
+ /** Remove every box this module drew. Safe to call when there are none. */
69
+ export declare function clearHighlights(page: Page): Promise<void>;
@@ -0,0 +1,181 @@
1
+ import { MAX_HIGHLIGHTS } from "../constants.js";
2
+ /**
3
+ * Element highlight overlay.
4
+ *
5
+ * A selector like `.nav > button:nth-of-type(3)` says nothing about where the
6
+ * problem is on screen. This draws a labelled box over each named element and
7
+ * leaves it there until the screenshot has been taken, so a report about
8
+ * elements comes with a picture of those elements.
9
+ *
10
+ * The boxes are drawn in *document* coordinates and appended to `<body>`, so
11
+ * one full-page screenshot shows every highlight, including the ones below the
12
+ * fold. They are `pointer-events: none` and are removed again by
13
+ * `clearHighlights`, so nothing here changes what the page does — but the
14
+ * overlay is still a DOM mutation, which is why callers that watch for
15
+ * mutations (framewatch_dead_clicks) draw it only once everything has been
16
+ * measured.
17
+ *
18
+ * Every style is set through the CSSOM rather than as a `style` attribute or a
19
+ * `<style>` block: a page with a strict Content-Security-Policy blocks both of
20
+ * those, and the pages most worth auditing are the ones that lock themselves
21
+ * down.
22
+ */
23
+ /** The id of the container every highlight lives in, so `clearHighlights` can find it. */
24
+ export const HIGHLIGHT_CONTAINER_ID = "__framewatch_highlights";
25
+ /**
26
+ * Draw a box over each element in `highlights` and return which ones landed.
27
+ *
28
+ * An element that is gone, or that has no size, is reported in `missing`
29
+ * rather than being an error: by the time a report is drawn the page may have
30
+ * re-rendered, and a screenshot missing one box is far better than no
31
+ * screenshot at all.
32
+ */
33
+ export async function highlightElements(page, highlights, max = MAX_HIGHLIGHTS) {
34
+ const wanted = highlights.slice(0, Math.max(0, max));
35
+ if (wanted.length === 0)
36
+ return { drawn: [], missing: [] };
37
+ try {
38
+ return await page.evaluate(drawHighlights, {
39
+ container_id: HIGHLIGHT_CONTAINER_ID,
40
+ items: wanted.map((item) => ({
41
+ selector: item.selector,
42
+ ...(item.box ? { box: item.box } : {}),
43
+ match_index: item.match_index ?? 0,
44
+ label: item.label ?? "",
45
+ colour: item.colour ?? "#e5194b",
46
+ wash: item.wash ?? "rgba(229, 25, 75, 0.16)",
47
+ })),
48
+ });
49
+ }
50
+ catch {
51
+ // A page that will not run script (torn down, mid-navigation) simply gets
52
+ // no overlay. The caller still has its screenshot.
53
+ return { drawn: [], missing: wanted.map((item) => item.selector) };
54
+ }
55
+ }
56
+ /** Remove every box this module drew. Safe to call when there are none. */
57
+ export async function clearHighlights(page) {
58
+ await page
59
+ .evaluate((id) => {
60
+ const node = globalThis.document?.getElementById(id);
61
+ if (node && node.parentNode)
62
+ node.parentNode.removeChild(node);
63
+ }, HIGHLIGHT_CONTAINER_ID)
64
+ .catch(() => { });
65
+ }
66
+ function drawHighlights(options) {
67
+ const doc = globalThis.document;
68
+ const drawn = [];
69
+ const missing = [];
70
+ if (!doc || !doc.body)
71
+ return { drawn, missing: options.items.map((item) => item.selector) };
72
+ const existing = doc.getElementById(options.container_id);
73
+ if (existing && existing.parentNode)
74
+ existing.parentNode.removeChild(existing);
75
+ const container = doc.createElement("div");
76
+ container.id = options.container_id;
77
+ const style = (node, rules) => {
78
+ for (const name in rules) {
79
+ try {
80
+ node.style.setProperty(name, rules[name], "important");
81
+ }
82
+ catch {
83
+ // A patched CSSOM; the box may look wrong but the screenshot survives.
84
+ }
85
+ }
86
+ };
87
+ style(container, {
88
+ position: "absolute",
89
+ left: "0",
90
+ top: "0",
91
+ width: "0",
92
+ height: "0",
93
+ margin: "0",
94
+ padding: "0",
95
+ border: "0",
96
+ "pointer-events": "none",
97
+ // Above anything the page itself can stack.
98
+ "z-index": "2147483647",
99
+ });
100
+ // The container goes in empty and is measured, so every box below can be
101
+ // positioned relative to it. Absolute coordinates would be wrong the moment
102
+ // the page gives <body> a `position` or a `transform` of its own, which is
103
+ // exactly the kind of page worth auditing.
104
+ doc.body.appendChild(container);
105
+ let originLeft = 0;
106
+ let originTop = 0;
107
+ try {
108
+ const origin = container.getBoundingClientRect();
109
+ originLeft = origin.left;
110
+ originTop = origin.top;
111
+ }
112
+ catch {
113
+ // No rect to correct against; boxes land in document coordinates.
114
+ }
115
+ for (const item of options.items) {
116
+ let rect = null;
117
+ if (item.box) {
118
+ // Already measured, in viewport pixels — the same space as
119
+ // getBoundingClientRect, so it corrects against the origin the same way.
120
+ rect = { left: item.box.x, top: item.box.y, width: item.box.width, height: item.box.height };
121
+ }
122
+ else {
123
+ let element = null;
124
+ try {
125
+ const matches = doc.querySelectorAll(item.selector);
126
+ element = matches[item.match_index] ?? matches[0] ?? null;
127
+ }
128
+ catch {
129
+ element = null;
130
+ }
131
+ rect = element && element.getBoundingClientRect ? element.getBoundingClientRect() : null;
132
+ }
133
+ if (!rect || rect.width <= 0 || rect.height <= 0) {
134
+ missing.push(item.selector);
135
+ continue;
136
+ }
137
+ const left = rect.left - originLeft;
138
+ const top = rect.top - originTop;
139
+ const box = doc.createElement("div");
140
+ style(box, {
141
+ position: "absolute",
142
+ // Laid out against the container, which sits at the document origin —
143
+ // so one full-page screenshot catches every box, including the ones
144
+ // below the fold.
145
+ left: `${Math.round(left)}px`,
146
+ top: `${Math.round(top)}px`,
147
+ width: `${Math.round(rect.width)}px`,
148
+ height: `${Math.round(rect.height)}px`,
149
+ "box-sizing": "border-box",
150
+ border: `3px solid ${item.colour}`,
151
+ "border-radius": "3px",
152
+ // A wash rather than a fill: what is under the box is the thing being
153
+ // reported on, and it has to stay readable.
154
+ "background-color": item.wash,
155
+ "box-shadow": "0 0 0 2px rgba(255, 255, 255, 0.85)",
156
+ "pointer-events": "none",
157
+ });
158
+ if (item.label !== "") {
159
+ const tag = doc.createElement("div");
160
+ tag.textContent = item.label;
161
+ style(tag, {
162
+ position: "absolute",
163
+ left: "-3px",
164
+ // Above the box, unless that would fall off the top of the document.
165
+ top: top >= 20 ? "-20px" : `${Math.round(rect.height)}px`,
166
+ padding: "1px 6px",
167
+ "background-color": item.colour,
168
+ color: "#ffffff",
169
+ font: "700 12px/16px ui-monospace, SFMono-Regular, Menlo, monospace",
170
+ "white-space": "nowrap",
171
+ "border-radius": "3px",
172
+ "pointer-events": "none",
173
+ });
174
+ box.appendChild(tag);
175
+ }
176
+ container.appendChild(box);
177
+ drawn.push(item.selector);
178
+ }
179
+ return { drawn, missing };
180
+ }
181
+ //# sourceMappingURL=highlight.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"highlight.js","sourceRoot":"","sources":["../../src/utils/highlight.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport { MAX_HIGHLIGHTS } from \"../constants.js\";\nimport type { BoundingBox } from \"../types.js\";\n\n/**\n * Element highlight overlay.\n *\n * A selector like `.nav > button:nth-of-type(3)` says nothing about where the\n * problem is on screen. This draws a labelled box over each named element and\n * leaves it there until the screenshot has been taken, so a report about\n * elements comes with a picture of those elements.\n *\n * The boxes are drawn in *document* coordinates and appended to `<body>`, so\n * one full-page screenshot shows every highlight, including the ones below the\n * fold. They are `pointer-events: none` and are removed again by\n * `clearHighlights`, so nothing here changes what the page does — but the\n * overlay is still a DOM mutation, which is why callers that watch for\n * mutations (framewatch_dead_clicks) draw it only once everything has been\n * measured.\n *\n * Every style is set through the CSSOM rather than as a `style` attribute or a\n * `<style>` block: a page with a strict Content-Security-Policy blocks both of\n * those, and the pages most worth auditing are the ones that lock themselves\n * down.\n */\n\n/** The id of the container every highlight lives in, so `clearHighlights` can find it. */\nexport const HIGHLIGHT_CONTAINER_ID = \"__framewatch_highlights\";\n\nexport interface Highlight {\n /**\n * CSS selector for the element to draw over — or, when `box` is given, just\n * the name this highlight is reported under in `drawn` / `missing`.\n */\n selector: string;\n /**\n * Draw here instead of looking `selector` up, in viewport pixels. For an\n * element that was resolved some other way (an aria ref, a handle) and has\n * already been measured.\n */\n box?: BoundingBox;\n /**\n * Which match of `selector` to draw over, when the selector is not unique.\n * Defaults to the first.\n */\n match_index?: number;\n /** Drawn in the corner of the box — a number, or a short phrase. */\n label?: string;\n /** Any CSS colour. The box is outlined in it, and the label badge is painted with it. */\n colour?: string;\n /**\n * The translucent fill inside the box. Give it a low alpha: what is under\n * the box is the thing being reported on, and it has to stay readable.\n */\n wash?: string;\n}\n\nexport interface HighlightResult {\n /** Selectors that were drawn. */\n drawn: string[];\n /** Selectors that matched nothing, or matched something with no box to draw. */\n missing: string[];\n}\n\n/**\n * Draw a box over each element in `highlights` and return which ones landed.\n *\n * An element that is gone, or that has no size, is reported in `missing`\n * rather than being an error: by the time a report is drawn the page may have\n * re-rendered, and a screenshot missing one box is far better than no\n * screenshot at all.\n */\nexport async function highlightElements(\n page: Page,\n highlights: readonly Highlight[],\n max: number = MAX_HIGHLIGHTS,\n): Promise<HighlightResult> {\n const wanted = highlights.slice(0, Math.max(0, max));\n if (wanted.length === 0) return { drawn: [], missing: [] };\n\n try {\n return await page.evaluate(drawHighlights, {\n container_id: HIGHLIGHT_CONTAINER_ID,\n items: wanted.map((item) => ({\n selector: item.selector,\n ...(item.box ? { box: item.box } : {}),\n match_index: item.match_index ?? 0,\n label: item.label ?? \"\",\n colour: item.colour ?? \"#e5194b\",\n wash: item.wash ?? \"rgba(229, 25, 75, 0.16)\",\n })),\n });\n } catch {\n // A page that will not run script (torn down, mid-navigation) simply gets\n // no overlay. The caller still has its screenshot.\n return { drawn: [], missing: wanted.map((item) => item.selector) };\n }\n}\n\n/** Remove every box this module drew. Safe to call when there are none. */\nexport async function clearHighlights(page: Page): Promise<void> {\n await page\n .evaluate((id: string) => {\n const node = (globalThis as any).document?.getElementById(id);\n if (node && node.parentNode) node.parentNode.removeChild(node);\n }, HIGHLIGHT_CONTAINER_ID)\n .catch(() => {});\n}\n\n/* ── In-page ──────────────────────────────────────────────────────────────\n * Everything below runs inside Chromium, so it is written against\n * `globalThis` and untyped nodes: this package is compiled with the Node lib\n * only, and the page it lands in may have patched half of these properties.\n */\n\ninterface DrawOptions {\n container_id: string;\n items: Array<{\n selector: string;\n box?: { x: number; y: number; width: number; height: number };\n match_index: number;\n label: string;\n colour: string;\n wash: string;\n }>;\n}\n\nfunction drawHighlights(options: DrawOptions): { drawn: string[]; missing: string[] } {\n const doc = (globalThis as any).document;\n const drawn: string[] = [];\n const missing: string[] = [];\n if (!doc || !doc.body) return { drawn, missing: options.items.map((item) => item.selector) };\n\n const existing = doc.getElementById(options.container_id);\n if (existing && existing.parentNode) existing.parentNode.removeChild(existing);\n\n const container = doc.createElement(\"div\");\n container.id = options.container_id;\n const style = (node: any, rules: Record<string, string>): void => {\n for (const name in rules) {\n try {\n node.style.setProperty(name, rules[name], \"important\");\n } catch {\n // A patched CSSOM; the box may look wrong but the screenshot survives.\n }\n }\n };\n\n style(container, {\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n width: \"0\",\n height: \"0\",\n margin: \"0\",\n padding: \"0\",\n border: \"0\",\n \"pointer-events\": \"none\",\n // Above anything the page itself can stack.\n \"z-index\": \"2147483647\",\n });\n\n // The container goes in empty and is measured, so every box below can be\n // positioned relative to it. Absolute coordinates would be wrong the moment\n // the page gives <body> a `position` or a `transform` of its own, which is\n // exactly the kind of page worth auditing.\n doc.body.appendChild(container);\n let originLeft = 0;\n let originTop = 0;\n try {\n const origin = container.getBoundingClientRect();\n originLeft = origin.left;\n originTop = origin.top;\n } catch {\n // No rect to correct against; boxes land in document coordinates.\n }\n\n for (const item of options.items) {\n let rect: { left: number; top: number; width: number; height: number } | null = null;\n if (item.box) {\n // Already measured, in viewport pixels — the same space as\n // getBoundingClientRect, so it corrects against the origin the same way.\n rect = { left: item.box.x, top: item.box.y, width: item.box.width, height: item.box.height };\n } else {\n let element: any = null;\n try {\n const matches = doc.querySelectorAll(item.selector);\n element = matches[item.match_index] ?? matches[0] ?? null;\n } catch {\n element = null;\n }\n rect = element && element.getBoundingClientRect ? element.getBoundingClientRect() : null;\n }\n if (!rect || rect.width <= 0 || rect.height <= 0) {\n missing.push(item.selector);\n continue;\n }\n\n const left = rect.left - originLeft;\n const top = rect.top - originTop;\n const box = doc.createElement(\"div\");\n style(box, {\n position: \"absolute\",\n // Laid out against the container, which sits at the document origin —\n // so one full-page screenshot catches every box, including the ones\n // below the fold.\n left: `${Math.round(left)}px`,\n top: `${Math.round(top)}px`,\n width: `${Math.round(rect.width)}px`,\n height: `${Math.round(rect.height)}px`,\n \"box-sizing\": \"border-box\",\n border: `3px solid ${item.colour}`,\n \"border-radius\": \"3px\",\n // A wash rather than a fill: what is under the box is the thing being\n // reported on, and it has to stay readable.\n \"background-color\": item.wash,\n \"box-shadow\": \"0 0 0 2px rgba(255, 255, 255, 0.85)\",\n \"pointer-events\": \"none\",\n });\n\n if (item.label !== \"\") {\n const tag = doc.createElement(\"div\");\n tag.textContent = item.label;\n style(tag, {\n position: \"absolute\",\n left: \"-3px\",\n // Above the box, unless that would fall off the top of the document.\n top: top >= 20 ? \"-20px\" : `${Math.round(rect.height)}px`,\n padding: \"1px 6px\",\n \"background-color\": item.colour,\n color: \"#ffffff\",\n font: \"700 12px/16px ui-monospace, SFMono-Regular, Menlo, monospace\",\n \"white-space\": \"nowrap\",\n \"border-radius\": \"3px\",\n \"pointer-events\": \"none\",\n });\n box.appendChild(tag);\n }\n\n container.appendChild(box);\n drawn.push(item.selector);\n }\n\n return { drawn, missing };\n}\n"]}
@@ -0,0 +1,100 @@
1
+ import type { CheckOutcome } from "../engine/links.js";
2
+ /**
3
+ * What counts as a broken link.
4
+ *
5
+ * Everything here is pure: it takes an href and a base, or the outcome of one
6
+ * HTTP check, and returns a verdict. Nothing in this file opens a browser or a
7
+ * socket, which is the point — "is this href even a request?", "did that chain
8
+ * of redirects end somewhere real?" and "is a 403 a broken link?" are all
9
+ * judgements with edge cases, and judgements need unit tests that run in
10
+ * milliseconds.
11
+ *
12
+ * The bias throughout is against false alarms. A report that calls a working
13
+ * link broken is worse than one that says nothing: it sends somebody to look
14
+ * at a page that is fine, and the next real finding gets ignored. So a status
15
+ * that means "I would not answer that" is separated from one that means "there
16
+ * is nothing here", and anything unrecognised is quoted rather than judged.
17
+ */
18
+ /**
19
+ * What kind of thing an href is.
20
+ *
21
+ * Only `http` is a request. `same_page` is answered by the DOM, the scheme
22
+ * kinds are handed to something that is not a browser, and `malformed` never
23
+ * leaves the page at all.
24
+ */
25
+ export type HrefKind = "http" | "same_page" | "empty" | "mailto" | "tel" | "javascript" | "data" | "scheme" | "malformed";
26
+ export interface ClassifiedHref {
27
+ kind: HrefKind;
28
+ /** The absolute URL, for `http` and `same_page`. */
29
+ resolved?: string;
30
+ /** The `#…` part, decoded and without its hash, when there is one. */
31
+ fragment?: string;
32
+ /** The scheme, for `scheme` — `ftp`, `chrome-extension`, whatever the page used. */
33
+ scheme?: string;
34
+ /** Why this one cannot be checked, or is suspect. */
35
+ reason?: string;
36
+ }
37
+ /**
38
+ * Work out what an href points at, resolved against the page it was found on.
39
+ *
40
+ * Order matters. An empty href and a bare `#` both resolve to the current
41
+ * document, so they are recognised from the raw text before the URL parser
42
+ * gets a chance to turn them into the page's own address and hide what the
43
+ * author actually wrote — and `<a href="#">` is the single most common dead
44
+ * link there is.
45
+ */
46
+ export declare function classifyHref(rawHref: string, base: string): ClassifiedHref;
47
+ /**
48
+ * The identity of a request.
49
+ *
50
+ * The fragment is dropped: it never reaches the server, so `/help#returns` and
51
+ * `/help#shipping` are one request and must not be checked twice. Everything
52
+ * else is kept, including the trailing slash — `/a` and `/a/` are different
53
+ * URLs and routinely redirect differently, so collapsing them would hide a
54
+ * redirect the tool exists to report.
55
+ */
56
+ export declare function dedupeKey(url: string): string;
57
+ /** Same scheme, host and port — the only definition a crawl can safely act on. */
58
+ export declare function isInternal(url: string, origin: string): boolean;
59
+ /**
60
+ * The two fragments every browser resolves without an element to scroll to:
61
+ * an empty one (the top of the document, which is what `href="#"` means) and
62
+ * `#top`, which HTML defines as the same thing.
63
+ */
64
+ export declare function isAlwaysValidFragment(fragment: string): boolean;
65
+ /** Where a checked link ended up. */
66
+ export type LinkCategory = "ok" | "redirect" | "broken" | "blocked" | "timeout" | "error";
67
+ export interface LinkVerdict {
68
+ category: LinkCategory;
69
+ /** What happened, in one phrase: "404 Not Found", "redirected to … (2 hops)". */
70
+ detail: string;
71
+ /** What to do about it. Only where there is something to do. */
72
+ fix?: string;
73
+ /** Redirects followed. */
74
+ hops: number;
75
+ /** Where the chain actually ended, when that is not where it started. */
76
+ final_url?: string;
77
+ }
78
+ /**
79
+ * Decide what one check means.
80
+ *
81
+ * The order is the order the failures shadow each other: a loop makes the
82
+ * final status meaningless, a chain that ran out of hops never got one, a
83
+ * transport error means there is no status at all, and only then is the status
84
+ * itself worth reading.
85
+ */
86
+ export declare function judgeCheck(outcome: CheckOutcome): LinkVerdict;
87
+ /**
88
+ * Turn a transport failure into something a reader can act on.
89
+ *
90
+ * These are the five that a link check actually hits. Anything else is quoted
91
+ * verbatim rather than guessed at — an error nobody predicted is still useful,
92
+ * and a wrong paraphrase of it is not.
93
+ */
94
+ export declare function classifyFailure(message: string): {
95
+ category: LinkCategory;
96
+ detail: string;
97
+ fix?: string;
98
+ };
99
+ /** The registered name of a status code, or "" for the ones nobody standardised. */
100
+ export declare function statusText(status: number): string;
@@ -0,0 +1,284 @@
1
+ import { STATUS_CODES } from "node:http";
2
+ import { MAX_LINK_REDIRECTS } from "../constants.js";
3
+ /**
4
+ * Work out what an href points at, resolved against the page it was found on.
5
+ *
6
+ * Order matters. An empty href and a bare `#` both resolve to the current
7
+ * document, so they are recognised from the raw text before the URL parser
8
+ * gets a chance to turn them into the page's own address and hide what the
9
+ * author actually wrote — and `<a href="#">` is the single most common dead
10
+ * link there is.
11
+ */
12
+ export function classifyHref(rawHref, base) {
13
+ const href = String(rawHref ?? "").trim();
14
+ // Per RFC 3986 an empty reference is the current document. Browsers reload
15
+ // the page; it is almost never what the author meant.
16
+ if (href === "") {
17
+ return { kind: "empty", reason: "an empty href reloads the current page" };
18
+ }
19
+ // A fragment-only reference never leaves the document, whatever the base is.
20
+ if (href.startsWith("#")) {
21
+ return { kind: "same_page", fragment: decodeFragment(href.slice(1)) };
22
+ }
23
+ let url;
24
+ try {
25
+ url = new URL(href, base);
26
+ }
27
+ catch {
28
+ return { kind: "malformed", reason: `"${href}" is not a URL a browser can resolve` };
29
+ }
30
+ const scheme = url.protocol.replace(/:$/, "").toLowerCase();
31
+ switch (scheme) {
32
+ case "http":
33
+ case "https":
34
+ break;
35
+ case "mailto":
36
+ return {
37
+ kind: "mailto",
38
+ ...(url.pathname.trim() === "" ? { reason: "a mailto: with no address after it" } : {}),
39
+ };
40
+ case "tel":
41
+ return {
42
+ kind: "tel",
43
+ ...(url.pathname.trim() === "" ? { reason: "a tel: with no number after it" } : {}),
44
+ };
45
+ case "javascript":
46
+ return { kind: "javascript", reason: "runs a script instead of going anywhere" };
47
+ case "data":
48
+ return { kind: "data" };
49
+ default:
50
+ return { kind: "scheme", scheme, reason: `${scheme}: is handed to the operating system, not fetched` };
51
+ }
52
+ const fragment = url.hash ? decodeFragment(url.hash.slice(1)) : undefined;
53
+ const kind = dedupeKey(url.href) === dedupeKey(base) ? "same_page" : "http";
54
+ return {
55
+ kind,
56
+ resolved: url.href,
57
+ ...(fragment !== undefined ? { fragment } : {}),
58
+ };
59
+ }
60
+ /** `%C3%A9` in an href is `é` in an id — browsers match the decoded form. */
61
+ function decodeFragment(fragment) {
62
+ try {
63
+ return decodeURIComponent(fragment);
64
+ }
65
+ catch {
66
+ return fragment;
67
+ }
68
+ }
69
+ /**
70
+ * The identity of a request.
71
+ *
72
+ * The fragment is dropped: it never reaches the server, so `/help#returns` and
73
+ * `/help#shipping` are one request and must not be checked twice. Everything
74
+ * else is kept, including the trailing slash — `/a` and `/a/` are different
75
+ * URLs and routinely redirect differently, so collapsing them would hide a
76
+ * redirect the tool exists to report.
77
+ */
78
+ export function dedupeKey(url) {
79
+ try {
80
+ const parsed = new URL(url);
81
+ parsed.hash = "";
82
+ return parsed.href;
83
+ }
84
+ catch {
85
+ return url;
86
+ }
87
+ }
88
+ /** Same scheme, host and port — the only definition a crawl can safely act on. */
89
+ export function isInternal(url, origin) {
90
+ try {
91
+ return new URL(url).origin === new URL(origin).origin;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * The two fragments every browser resolves without an element to scroll to:
99
+ * an empty one (the top of the document, which is what `href="#"` means) and
100
+ * `#top`, which HTML defines as the same thing.
101
+ */
102
+ export function isAlwaysValidFragment(fragment) {
103
+ return fragment === "" || fragment.toLowerCase() === "top";
104
+ }
105
+ /**
106
+ * Statuses that mean "I will not answer that", not "there is nothing here".
107
+ *
108
+ * A link behind a login answers 401, a WAF answers 403 to anything without a
109
+ * browser's fingerprint, a rate limiter answers 429, and LinkedIn answers 999
110
+ * to automated checks. Every one of those links works perfectly for a person
111
+ * with a browser, and reporting them as broken is how a link report gets
112
+ * ignored. 405 is here for the same reason from the other direction: the
113
+ * server refused the *method*, so the check failed, not the link.
114
+ */
115
+ const REFUSED = {
116
+ 401: "the server wants credentials before it will answer — the link is likely fine for a signed-in visitor",
117
+ 403: "the server refused the check (bot protection usually answers this) — the link may well work in a browser",
118
+ 405: "the server refused the request method",
119
+ 429: "the check was rate-limited, not the link broken",
120
+ 999: "a non-standard status some sites return to automated checks — not a broken link",
121
+ };
122
+ /**
123
+ * Decide what one check means.
124
+ *
125
+ * The order is the order the failures shadow each other: a loop makes the
126
+ * final status meaningless, a chain that ran out of hops never got one, a
127
+ * transport error means there is no status at all, and only then is the status
128
+ * itself worth reading.
129
+ */
130
+ export function judgeCheck(outcome) {
131
+ const chain = outcome.chain.length > 0 ? outcome.chain : [""];
132
+ const hops = chain.length - 1;
133
+ const final = chain[chain.length - 1];
134
+ const landed = hops > 0 ? { final_url: final } : {};
135
+ const repeat = firstRepeat(chain);
136
+ if (repeat !== undefined) {
137
+ return {
138
+ category: "error",
139
+ detail: `the redirects loop — ${repeat} is visited twice`,
140
+ fix: "Follow the chain by hand: a redirect loop is a page nobody can reach.",
141
+ hops,
142
+ ...landed,
143
+ };
144
+ }
145
+ if (outcome.hops_exceeded) {
146
+ return {
147
+ category: "error",
148
+ detail: `still redirecting after ${MAX_LINK_REDIRECTS} redirects, last at ${final}`,
149
+ fix: "A chain this long is a configuration bug — most crawlers give up after five.",
150
+ hops,
151
+ ...landed,
152
+ };
153
+ }
154
+ if (outcome.error !== undefined) {
155
+ const failure = classifyFailure(outcome.error);
156
+ return { ...failure, hops, ...landed };
157
+ }
158
+ const status = outcome.status;
159
+ if (status === undefined) {
160
+ return { category: "error", detail: "the check produced no answer at all", hops, ...landed };
161
+ }
162
+ const named = label(status);
163
+ // A 3xx that is still a 3xx after the chain was followed had nowhere to go.
164
+ if (status >= 300 && status < 400) {
165
+ return {
166
+ category: "broken",
167
+ detail: `${named} — a redirect with no Location header to follow`,
168
+ fix: "Give the redirect a Location, or serve the page directly.",
169
+ hops,
170
+ ...landed,
171
+ };
172
+ }
173
+ if (status >= 400) {
174
+ const refused = REFUSED[status];
175
+ if (refused !== undefined) {
176
+ const method = status === 405 ? ` (${outcome.method} was refused too)` : "";
177
+ return {
178
+ category: "blocked",
179
+ detail: `${named} — ${refused}${method}`,
180
+ ...(status === 429
181
+ ? { fix: "Lower `concurrency` or raise `timeout_ms` and check this one again." }
182
+ : {}),
183
+ hops,
184
+ ...landed,
185
+ };
186
+ }
187
+ return {
188
+ category: "broken",
189
+ detail: hops > 0
190
+ ? `${named} — after ${plural(hops, "redirect")}, ending at ${final}`
191
+ : named,
192
+ fix: status === 404
193
+ ? "Point the link somewhere that exists, or restore the page."
194
+ : "This link answers an error to every visitor.",
195
+ hops,
196
+ ...landed,
197
+ };
198
+ }
199
+ if (hops === 0) {
200
+ return { category: "ok", detail: named, hops };
201
+ }
202
+ const upgraded = isUpgrade(chain);
203
+ return {
204
+ category: "redirect",
205
+ detail: `redirected to ${final} (${plural(hops, "hop")}), which answered ${named}`,
206
+ ...(hops > 1
207
+ ? { fix: "Link straight to the final address — every hop in the chain is another round trip." }
208
+ : upgraded
209
+ ? { fix: "Write the link as https:// — the http:// form costs an extra round trip on every visit." }
210
+ : {}),
211
+ hops,
212
+ final_url: final,
213
+ };
214
+ }
215
+ /** Did this chain exist only to swap http for https? */
216
+ function isUpgrade(chain) {
217
+ try {
218
+ return new URL(chain[0]).protocol === "http:" && new URL(chain[chain.length - 1]).protocol === "https:";
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ /** The first URL that appears twice, which is a loop however long the chain is. */
225
+ function firstRepeat(chain) {
226
+ const seen = new Set();
227
+ for (const url of chain) {
228
+ if (seen.has(url))
229
+ return url;
230
+ seen.add(url);
231
+ }
232
+ return undefined;
233
+ }
234
+ /**
235
+ * Turn a transport failure into something a reader can act on.
236
+ *
237
+ * These are the five that a link check actually hits. Anything else is quoted
238
+ * verbatim rather than guessed at — an error nobody predicted is still useful,
239
+ * and a wrong paraphrase of it is not.
240
+ */
241
+ export function classifyFailure(message) {
242
+ const line = String(message).split("\n")[0];
243
+ if (/Timeout\s+\d+\s*ms\s+exceeded|timed?\s?out|ETIMEDOUT/i.test(line)) {
244
+ return {
245
+ category: "timeout",
246
+ detail: "no answer within the timeout",
247
+ fix: "Raise `timeout_ms` if the host is simply slow; a link nobody's browser will wait for is broken in practice.",
248
+ };
249
+ }
250
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|NAME_NOT_RESOLVED|ERR_NAME/i.test(line)) {
251
+ return {
252
+ category: "error",
253
+ detail: "the host name does not resolve",
254
+ fix: "Check the domain for a typo, and that it has not expired.",
255
+ };
256
+ }
257
+ if (/ECONNREFUSED|CONNECTION_REFUSED/i.test(line)) {
258
+ return { category: "error", detail: "nothing is listening on that address — the connection was refused" };
259
+ }
260
+ if (/ECONNRESET|socket hang up|EPIPE/i.test(line)) {
261
+ return { category: "error", detail: "the connection was closed before an answer arrived (socket hang up)" };
262
+ }
263
+ if (/certificate|CERT_|ERR_CERT|SSL|TLS|self-signed/i.test(line)) {
264
+ return {
265
+ category: "error",
266
+ detail: "the TLS certificate was rejected",
267
+ fix: "A browser shows an interstitial for this — to a visitor it is a broken link.",
268
+ };
269
+ }
270
+ return { category: "error", detail: line };
271
+ }
272
+ /** The registered name of a status code, or "" for the ones nobody standardised. */
273
+ export function statusText(status) {
274
+ return STATUS_CODES[status] ?? "";
275
+ }
276
+ /** `404 Not Found`, or just `999` where inventing a name would be worse than having none. */
277
+ function label(status) {
278
+ const name = statusText(status);
279
+ return name === "" ? String(status) : `${status} ${name}`;
280
+ }
281
+ function plural(n, noun) {
282
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
283
+ }
284
+ //# sourceMappingURL=link-rules.js.map