ssrwire 0.1.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 (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +21 -0
  4. package/PUBLISHING.md +134 -0
  5. package/README.md +403 -0
  6. package/SECURITY.md +26 -0
  7. package/dist/agents.d.ts +14 -0
  8. package/dist/agents.d.ts.map +1 -0
  9. package/dist/agents.js +100 -0
  10. package/dist/agents.js.map +1 -0
  11. package/dist/analyze.d.ts +4 -0
  12. package/dist/analyze.d.ts.map +1 -0
  13. package/dist/analyze.js +494 -0
  14. package/dist/analyze.js.map +1 -0
  15. package/dist/audit.d.ts +3 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +69 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/bin.d.ts +3 -0
  20. package/dist/bin.d.ts.map +1 -0
  21. package/dist/bin.js +4 -0
  22. package/dist/bin.js.map +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +173 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +262 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/http-probe.d.ts +7 -0
  32. package/dist/http-probe.d.ts.map +1 -0
  33. package/dist/http-probe.js +406 -0
  34. package/dist/http-probe.js.map +1 -0
  35. package/dist/index.d.ts +11 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +10 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/redact.d.ts +10 -0
  40. package/dist/redact.d.ts.map +1 -0
  41. package/dist/redact.js +154 -0
  42. package/dist/redact.js.map +1 -0
  43. package/dist/reporters.d.ts +9 -0
  44. package/dist/reporters.d.ts.map +1 -0
  45. package/dist/reporters.js +220 -0
  46. package/dist/reporters.js.map +1 -0
  47. package/dist/stream-parser.d.ts +9 -0
  48. package/dist/stream-parser.d.ts.map +1 -0
  49. package/dist/stream-parser.js +366 -0
  50. package/dist/stream-parser.js.map +1 -0
  51. package/dist/types.d.ts +134 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +14 -0
  58. package/dist/version.js.map +1 -0
  59. package/examples/github-actions.yml +73 -0
  60. package/examples/ssrwire.config.yml +37 -0
  61. package/package.json +91 -0
  62. package/src/agents.ts +129 -0
  63. package/src/analyze.ts +628 -0
  64. package/src/audit.ts +89 -0
  65. package/src/bin.ts +5 -0
  66. package/src/cli.ts +207 -0
  67. package/src/config.ts +313 -0
  68. package/src/http-probe.ts +461 -0
  69. package/src/index.ts +34 -0
  70. package/src/redact.ts +173 -0
  71. package/src/reporters.ts +274 -0
  72. package/src/stream-parser.ts +424 -0
  73. package/src/types.ts +160 -0
  74. package/src/version.ts +19 -0
@@ -0,0 +1,274 @@
1
+ import type {
2
+ AuditResult,
3
+ ElementSignal,
4
+ Finding,
5
+ ProbeResult,
6
+ ReportFormat,
7
+ Severity,
8
+ } from "./types.js";
9
+
10
+ export interface ReporterOptions {
11
+ readonly color?: boolean;
12
+ }
13
+
14
+ const ANSI = {
15
+ red: "\u001b[31m",
16
+ yellow: "\u001b[33m",
17
+ blue: "\u001b[36m",
18
+ bold: "\u001b[1m",
19
+ dim: "\u001b[2m",
20
+ reset: "\u001b[0m",
21
+ } as const;
22
+
23
+ function paint(value: string, code: string, enabled: boolean): string {
24
+ return enabled ? `${code}${value}${ANSI.reset}` : value;
25
+ }
26
+
27
+ function severityColor(severity: Severity): string {
28
+ if (severity === "error") {
29
+ return ANSI.red;
30
+ }
31
+ if (severity === "warning") {
32
+ return ANSI.yellow;
33
+ }
34
+ return ANSI.blue;
35
+ }
36
+
37
+ function terminalSafe(value: string): string {
38
+ let safe = "";
39
+ for (const character of value) {
40
+ const codePoint = character.codePointAt(0) ?? 0;
41
+ safe +=
42
+ codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
43
+ ? `\\u${codePoint.toString(16).toUpperCase().padStart(4, "0")}`
44
+ : character;
45
+ }
46
+ return safe;
47
+ }
48
+
49
+ function oneLine(value: string): string {
50
+ return terminalSafe(value).replace(/\s+/g, " ").trim();
51
+ }
52
+
53
+ function truncate(value: string, length: number): string {
54
+ const clean = oneLine(value);
55
+ if (clean.length <= length) {
56
+ return clean;
57
+ }
58
+ return `${clean.slice(0, Math.max(0, length - 1))}…`;
59
+ }
60
+
61
+ function formatMs(value: number | undefined): string {
62
+ return value === undefined ? "—" : `${Math.round(value)} ms`;
63
+ }
64
+
65
+ function formatSignal(signal: ElementSignal | undefined): string {
66
+ return signal === undefined ? "—" : `${Math.round(signal.atMs)} ms/${signal.location}`;
67
+ }
68
+
69
+ function firstSignal(signals: readonly ElementSignal[]): ElementSignal | undefined {
70
+ return signals.find((signal) => signal.value.trim().length > 0) ?? signals[0];
71
+ }
72
+
73
+ function probeRow(probe: ProbeResult): readonly string[] {
74
+ return [
75
+ truncate(probe.agent.label, 24),
76
+ probe.status === undefined ? "—" : String(probe.status),
77
+ probe.completion,
78
+ formatMs(probe.timings.firstByteMs),
79
+ formatMs(probe.timings.completeMs),
80
+ formatSignal(probe.signals.title),
81
+ formatSignal(firstSignal(probe.signals.descriptions)),
82
+ formatSignal(firstSignal(probe.signals.canonicals)),
83
+ formatSignal(probe.signals.firstMainText),
84
+ ];
85
+ }
86
+
87
+ function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
88
+ const widths = headers.map((header, column) =>
89
+ Math.max(header.length, ...rows.map((row) => row[column]?.length ?? 0)),
90
+ );
91
+ const line = (cells: readonly string[]): string =>
92
+ cells
93
+ .map((cell, column) => cell.padEnd(widths[column] ?? cell.length))
94
+ .join(" ")
95
+ .trimEnd();
96
+ const separator = widths.map((width) => "-".repeat(width)).join(" ");
97
+ return [line(headers), separator, ...rows.map(line)].join("\n");
98
+ }
99
+
100
+ function formatFinding(finding: Finding, color: boolean): string {
101
+ const severity = finding.severity.toUpperCase().padEnd(7);
102
+ const prefix = paint(severity, severityColor(finding.severity), color);
103
+ const agent = finding.agent === undefined ? "" : ` [${terminalSafe(finding.agent)}]`;
104
+ const evidence =
105
+ finding.evidence === undefined
106
+ ? ""
107
+ : ` (${Object.entries(finding.evidence)
108
+ .map(([key, value]) => `${terminalSafe(key)}: ${terminalSafe(String(value))}`)
109
+ .join(", ")})`;
110
+ return ` ${prefix} ${terminalSafe(finding.code)}${agent}: ${terminalSafe(finding.message)}${evidence}`;
111
+ }
112
+
113
+ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}): string {
114
+ const color = options.color ?? Boolean(process.stdout.isTTY);
115
+ const lines: string[] = [
116
+ paint(`SSRWire ${audit.version}`, ANSI.bold, color),
117
+ paint(`Generated ${audit.generatedAt} in ${formatMs(audit.durationMs)}`, ANSI.dim, color),
118
+ ];
119
+
120
+ for (const result of audit.results) {
121
+ lines.push("", paint(terminalSafe(result.target.url), ANSI.bold, color));
122
+ lines.push(
123
+ renderTable(
124
+ [
125
+ "Agent",
126
+ "HTTP",
127
+ "Result",
128
+ "First byte",
129
+ "Complete",
130
+ "Title",
131
+ "Description",
132
+ "Canonical",
133
+ "Main",
134
+ ],
135
+ result.probes.map(probeRow),
136
+ ),
137
+ );
138
+
139
+ if (result.findings.length === 0) {
140
+ lines.push("Findings: none");
141
+ } else {
142
+ lines.push("Findings:", ...result.findings.map((finding) => formatFinding(finding, color)));
143
+ }
144
+ }
145
+
146
+ const { summary } = audit;
147
+ const summaryText =
148
+ `Summary: ${summary.targets} target(s), ${summary.probes} probe(s), ` +
149
+ `${summary.errors} error(s), ${summary.warnings} warning(s), ` +
150
+ `${summary.info} info, ${summary.incomplete} incomplete`;
151
+ const summaryColor =
152
+ summary.errors > 0 ? ANSI.red : summary.warnings > 0 ? ANSI.yellow : ANSI.blue;
153
+ lines.push("", paint(summaryText, summaryColor, color));
154
+
155
+ return `${lines.join("\n")}\n`;
156
+ }
157
+
158
+ function stableValue(value: unknown): unknown {
159
+ if (Array.isArray(value)) {
160
+ return value.map(stableValue);
161
+ }
162
+ if (value !== null && typeof value === "object") {
163
+ return Object.fromEntries(
164
+ Object.entries(value)
165
+ .sort(([left], [right]) => left.localeCompare(right))
166
+ .map(([key, entry]) => [key, stableValue(entry)]),
167
+ );
168
+ }
169
+ return value;
170
+ }
171
+
172
+ function stableJson(value: unknown): string {
173
+ return `${JSON.stringify(stableValue(value), null, 2)}\n`;
174
+ }
175
+
176
+ export function renderJson(audit: AuditResult): string {
177
+ return stableJson(audit);
178
+ }
179
+
180
+ function sarifLevel(severity: Severity): "error" | "warning" | "note" {
181
+ return severity === "info" ? "note" : severity;
182
+ }
183
+
184
+ function ruleDescription(code: string): string {
185
+ const description = code.replaceAll("-", " ");
186
+ return `${description.charAt(0).toUpperCase()}${description.slice(1)}`;
187
+ }
188
+
189
+ function artifactUri(url: string): string {
190
+ try {
191
+ return new URL(url).href;
192
+ } catch {
193
+ return encodeURI(url);
194
+ }
195
+ }
196
+
197
+ export function renderSarif(audit: AuditResult): string {
198
+ const findings = audit.results.flatMap((result) => result.findings);
199
+ const codes = [...new Set(findings.map((finding) => finding.code))].sort();
200
+ const rules = codes.map((code) => {
201
+ const severities = findings
202
+ .filter((finding) => finding.code === code)
203
+ .map((finding) => finding.severity);
204
+ const defaultSeverity: Severity = severities.includes("error")
205
+ ? "error"
206
+ : severities.includes("warning")
207
+ ? "warning"
208
+ : "info";
209
+ return {
210
+ id: code,
211
+ name: code,
212
+ shortDescription: { text: ruleDescription(code) },
213
+ defaultConfiguration: { level: sarifLevel(defaultSeverity) },
214
+ };
215
+ });
216
+
217
+ const results = findings.map((finding) => ({
218
+ ruleId: finding.code,
219
+ level: sarifLevel(finding.severity),
220
+ message: { text: finding.message },
221
+ locations: [
222
+ {
223
+ physicalLocation: {
224
+ artifactLocation: { uri: artifactUri(finding.url) },
225
+ },
226
+ },
227
+ ],
228
+ ...(finding.agent === undefined && finding.evidence === undefined
229
+ ? {}
230
+ : {
231
+ properties: {
232
+ ...(finding.agent === undefined ? {} : { agent: finding.agent }),
233
+ ...(finding.evidence ?? {}),
234
+ },
235
+ }),
236
+ }));
237
+
238
+ return stableJson({
239
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
240
+ version: "2.1.0",
241
+ runs: [
242
+ {
243
+ tool: {
244
+ driver: {
245
+ name: "SSRWire",
246
+ version: audit.version,
247
+ informationUri: "https://nikom.work",
248
+ rules,
249
+ },
250
+ },
251
+ results,
252
+ },
253
+ ],
254
+ });
255
+ }
256
+
257
+ export function renderReport(
258
+ audit: AuditResult,
259
+ format: ReportFormat,
260
+ options: ReporterOptions = {},
261
+ ): string {
262
+ if (format === "terminal") {
263
+ return renderTerminal(audit, options);
264
+ }
265
+ if (format === "json") {
266
+ return renderJson(audit);
267
+ }
268
+ if (format === "sarif") {
269
+ return renderSarif(audit);
270
+ }
271
+
272
+ const exhaustive: never = format;
273
+ return exhaustive;
274
+ }
@@ -0,0 +1,424 @@
1
+ import { Parser } from "htmlparser2";
2
+
3
+ import type {
4
+ DocumentSignals,
5
+ ElementLocation,
6
+ ElementSignal,
7
+ JsonLdSignal,
8
+ RobotsAudience,
9
+ RobotsSignal,
10
+ TimingMark,
11
+ } from "./types.js";
12
+
13
+ const ELEMENT_VALUE_LIMIT = 4_096;
14
+ const MAIN_TEXT_LIMIT = 240;
15
+ const SIGNAL_LIMIT = 256;
16
+ const JSON_LD_BLOCK_LIMIT = 64;
17
+ const JSON_LD_CAPTURE_LIMIT = 1_048_576;
18
+ const JSON_LD_NODE_LIMIT = 10_000;
19
+ const JSON_LD_TYPE_LIMIT = 256;
20
+
21
+ interface TextCapture {
22
+ readonly location: ElementLocation;
23
+ value: string;
24
+ }
25
+
26
+ interface ScriptCapture extends TextCapture {
27
+ readonly jsonLd: boolean;
28
+ bytes: number;
29
+ truncated: boolean;
30
+ omitted: boolean;
31
+ }
32
+
33
+ export interface StreamInspector {
34
+ readonly bytesObserved: number;
35
+ write(chunk: Uint8Array, atMs: number): void;
36
+ end(atMs?: number): DocumentSignals;
37
+ finish(atMs?: number): DocumentSignals;
38
+ }
39
+
40
+ function normalizedText(value: string, limit = ELEMENT_VALUE_LIMIT): string {
41
+ return value.replace(/\s+/gu, " ").trim().slice(0, limit);
42
+ }
43
+
44
+ function timing(atMs: number, observedByByte: number): TimingMark {
45
+ return {
46
+ atMs: Number.isFinite(atMs) ? Math.max(0, atMs) : 0,
47
+ observedByByte,
48
+ };
49
+ }
50
+
51
+ function isRobotsAudience(value: string | undefined): value is RobotsAudience {
52
+ return value === "robots" || value === "googlebot" || value === "bingbot";
53
+ }
54
+
55
+ function collectJsonLdTypes(value: unknown, output: Set<string>): void {
56
+ const pending: unknown[] = [value];
57
+ let visited = 0;
58
+ while (pending.length > 0 && visited < JSON_LD_NODE_LIMIT) {
59
+ const current = pending.pop();
60
+ visited += 1;
61
+ if (Array.isArray(current)) {
62
+ for (const entry of current) {
63
+ if (pending.length + visited >= JSON_LD_NODE_LIMIT) break;
64
+ pending.push(entry);
65
+ }
66
+ continue;
67
+ }
68
+ if (typeof current !== "object" || current === null) continue;
69
+
70
+ const object = current as Readonly<Record<string, unknown>>;
71
+ const type = object["@type"];
72
+ if (typeof type === "string" && type.trim().length > 0) {
73
+ if (output.size < JSON_LD_TYPE_LIMIT) output.add(type.trim().slice(0, ELEMENT_VALUE_LIMIT));
74
+ } else if (Array.isArray(type)) {
75
+ for (const entry of type) {
76
+ if (output.size >= JSON_LD_TYPE_LIMIT) break;
77
+ if (typeof entry === "string" && entry.trim().length > 0) {
78
+ output.add(entry.trim().slice(0, ELEMENT_VALUE_LIMIT));
79
+ }
80
+ }
81
+ }
82
+
83
+ for (const key of Object.keys(object)) {
84
+ if (pending.length + visited >= JSON_LD_NODE_LIMIT) break;
85
+ pending.push(object[key]);
86
+ }
87
+ }
88
+ }
89
+
90
+ class HtmlStreamInspector implements StreamInspector {
91
+ readonly #decoder = new TextDecoder();
92
+ readonly #parser: Parser;
93
+ readonly #descriptions: ElementSignal[] = [];
94
+ readonly #canonicals: ElementSignal[] = [];
95
+ readonly #robots: RobotsSignal[] = [];
96
+ readonly #h1s: ElementSignal[] = [];
97
+ readonly #jsonLd: JsonLdSignal[] = [];
98
+ readonly #titles: ElementSignal[] = [];
99
+
100
+ #bytesObserved = 0;
101
+ #currentAtMs = 0;
102
+ #ended = false;
103
+ #result?: DocumentSignals;
104
+ #inHead = false;
105
+ #inBody = false;
106
+ #templateDepth = 0;
107
+ #foreignContentDepth = 0;
108
+ #mainDepth = 0;
109
+ #ignoredMainTextDepth = 0;
110
+ #titleCapture: TextCapture | undefined;
111
+ #h1Capture: TextCapture | undefined;
112
+ #scriptCapture: ScriptCapture | undefined;
113
+ #jsonLdLimitReported = false;
114
+ #mainText = "";
115
+ #mainTextStarted?: TimingMark;
116
+ #mainTextLocation: ElementLocation = "document";
117
+ #headClosed?: TimingMark;
118
+ #bodyStarted?: TimingMark;
119
+ #documentClosed?: TimingMark;
120
+
121
+ constructor() {
122
+ this.#parser = new Parser(
123
+ {
124
+ onopentag: (name, attributes) => this.#onOpenTag(name, attributes),
125
+ ontext: (value) => this.#onText(value),
126
+ onclosetag: (name, isImplied) => this.#onCloseTag(name, isImplied),
127
+ },
128
+ {
129
+ decodeEntities: true,
130
+ lowerCaseAttributeNames: true,
131
+ lowerCaseTags: true,
132
+ recognizeCDATA: true,
133
+ },
134
+ );
135
+ }
136
+
137
+ get bytesObserved(): number {
138
+ return this.#bytesObserved;
139
+ }
140
+
141
+ write(chunk: Uint8Array, atMs: number): void {
142
+ if (this.#ended) throw new Error("Cannot write to a finished stream inspector.");
143
+ if (chunk.byteLength === 0) return;
144
+
145
+ this.#bytesObserved += chunk.byteLength;
146
+ this.#currentAtMs = Number.isFinite(atMs) ? Math.max(0, atMs) : 0;
147
+ const decoded = this.#decoder.decode(chunk, { stream: true });
148
+ if (decoded.length > 0) this.#parser.write(decoded);
149
+ }
150
+
151
+ end(atMs = this.#currentAtMs): DocumentSignals {
152
+ if (this.#result !== undefined) return this.#result;
153
+
154
+ this.#currentAtMs = Number.isFinite(atMs) ? Math.max(0, atMs) : this.#currentAtMs;
155
+ const tail = this.#decoder.decode();
156
+ this.#parser.end(tail.length > 0 ? tail : undefined);
157
+ this.#ended = true;
158
+
159
+ const firstMainText = this.#mainTextStarted
160
+ ? {
161
+ value: normalizedText(this.#mainText, MAIN_TEXT_LIMIT),
162
+ location: this.#mainTextLocation,
163
+ ...this.#mainTextStarted,
164
+ }
165
+ : undefined;
166
+
167
+ this.#result = {
168
+ ...(this.#titles[0] === undefined ? {} : { title: this.#titles[0] }),
169
+ titles: [...this.#titles],
170
+ descriptions: [...this.#descriptions],
171
+ canonicals: [...this.#canonicals],
172
+ robots: [...this.#robots],
173
+ h1s: [...this.#h1s],
174
+ ...(firstMainText === undefined || firstMainText.value.length === 0 ? {} : { firstMainText }),
175
+ jsonLd: [...this.#jsonLd],
176
+ ...(this.#headClosed === undefined ? {} : { headClosed: this.#headClosed }),
177
+ ...(this.#bodyStarted === undefined ? {} : { bodyStarted: this.#bodyStarted }),
178
+ ...(this.#documentClosed === undefined ? {} : { documentClosed: this.#documentClosed }),
179
+ };
180
+ return this.#result;
181
+ }
182
+
183
+ finish(atMs = this.#currentAtMs): DocumentSignals {
184
+ return this.end(atMs);
185
+ }
186
+
187
+ #mark(): TimingMark {
188
+ return timing(this.#currentAtMs, this.#bytesObserved);
189
+ }
190
+
191
+ #location(): ElementLocation {
192
+ if (this.#inHead) return "head";
193
+ if (this.#inBody) return "body";
194
+ return "document";
195
+ }
196
+
197
+ #elementSignal(value: string, location = this.#location()): ElementSignal {
198
+ return {
199
+ value: normalizedText(value),
200
+ location,
201
+ ...this.#mark(),
202
+ };
203
+ }
204
+
205
+ #onOpenTag(name: string, attributes: Readonly<Record<string, string>>): void {
206
+ const alreadyExcluded = this.#templateDepth > 0 || this.#foreignContentDepth > 0;
207
+ if (name === "template") this.#templateDepth += 1;
208
+ if (name === "svg" || name === "math") this.#foreignContentDepth += 1;
209
+
210
+ // A template's inert document fragment and SVG/MathML foreign content are not
211
+ // document SEO signals, even when they contain HTML-looking element names.
212
+ if (alreadyExcluded || name === "template" || name === "svg" || name === "math") {
213
+ return;
214
+ }
215
+
216
+ const { name: metaNameAttribute, content, rel, href, type: scriptType } = attributes;
217
+ if (name === "head") this.#inHead = true;
218
+ if (name === "body") {
219
+ if (this.#bodyStarted === undefined) this.#bodyStarted = this.#mark();
220
+ this.#inBody = true;
221
+ }
222
+
223
+ const location = this.#location();
224
+
225
+ if (
226
+ name === "title" &&
227
+ this.#titleCapture === undefined &&
228
+ this.#titles.length < SIGNAL_LIMIT
229
+ ) {
230
+ this.#titleCapture = { location, value: "" };
231
+ }
232
+
233
+ if (name === "meta") {
234
+ const metaName = metaNameAttribute?.trim().toLowerCase();
235
+ if (
236
+ content !== undefined &&
237
+ metaName === "description" &&
238
+ this.#descriptions.length < SIGNAL_LIMIT
239
+ ) {
240
+ this.#descriptions.push(this.#elementSignal(content, location));
241
+ }
242
+ if (
243
+ content !== undefined &&
244
+ isRobotsAudience(metaName) &&
245
+ this.#robots.length < SIGNAL_LIMIT
246
+ ) {
247
+ this.#robots.push({
248
+ ...this.#elementSignal(content, location),
249
+ audience: metaName,
250
+ });
251
+ }
252
+ }
253
+
254
+ if (name === "link") {
255
+ const relations = rel?.toLowerCase().split(/\s+/u) ?? [];
256
+ if (
257
+ href !== undefined &&
258
+ relations.includes("canonical") &&
259
+ this.#canonicals.length < SIGNAL_LIMIT
260
+ ) {
261
+ this.#canonicals.push(this.#elementSignal(href, location));
262
+ }
263
+ }
264
+
265
+ if (name === "h1" && this.#h1Capture === undefined && this.#h1s.length < SIGNAL_LIMIT) {
266
+ this.#h1Capture = { location, value: "" };
267
+ }
268
+
269
+ if (name === "main") this.#mainDepth += 1;
270
+ if (this.#mainDepth > 0 && ["script", "style", "template", "noscript"].includes(name)) {
271
+ this.#ignoredMainTextDepth += 1;
272
+ }
273
+
274
+ const scriptMediaType = scriptType?.split(";", 1)[0]?.trim().toLowerCase();
275
+ if (name === "script" && scriptMediaType === "application/ld+json") {
276
+ this.#scriptCapture = {
277
+ jsonLd: true,
278
+ location,
279
+ value: "",
280
+ bytes: 0,
281
+ truncated: false,
282
+ omitted: this.#jsonLd.length >= JSON_LD_BLOCK_LIMIT,
283
+ };
284
+ }
285
+ }
286
+
287
+ #onText(value: string): void {
288
+ if (this.#templateDepth > 0 || this.#foreignContentDepth > 0) return;
289
+
290
+ if (this.#titleCapture !== undefined) {
291
+ this.#titleCapture.value = `${this.#titleCapture.value}${value}`.slice(
292
+ 0,
293
+ ELEMENT_VALUE_LIMIT,
294
+ );
295
+ }
296
+ if (this.#h1Capture !== undefined) {
297
+ this.#h1Capture.value = `${this.#h1Capture.value}${value}`.slice(0, ELEMENT_VALUE_LIMIT);
298
+ }
299
+ if (this.#scriptCapture !== undefined) {
300
+ this.#scriptCapture.bytes += Buffer.byteLength(value, "utf8");
301
+ if (!this.#scriptCapture.omitted) {
302
+ const remaining = JSON_LD_CAPTURE_LIMIT - this.#scriptCapture.value.length;
303
+ if (remaining > 0) this.#scriptCapture.value += value.slice(0, remaining);
304
+ if (value.length > remaining) this.#scriptCapture.truncated = true;
305
+ }
306
+ }
307
+
308
+ if (this.#mainDepth > 0 && this.#ignoredMainTextDepth === 0 && this.#mainText.length < 1_024) {
309
+ if (this.#mainTextStarted === undefined && /\S/u.test(value)) {
310
+ this.#mainTextStarted = this.#mark();
311
+ this.#mainTextLocation = this.#location();
312
+ }
313
+ if (this.#mainTextStarted !== undefined) {
314
+ this.#mainText += value.slice(0, 1_024 - this.#mainText.length);
315
+ }
316
+ }
317
+ }
318
+
319
+ #onCloseTag(name: string, isImplied: boolean): void {
320
+ if (this.#templateDepth > 0 || this.#foreignContentDepth > 0) {
321
+ if (name === "template") this.#templateDepth = Math.max(0, this.#templateDepth - 1);
322
+ if (name === "svg" || name === "math") {
323
+ this.#foreignContentDepth = Math.max(0, this.#foreignContentDepth - 1);
324
+ }
325
+ return;
326
+ }
327
+
328
+ if (name === "title" && this.#titleCapture !== undefined) {
329
+ const value = normalizedText(this.#titleCapture.value);
330
+ if (value.length > 0) {
331
+ this.#titles.push({
332
+ value,
333
+ location: this.#titleCapture.location,
334
+ ...this.#mark(),
335
+ });
336
+ }
337
+ this.#titleCapture = undefined;
338
+ }
339
+
340
+ if (name === "h1" && this.#h1Capture !== undefined) {
341
+ const value = normalizedText(this.#h1Capture.value);
342
+ if (value.length > 0) {
343
+ this.#h1s.push({
344
+ value,
345
+ location: this.#h1Capture.location,
346
+ ...this.#mark(),
347
+ });
348
+ }
349
+ this.#h1Capture = undefined;
350
+ }
351
+
352
+ if (name === "script" && this.#scriptCapture !== undefined) {
353
+ if (this.#scriptCapture.jsonLd) this.#recordJsonLd(this.#scriptCapture);
354
+ this.#scriptCapture = undefined;
355
+ }
356
+
357
+ if (this.#mainDepth > 0 && ["script", "style", "template", "noscript"].includes(name)) {
358
+ this.#ignoredMainTextDepth = Math.max(0, this.#ignoredMainTextDepth - 1);
359
+ }
360
+ if (name === "main") this.#mainDepth = Math.max(0, this.#mainDepth - 1);
361
+
362
+ if (name === "head") {
363
+ if (this.#headClosed === undefined) this.#headClosed = this.#mark();
364
+ this.#inHead = false;
365
+ }
366
+ if (name === "body") this.#inBody = false;
367
+ if (name === "html" && !isImplied && this.#documentClosed === undefined) {
368
+ this.#documentClosed = this.#mark();
369
+ }
370
+ }
371
+
372
+ #recordJsonLd(capture: ScriptCapture): void {
373
+ const mark = this.#mark();
374
+ if (capture.omitted) {
375
+ if (!this.#jsonLdLimitReported) {
376
+ this.#jsonLdLimitReported = true;
377
+ this.#jsonLd.push({
378
+ location: capture.location,
379
+ types: [],
380
+ bytes: capture.bytes,
381
+ analysisLimit: `Additional JSON-LD blocks exceeded the ${JSON_LD_BLOCK_LIMIT}-block analysis limit.`,
382
+ ...mark,
383
+ });
384
+ }
385
+ return;
386
+ }
387
+ if (capture.truncated) {
388
+ this.#jsonLd.push({
389
+ location: capture.location,
390
+ types: [],
391
+ bytes: capture.bytes,
392
+ analysisLimit: `JSON-LD block exceeded the ${JSON_LD_CAPTURE_LIMIT}-character analysis limit.`,
393
+ ...mark,
394
+ });
395
+ return;
396
+ }
397
+ try {
398
+ const parsed: unknown = JSON.parse(capture.value);
399
+ const types = new Set<string>();
400
+ collectJsonLdTypes(parsed, types);
401
+ this.#jsonLd.push({
402
+ location: capture.location,
403
+ valid: true,
404
+ types: [...types].sort(),
405
+ bytes: capture.bytes,
406
+ ...mark,
407
+ });
408
+ } catch (error) {
409
+ const message = error instanceof Error ? error.message : "Invalid JSON";
410
+ this.#jsonLd.push({
411
+ location: capture.location,
412
+ valid: false,
413
+ types: [],
414
+ bytes: capture.bytes,
415
+ error: message.slice(0, 240),
416
+ ...mark,
417
+ });
418
+ }
419
+ }
420
+ }
421
+
422
+ export function createStreamInspector(): StreamInspector {
423
+ return new HtmlStreamInspector();
424
+ }