@svelte-vitals/vite 0.32.1 → 0.32.3

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.
@@ -0,0 +1,321 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { IDREF_ATTRS, LANDMARK_ROLES, decodeFragmentId, isTopFragment, splitTokens, stripTextDirective } from "@svelte-vitals/core/internal";
4
+ import { glob } from "tinyglobby";
5
+ import { parse } from "node-html-parser";
6
+ //#region src/providers/rendered/parse-html.ts
7
+ function attrValue(v) {
8
+ return v !== void 0 && v.trim().length > 0 ? "static" : "absent";
9
+ }
10
+ const JS_MIME_TYPES = /* @__PURE__ */ new Set([
11
+ "application/ecmascript",
12
+ "application/javascript",
13
+ "application/x-ecmascript",
14
+ "application/x-javascript",
15
+ "text/ecmascript",
16
+ "text/javascript",
17
+ "text/javascript1.0",
18
+ "text/javascript1.1",
19
+ "text/javascript1.2",
20
+ "text/javascript1.3",
21
+ "text/javascript1.4",
22
+ "text/javascript1.5",
23
+ "text/jscript",
24
+ "text/livescript",
25
+ "text/x-ecmascript",
26
+ "text/x-javascript"
27
+ ]);
28
+ function isClassicScriptType(type) {
29
+ if (type === void 0) return true;
30
+ const normalized = type.trim().toLowerCase();
31
+ return normalized === "" || JS_MIME_TYPES.has(normalized);
32
+ }
33
+ const SECTIONING_TAGS = /* @__PURE__ */ new Set([
34
+ "article",
35
+ "aside",
36
+ "main",
37
+ "nav",
38
+ "section"
39
+ ]);
40
+ const ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set([
41
+ "article",
42
+ "aside",
43
+ "nav",
44
+ "section"
45
+ ]);
46
+ /** Whole-document scan (not body-scoped: app.html shell ids/refs are real occurrences too). */
47
+ function collectA11y(root) {
48
+ const landmarks = [];
49
+ const nestedLandmarks = [];
50
+ const ids = [];
51
+ const idRefs = [];
52
+ const elementTags = /* @__PURE__ */ new Set();
53
+ const walk = (el, ctx) => {
54
+ const tag = el.rawTagName?.toLowerCase();
55
+ if (tag && ctx.inBody) elementTags.add(tag);
56
+ const roleAttr = el.getAttribute("role");
57
+ let landmark;
58
+ if (roleAttr !== void 0) {
59
+ const role = splitTokens(roleAttr)[0];
60
+ landmark = role && LANDMARK_ROLES.has(role) ? role : void 0;
61
+ } else if (tag === "main") landmark = "main";
62
+ else if ((tag === "header" || tag === "footer") && ctx.sectioning === 0) landmark = tag === "header" ? "banner" : "contentinfo";
63
+ else if (tag === "aside") {
64
+ const named = ["aria-label", "aria-labelledby"].some((a) => (el.getAttribute(a) ?? "").trim().length > 0);
65
+ landmark = ctx.asideDemoting === 0 || named ? "complementary" : void 0;
66
+ }
67
+ if (landmark) {
68
+ landmarks.push(landmark);
69
+ const within = ctx.landmarks.at(-1);
70
+ if (within) nestedLandmarks.push({
71
+ kind: landmark,
72
+ within
73
+ });
74
+ }
75
+ const id = el.getAttribute("id");
76
+ if (id && id.trim()) ids.push(id);
77
+ const href = el.getAttribute("href");
78
+ const target = href?.startsWith("#") ? stripTextDirective(href.slice(1)) : "";
79
+ if (target) {
80
+ const fragment = decodeFragmentId(target);
81
+ if (!isTopFragment(fragment)) idRefs.push({
82
+ id: fragment,
83
+ attr: "href"
84
+ });
85
+ }
86
+ for (const attr of IDREF_ATTRS) for (const token of splitTokens(el.getAttribute(attr) ?? void 0)) idRefs.push({
87
+ id: token,
88
+ attr
89
+ });
90
+ if (tag === "template") return;
91
+ const nextCtx = {
92
+ sectioning: ctx.sectioning + (SECTIONING_TAGS.has(tag) ? 1 : 0),
93
+ asideDemoting: ctx.asideDemoting + (ASIDE_DEMOTING_TAGS.has(tag) ? 1 : 0),
94
+ landmarks: landmark ? [...ctx.landmarks, landmark] : ctx.landmarks,
95
+ inBody: ctx.inBody || tag === "body"
96
+ };
97
+ for (const child of el.children) walk(child, nextCtx);
98
+ };
99
+ for (const child of root.children) walk(child, {
100
+ sectioning: 0,
101
+ asideDemoting: 0,
102
+ landmarks: [],
103
+ inBody: false
104
+ });
105
+ return {
106
+ landmarks,
107
+ nestedLandmarks,
108
+ ids,
109
+ idRefs,
110
+ elementTags: [...elementTags]
111
+ };
112
+ }
113
+ /** Parse a fully-rendered HTML document's <head> into normalized head tags. */
114
+ function parseHtmlHead(html) {
115
+ const root = parse(html);
116
+ const head = root.querySelector("head") ?? root;
117
+ const tags = [];
118
+ const title = head.querySelector("title");
119
+ if (title) {
120
+ const text = title.text;
121
+ tags.push({
122
+ kind: "title",
123
+ presence: "own",
124
+ value: attrValue(text),
125
+ ...text && text.trim().length > 0 ? { text } : {}
126
+ });
127
+ }
128
+ for (const meta of head.querySelectorAll("meta")) {
129
+ const name = meta.getAttribute("name");
130
+ const property = meta.getAttribute("property");
131
+ const charset = meta.getAttribute("charset");
132
+ if (charset != null) {
133
+ tags.push({
134
+ kind: "meta",
135
+ name: "charset",
136
+ presence: "own",
137
+ value: attrValue(charset)
138
+ });
139
+ continue;
140
+ }
141
+ if (!name && !property) continue;
142
+ const content = name === "robots" ? meta.getAttribute("content") : null;
143
+ const noindex = content != null && /(^|[\s,])(noindex|none)([\s,]|$)/i.test(content);
144
+ const descText = name === "description" ? meta.getAttribute("content") : null;
145
+ tags.push({
146
+ kind: "meta",
147
+ ...name ? { name } : {},
148
+ ...property ? { property } : {},
149
+ presence: "own",
150
+ value: attrValue(meta.getAttribute("content")),
151
+ ...noindex ? { noindex: true } : {},
152
+ ...descText && descText.trim().length > 0 ? { text: descText } : {}
153
+ });
154
+ }
155
+ for (const link of head.querySelectorAll("link")) {
156
+ const rel = link.getAttribute("rel")?.toLowerCase();
157
+ if (!rel) continue;
158
+ const asAttr = link.getAttribute("as")?.toLowerCase();
159
+ const hasCrossorigin = link.hasAttribute("crossorigin");
160
+ const hreflang = link.getAttribute("hreflang");
161
+ const href = link.getAttribute("href");
162
+ tags.push({
163
+ kind: "link",
164
+ rel,
165
+ presence: "own",
166
+ value: attrValue(href),
167
+ ...asAttr != null ? {
168
+ hasAs: true,
169
+ as: asAttr
170
+ } : {},
171
+ ...hasCrossorigin ? { hasCrossorigin: true } : {},
172
+ ...hreflang != null ? { hreflang } : {},
173
+ ...href ? { href } : {}
174
+ });
175
+ }
176
+ for (const script of head.querySelectorAll("script")) {
177
+ const type = script.getAttribute("type");
178
+ if (type === "application/ld+json") {
179
+ const raw = script.rawText;
180
+ tags.push({
181
+ kind: "jsonld",
182
+ presence: "own",
183
+ value: attrValue(raw),
184
+ ...raw && raw.trim().length > 0 ? { jsonld: raw } : {}
185
+ });
186
+ continue;
187
+ }
188
+ const src = script.getAttribute("src");
189
+ if (src) {
190
+ const blocking = isClassicScriptType(type) && !script.hasAttribute("defer") && !script.hasAttribute("async");
191
+ tags.push({
192
+ kind: "script",
193
+ presence: "own",
194
+ value: "static",
195
+ href: src,
196
+ ...blocking ? { blocking: true } : {}
197
+ });
198
+ }
199
+ }
200
+ const lang = root.querySelector("html")?.getAttribute("lang");
201
+ return {
202
+ tags,
203
+ htmlLang: lang === void 0 || lang === null ? {
204
+ presence: "none",
205
+ value: "absent"
206
+ } : {
207
+ presence: "own",
208
+ value: attrValue(lang)
209
+ },
210
+ headings: (root.querySelector("body") ?? root).querySelectorAll("h1,h2,h3,h4,h5,h6").map((el) => Number(el.rawTagName[1])),
211
+ images: (root.querySelector("body") ?? root).querySelectorAll("img").map((img) => ({
212
+ hasWidth: img.hasAttribute("width"),
213
+ hasHeight: img.hasAttribute("height"),
214
+ hasLoading: img.hasAttribute("loading"),
215
+ hasAlt: img.hasAttribute("alt"),
216
+ lazy: img.getAttribute("loading") === "lazy",
217
+ hasSrcset: img.hasAttribute("srcset"),
218
+ line: 0
219
+ })),
220
+ ...collectA11y(root)
221
+ };
222
+ }
223
+ //#endregion
224
+ //#region src/providers/rendered/collect.ts
225
+ /** Group raw occurrence keys (one entry per hit, in document order) by key, `file` attached, `line: 0` (rendered mode does not track source lines). */
226
+ function toOccurrenceMap(keys, file) {
227
+ const out = Object.create(null);
228
+ for (const key of keys) (out[key] ??= []).push({
229
+ file,
230
+ line: 0
231
+ });
232
+ return out;
233
+ }
234
+ /** Map a prerendered HTML path (relative to pages/, POSIX) to its route. */
235
+ function deriveRouteFromHtmlPath(relPath) {
236
+ let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
237
+ if (p === "index") return "/";
238
+ if (p.endsWith("/index")) p = p.slice(0, -6);
239
+ return "/" + p;
240
+ }
241
+ /** Read every prerendered HTML page under `prerenderPagesDir` into ResolvedHead[]. */
242
+ async function collectRenderedHeads(prerenderPagesDir) {
243
+ const files = (await glob("**/*.html", { cwd: prerenderPagesDir })).sort();
244
+ const parsedFiles = await Promise.all(files.map(async (rel) => ({
245
+ rel,
246
+ parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8"))
247
+ })));
248
+ const heads = [];
249
+ const headings = [];
250
+ const images = [];
251
+ const a11y = [];
252
+ let htmlLang = {
253
+ presence: "none",
254
+ value: "absent"
255
+ };
256
+ for (const { rel, parsed } of parsedFiles) {
257
+ if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
258
+ const route = deriveRouteFromHtmlPath(rel);
259
+ heads.push({
260
+ route,
261
+ source: "rendered",
262
+ tags: parsed.tags,
263
+ file: rel
264
+ });
265
+ headings.push({
266
+ route,
267
+ headings: parsed.headings.map((level) => ({
268
+ level,
269
+ line: 0,
270
+ file: rel
271
+ }))
272
+ });
273
+ images.push({
274
+ route,
275
+ images: parsed.images.map((img) => ({
276
+ ...img,
277
+ file: rel
278
+ }))
279
+ });
280
+ a11y.push({
281
+ route,
282
+ landmarks: toOccurrenceMap(parsed.landmarks, rel),
283
+ nestedLandmarks: parsed.nestedLandmarks.map((n) => ({
284
+ ...n,
285
+ file: rel,
286
+ line: 0
287
+ })),
288
+ ids: toOccurrenceMap(parsed.ids, rel),
289
+ idRefs: parsed.idRefs.map((r) => ({
290
+ ...r,
291
+ file: rel,
292
+ line: 0
293
+ })),
294
+ idCandidates: [...new Set(parsed.ids)],
295
+ fullyResolved: true,
296
+ elementTags: parsed.elementTags,
297
+ elementsClosed: true,
298
+ file: rel
299
+ });
300
+ }
301
+ return {
302
+ heads,
303
+ headings,
304
+ images,
305
+ a11y,
306
+ htmlLang
307
+ };
308
+ }
309
+ //#endregion
310
+ //#region src/loopback.ts
311
+ /** Only the local dev server hosts the ingest endpoint, so never POST off-box. */
312
+ function isLoopbackOrigin(origin) {
313
+ try {
314
+ const host = new URL(origin).hostname;
315
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+ //#endregion
321
+ export { parseHtmlHead as i, collectRenderedHeads as n, toOccurrenceMap as r, isLoopbackOrigin as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/vite",
3
- "version": "0.32.1",
3
+ "version": "0.32.3",
4
4
  "description": "Vite/SvelteKit plugin for svelte-vitals — analyzes prerendered HTML during vite build, plus a live dev dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,12 +42,12 @@
42
42
  "esm-env": "^1.2.2",
43
43
  "node-html-parser": "^9.0.1",
44
44
  "tinyglobby": "^0.2.17",
45
- "svelte-vitals": "0.50.0",
46
- "@svelte-vitals/core": "0.47.0"
45
+ "@svelte-vitals/core": "0.47.2",
46
+ "svelte-vitals": "0.51.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@sveltejs/kit": "^2.70.3",
50
- "vite": "^8.2.1"
50
+ "vite": "^8.2.2"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@sveltejs/kit": {
@@ -57,12 +57,12 @@
57
57
  "devDependencies": {
58
58
  "@sveltejs/kit": "^2.70.3",
59
59
  "@types/node": "^24.13.3",
60
- "jsdom": "^30.0.1",
61
- "svelte": "^5.56.9",
62
- "vite": "^8.2.1"
60
+ "happy-dom": "^20.11.6",
61
+ "svelte": "^5.56.10",
62
+ "vite": "^8.2.2"
63
63
  },
64
64
  "scripts": {
65
- "build": "tsup",
65
+ "build": "tsdown",
66
66
  "bench": "node scripts/bench/bench.js",
67
67
  "typecheck": "tsc --noEmit",
68
68
  "test": "vitest run"
@@ -1,258 +0,0 @@
1
- // src/providers/rendered/parse-html.ts
2
- import { parse } from "node-html-parser";
3
- import {
4
- decodeFragmentId,
5
- splitTokens,
6
- isTopFragment,
7
- stripTextDirective,
8
- LANDMARK_ROLES,
9
- IDREF_ATTRS
10
- } from "@svelte-vitals/core/internal";
11
- function attrValue(v) {
12
- return v !== void 0 && v.trim().length > 0 ? "static" : "absent";
13
- }
14
- var JS_MIME_TYPES = /* @__PURE__ */ new Set([
15
- "application/ecmascript",
16
- "application/javascript",
17
- "application/x-ecmascript",
18
- "application/x-javascript",
19
- "text/ecmascript",
20
- "text/javascript",
21
- "text/javascript1.0",
22
- "text/javascript1.1",
23
- "text/javascript1.2",
24
- "text/javascript1.3",
25
- "text/javascript1.4",
26
- "text/javascript1.5",
27
- "text/jscript",
28
- "text/livescript",
29
- "text/x-ecmascript",
30
- "text/x-javascript"
31
- ]);
32
- function isClassicScriptType(type) {
33
- if (type === void 0) return true;
34
- const normalized = type.trim().toLowerCase();
35
- return normalized === "" || JS_MIME_TYPES.has(normalized);
36
- }
37
- var SECTIONING_TAGS = /* @__PURE__ */ new Set(["article", "aside", "main", "nav", "section"]);
38
- var ASIDE_DEMOTING_TAGS = /* @__PURE__ */ new Set(["article", "aside", "nav", "section"]);
39
- function collectA11y(root) {
40
- const landmarks = [];
41
- const nestedLandmarks = [];
42
- const ids = [];
43
- const idRefs = [];
44
- const elementTags = /* @__PURE__ */ new Set();
45
- const walk = (el, ctx) => {
46
- const tag = el.rawTagName?.toLowerCase();
47
- if (tag && ctx.inBody) elementTags.add(tag);
48
- const roleAttr = el.getAttribute("role");
49
- let landmark;
50
- if (roleAttr !== void 0) {
51
- const role = splitTokens(roleAttr)[0];
52
- landmark = role && LANDMARK_ROLES.has(role) ? role : void 0;
53
- } else if (tag === "main") {
54
- landmark = "main";
55
- } else if ((tag === "header" || tag === "footer") && ctx.sectioning === 0) {
56
- landmark = tag === "header" ? "banner" : "contentinfo";
57
- } else if (tag === "aside") {
58
- const named = ["aria-label", "aria-labelledby"].some((a) => (el.getAttribute(a) ?? "").trim().length > 0);
59
- landmark = ctx.asideDemoting === 0 || named ? "complementary" : void 0;
60
- }
61
- if (landmark) {
62
- landmarks.push(landmark);
63
- const within = ctx.landmarks.at(-1);
64
- if (within) nestedLandmarks.push({ kind: landmark, within });
65
- }
66
- const id = el.getAttribute("id");
67
- if (id && id.trim()) ids.push(id);
68
- const href = el.getAttribute("href");
69
- const target = href?.startsWith("#") ? stripTextDirective(href.slice(1)) : "";
70
- if (target) {
71
- const fragment = decodeFragmentId(target);
72
- if (!isTopFragment(fragment)) idRefs.push({ id: fragment, attr: "href" });
73
- }
74
- for (const attr of IDREF_ATTRS) {
75
- for (const token of splitTokens(el.getAttribute(attr) ?? void 0)) idRefs.push({ id: token, attr });
76
- }
77
- if (tag === "template") return;
78
- const nextCtx = {
79
- sectioning: ctx.sectioning + (SECTIONING_TAGS.has(tag) ? 1 : 0),
80
- asideDemoting: ctx.asideDemoting + (ASIDE_DEMOTING_TAGS.has(tag) ? 1 : 0),
81
- landmarks: landmark ? [...ctx.landmarks, landmark] : ctx.landmarks,
82
- inBody: ctx.inBody || tag === "body"
83
- };
84
- for (const child of el.children) walk(child, nextCtx);
85
- };
86
- for (const child of root.children) walk(child, { sectioning: 0, asideDemoting: 0, landmarks: [], inBody: false });
87
- return { landmarks, nestedLandmarks, ids, idRefs, elementTags: [...elementTags] };
88
- }
89
- function parseHtmlHead(html) {
90
- const root = parse(html);
91
- const head = root.querySelector("head") ?? root;
92
- const tags = [];
93
- const title = head.querySelector("title");
94
- if (title) {
95
- const text = title.text;
96
- tags.push({
97
- kind: "title",
98
- presence: "own",
99
- value: attrValue(text),
100
- ...text && text.trim().length > 0 ? { text } : {}
101
- });
102
- }
103
- for (const meta of head.querySelectorAll("meta")) {
104
- const name = meta.getAttribute("name");
105
- const property = meta.getAttribute("property");
106
- const charset = meta.getAttribute("charset");
107
- if (charset != null) {
108
- tags.push({ kind: "meta", name: "charset", presence: "own", value: attrValue(charset) });
109
- continue;
110
- }
111
- if (!name && !property) continue;
112
- const content = name === "robots" ? meta.getAttribute("content") : null;
113
- const noindex = content != null && /(^|[\s,])(noindex|none)([\s,]|$)/i.test(content);
114
- const descText = name === "description" ? meta.getAttribute("content") : null;
115
- tags.push({
116
- kind: "meta",
117
- ...name ? { name } : {},
118
- ...property ? { property } : {},
119
- presence: "own",
120
- value: attrValue(meta.getAttribute("content")),
121
- ...noindex ? { noindex: true } : {},
122
- ...descText && descText.trim().length > 0 ? { text: descText } : {}
123
- });
124
- }
125
- for (const link of head.querySelectorAll("link")) {
126
- const rel = link.getAttribute("rel")?.toLowerCase();
127
- if (!rel) continue;
128
- const asAttr = link.getAttribute("as")?.toLowerCase();
129
- const hasCrossorigin = link.hasAttribute("crossorigin");
130
- const hreflang = link.getAttribute("hreflang");
131
- const href = link.getAttribute("href");
132
- tags.push({
133
- kind: "link",
134
- rel,
135
- presence: "own",
136
- value: attrValue(href),
137
- ...asAttr != null ? { hasAs: true, as: asAttr } : {},
138
- ...hasCrossorigin ? { hasCrossorigin: true } : {},
139
- // Keep a literal empty hreflang="" (present-but-invalid) so seo/hreflang can flag it.
140
- ...hreflang != null ? { hreflang } : {},
141
- ...href ? { href } : {}
142
- });
143
- }
144
- for (const script of head.querySelectorAll("script")) {
145
- const type = script.getAttribute("type");
146
- if (type === "application/ld+json") {
147
- const raw = script.rawText;
148
- tags.push({
149
- kind: "jsonld",
150
- presence: "own",
151
- value: attrValue(raw),
152
- ...raw && raw.trim().length > 0 ? { jsonld: raw } : {}
153
- });
154
- continue;
155
- }
156
- const src = script.getAttribute("src");
157
- if (src) {
158
- const blocking = isClassicScriptType(type) && !script.hasAttribute("defer") && !script.hasAttribute("async");
159
- tags.push({
160
- kind: "script",
161
- presence: "own",
162
- value: "static",
163
- href: src,
164
- ...blocking ? { blocking: true } : {}
165
- });
166
- }
167
- }
168
- const htmlEl = root.querySelector("html");
169
- const lang = htmlEl?.getAttribute("lang");
170
- const htmlLang = lang === void 0 || lang === null ? { presence: "none", value: "absent" } : { presence: "own", value: attrValue(lang) };
171
- const headings = (root.querySelector("body") ?? root).querySelectorAll("h1,h2,h3,h4,h5,h6").map((el) => Number(el.rawTagName[1]));
172
- const images = (root.querySelector("body") ?? root).querySelectorAll("img").map((img) => ({
173
- hasWidth: img.hasAttribute("width"),
174
- hasHeight: img.hasAttribute("height"),
175
- hasLoading: img.hasAttribute("loading"),
176
- hasAlt: img.hasAttribute("alt"),
177
- lazy: img.getAttribute("loading") === "lazy",
178
- hasSrcset: img.hasAttribute("srcset"),
179
- line: 0
180
- }));
181
- const a11y = collectA11y(root);
182
- return { tags, htmlLang, headings, images, ...a11y };
183
- }
184
-
185
- // src/providers/rendered/collect.ts
186
- import { readFile } from "fs/promises";
187
- import { join } from "path";
188
- import { glob } from "tinyglobby";
189
- function toOccurrenceMap(keys, file) {
190
- const out = /* @__PURE__ */ Object.create(null);
191
- for (const key of keys) (out[key] ??= []).push({ file, line: 0 });
192
- return out;
193
- }
194
- function deriveRouteFromHtmlPath(relPath) {
195
- let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
196
- if (p === "index") return "/";
197
- if (p.endsWith("/index")) p = p.slice(0, -"/index".length);
198
- return "/" + p;
199
- }
200
- async function collectRenderedHeads(prerenderPagesDir) {
201
- const files = (await glob("**/*.html", { cwd: prerenderPagesDir })).sort();
202
- const parsedFiles = await Promise.all(
203
- files.map(async (rel) => ({ rel, parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8")) }))
204
- );
205
- const heads = [];
206
- const headings = [];
207
- const images = [];
208
- const a11y = [];
209
- let htmlLang = { presence: "none", value: "absent" };
210
- for (const { rel, parsed } of parsedFiles) {
211
- if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
212
- const route = deriveRouteFromHtmlPath(rel);
213
- heads.push({
214
- route,
215
- source: "rendered",
216
- tags: parsed.tags,
217
- file: rel
218
- });
219
- headings.push({
220
- route,
221
- // Rendered mode does not track source lines (line 0 = unknown); file is the HTML path.
222
- headings: parsed.headings.map((level) => ({ level, line: 0, file: rel }))
223
- });
224
- images.push({ route, images: parsed.images.map((img) => ({ ...img, file: rel })) });
225
- a11y.push({
226
- route,
227
- landmarks: toOccurrenceMap(parsed.landmarks, rel),
228
- nestedLandmarks: parsed.nestedLandmarks.map((n) => ({ ...n, file: rel, line: 0 })),
229
- ids: toOccurrenceMap(parsed.ids, rel),
230
- idRefs: parsed.idRefs.map((r) => ({ ...r, file: rel, line: 0 })),
231
- idCandidates: [...new Set(parsed.ids)],
232
- // The prerendered document IS the closed world: every id/landmark/reference it can ever
233
- // have is already in it, unlike source mode which may hit an unresolved component.
234
- fullyResolved: true,
235
- elementTags: parsed.elementTags,
236
- elementsClosed: true,
237
- file: rel
238
- });
239
- }
240
- return { heads, headings, images, a11y, htmlLang };
241
- }
242
-
243
- // src/loopback.ts
244
- function isLoopbackOrigin(origin) {
245
- try {
246
- const host = new URL(origin).hostname;
247
- return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
248
- } catch {
249
- return false;
250
- }
251
- }
252
-
253
- export {
254
- parseHtmlHead,
255
- toOccurrenceMap,
256
- collectRenderedHeads,
257
- isLoopbackOrigin
258
- };