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.
- package/README.md +895 -15
- package/dist/constants.d.ts +274 -0
- package/dist/constants.js +279 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.d.ts +20 -4
- package/dist/engine/browser.js +26 -9
- package/dist/engine/browser.js.map +1 -1
- package/dist/engine/clicks.d.ts +221 -0
- package/dist/engine/clicks.js +801 -0
- package/dist/engine/clicks.js.map +1 -0
- package/dist/engine/forms.d.ts +137 -0
- package/dist/engine/forms.js +474 -0
- package/dist/engine/forms.js.map +1 -0
- package/dist/engine/hmr.d.ts +41 -0
- package/dist/engine/hmr.js +91 -0
- package/dist/engine/hmr.js.map +1 -0
- package/dist/engine/inspect.d.ts +31 -0
- package/dist/engine/inspect.js +383 -0
- package/dist/engine/inspect.js.map +1 -0
- package/dist/engine/interaction.d.ts +12 -7
- package/dist/engine/interaction.js +110 -18
- package/dist/engine/interaction.js.map +1 -1
- package/dist/engine/links.d.ts +134 -0
- package/dist/engine/links.js +384 -0
- package/dist/engine/links.js.map +1 -0
- package/dist/engine/mocks.d.ts +53 -0
- package/dist/engine/mocks.js +148 -0
- package/dist/engine/mocks.js.map +1 -0
- package/dist/engine/rtl.d.ts +129 -0
- package/dist/engine/rtl.js +540 -0
- package/dist/engine/rtl.js.map +1 -0
- package/dist/engine/seo.d.ts +189 -0
- package/dist/engine/seo.js +398 -0
- package/dist/engine/seo.js.map +1 -0
- package/dist/engine/snapshot.d.ts +29 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/snapshot.js.map +1 -0
- package/dist/engine/vue.d.ts +54 -0
- package/dist/engine/vue.js +419 -0
- package/dist/engine/vue.js.map +1 -0
- package/dist/index.js +45 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.d.ts +4 -0
- package/dist/tools/accessibility.js +9 -2
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/api-mock.d.ts +405 -0
- package/dist/tools/api-mock.js +186 -0
- package/dist/tools/api-mock.js.map +1 -0
- package/dist/tools/capture.d.ts +90 -26
- package/dist/tools/capture.js +109 -58
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.d.ts +4 -0
- package/dist/tools/compare.js +16 -5
- package/dist/tools/compare.js.map +1 -1
- package/dist/tools/dead-clicks.d.ts +128 -0
- package/dist/tools/dead-clicks.js +570 -0
- package/dist/tools/dead-clicks.js.map +1 -0
- package/dist/tools/form-test.d.ts +112 -0
- package/dist/tools/form-test.js +477 -0
- package/dist/tools/form-test.js.map +1 -0
- package/dist/tools/index.d.ts +17 -1
- package/dist/tools/index.js +45 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts +78 -0
- package/dist/tools/inspect.js +136 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/interact.d.ts +37 -18
- package/dist/tools/interact.js +113 -13
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/links.d.ts +129 -0
- package/dist/tools/links.js +640 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/responsive.d.ts +10 -6
- package/dist/tools/responsive.js +21 -4
- package/dist/tools/responsive.js.map +1 -1
- package/dist/tools/rtl.d.ts +241 -0
- package/dist/tools/rtl.js +410 -0
- package/dist/tools/rtl.js.map +1 -0
- package/dist/tools/save-auth.d.ts +263 -0
- package/dist/tools/save-auth.js +253 -0
- package/dist/tools/save-auth.js.map +1 -0
- package/dist/tools/screenshot.d.ts +4 -0
- package/dist/tools/screenshot.js +15 -4
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/seo.d.ts +113 -0
- package/dist/tools/seo.js +281 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/snapshot.d.ts +122 -0
- package/dist/tools/snapshot.js +183 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/wait-for.d.ts +107 -0
- package/dist/tools/wait-for.js +167 -0
- package/dist/tools/wait-for.js.map +1 -0
- package/dist/utils/arabic-text.d.ts +14 -0
- package/dist/utils/arabic-text.js +193 -0
- package/dist/utils/arabic-text.js.map +1 -0
- package/dist/utils/budget.d.ts +41 -0
- package/dist/utils/budget.js +182 -0
- package/dist/utils/budget.js.map +1 -0
- package/dist/utils/format.d.ts +11 -1
- package/dist/utils/format.js +27 -4
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/highlight.d.ts +69 -0
- package/dist/utils/highlight.js +181 -0
- package/dist/utils/highlight.js.map +1 -0
- package/dist/utils/link-rules.d.ts +100 -0
- package/dist/utils/link-rules.js +284 -0
- package/dist/utils/link-rules.js.map +1 -0
- package/dist/utils/mock-rules.d.ts +144 -0
- package/dist/utils/mock-rules.js +224 -0
- package/dist/utils/mock-rules.js.map +1 -0
- package/dist/utils/rtl-rules.d.ts +142 -0
- package/dist/utils/rtl-rules.js +296 -0
- package/dist/utils/rtl-rules.js.map +1 -0
- package/dist/utils/seo-rules.d.ts +129 -0
- package/dist/utils/seo-rules.js +726 -0
- package/dist/utils/seo-rules.js.map +1 -0
- package/dist/utils/snapshot-rules.d.ts +33 -0
- package/dist/utils/snapshot-rules.js +111 -0
- package/dist/utils/snapshot-rules.js.map +1 -0
- package/dist/utils/storage-state.d.ts +76 -0
- package/dist/utils/storage-state.js +195 -0
- package/dist/utils/storage-state.js.map +1 -0
- package/dist/utils/style-rules.d.ts +107 -0
- package/dist/utils/style-rules.js +223 -0
- package/dist/utils/style-rules.js.map +1 -0
- package/dist/utils/test-data.d.ts +75 -0
- package/dist/utils/test-data.js +294 -0
- package/dist/utils/test-data.js.map +1 -0
- package/dist/utils/vue-rules.d.ts +72 -0
- package/dist/utils/vue-rules.js +108 -0
- package/dist/utils/vue-rules.js.map +1 -0
- 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
|