@jitsusama/agentic-harness.core 0.1.0 → 0.3.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/dist/bin/cli.js +7 -0
- package/dist/bin/web.d.ts +30 -0
- package/dist/bin/web.js +97 -0
- package/dist/observability/index.d.ts +8 -0
- package/dist/observability/index.js +8 -0
- package/dist/observability/ledger/index.d.ts +14 -0
- package/dist/observability/ledger/index.js +13 -0
- package/dist/observability/ledger/store.d.ts +50 -0
- package/dist/observability/ledger/store.js +573 -0
- package/dist/observability/ledger/types.d.ts +197 -0
- package/dist/observability/ledger/types.js +1 -0
- package/dist/observability/recorder.d.ts +9 -2
- package/dist/observability/recorder.js +11 -18
- package/dist/observability/store.d.ts +5 -3
- package/dist/observability/store.js +152 -55
- package/dist/observability/types.d.ts +32 -4
- package/dist/web/audit/index.d.ts +1 -0
- package/dist/web/audit/index.js +1 -0
- package/dist/web/audit/motion.d.ts +87 -0
- package/dist/web/audit/motion.js +239 -0
- package/dist/web/design/index.d.ts +1 -0
- package/dist/web/design/index.js +1 -0
- package/dist/web/design/typography.d.ts +71 -0
- package/dist/web/design/typography.js +221 -0
- package/dist/web/hydration/capture.d.ts +37 -0
- package/dist/web/hydration/capture.js +96 -0
- package/dist/web/hydration/index.d.ts +10 -0
- package/dist/web/hydration/index.js +10 -0
- package/dist/web/hydration/judge.d.ts +56 -0
- package/dist/web/hydration/judge.js +191 -0
- package/dist/web/index.d.ts +1 -0
- package/dist/web/index.js +1 -0
- package/dist/web/perf/index.d.ts +1 -1
- package/dist/web/perf/index.js +1 -1
- package/dist/web/perf/view.js +19 -1
- package/dist/web/perf/vitals.d.ts +29 -0
- package/dist/web/perf/vitals.js +70 -0
- package/dist/web/session.d.ts +31 -2
- package/dist/web/session.js +75 -2
- package/package.json +12 -3
- package/dist/memory/db.d.ts +0 -15
- package/dist/memory/db.js +0 -25
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page honours a request for reduced motion.
|
|
3
|
+
*
|
|
4
|
+
* The preference is the one accessibility setting a page can
|
|
5
|
+
* ignore without failing a single axe rule: the markup is fine,
|
|
6
|
+
* the contrast is fine, and the page is still unusable for the
|
|
7
|
+
* person who asked it to hold still. The only way to know is to
|
|
8
|
+
* ask for reduce and then look at what is still moving, which is
|
|
9
|
+
* what this module judges from a capture taken under emulation.
|
|
10
|
+
*
|
|
11
|
+
* The analysis is pure and takes serialized data, like every
|
|
12
|
+
* other audit here: nothing in this file can start a browser.
|
|
13
|
+
*
|
|
14
|
+
* Adapted from the reduced-motion conventions in Carolyn
|
|
15
|
+
* McNeillie's review-page skill set, which treats a page that
|
|
16
|
+
* ignores the preference outright as one of the few
|
|
17
|
+
* symptom-only blockers: user-observable, confirmed from the
|
|
18
|
+
* capture, no mechanism claim required.
|
|
19
|
+
*/
|
|
20
|
+
import type { A11yFinding } from "./axe.js";
|
|
21
|
+
/** A video as the page reported it under reduce. */
|
|
22
|
+
export interface MotionVideo {
|
|
23
|
+
readonly selector: string;
|
|
24
|
+
readonly html: string;
|
|
25
|
+
readonly playing: boolean;
|
|
26
|
+
readonly autoplay: boolean;
|
|
27
|
+
readonly loop: boolean;
|
|
28
|
+
readonly controls: boolean;
|
|
29
|
+
readonly visible: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** An animation as the page reported it under reduce. */
|
|
32
|
+
export interface MotionAnimation {
|
|
33
|
+
readonly selector: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly kind: string;
|
|
36
|
+
readonly playState: string;
|
|
37
|
+
/** May be absent when the effect reports no timing. */
|
|
38
|
+
readonly durationMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Repeats, as text: Infinity does not survive serialization,
|
|
41
|
+
* so the page sends the word.
|
|
42
|
+
*/
|
|
43
|
+
readonly iterations: string;
|
|
44
|
+
/** Driven by scroll rather than by time. */
|
|
45
|
+
readonly scroll: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** What the page was doing while asked to hold still. */
|
|
48
|
+
export interface MotionCapture {
|
|
49
|
+
/** Whether the page actually saw the reduce preference. */
|
|
50
|
+
readonly reduced: boolean;
|
|
51
|
+
readonly videos: readonly MotionVideo[];
|
|
52
|
+
readonly animations: readonly MotionAnimation[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Animations at or under this total runtime are left alone.
|
|
56
|
+
*
|
|
57
|
+
* The preference asks for less motion, not none: a brief fade is
|
|
58
|
+
* how a page avoids a jarring pop, and flagging every 150ms
|
|
59
|
+
* transition would bury the marquee that never stops.
|
|
60
|
+
*/
|
|
61
|
+
export declare const BRIEF_MS = 250;
|
|
62
|
+
/**
|
|
63
|
+
* Motion running longer than this needs a way to stop.
|
|
64
|
+
*
|
|
65
|
+
* The five second line is WCAG 2.2.2's own: moving content that
|
|
66
|
+
* starts automatically and lasts longer than five seconds must
|
|
67
|
+
* have a mechanism to pause, stop or hide it.
|
|
68
|
+
*/
|
|
69
|
+
export declare const PAUSE_STOP_HIDE_MS = 5000;
|
|
70
|
+
/**
|
|
71
|
+
* Judge a capture taken under reduced motion.
|
|
72
|
+
*
|
|
73
|
+
* An empty answer from a capture where the page never saw the
|
|
74
|
+
* preference would be a lie, so a capture with reduced false
|
|
75
|
+
* produces a single needs-review finding naming the problem
|
|
76
|
+
* instead of a clean pass.
|
|
77
|
+
*/
|
|
78
|
+
export declare function analyseMotion(capture: MotionCapture): readonly A11yFinding[];
|
|
79
|
+
/**
|
|
80
|
+
* The expression that reads what is moving, run in the page
|
|
81
|
+
* while reduce is emulated.
|
|
82
|
+
*
|
|
83
|
+
* Selectors are built the same way the other captures build
|
|
84
|
+
* them: something a person could paste into the console, never a
|
|
85
|
+
* retained node.
|
|
86
|
+
*/
|
|
87
|
+
export declare const MOTION_CAPTURE = "(() => {\n\tconst selectorFor = (el) => {\n\t\tif (!el || !el.tagName) return \"(detached)\";\n\t\tconst tag = el.tagName.toLowerCase();\n\t\tif (el.id) return \"#\" + el.id;\n\t\tconst hook = el.getAttribute && el.getAttribute(\"data-testid\");\n\t\tif (hook) return tag + '[data-testid=\"' + hook + '\"]';\n\t\tconst first = el.classList && el.classList[0];\n\t\tif (first) return tag + \".\" + first;\n\t\treturn tag;\n\t};\n\n\tconst videos = [...document.querySelectorAll(\"video\")].map((video) => ({\n\t\tselector: selectorFor(video),\n\t\thtml: video.outerHTML.slice(0, 160),\n\t\tplaying:\n\t\t\t!video.paused && !video.ended && video.readyState > 2,\n\t\tautoplay: video.autoplay,\n\t\tloop: video.loop,\n\t\tcontrols: video.controls,\n\t\tvisible: video.getClientRects().length > 0,\n\t}));\n\n\tconst animations = document.getAnimations\n\t\t? document.getAnimations({ subtree: true }).map((animation) => {\n\t\t\t\tconst effect = animation.effect;\n\t\t\t\tconst timing = effect && effect.getTiming ? effect.getTiming() : {};\n\t\t\t\tconst duration =\n\t\t\t\t\ttypeof timing.duration === \"number\" ? timing.duration : undefined;\n\t\t\t\tconst target = effect && effect.target ? effect.target : null;\n\t\t\t\treturn {\n\t\t\t\t\tselector: selectorFor(target),\n\t\t\t\t\tname:\n\t\t\t\t\t\tanimation.animationName ||\n\t\t\t\t\t\tanimation.transitionProperty ||\n\t\t\t\t\t\tanimation.id ||\n\t\t\t\t\t\t\"unnamed\",\n\t\t\t\t\tkind: animation.constructor.name,\n\t\t\t\t\tplayState: animation.playState,\n\t\t\t\t\t...(duration === undefined ? {} : { durationMs: duration }),\n\t\t\t\t\titerations: String(timing.iterations === undefined ? 1 : timing.iterations),\n\t\t\t\t\tscroll: !!(\n\t\t\t\t\t\tanimation.timeline &&\n\t\t\t\t\t\tanimation.timeline.constructor.name !== \"DocumentTimeline\"\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t})\n\t\t: [];\n\n\treturn {\n\t\treduced: matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n\t\tvideos,\n\t\tanimations,\n\t};\n})()";
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page honours a request for reduced motion.
|
|
3
|
+
*
|
|
4
|
+
* The preference is the one accessibility setting a page can
|
|
5
|
+
* ignore without failing a single axe rule: the markup is fine,
|
|
6
|
+
* the contrast is fine, and the page is still unusable for the
|
|
7
|
+
* person who asked it to hold still. The only way to know is to
|
|
8
|
+
* ask for reduce and then look at what is still moving, which is
|
|
9
|
+
* what this module judges from a capture taken under emulation.
|
|
10
|
+
*
|
|
11
|
+
* The analysis is pure and takes serialized data, like every
|
|
12
|
+
* other audit here: nothing in this file can start a browser.
|
|
13
|
+
*
|
|
14
|
+
* Adapted from the reduced-motion conventions in Carolyn
|
|
15
|
+
* McNeillie's review-page skill set, which treats a page that
|
|
16
|
+
* ignores the preference outright as one of the few
|
|
17
|
+
* symptom-only blockers: user-observable, confirmed from the
|
|
18
|
+
* capture, no mechanism claim required.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Animations at or under this total runtime are left alone.
|
|
22
|
+
*
|
|
23
|
+
* The preference asks for less motion, not none: a brief fade is
|
|
24
|
+
* how a page avoids a jarring pop, and flagging every 150ms
|
|
25
|
+
* transition would bury the marquee that never stops.
|
|
26
|
+
*/
|
|
27
|
+
export const BRIEF_MS = 250;
|
|
28
|
+
/**
|
|
29
|
+
* Motion running longer than this needs a way to stop.
|
|
30
|
+
*
|
|
31
|
+
* The five second line is WCAG 2.2.2's own: moving content that
|
|
32
|
+
* starts automatically and lasts longer than five seconds must
|
|
33
|
+
* have a mechanism to pause, stop or hide it.
|
|
34
|
+
*/
|
|
35
|
+
export const PAUSE_STOP_HIDE_MS = 5000;
|
|
36
|
+
/** Total runtime, or none for an animation that never ends. */
|
|
37
|
+
function totalMs(animation) {
|
|
38
|
+
if (animation.iterations === "Infinity")
|
|
39
|
+
return undefined;
|
|
40
|
+
if (animation.durationMs === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const repeats = Number(animation.iterations);
|
|
43
|
+
if (!Number.isFinite(repeats))
|
|
44
|
+
return undefined;
|
|
45
|
+
return animation.durationMs * repeats;
|
|
46
|
+
}
|
|
47
|
+
function videoNode(video, message) {
|
|
48
|
+
return { selector: video.selector, html: video.html, messages: [message] };
|
|
49
|
+
}
|
|
50
|
+
function animationNode(animation, message) {
|
|
51
|
+
return {
|
|
52
|
+
selector: animation.selector,
|
|
53
|
+
html: "",
|
|
54
|
+
messages: [message],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Judge a capture taken under reduced motion.
|
|
59
|
+
*
|
|
60
|
+
* An empty answer from a capture where the page never saw the
|
|
61
|
+
* preference would be a lie, so a capture with reduced false
|
|
62
|
+
* produces a single needs-review finding naming the problem
|
|
63
|
+
* instead of a clean pass.
|
|
64
|
+
*/
|
|
65
|
+
export function analyseMotion(capture) {
|
|
66
|
+
if (!capture.reduced) {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
rule: "reduced-motion-not-emulated",
|
|
70
|
+
kind: "needs-review",
|
|
71
|
+
impact: "moderate",
|
|
72
|
+
authority: "best-practice",
|
|
73
|
+
criteria: [],
|
|
74
|
+
levels: [],
|
|
75
|
+
help: "The page never saw prefers-reduced-motion: reduce, so " +
|
|
76
|
+
"nothing here was judged. The emulation did not take; " +
|
|
77
|
+
"nothing can be said about how the page behaves for a " +
|
|
78
|
+
"person who asked it to hold still.",
|
|
79
|
+
nodes: [],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
const findings = [];
|
|
84
|
+
const playing = capture.videos.filter((video) => video.visible && video.playing);
|
|
85
|
+
const unstoppable = playing.filter((video) => !video.controls);
|
|
86
|
+
if (unstoppable.length > 0) {
|
|
87
|
+
findings.push({
|
|
88
|
+
rule: "video-plays-under-reduced-motion",
|
|
89
|
+
kind: "violation",
|
|
90
|
+
impact: "serious",
|
|
91
|
+
authority: "wcag",
|
|
92
|
+
criteria: ["2.2.2"],
|
|
93
|
+
levels: ["A"],
|
|
94
|
+
help: "Video still playing with no controls while the page was " +
|
|
95
|
+
"asked for reduced motion. Auto-playing movement that " +
|
|
96
|
+
"cannot be paused, stopped or hidden fails 2.2.2, and " +
|
|
97
|
+
"playing it at all ignores the stated preference.",
|
|
98
|
+
nodes: unstoppable.map((video) => videoNode(video, video.loop
|
|
99
|
+
? "playing on a loop, no controls to stop it"
|
|
100
|
+
: "playing, no controls to stop it")),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const pausable = playing.filter((video) => video.controls);
|
|
104
|
+
if (pausable.length > 0) {
|
|
105
|
+
findings.push({
|
|
106
|
+
rule: "video-ignores-reduced-motion",
|
|
107
|
+
kind: "violation",
|
|
108
|
+
impact: "moderate",
|
|
109
|
+
authority: "best-practice",
|
|
110
|
+
criteria: [],
|
|
111
|
+
levels: [],
|
|
112
|
+
help: "Video plays automatically under reduced motion. It has " +
|
|
113
|
+
"controls, so 2.2.2 is met, but the preference asked the " +
|
|
114
|
+
"page not to start the motion in the first place.",
|
|
115
|
+
nodes: pausable.map((video) => videoNode(video, "autoplays")),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const running = capture.animations.filter((animation) => animation.playState === "running");
|
|
119
|
+
const scrollDriven = running.filter((animation) => animation.scroll);
|
|
120
|
+
if (scrollDriven.length > 0) {
|
|
121
|
+
findings.push({
|
|
122
|
+
rule: "scroll-animation-under-reduced-motion",
|
|
123
|
+
kind: "violation",
|
|
124
|
+
impact: "moderate",
|
|
125
|
+
authority: "wcag",
|
|
126
|
+
criteria: ["2.3.3"],
|
|
127
|
+
levels: ["AAA"],
|
|
128
|
+
help: "Scroll-driven animation still active under reduced " +
|
|
129
|
+
"motion. Motion triggered by interaction is exactly what " +
|
|
130
|
+
"2.3.3 says the preference should disable.",
|
|
131
|
+
nodes: scrollDriven.map((animation) => animationNode(animation, `${animation.name} rides the scroll`)),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const timed = running.filter((animation) => !animation.scroll);
|
|
135
|
+
const endlessOrLong = timed.filter((animation) => {
|
|
136
|
+
const total = totalMs(animation);
|
|
137
|
+
return total === undefined || total > PAUSE_STOP_HIDE_MS;
|
|
138
|
+
});
|
|
139
|
+
if (endlessOrLong.length > 0) {
|
|
140
|
+
findings.push({
|
|
141
|
+
rule: "animation-runs-under-reduced-motion",
|
|
142
|
+
kind: "violation",
|
|
143
|
+
impact: "serious",
|
|
144
|
+
authority: "wcag",
|
|
145
|
+
criteria: ["2.2.2"],
|
|
146
|
+
levels: ["A"],
|
|
147
|
+
help: "Animation still running past the five second line while " +
|
|
148
|
+
"the page was asked for reduced motion. Moving content " +
|
|
149
|
+
"that starts on its own and runs this long needs a way to " +
|
|
150
|
+
"pause, stop or hide it, and honouring the preference is " +
|
|
151
|
+
"the way that costs nothing.",
|
|
152
|
+
nodes: endlessOrLong.map((animation) => animationNode(animation, animation.iterations === "Infinity"
|
|
153
|
+
? `${animation.name} never ends`
|
|
154
|
+
: `${animation.name} runs ${Math.round((totalMs(animation) ?? 0) / 1000)}s`)),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const brief = timed.filter((animation) => {
|
|
158
|
+
const total = totalMs(animation);
|
|
159
|
+
return (total !== undefined && total > BRIEF_MS && total <= PAUSE_STOP_HIDE_MS);
|
|
160
|
+
});
|
|
161
|
+
if (brief.length > 0) {
|
|
162
|
+
findings.push({
|
|
163
|
+
rule: "animation-under-reduced-motion",
|
|
164
|
+
kind: "violation",
|
|
165
|
+
impact: "minor",
|
|
166
|
+
authority: "best-practice",
|
|
167
|
+
criteria: [],
|
|
168
|
+
levels: [],
|
|
169
|
+
help: "Animation still running under reduced motion. Each one " +
|
|
170
|
+
"ends on its own, so no standard is failed; the preference " +
|
|
171
|
+
"still asked for less of this.",
|
|
172
|
+
nodes: brief.map((animation) => animationNode(animation, `${animation.name} runs ${totalMs(animation)}ms`)),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The expression that reads what is moving, run in the page
|
|
179
|
+
* while reduce is emulated.
|
|
180
|
+
*
|
|
181
|
+
* Selectors are built the same way the other captures build
|
|
182
|
+
* them: something a person could paste into the console, never a
|
|
183
|
+
* retained node.
|
|
184
|
+
*/
|
|
185
|
+
export const MOTION_CAPTURE = `(() => {
|
|
186
|
+
const selectorFor = (el) => {
|
|
187
|
+
if (!el || !el.tagName) return "(detached)";
|
|
188
|
+
const tag = el.tagName.toLowerCase();
|
|
189
|
+
if (el.id) return "#" + el.id;
|
|
190
|
+
const hook = el.getAttribute && el.getAttribute("data-testid");
|
|
191
|
+
if (hook) return tag + '[data-testid="' + hook + '"]';
|
|
192
|
+
const first = el.classList && el.classList[0];
|
|
193
|
+
if (first) return tag + "." + first;
|
|
194
|
+
return tag;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const videos = [...document.querySelectorAll("video")].map((video) => ({
|
|
198
|
+
selector: selectorFor(video),
|
|
199
|
+
html: video.outerHTML.slice(0, 160),
|
|
200
|
+
playing:
|
|
201
|
+
!video.paused && !video.ended && video.readyState > 2,
|
|
202
|
+
autoplay: video.autoplay,
|
|
203
|
+
loop: video.loop,
|
|
204
|
+
controls: video.controls,
|
|
205
|
+
visible: video.getClientRects().length > 0,
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const animations = document.getAnimations
|
|
209
|
+
? document.getAnimations({ subtree: true }).map((animation) => {
|
|
210
|
+
const effect = animation.effect;
|
|
211
|
+
const timing = effect && effect.getTiming ? effect.getTiming() : {};
|
|
212
|
+
const duration =
|
|
213
|
+
typeof timing.duration === "number" ? timing.duration : undefined;
|
|
214
|
+
const target = effect && effect.target ? effect.target : null;
|
|
215
|
+
return {
|
|
216
|
+
selector: selectorFor(target),
|
|
217
|
+
name:
|
|
218
|
+
animation.animationName ||
|
|
219
|
+
animation.transitionProperty ||
|
|
220
|
+
animation.id ||
|
|
221
|
+
"unnamed",
|
|
222
|
+
kind: animation.constructor.name,
|
|
223
|
+
playState: animation.playState,
|
|
224
|
+
...(duration === undefined ? {} : { durationMs: duration }),
|
|
225
|
+
iterations: String(timing.iterations === undefined ? 1 : timing.iterations),
|
|
226
|
+
scroll: !!(
|
|
227
|
+
animation.timeline &&
|
|
228
|
+
animation.timeline.constructor.name !== "DocumentTimeline"
|
|
229
|
+
),
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
: [];
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
reduced: matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
236
|
+
videos,
|
|
237
|
+
animations,
|
|
238
|
+
};
|
|
239
|
+
})()`;
|
|
@@ -7,3 +7,4 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { type Cluster, COLOUR_SAMENESS, canonicalLengths, clusterUsage, coloursAreNear, DIMENSIONS, type Dimension, exactlyEqual, LENGTH_SAMENESS, lengthsAreNear, type Nearness, renderInventory, type StyleSample, takeInventory, tallyUsage, type Usage, } from "./inventory.js";
|
|
9
9
|
export { inventorySource, SAMPLED_PROPERTIES } from "./probe.js";
|
|
10
|
+
export { analyseTypography, BODY_FLOOR_CHARS, RUNT_MAX_WORDS, RUNT_WIDTH_SHARE, renderTypography, type TextBlock, TYPOGRAPHY_CAPTURE, type TypographyFinding, } from "./typography.js";
|
package/dist/web/design/index.js
CHANGED
|
@@ -7,3 +7,4 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { COLOUR_SAMENESS, canonicalLengths, clusterUsage, coloursAreNear, DIMENSIONS, exactlyEqual, LENGTH_SAMENESS, lengthsAreNear, renderInventory, takeInventory, tallyUsage, } from "./inventory.js";
|
|
9
9
|
export { inventorySource, SAMPLED_PROPERTIES } from "./probe.js";
|
|
10
|
+
export { analyseTypography, BODY_FLOOR_CHARS, RUNT_MAX_WORDS, RUNT_WIDTH_SHARE, renderTypography, TYPOGRAPHY_CAPTURE, } from "./typography.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How text blocks end: orphans and runts, from real layout.
|
|
3
|
+
*
|
|
4
|
+
* A single word stranded on the last line of a paragraph or
|
|
5
|
+
* heading reads as a typesetting accident, and a very short last
|
|
6
|
+
* line nearly does. Neither can be judged from markup or font
|
|
7
|
+
* metrics, because where the browser broke the lines is the
|
|
8
|
+
* fact, so the capture reads actual line boxes word by word
|
|
9
|
+
* through Range.getBoundingClientRect.
|
|
10
|
+
*
|
|
11
|
+
* This is taste, not conformance: nothing here fails a
|
|
12
|
+
* standard, and the verdict never goes past WARN. It sits in
|
|
13
|
+
* the design domain beside the inventory for the same reason
|
|
14
|
+
* the inventory does: it reports what a person would want to
|
|
15
|
+
* polish, and the judgment of whether it matters is theirs.
|
|
16
|
+
*
|
|
17
|
+
* The line-reading approach is ported from the orphans-and-runts
|
|
18
|
+
* check in Carolyn McNeillie's review-page skill set, including
|
|
19
|
+
* its exclusions: text the author already asked the browser to
|
|
20
|
+
* balance is the browser's business, and text inside a collapsed
|
|
21
|
+
* disclosure is not laid out at all.
|
|
22
|
+
*/
|
|
23
|
+
/** One text-bearing block, as the page measured it. */
|
|
24
|
+
export interface TextBlock {
|
|
25
|
+
readonly selector: string;
|
|
26
|
+
readonly tag: string;
|
|
27
|
+
readonly textLength: number;
|
|
28
|
+
/** The width the text had to work with, in pixels. */
|
|
29
|
+
readonly containerWidth: number;
|
|
30
|
+
readonly lineCount: number;
|
|
31
|
+
readonly lastLine: {
|
|
32
|
+
readonly words: number;
|
|
33
|
+
/** How wide the last line's ink actually is. */
|
|
34
|
+
readonly width: number;
|
|
35
|
+
/** The line itself, clipped. */
|
|
36
|
+
readonly text: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A block that ends badly, and how. */
|
|
40
|
+
export interface TypographyFinding {
|
|
41
|
+
/** orphan: one word alone. runt: a stub of a last line. */
|
|
42
|
+
readonly kind: "orphan" | "runt";
|
|
43
|
+
/** Heading orphans read worst, so the caller may sort by this. */
|
|
44
|
+
readonly heading: boolean;
|
|
45
|
+
readonly block: TextBlock;
|
|
46
|
+
}
|
|
47
|
+
/** Body text shorter than this is skipped in the capture. */
|
|
48
|
+
export declare const BODY_FLOOR_CHARS = 80;
|
|
49
|
+
/** A last line of this many words or fewer can be a runt. */
|
|
50
|
+
export declare const RUNT_MAX_WORDS = 3;
|
|
51
|
+
/** ...when it fills less than this share of the container. */
|
|
52
|
+
export declare const RUNT_WIDTH_SHARE = 0.25;
|
|
53
|
+
/**
|
|
54
|
+
* Judge how the captured blocks end.
|
|
55
|
+
*
|
|
56
|
+
* Only blocks that actually wrapped are judged: a one-line
|
|
57
|
+
* heading has no last line to strand anything on.
|
|
58
|
+
*/
|
|
59
|
+
export declare function analyseTypography(blocks: readonly TextBlock[]): readonly TypographyFinding[];
|
|
60
|
+
/** Say how the page's text blocks end. */
|
|
61
|
+
export declare function renderTypography(blocks: readonly TextBlock[], findings: readonly TypographyFinding[]): string;
|
|
62
|
+
/**
|
|
63
|
+
* The expression that measures how text blocks wrap.
|
|
64
|
+
*
|
|
65
|
+
* The word walk reads each word's rectangle through a Range and
|
|
66
|
+
* groups words into lines by their tops, so the lines are the
|
|
67
|
+
* browser's own. Kept close to the tuned original: the fixes it
|
|
68
|
+
* carries (zero-size rects from hidden text, line grouping by
|
|
69
|
+
* half a line-height) were earned against real pages.
|
|
70
|
+
*/
|
|
71
|
+
export declare const TYPOGRAPHY_CAPTURE = "(() => {\n\tconst BODY_FLOOR = 80;\n\tconst MAX_TEXT = 80;\n\n\tconst selectorFor = (el) => {\n\t\tconst tag = el.tagName.toLowerCase();\n\t\tif (el.id) return \"#\" + el.id;\n\t\tconst hook = el.getAttribute(\"data-testid\");\n\t\tif (hook) return tag + '[data-testid=\"' + hook + '\"]';\n\t\tconst first = el.classList[0];\n\t\tif (first) return tag + \".\" + first;\n\t\treturn tag;\n\t};\n\n\tconst visualLines = (element) => {\n\t\tconst walker = document.createTreeWalker(\n\t\t\telement,\n\t\t\tNodeFilter.SHOW_TEXT,\n\t\t\tnull,\n\t\t);\n\t\tconst nodes = [];\n\t\twhile (walker.nextNode()) {\n\t\t\tif (walker.currentNode.textContent.trim()) {\n\t\t\t\tnodes.push(walker.currentNode);\n\t\t\t}\n\t\t}\n\t\tconst range = document.createRange();\n\t\tconst lines = [];\n\t\tlet top = null;\n\t\tlet height = 0;\n\t\tlet words = 0;\n\t\tlet left = Infinity;\n\t\tlet right = -Infinity;\n\t\tlet text = \"\";\n\t\tconst flush = () => {\n\t\t\tif (words > 0) {\n\t\t\t\tlines.push({ words, width: right - left, text });\n\t\t\t}\n\t\t\twords = 0;\n\t\t\tleft = Infinity;\n\t\t\tright = -Infinity;\n\t\t\ttext = \"\";\n\t\t};\n\t\tfor (const node of nodes) {\n\t\t\tconst content = node.textContent;\n\t\t\tconst matcher = /\\S+/g;\n\t\t\tlet match = matcher.exec(content);\n\t\t\twhile (match) {\n\t\t\t\trange.setStart(node, match.index);\n\t\t\t\trange.setEnd(node, match.index + match[0].length);\n\t\t\t\tconst rect = range.getBoundingClientRect();\n\t\t\t\tif (rect.width > 0 || rect.height > 0) {\n\t\t\t\t\tconst sameLine =\n\t\t\t\t\t\ttop !== null && Math.abs(rect.top - top) <= height / 2;\n\t\t\t\t\tif (!sameLine) {\n\t\t\t\t\t\tflush();\n\t\t\t\t\t\ttop = rect.top;\n\t\t\t\t\t\theight = rect.height;\n\t\t\t\t\t}\n\t\t\t\t\twords += 1;\n\t\t\t\t\tleft = Math.min(left, rect.left);\n\t\t\t\t\tright = Math.max(right, rect.right);\n\t\t\t\t\tif (text.length < MAX_TEXT) {\n\t\t\t\t\t\ttext = (text ? text + \" \" : \"\") + match[0];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tmatch = matcher.exec(content);\n\t\t\t}\n\t\t}\n\t\tflush();\n\t\treturn lines;\n\t};\n\n\tconst wrapStyle = (el) => {\n\t\tconst style = getComputedStyle(el);\n\t\treturn style.textWrap || style.textWrapStyle || \"\";\n\t};\n\n\tconst candidates = document.querySelectorAll(\n\t\t\"p, h1, h2, h3, h4, h5, h6, figcaption\",\n\t);\n\tconst blocks = [];\n\tfor (const el of candidates) {\n\t\tif (el.getClientRects().length === 0) continue;\n\t\tconst tag = el.tagName.toLowerCase();\n\t\tconst textLength = (el.innerText || \"\").trim().length;\n\t\tif (textLength === 0) continue;\n\t\t// Headings matter most and are short; body text below the\n\t\t// floor has no room for a wrap worth judging.\n\t\tconst heading = tag[0] === \"h\";\n\t\tif (!heading && textLength < BODY_FLOOR) continue;\n\t\t// The author already asked the browser to mind the wrapping.\n\t\tconst wrap = wrapStyle(el);\n\t\tif (wrap.includes(\"balance\") || wrap.includes(\"pretty\")) continue;\n\t\t// Text inside a collapsed disclosure is not laid out.\n\t\tconst details = el.closest(\"details\");\n\t\tif (details && !details.open) continue;\n\t\tconst lines = visualLines(el);\n\t\tconst last = lines[lines.length - 1];\n\t\tif (!last) continue;\n\t\tblocks.push({\n\t\t\tselector: selectorFor(el),\n\t\t\ttag,\n\t\t\ttextLength,\n\t\t\tcontainerWidth: el.clientWidth,\n\t\t\tlineCount: lines.length,\n\t\t\tlastLine: {\n\t\t\t\twords: last.words,\n\t\t\t\twidth: last.width,\n\t\t\t\ttext: last.text.slice(0, MAX_TEXT),\n\t\t\t},\n\t\t});\n\t}\n\treturn blocks;\n})()";
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How text blocks end: orphans and runts, from real layout.
|
|
3
|
+
*
|
|
4
|
+
* A single word stranded on the last line of a paragraph or
|
|
5
|
+
* heading reads as a typesetting accident, and a very short last
|
|
6
|
+
* line nearly does. Neither can be judged from markup or font
|
|
7
|
+
* metrics, because where the browser broke the lines is the
|
|
8
|
+
* fact, so the capture reads actual line boxes word by word
|
|
9
|
+
* through Range.getBoundingClientRect.
|
|
10
|
+
*
|
|
11
|
+
* This is taste, not conformance: nothing here fails a
|
|
12
|
+
* standard, and the verdict never goes past WARN. It sits in
|
|
13
|
+
* the design domain beside the inventory for the same reason
|
|
14
|
+
* the inventory does: it reports what a person would want to
|
|
15
|
+
* polish, and the judgment of whether it matters is theirs.
|
|
16
|
+
*
|
|
17
|
+
* The line-reading approach is ported from the orphans-and-runts
|
|
18
|
+
* check in Carolyn McNeillie's review-page skill set, including
|
|
19
|
+
* its exclusions: text the author already asked the browser to
|
|
20
|
+
* balance is the browser's business, and text inside a collapsed
|
|
21
|
+
* disclosure is not laid out at all.
|
|
22
|
+
*/
|
|
23
|
+
import { count } from "../../ui/count.js";
|
|
24
|
+
import { renderVerdict } from "../audit/verdict.js";
|
|
25
|
+
/** Body text shorter than this is skipped in the capture. */
|
|
26
|
+
export const BODY_FLOOR_CHARS = 80;
|
|
27
|
+
/** A last line of this many words or fewer can be a runt. */
|
|
28
|
+
export const RUNT_MAX_WORDS = 3;
|
|
29
|
+
/** ...when it fills less than this share of the container. */
|
|
30
|
+
export const RUNT_WIDTH_SHARE = 0.25;
|
|
31
|
+
const HEADINGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
32
|
+
/**
|
|
33
|
+
* Judge how the captured blocks end.
|
|
34
|
+
*
|
|
35
|
+
* Only blocks that actually wrapped are judged: a one-line
|
|
36
|
+
* heading has no last line to strand anything on.
|
|
37
|
+
*/
|
|
38
|
+
export function analyseTypography(blocks) {
|
|
39
|
+
const findings = [];
|
|
40
|
+
for (const block of blocks) {
|
|
41
|
+
if (block.lineCount < 2)
|
|
42
|
+
continue;
|
|
43
|
+
const heading = HEADINGS.has(block.tag);
|
|
44
|
+
if (block.lastLine.words === 1) {
|
|
45
|
+
findings.push({ kind: "orphan", heading, block });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (block.lastLine.words <= RUNT_MAX_WORDS &&
|
|
49
|
+
block.containerWidth > 0 &&
|
|
50
|
+
block.lastLine.width < block.containerWidth * RUNT_WIDTH_SHARE) {
|
|
51
|
+
findings.push({ kind: "runt", heading, block });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Headings first, orphans before runts, so the worst reads first.
|
|
55
|
+
return findings.sort((a, b) => {
|
|
56
|
+
if (a.heading !== b.heading)
|
|
57
|
+
return a.heading ? -1 : 1;
|
|
58
|
+
if (a.kind !== b.kind)
|
|
59
|
+
return a.kind === "orphan" ? -1 : 1;
|
|
60
|
+
return 0;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Say how the page's text blocks end. */
|
|
64
|
+
export function renderTypography(blocks, findings) {
|
|
65
|
+
const wrapped = blocks.filter((block) => block.lineCount >= 2).length;
|
|
66
|
+
const measured = `Measured ${count(blocks.length, "text block")}, ` +
|
|
67
|
+
`${wrapped} of them wrapped. Single-line blocks have no last ` +
|
|
68
|
+
`line to judge; text under ${BODY_FLOOR_CHARS} characters and ` +
|
|
69
|
+
`text the author asked the browser to balance were left out ` +
|
|
70
|
+
`at capture.`;
|
|
71
|
+
if (findings.length === 0) {
|
|
72
|
+
return renderVerdict({
|
|
73
|
+
standing: "pass",
|
|
74
|
+
headline: "Every wrapped text block ends cleanly.",
|
|
75
|
+
measured,
|
|
76
|
+
}, "");
|
|
77
|
+
}
|
|
78
|
+
const orphans = findings.filter((one) => one.kind === "orphan");
|
|
79
|
+
const inHeadings = findings.filter((one) => one.heading);
|
|
80
|
+
const lines = findings.map((one) => {
|
|
81
|
+
const what = one.kind === "orphan"
|
|
82
|
+
? `one word alone on the last line: "${one.block.lastLine.text}"`
|
|
83
|
+
: `${count(one.block.lastLine.words, "word")} on a last line ` +
|
|
84
|
+
`filling ${Math.round((one.block.lastLine.width / one.block.containerWidth) * 100)}% of the container: "${one.block.lastLine.text}"`;
|
|
85
|
+
return ` ${one.block.selector} <${one.block.tag}> ${what}`;
|
|
86
|
+
});
|
|
87
|
+
return renderVerdict({
|
|
88
|
+
// Taste, not conformance: this never fails a page.
|
|
89
|
+
standing: "warn",
|
|
90
|
+
headline: `${count(findings.length, "text block")} of ${wrapped} ` +
|
|
91
|
+
`wrapped ${wrapped === 1 ? "ends" : "end"} badly: ` +
|
|
92
|
+
`${count(orphans.length, "orphan")}, ` +
|
|
93
|
+
`${count(findings.length - orphans.length, "runt")}` +
|
|
94
|
+
`${inHeadings.length === 0 ? "" : `, ${inHeadings.length} in headings`}.`,
|
|
95
|
+
measured,
|
|
96
|
+
}, lines.join("\n"));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The expression that measures how text blocks wrap.
|
|
100
|
+
*
|
|
101
|
+
* The word walk reads each word's rectangle through a Range and
|
|
102
|
+
* groups words into lines by their tops, so the lines are the
|
|
103
|
+
* browser's own. Kept close to the tuned original: the fixes it
|
|
104
|
+
* carries (zero-size rects from hidden text, line grouping by
|
|
105
|
+
* half a line-height) were earned against real pages.
|
|
106
|
+
*/
|
|
107
|
+
export const TYPOGRAPHY_CAPTURE = `(() => {
|
|
108
|
+
const BODY_FLOOR = ${BODY_FLOOR_CHARS};
|
|
109
|
+
const MAX_TEXT = 80;
|
|
110
|
+
|
|
111
|
+
const selectorFor = (el) => {
|
|
112
|
+
const tag = el.tagName.toLowerCase();
|
|
113
|
+
if (el.id) return "#" + el.id;
|
|
114
|
+
const hook = el.getAttribute("data-testid");
|
|
115
|
+
if (hook) return tag + '[data-testid="' + hook + '"]';
|
|
116
|
+
const first = el.classList[0];
|
|
117
|
+
if (first) return tag + "." + first;
|
|
118
|
+
return tag;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const visualLines = (element) => {
|
|
122
|
+
const walker = document.createTreeWalker(
|
|
123
|
+
element,
|
|
124
|
+
NodeFilter.SHOW_TEXT,
|
|
125
|
+
null,
|
|
126
|
+
);
|
|
127
|
+
const nodes = [];
|
|
128
|
+
while (walker.nextNode()) {
|
|
129
|
+
if (walker.currentNode.textContent.trim()) {
|
|
130
|
+
nodes.push(walker.currentNode);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const range = document.createRange();
|
|
134
|
+
const lines = [];
|
|
135
|
+
let top = null;
|
|
136
|
+
let height = 0;
|
|
137
|
+
let words = 0;
|
|
138
|
+
let left = Infinity;
|
|
139
|
+
let right = -Infinity;
|
|
140
|
+
let text = "";
|
|
141
|
+
const flush = () => {
|
|
142
|
+
if (words > 0) {
|
|
143
|
+
lines.push({ words, width: right - left, text });
|
|
144
|
+
}
|
|
145
|
+
words = 0;
|
|
146
|
+
left = Infinity;
|
|
147
|
+
right = -Infinity;
|
|
148
|
+
text = "";
|
|
149
|
+
};
|
|
150
|
+
for (const node of nodes) {
|
|
151
|
+
const content = node.textContent;
|
|
152
|
+
const matcher = /\\S+/g;
|
|
153
|
+
let match = matcher.exec(content);
|
|
154
|
+
while (match) {
|
|
155
|
+
range.setStart(node, match.index);
|
|
156
|
+
range.setEnd(node, match.index + match[0].length);
|
|
157
|
+
const rect = range.getBoundingClientRect();
|
|
158
|
+
if (rect.width > 0 || rect.height > 0) {
|
|
159
|
+
const sameLine =
|
|
160
|
+
top !== null && Math.abs(rect.top - top) <= height / 2;
|
|
161
|
+
if (!sameLine) {
|
|
162
|
+
flush();
|
|
163
|
+
top = rect.top;
|
|
164
|
+
height = rect.height;
|
|
165
|
+
}
|
|
166
|
+
words += 1;
|
|
167
|
+
left = Math.min(left, rect.left);
|
|
168
|
+
right = Math.max(right, rect.right);
|
|
169
|
+
if (text.length < MAX_TEXT) {
|
|
170
|
+
text = (text ? text + " " : "") + match[0];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
match = matcher.exec(content);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
flush();
|
|
177
|
+
return lines;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const wrapStyle = (el) => {
|
|
181
|
+
const style = getComputedStyle(el);
|
|
182
|
+
return style.textWrap || style.textWrapStyle || "";
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const candidates = document.querySelectorAll(
|
|
186
|
+
"p, h1, h2, h3, h4, h5, h6, figcaption",
|
|
187
|
+
);
|
|
188
|
+
const blocks = [];
|
|
189
|
+
for (const el of candidates) {
|
|
190
|
+
if (el.getClientRects().length === 0) continue;
|
|
191
|
+
const tag = el.tagName.toLowerCase();
|
|
192
|
+
const textLength = (el.innerText || "").trim().length;
|
|
193
|
+
if (textLength === 0) continue;
|
|
194
|
+
// Headings matter most and are short; body text below the
|
|
195
|
+
// floor has no room for a wrap worth judging.
|
|
196
|
+
const heading = tag[0] === "h";
|
|
197
|
+
if (!heading && textLength < BODY_FLOOR) continue;
|
|
198
|
+
// The author already asked the browser to mind the wrapping.
|
|
199
|
+
const wrap = wrapStyle(el);
|
|
200
|
+
if (wrap.includes("balance") || wrap.includes("pretty")) continue;
|
|
201
|
+
// Text inside a collapsed disclosure is not laid out.
|
|
202
|
+
const details = el.closest("details");
|
|
203
|
+
if (details && !details.open) continue;
|
|
204
|
+
const lines = visualLines(el);
|
|
205
|
+
const last = lines[lines.length - 1];
|
|
206
|
+
if (!last) continue;
|
|
207
|
+
blocks.push({
|
|
208
|
+
selector: selectorFor(el),
|
|
209
|
+
tag,
|
|
210
|
+
textLength,
|
|
211
|
+
containerWidth: el.clientWidth,
|
|
212
|
+
lineCount: lines.length,
|
|
213
|
+
lastLine: {
|
|
214
|
+
words: last.words,
|
|
215
|
+
width: last.width,
|
|
216
|
+
text: last.text.slice(0, MAX_TEXT),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return blocks;
|
|
221
|
+
})()`;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the server render and the hydrated page side by side.
|
|
3
|
+
*
|
|
4
|
+
* The server's HTML is fetched from inside the page, so it
|
|
5
|
+
* travels with the session's own cookies and headers, and it is
|
|
6
|
+
* parsed with DOMParser, which builds a document without running
|
|
7
|
+
* a single script: exactly what the browser had before hydration
|
|
8
|
+
* ran. Both documents are reduced to the same compact shape,
|
|
9
|
+
* text and tag counts, because the judgment belongs to the pure
|
|
10
|
+
* side and whole DOM trees do not fit through a capture.
|
|
11
|
+
*/
|
|
12
|
+
/** Both renders, reduced to comparable shape. */
|
|
13
|
+
export interface HydrationCapture {
|
|
14
|
+
readonly url: string;
|
|
15
|
+
/** Whether the server render could be fetched at all. */
|
|
16
|
+
readonly fetched: boolean;
|
|
17
|
+
readonly status?: number;
|
|
18
|
+
readonly serverTexts: readonly string[];
|
|
19
|
+
readonly clientTexts: readonly string[];
|
|
20
|
+
readonly serverTags: Readonly<Record<string, number>>;
|
|
21
|
+
readonly clientTags: Readonly<Record<string, number>>;
|
|
22
|
+
}
|
|
23
|
+
/** Texts shorter than this are markup lint, not content. */
|
|
24
|
+
export declare const MIN_TEXT_CHARS = 3;
|
|
25
|
+
/** How many texts to carry per side. */
|
|
26
|
+
export declare const MAX_TEXTS = 500;
|
|
27
|
+
/** How much of each text to keep. */
|
|
28
|
+
export declare const MAX_TEXT_CHARS = 120;
|
|
29
|
+
/**
|
|
30
|
+
* The expression that reads both renders.
|
|
31
|
+
*
|
|
32
|
+
* Async because the server render is a fetch; evaluate it with
|
|
33
|
+
* awaitPromise. Scripts, styles and noscript are excluded from
|
|
34
|
+
* both sides: they are machinery, not content, and noscript text
|
|
35
|
+
* is visible in exactly one of the two renders by definition.
|
|
36
|
+
*/
|
|
37
|
+
export declare const HYDRATION_CAPTURE = "(async () => {\n\tconst MIN_TEXT = 3;\n\tconst MAX_TEXTS = 500;\n\tconst MAX_CHARS = 120;\n\tconst MACHINERY = new Set([\"script\", \"style\", \"noscript\", \"template\"]);\n\n\tconst textsOf = (root) => {\n\t\tconst walker = document.createTreeWalker(\n\t\t\troot,\n\t\t\tNodeFilter.SHOW_TEXT,\n\t\t\tnull,\n\t\t);\n\t\tconst texts = [];\n\t\twhile (walker.nextNode() && texts.length < MAX_TEXTS) {\n\t\t\tconst node = walker.currentNode;\n\t\t\tconst parent = node.parentElement;\n\t\t\tif (!parent) continue;\n\t\t\tif (MACHINERY.has(parent.tagName.toLowerCase())) continue;\n\t\t\tconst text = (node.textContent || \"\")\n\t\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t\t.trim()\n\t\t\t\t.slice(0, MAX_CHARS);\n\t\t\tif (text.length >= MIN_TEXT) texts.push(text);\n\t\t}\n\t\treturn texts;\n\t};\n\n\tconst tagsOf = (root) => {\n\t\tconst counts = {};\n\t\tfor (const el of root.querySelectorAll(\"*\")) {\n\t\t\tconst tag = el.tagName.toLowerCase();\n\t\t\tif (MACHINERY.has(tag) || tag === \"link\" || tag === \"meta\") continue;\n\t\t\tcounts[tag] = (counts[tag] || 0) + 1;\n\t\t}\n\t\treturn counts;\n\t};\n\n\tlet fetched = false;\n\tlet status;\n\tlet serverTexts = [];\n\tlet serverTags = {};\n\ttry {\n\t\tconst response = await fetch(location.href, {\n\t\t\theaders: { accept: \"text/html\" },\n\t\t\tcredentials: \"include\",\n\t\t\tcache: \"no-store\",\n\t\t});\n\t\tstatus = response.status;\n\t\tif (response.ok) {\n\t\t\tconst html = await response.text();\n\t\t\tconst server = new DOMParser().parseFromString(html, \"text/html\");\n\t\t\tconst root = server.body || server.documentElement;\n\t\t\tserverTexts = textsOf(root);\n\t\t\tserverTags = tagsOf(root);\n\t\t\tfetched = true;\n\t\t}\n\t} catch (error) {\n\t\t// fetched stays false, which the judge reports honestly.\n\t}\n\n\tconst live = document.body || document.documentElement;\n\treturn {\n\t\turl: location.href,\n\t\tfetched,\n\t\t...(status === undefined ? {} : { status }),\n\t\tserverTexts,\n\t\tserverTags,\n\t\tclientTexts: textsOf(live),\n\t\tclientTags: tagsOf(live),\n\t};\n})()";
|