@svelte-vitals/vite 0.32.0 → 0.32.2
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/hooks/index.d.ts +9 -9
- package/dist/hooks/index.js +103 -100
- package/dist/index.d.ts +29 -29
- package/dist/index.js +715 -669
- package/dist/loopback-dIwCgCTG.js +321 -0
- package/package.json +7 -7
- package/dist/chunk-KII3SU3J.js +0 -258
|
@@ -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.
|
|
3
|
+
"version": "0.32.2",
|
|
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,11 +42,11 @@
|
|
|
42
42
|
"esm-env": "^1.2.2",
|
|
43
43
|
"node-html-parser": "^9.0.1",
|
|
44
44
|
"tinyglobby": "^0.2.17",
|
|
45
|
-
"@svelte-vitals/core": "0.
|
|
46
|
-
"svelte-vitals": "0.
|
|
45
|
+
"@svelte-vitals/core": "0.47.1",
|
|
46
|
+
"svelte-vitals": "0.50.1"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@sveltejs/kit": "^2.70.
|
|
49
|
+
"@sveltejs/kit": "^2.70.3",
|
|
50
50
|
"vite": "^8.2.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependenciesMeta": {
|
|
@@ -55,14 +55,14 @@
|
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@sveltejs/kit": "^2.70.
|
|
58
|
+
"@sveltejs/kit": "^2.70.3",
|
|
59
59
|
"@types/node": "^24.13.3",
|
|
60
|
-
"
|
|
60
|
+
"happy-dom": "^20.11.2",
|
|
61
61
|
"svelte": "^5.56.9",
|
|
62
62
|
"vite": "^8.2.1"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
|
-
"build": "
|
|
65
|
+
"build": "tsdown",
|
|
66
66
|
"bench": "node scripts/bench/bench.js",
|
|
67
67
|
"typecheck": "tsc --noEmit",
|
|
68
68
|
"test": "vitest run"
|
package/dist/chunk-KII3SU3J.js
DELETED
|
@@ -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
|
-
};
|