@svelte-vitals/vite 0.29.6 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{chunk-2LDMPITM.js → chunk-VLQYXCE6.js} +104 -6
- package/dist/hooks/index.js +41 -20
- package/dist/index.js +69 -91
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Vite/SvelteKit plugin for [svelte-vitals](https://github.com/oekazuma/svelte-vitals). Two things in one package:
|
|
6
6
|
|
|
7
|
-
- **Build-time gate** — piggybacks on `vite build`, parses the **prerendered HTML
|
|
7
|
+
- **Build-time gate** — piggybacks on `vite build`, parses the **prerendered HTML** (head metadata, images, headings, landmarks and ids), and runs the same rule engine as the CLI (SEO, Performance, Correctness, Security, Architecture, Accessibility) — library-agnostic, because it inspects the real output. Fails the build when findings reach `failOn`.
|
|
8
8
|
- **Live dev dashboard** — on by default during `vite dev`: a searchable, filterable code-health dashboard at `/__svelte-vitals/`, covering the whole project from startup via static analysis, refined to real rendered values as you browse.
|
|
9
9
|
|
|
10
10
|
> **ESM-only** (Node 22.13+). Ships ES modules only; `require()` is unsupported by design.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/providers/rendered/parse-html.ts
|
|
2
2
|
import { parse } from "node-html-parser";
|
|
3
|
+
import { decodeFragmentId, splitTokens, isTopFragment, LANDMARK_ROLES, IDREF_ATTRS } from "@svelte-vitals/core";
|
|
3
4
|
function attrValue(v) {
|
|
4
5
|
return v !== void 0 && v.trim().length > 0 ? "static" : "absent";
|
|
5
6
|
}
|
|
@@ -26,6 +27,49 @@ function isClassicScriptType(type) {
|
|
|
26
27
|
const normalized = type.trim().toLowerCase();
|
|
27
28
|
return normalized === "" || JS_MIME_TYPES.has(normalized);
|
|
28
29
|
}
|
|
30
|
+
var SECTIONING_TAGS = /* @__PURE__ */ new Set(["article", "aside", "main", "nav", "section"]);
|
|
31
|
+
function collectA11y(root) {
|
|
32
|
+
const landmarks = [];
|
|
33
|
+
const nestedLandmarks = [];
|
|
34
|
+
const ids = [];
|
|
35
|
+
const idRefs = [];
|
|
36
|
+
const walk = (el, ctx) => {
|
|
37
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
38
|
+
const roleAttr = el.getAttribute("role");
|
|
39
|
+
let landmark;
|
|
40
|
+
if (roleAttr !== void 0) {
|
|
41
|
+
const role = splitTokens(roleAttr)[0];
|
|
42
|
+
landmark = role && LANDMARK_ROLES.has(role) ? role : void 0;
|
|
43
|
+
} else if (tag === "main") {
|
|
44
|
+
landmark = "main";
|
|
45
|
+
} else if ((tag === "header" || tag === "footer") && ctx.sectioning === 0) {
|
|
46
|
+
landmark = tag === "header" ? "banner" : "contentinfo";
|
|
47
|
+
}
|
|
48
|
+
if (landmark) {
|
|
49
|
+
landmarks.push(landmark);
|
|
50
|
+
const within = ctx.landmarks.at(-1);
|
|
51
|
+
if (within) nestedLandmarks.push({ kind: landmark, within });
|
|
52
|
+
}
|
|
53
|
+
const id = el.getAttribute("id");
|
|
54
|
+
if (id && id.trim()) ids.push(id);
|
|
55
|
+
const href = el.getAttribute("href");
|
|
56
|
+
if (href && href.startsWith("#") && href.length > 1) {
|
|
57
|
+
const fragment = decodeFragmentId(href.slice(1));
|
|
58
|
+
if (!isTopFragment(fragment)) idRefs.push({ id: fragment, attr: "href" });
|
|
59
|
+
}
|
|
60
|
+
for (const attr of IDREF_ATTRS) {
|
|
61
|
+
for (const token of splitTokens(el.getAttribute(attr) ?? void 0)) idRefs.push({ id: token, attr });
|
|
62
|
+
}
|
|
63
|
+
if (tag === "template") return;
|
|
64
|
+
const nextCtx = {
|
|
65
|
+
sectioning: ctx.sectioning + (SECTIONING_TAGS.has(tag) ? 1 : 0),
|
|
66
|
+
landmarks: landmark ? [...ctx.landmarks, landmark] : ctx.landmarks
|
|
67
|
+
};
|
|
68
|
+
for (const child of el.children) walk(child, nextCtx);
|
|
69
|
+
};
|
|
70
|
+
for (const child of root.children) walk(child, { sectioning: 0, landmarks: [] });
|
|
71
|
+
return { landmarks, nestedLandmarks, ids, idRefs };
|
|
72
|
+
}
|
|
29
73
|
function parseHtmlHead(html) {
|
|
30
74
|
const root = parse(html);
|
|
31
75
|
const head = root.querySelector("head") ?? root;
|
|
@@ -118,7 +162,63 @@ function parseHtmlHead(html) {
|
|
|
118
162
|
hasSrcset: img.hasAttribute("srcset"),
|
|
119
163
|
line: 0
|
|
120
164
|
}));
|
|
121
|
-
|
|
165
|
+
const a11y = collectA11y(root);
|
|
166
|
+
return { tags, htmlLang, headings, images, ...a11y };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/providers/rendered/collect.ts
|
|
170
|
+
import { readFile } from "fs/promises";
|
|
171
|
+
import { join } from "path";
|
|
172
|
+
import { glob } from "tinyglobby";
|
|
173
|
+
function toOccurrenceMap(keys, file) {
|
|
174
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
175
|
+
for (const key of keys) (out[key] ??= []).push({ file, line: 0 });
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
function deriveRouteFromHtmlPath(relPath) {
|
|
179
|
+
let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
|
|
180
|
+
if (p === "index") return "/";
|
|
181
|
+
if (p.endsWith("/index")) p = p.slice(0, -"/index".length);
|
|
182
|
+
return "/" + p;
|
|
183
|
+
}
|
|
184
|
+
async function collectRenderedHeads(prerenderPagesDir) {
|
|
185
|
+
const files = (await glob("**/*.html", { cwd: prerenderPagesDir })).sort();
|
|
186
|
+
const parsedFiles = await Promise.all(
|
|
187
|
+
files.map(async (rel) => ({ rel, parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8")) }))
|
|
188
|
+
);
|
|
189
|
+
const heads = [];
|
|
190
|
+
const headings = [];
|
|
191
|
+
const images = [];
|
|
192
|
+
const a11y = [];
|
|
193
|
+
let htmlLang = { presence: "none", value: "absent" };
|
|
194
|
+
for (const { rel, parsed } of parsedFiles) {
|
|
195
|
+
if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
|
|
196
|
+
const route = deriveRouteFromHtmlPath(rel);
|
|
197
|
+
heads.push({
|
|
198
|
+
route,
|
|
199
|
+
source: "rendered",
|
|
200
|
+
tags: parsed.tags,
|
|
201
|
+
file: rel
|
|
202
|
+
});
|
|
203
|
+
headings.push({
|
|
204
|
+
route,
|
|
205
|
+
// Rendered mode does not track source lines (line 0 = unknown); file is the HTML path.
|
|
206
|
+
headings: parsed.headings.map((level) => ({ level, line: 0, file: rel }))
|
|
207
|
+
});
|
|
208
|
+
images.push({ route, images: parsed.images.map((img) => ({ ...img, file: rel })) });
|
|
209
|
+
a11y.push({
|
|
210
|
+
route,
|
|
211
|
+
landmarks: toOccurrenceMap(parsed.landmarks, rel),
|
|
212
|
+
nestedLandmarks: parsed.nestedLandmarks.map((n) => ({ ...n, file: rel, line: 0 })),
|
|
213
|
+
ids: toOccurrenceMap(parsed.ids, rel),
|
|
214
|
+
idRefs: parsed.idRefs.map((r) => ({ ...r, file: rel, line: 0 })),
|
|
215
|
+
idCandidates: [...new Set(parsed.ids)],
|
|
216
|
+
// The prerendered document IS the closed world: every id/landmark/reference it can ever
|
|
217
|
+
// have is already in it, unlike source mode which may hit an unresolved component.
|
|
218
|
+
fullyResolved: true
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return { heads, headings, images, a11y, htmlLang };
|
|
122
222
|
}
|
|
123
223
|
|
|
124
224
|
// src/loopback.ts
|
|
@@ -130,12 +230,10 @@ function isLoopbackOrigin(origin) {
|
|
|
130
230
|
return false;
|
|
131
231
|
}
|
|
132
232
|
}
|
|
133
|
-
function isLoopbackHost(host) {
|
|
134
|
-
return host !== void 0 && isLoopbackOrigin(`http://${host}`);
|
|
135
|
-
}
|
|
136
233
|
|
|
137
234
|
export {
|
|
138
235
|
parseHtmlHead,
|
|
139
|
-
|
|
140
|
-
|
|
236
|
+
toOccurrenceMap,
|
|
237
|
+
collectRenderedHeads,
|
|
238
|
+
isLoopbackOrigin
|
|
141
239
|
};
|
package/dist/hooks/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isLoopbackOrigin,
|
|
3
|
-
parseHtmlHead
|
|
4
|
-
|
|
3
|
+
parseHtmlHead,
|
|
4
|
+
toOccurrenceMap
|
|
5
|
+
} from "../chunk-VLQYXCE6.js";
|
|
5
6
|
|
|
6
7
|
// src/hooks/handle.ts
|
|
7
8
|
import { DEV } from "esm-env";
|
|
@@ -10,17 +11,20 @@ import {
|
|
|
10
11
|
applyRuleSeverities,
|
|
11
12
|
defineConfig,
|
|
12
13
|
effectiveSeverity,
|
|
14
|
+
formatFailedRuleWarning,
|
|
13
15
|
isPenalized,
|
|
14
16
|
runRules,
|
|
15
|
-
selectRules
|
|
17
|
+
selectRules,
|
|
18
|
+
terminalSafe
|
|
16
19
|
} from "@svelte-vitals/core";
|
|
17
20
|
function findingSignature(results, config) {
|
|
18
21
|
return results.filter((r) => isPenalized(r.detection, config.treatDynamicAs)).map((r) => `${r.id}:${effectiveSeverity(r, config)}:${r.detection.presence}:${r.detection.value}`).sort().join("|");
|
|
19
22
|
}
|
|
20
|
-
|
|
23
|
+
var warn = (line) => console.warn(terminalSafe(line));
|
|
24
|
+
async function postIngest(origin, route, results, failedRuleIds) {
|
|
21
25
|
if (!isLoopbackOrigin(origin)) {
|
|
22
26
|
if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
|
|
23
|
-
|
|
27
|
+
warn(
|
|
24
28
|
`[svelte-vitals] live UI ingest skipped for non-loopback origin ${origin} \u2014 open the dashboard via localhost`
|
|
25
29
|
);
|
|
26
30
|
}
|
|
@@ -30,38 +34,62 @@ async function postIngest(origin, route, results) {
|
|
|
30
34
|
await fetch(`${origin}/__svelte-vitals/ingest`, {
|
|
31
35
|
method: "POST",
|
|
32
36
|
headers: { "content-type": "application/json" },
|
|
33
|
-
|
|
37
|
+
// failedRuleIds is always sent, empty array included, so a route that recovers from
|
|
38
|
+
// a previously-crashing rule clears its stale entry on the receiving store.
|
|
39
|
+
body: JSON.stringify({ route, results, failedRuleIds })
|
|
34
40
|
});
|
|
35
41
|
} catch {
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
async function analyzeAndIngest(html, route, origin, rules, config, lastSignature) {
|
|
39
45
|
try {
|
|
40
|
-
const {
|
|
46
|
+
const {
|
|
47
|
+
tags,
|
|
48
|
+
htmlLang,
|
|
49
|
+
headings: levels,
|
|
50
|
+
images: imgs,
|
|
51
|
+
landmarks,
|
|
52
|
+
nestedLandmarks,
|
|
53
|
+
ids,
|
|
54
|
+
idRefs
|
|
55
|
+
} = parseHtmlHead(html);
|
|
41
56
|
const head = { route, source: "rendered", tags, file: route };
|
|
42
57
|
const headings = [
|
|
43
58
|
{ route, headings: levels.map((level) => ({ level, line: 0, file: route })) }
|
|
44
59
|
];
|
|
45
60
|
const images = [{ route, images: imgs.map((img) => ({ ...img, file: route })) }];
|
|
61
|
+
const a11y = [
|
|
62
|
+
{
|
|
63
|
+
route,
|
|
64
|
+
landmarks: toOccurrenceMap(landmarks, route),
|
|
65
|
+
nestedLandmarks: nestedLandmarks.map((n) => ({ ...n, file: route, line: 0 })),
|
|
66
|
+
ids: toOccurrenceMap(ids, route),
|
|
67
|
+
idRefs: idRefs.map((r) => ({ ...r, file: route, line: 0 })),
|
|
68
|
+
idCandidates: [...new Set(ids)],
|
|
69
|
+
fullyResolved: true
|
|
70
|
+
}
|
|
71
|
+
];
|
|
46
72
|
const project = { hasRobotsTxt: true, hasSitemap: true, htmlLang };
|
|
47
73
|
const { results: ruleResults, failedRules } = await runRules(rules, {
|
|
48
74
|
heads: [head],
|
|
49
75
|
headings,
|
|
50
76
|
images,
|
|
77
|
+
a11y,
|
|
51
78
|
project,
|
|
52
79
|
config
|
|
53
80
|
});
|
|
54
81
|
const results = applyRuleSeverities(ruleResults, config);
|
|
82
|
+
const failedRuleIds = failedRules.map((f) => f.id);
|
|
55
83
|
if (failedRules.length > 0 && globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
|
|
56
|
-
for (const f of failedRules)
|
|
84
|
+
for (const f of failedRules) warn(formatFailedRuleWarning(f));
|
|
57
85
|
}
|
|
58
|
-
const signature = findingSignature(results, config)
|
|
86
|
+
const signature = `${findingSignature(results, config)}|failed:${[...failedRuleIds].sort().join(",")}`;
|
|
59
87
|
if (lastSignature.get(route) === signature) return;
|
|
60
88
|
lastSignature.set(route, signature);
|
|
61
|
-
if (globalThis.process?.env?.SVELTE_VITALS_UI) void postIngest(origin, route, results);
|
|
89
|
+
if (globalThis.process?.env?.SVELTE_VITALS_UI) void postIngest(origin, route, results, failedRuleIds);
|
|
62
90
|
} catch (err) {
|
|
63
91
|
if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
|
|
64
|
-
|
|
92
|
+
warn(`[svelte-vitals] dev analysis failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
93
|
}
|
|
66
94
|
}
|
|
67
95
|
}
|
|
@@ -78,15 +106,8 @@ function svelteVitalsHandle(options = {}) {
|
|
|
78
106
|
return resolve(event, {
|
|
79
107
|
transformPageChunk: ({ html, done }) => {
|
|
80
108
|
buffer += html;
|
|
81
|
-
if (done)
|
|
82
|
-
void analyzeAndIngest(
|
|
83
|
-
buffer,
|
|
84
|
-
event.route.id ?? event.url.pathname,
|
|
85
|
-
event.url.origin,
|
|
86
|
-
rules,
|
|
87
|
-
config,
|
|
88
|
-
lastSignature
|
|
89
|
-
);
|
|
109
|
+
if (done && event.route.id != null)
|
|
110
|
+
void analyzeAndIngest(buffer, event.route.id, event.url.origin, rules, config, lastSignature);
|
|
90
111
|
return html;
|
|
91
112
|
}
|
|
92
113
|
});
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
isLoopbackOrigin
|
|
4
|
-
|
|
5
|
-
} from "./chunk-2LDMPITM.js";
|
|
2
|
+
collectRenderedHeads,
|
|
3
|
+
isLoopbackOrigin
|
|
4
|
+
} from "./chunk-VLQYXCE6.js";
|
|
6
5
|
|
|
7
6
|
// src/plugin.ts
|
|
8
7
|
import { existsSync } from "fs";
|
|
9
8
|
import { writeFile, mkdir } from "fs/promises";
|
|
10
|
-
import { join as
|
|
9
|
+
import { join as join3, isAbsolute, dirname, relative as relative3, basename, sep as sep2 } from "path";
|
|
11
10
|
import {
|
|
12
11
|
CATEGORIES as CATEGORIES2,
|
|
13
12
|
defaultConfig,
|
|
14
13
|
resolveRuleOptions,
|
|
15
14
|
shouldSkipRangeCheck,
|
|
15
|
+
terminalSafe,
|
|
16
16
|
validateRuleSetting
|
|
17
17
|
} from "@svelte-vitals/core";
|
|
18
18
|
import { findUnknownRuleIds, knownRuleIds, ruleOptionsSpec } from "svelte-vitals";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
applyOverrides,
|
|
26
26
|
runRules,
|
|
27
27
|
withFailedRulesOff,
|
|
28
|
+
formatFailedRuleWarning,
|
|
28
29
|
computeScore,
|
|
29
30
|
summarize,
|
|
30
31
|
hasFailureAtOrAbove,
|
|
@@ -34,47 +35,9 @@ import {
|
|
|
34
35
|
} from "@svelte-vitals/core";
|
|
35
36
|
import { loadConfigFile } from "svelte-vitals";
|
|
36
37
|
|
|
37
|
-
// src/providers/rendered/collect.ts
|
|
38
|
-
import { readFile } from "fs/promises";
|
|
39
|
-
import { join } from "path";
|
|
40
|
-
import { glob } from "tinyglobby";
|
|
41
|
-
function deriveRouteFromHtmlPath(relPath) {
|
|
42
|
-
let p = relPath.replace(/\\/g, "/").replace(/\.html$/, "");
|
|
43
|
-
if (p === "index") return "/";
|
|
44
|
-
if (p.endsWith("/index")) p = p.slice(0, -"/index".length);
|
|
45
|
-
return "/" + p;
|
|
46
|
-
}
|
|
47
|
-
async function collectRenderedHeads(prerenderPagesDir) {
|
|
48
|
-
const files = (await glob("**/*.html", { cwd: prerenderPagesDir })).sort();
|
|
49
|
-
const parsedFiles = await Promise.all(
|
|
50
|
-
files.map(async (rel) => ({ rel, parsed: parseHtmlHead(await readFile(join(prerenderPagesDir, rel), "utf8")) }))
|
|
51
|
-
);
|
|
52
|
-
const heads = [];
|
|
53
|
-
const headings = [];
|
|
54
|
-
const images = [];
|
|
55
|
-
let htmlLang = { presence: "none", value: "absent" };
|
|
56
|
-
for (const { rel, parsed } of parsedFiles) {
|
|
57
|
-
if (htmlLang.presence === "none" && parsed.htmlLang.presence === "own") htmlLang = parsed.htmlLang;
|
|
58
|
-
const route = deriveRouteFromHtmlPath(rel);
|
|
59
|
-
heads.push({
|
|
60
|
-
route,
|
|
61
|
-
source: "rendered",
|
|
62
|
-
tags: parsed.tags,
|
|
63
|
-
file: rel
|
|
64
|
-
});
|
|
65
|
-
headings.push({
|
|
66
|
-
route,
|
|
67
|
-
// Rendered mode does not track source lines (line 0 = unknown); file is the HTML path.
|
|
68
|
-
headings: parsed.headings.map((level) => ({ level, line: 0, file: rel }))
|
|
69
|
-
});
|
|
70
|
-
images.push({ route, images: parsed.images.map((img) => ({ ...img, file: rel })) });
|
|
71
|
-
}
|
|
72
|
-
return { heads, headings, images, htmlLang };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
38
|
// src/providers/rendered/project.ts
|
|
76
|
-
import { access, readFile
|
|
77
|
-
import { join
|
|
39
|
+
import { access, readFile } from "fs/promises";
|
|
40
|
+
import { join } from "path";
|
|
78
41
|
import {
|
|
79
42
|
ROBOTS_SOURCE_PATHS,
|
|
80
43
|
SITEMAP_SOURCE_PATHS,
|
|
@@ -92,22 +55,22 @@ async function exists(path) {
|
|
|
92
55
|
}
|
|
93
56
|
}
|
|
94
57
|
async function existsAny(cwd, paths) {
|
|
95
|
-
const found = await Promise.all(paths.map((p) => exists(
|
|
58
|
+
const found = await Promise.all(paths.map((p) => exists(join(cwd, p))));
|
|
96
59
|
return found.some(Boolean);
|
|
97
60
|
}
|
|
98
61
|
async function robotsRefsSitemap(cwd) {
|
|
99
62
|
try {
|
|
100
|
-
return /^\s*sitemap:/im.test(await
|
|
63
|
+
return /^\s*sitemap:/im.test(await readFile(join(cwd, "static/robots.txt"), "utf8"));
|
|
101
64
|
} catch {
|
|
102
65
|
return void 0;
|
|
103
66
|
}
|
|
104
67
|
}
|
|
105
68
|
async function readFirstConfig(cwd, files) {
|
|
106
69
|
for (const file of files) {
|
|
107
|
-
const path =
|
|
70
|
+
const path = join(cwd, file);
|
|
108
71
|
if (!await exists(path)) continue;
|
|
109
72
|
try {
|
|
110
|
-
return { file, source: await
|
|
73
|
+
return { file, source: await readFile(path, "utf8") };
|
|
111
74
|
} catch {
|
|
112
75
|
return void 0;
|
|
113
76
|
}
|
|
@@ -135,16 +98,16 @@ async function collectRenderedProject(cwd, htmlLang) {
|
|
|
135
98
|
}
|
|
136
99
|
|
|
137
100
|
// src/providers/source/components.ts
|
|
138
|
-
import { readFile as
|
|
139
|
-
import { join as
|
|
140
|
-
import { glob
|
|
101
|
+
import { readFile as readFile2, access as access2 } from "fs/promises";
|
|
102
|
+
import { join as join2 } from "path";
|
|
103
|
+
import { glob } from "tinyglobby";
|
|
141
104
|
import {
|
|
142
105
|
collectComponentFacts as collect,
|
|
143
106
|
collectKitModuleFacts as collectKit,
|
|
144
107
|
collectSourceFiles as collectFiles
|
|
145
108
|
} from "@svelte-vitals/core";
|
|
146
109
|
var nodeRuntime = {
|
|
147
|
-
readFile: (path) =>
|
|
110
|
+
readFile: (path) => readFile2(path, "utf8"),
|
|
148
111
|
async exists(path) {
|
|
149
112
|
try {
|
|
150
113
|
await access2(path);
|
|
@@ -153,8 +116,8 @@ var nodeRuntime = {
|
|
|
153
116
|
return false;
|
|
154
117
|
}
|
|
155
118
|
},
|
|
156
|
-
glob: (pattern, cwd) =>
|
|
157
|
-
join: (...parts) =>
|
|
119
|
+
glob: (pattern, cwd) => glob(pattern, { cwd, dot: false }),
|
|
120
|
+
join: (...parts) => join2(...parts)
|
|
158
121
|
};
|
|
159
122
|
function collectComponentFacts(root) {
|
|
160
123
|
return collect(nodeRuntime, root);
|
|
@@ -206,7 +169,7 @@ async function resolveConfig(cwd, options) {
|
|
|
206
169
|
}
|
|
207
170
|
async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resolved) {
|
|
208
171
|
const { config, warnings } = resolved ?? await resolveConfig(cwd, options);
|
|
209
|
-
const [{ heads, headings, images, htmlLang }, components, sourceFiles] = await Promise.all([
|
|
172
|
+
const [{ heads, headings, images, a11y, htmlLang }, components, sourceFiles] = await Promise.all([
|
|
210
173
|
collectRenderedHeads(prerenderPagesDir),
|
|
211
174
|
collectComponentFacts(cwd),
|
|
212
175
|
collectSourceFiles(cwd)
|
|
@@ -225,6 +188,7 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
|
|
|
225
188
|
heads,
|
|
226
189
|
headings,
|
|
227
190
|
images,
|
|
191
|
+
a11y,
|
|
228
192
|
project,
|
|
229
193
|
components,
|
|
230
194
|
config,
|
|
@@ -232,7 +196,7 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
|
|
|
232
196
|
sourceFiles
|
|
233
197
|
});
|
|
234
198
|
const results = applyOverrides(applyRuleSeverities(rawResults, config), config);
|
|
235
|
-
for (const f of failedRules) warnings.push(
|
|
199
|
+
for (const f of failedRules) warnings.push(formatFailedRuleWarning(f));
|
|
236
200
|
const scoringConfig = withFailedRulesOff(
|
|
237
201
|
config,
|
|
238
202
|
failedRules.map((f) => f.id)
|
|
@@ -241,7 +205,7 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
|
|
|
241
205
|
const summary = summarize(results, scoringConfig);
|
|
242
206
|
const failed = hasFailureAtOrAbove(summary, scoringConfig.failOn);
|
|
243
207
|
const coverageNote = `Analyzed ${heads.length} prerendered route(s). SSR/dynamic routes are not covered \u2014 run \`npx svelte-vitals\` for those.
|
|
244
|
-
Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Bundle findings.`;
|
|
208
|
+
Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Accessibility/Bundle findings.`;
|
|
245
209
|
const consoleReport = formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n";
|
|
246
210
|
const jsonReport = formatJsonReport(
|
|
247
211
|
results,
|
|
@@ -264,7 +228,7 @@ Scanned ${components.length} component(s) under src/ for Correctness/Security/Ar
|
|
|
264
228
|
}
|
|
265
229
|
|
|
266
230
|
// src/minify-flag.ts
|
|
267
|
-
import { readFile as
|
|
231
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
268
232
|
import { relative } from "path";
|
|
269
233
|
import { findMinifyDisabled } from "@svelte-vitals/core";
|
|
270
234
|
async function resolveMinifyDisabled(minify, configFile, root) {
|
|
@@ -274,7 +238,7 @@ async function resolveMinifyDisabled(minify, configFile, root) {
|
|
|
274
238
|
const file = rel === "" ? configFile.split("\\").join("/") : rel;
|
|
275
239
|
let line;
|
|
276
240
|
try {
|
|
277
|
-
line = findMinifyDisabled(await
|
|
241
|
+
line = findMinifyDisabled(await readFile3(configFile, "utf8"))?.line;
|
|
278
242
|
} catch {
|
|
279
243
|
}
|
|
280
244
|
return { file, ...line !== void 0 ? { line } : {} };
|
|
@@ -284,7 +248,11 @@ async function resolveMinifyDisabled(minify, configFile, root) {
|
|
|
284
248
|
import { CATEGORIES, renderAppShell } from "@svelte-vitals/core";
|
|
285
249
|
|
|
286
250
|
// src/ui/snapshot.ts
|
|
287
|
-
import {
|
|
251
|
+
import {
|
|
252
|
+
buildJsonReport,
|
|
253
|
+
safeHref,
|
|
254
|
+
withFailedRulesOff as withFailedRulesOff2
|
|
255
|
+
} from "@svelte-vitals/core";
|
|
288
256
|
function sanitizeDocsUrl(issue) {
|
|
289
257
|
if (issue.docsUrl === void 0) return issue;
|
|
290
258
|
if (safeHref(issue.docsUrl) !== null) return issue;
|
|
@@ -297,12 +265,14 @@ function sanitizeReport(report) {
|
|
|
297
265
|
siteIssues: report.siteIssues.map(sanitizeDocsUrl)
|
|
298
266
|
};
|
|
299
267
|
}
|
|
300
|
-
function buildSnapshot(store, config, meta) {
|
|
268
|
+
function buildSnapshot(store, config, meta, staticFailedRuleIds) {
|
|
269
|
+
const failedRuleIds = [.../* @__PURE__ */ new Set([...staticFailedRuleIds ?? [], ...store.failedRuleIds()])];
|
|
270
|
+
const scoringConfig = withFailedRulesOff2(config, failedRuleIds);
|
|
301
271
|
return {
|
|
302
272
|
// No rule-id list threaded through: `report.rules` is seeded from `store.snapshot()`
|
|
303
273
|
// alone here, so presence means "produced a result", not "was selected" — unlike the
|
|
304
274
|
// `json` reporter (design doc 2026-08-03-json-rule-evidence-design.md, Not in scope).
|
|
305
|
-
report: sanitizeReport(buildJsonReport(store.snapshot(),
|
|
275
|
+
report: sanitizeReport(buildJsonReport(store.snapshot(), scoringConfig, meta)),
|
|
306
276
|
badges: store.badges(),
|
|
307
277
|
analyzing: store.isAnalyzing(),
|
|
308
278
|
sequence: store.sequence(),
|
|
@@ -324,7 +294,7 @@ function isResultLike(x) {
|
|
|
324
294
|
const f = r.fix;
|
|
325
295
|
return typeof r.id === "string" && typeof r.message === "string" && typeof r.severity === "string" && SEVERITIES.has(r.severity) && typeof d === "object" && d !== null && typeof d.presence === "string" && typeof d.value === "string" && (r.category === void 0 || typeof r.category === "string" && CATEGORY_SET.has(r.category)) && isOptionalString(r.location) && isOptionalString(r.recommendation) && isOptionalString(r.docsUrl) && (f === void 0 || typeof f === "object" && f !== null && typeof f.description === "string" && isOptionalString(f.snippet) && isOptionalString(f.lang)) && (r.line === void 0 || typeof r.line === "number") && isOptionalString(r.route);
|
|
326
296
|
}
|
|
327
|
-
function installUiMiddleware(server, config, version, store, coreVersion) {
|
|
297
|
+
function installUiMiddleware(server, config, version, store, coreVersion, getStaticFailedRuleIds) {
|
|
328
298
|
const clients = /* @__PURE__ */ new Set();
|
|
329
299
|
store.subscribe(() => {
|
|
330
300
|
for (const res of clients) {
|
|
@@ -343,7 +313,7 @@ function installUiMiddleware(server, config, version, store, coreVersion) {
|
|
|
343
313
|
const url = req.url ?? "/";
|
|
344
314
|
const origin = req.headers.origin;
|
|
345
315
|
const host = req.headers.host;
|
|
346
|
-
if (typeof origin === "string" && !isLoopbackOrigin(origin) || !
|
|
316
|
+
if (typeof origin === "string" && !isLoopbackOrigin(origin) || host === void 0 || !isLoopbackOrigin(`http://${host}`)) {
|
|
347
317
|
req.resume();
|
|
348
318
|
res.statusCode = 403;
|
|
349
319
|
res.end("svelte-vitals dev UI is only available from localhost");
|
|
@@ -354,9 +324,10 @@ function installUiMiddleware(server, config, version, store, coreVersion) {
|
|
|
354
324
|
req.on("data", (c) => chunks.push(c));
|
|
355
325
|
req.on("end", () => {
|
|
356
326
|
try {
|
|
357
|
-
const { route, results } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
327
|
+
const { route, results, failedRuleIds } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
358
328
|
if (typeof route === "string" && Array.isArray(results)) {
|
|
359
|
-
|
|
329
|
+
const failedIds = Array.isArray(failedRuleIds) ? failedRuleIds.filter((id) => typeof id === "string") : [];
|
|
330
|
+
store.set(route, results.filter(isResultLike), failedIds);
|
|
360
331
|
}
|
|
361
332
|
} catch {
|
|
362
333
|
}
|
|
@@ -378,7 +349,7 @@ function installUiMiddleware(server, config, version, store, coreVersion) {
|
|
|
378
349
|
}
|
|
379
350
|
if (url.startsWith("/data.json")) {
|
|
380
351
|
try {
|
|
381
|
-
const snapshot = buildSnapshot(store, config, { version, coreVersion });
|
|
352
|
+
const snapshot = buildSnapshot(store, config, { version, coreVersion }, getStaticFailedRuleIds?.());
|
|
382
353
|
res.setHeader("Content-Type", "application/json");
|
|
383
354
|
res.end(JSON.stringify(snapshot));
|
|
384
355
|
} catch {
|
|
@@ -388,7 +359,7 @@ function installUiMiddleware(server, config, version, store, coreVersion) {
|
|
|
388
359
|
return;
|
|
389
360
|
}
|
|
390
361
|
try {
|
|
391
|
-
const html = renderAppShell(buildSnapshot(store, config, { version, coreVersion }));
|
|
362
|
+
const html = renderAppShell(buildSnapshot(store, config, { version, coreVersion }, getStaticFailedRuleIds?.()));
|
|
392
363
|
res.setHeader("Content-Type", "text/html");
|
|
393
364
|
res.end(html);
|
|
394
365
|
} catch {
|
|
@@ -432,6 +403,7 @@ function composeBadges(staticResults, liveByRoute) {
|
|
|
432
403
|
function createStore() {
|
|
433
404
|
let staticResults = [];
|
|
434
405
|
const liveByRoute = /* @__PURE__ */ new Map();
|
|
406
|
+
const liveFailedByRoute = /* @__PURE__ */ new Map();
|
|
435
407
|
const subs = /* @__PURE__ */ new Set();
|
|
436
408
|
let analyzing = false;
|
|
437
409
|
let seq = 0;
|
|
@@ -440,11 +412,13 @@ function createStore() {
|
|
|
440
412
|
for (const fn of subs) fn();
|
|
441
413
|
}
|
|
442
414
|
return {
|
|
443
|
-
set(route, results) {
|
|
415
|
+
set(route, results, failedRuleIds) {
|
|
444
416
|
liveByRoute.set(
|
|
445
417
|
route,
|
|
446
418
|
results.map((r) => r.route ? r : { ...r, route })
|
|
447
419
|
);
|
|
420
|
+
if (failedRuleIds && failedRuleIds.length > 0) liveFailedByRoute.set(route, failedRuleIds);
|
|
421
|
+
else liveFailedByRoute.delete(route);
|
|
448
422
|
notify();
|
|
449
423
|
},
|
|
450
424
|
setStatic(results) {
|
|
@@ -464,6 +438,9 @@ function createStore() {
|
|
|
464
438
|
badges() {
|
|
465
439
|
return composeBadges(staticResults, liveByRoute);
|
|
466
440
|
},
|
|
441
|
+
failedRuleIds() {
|
|
442
|
+
return [...new Set([...liveFailedByRoute.values()].flat())].sort();
|
|
443
|
+
},
|
|
467
444
|
sequence() {
|
|
468
445
|
return seq;
|
|
469
446
|
},
|
|
@@ -476,28 +453,21 @@ function createStore() {
|
|
|
476
453
|
|
|
477
454
|
// src/ui/analysis.ts
|
|
478
455
|
import { relative as relative2, sep } from "path";
|
|
456
|
+
import { analyzeProject } from "svelte-vitals";
|
|
479
457
|
function createAnalysisRunner(opts) {
|
|
480
458
|
const debounceMs = opts.debounceMs ?? 500;
|
|
481
|
-
|
|
459
|
+
const analyze2 = opts.analyze ?? analyzeProject;
|
|
482
460
|
const parseCache = /* @__PURE__ */ new Map();
|
|
483
461
|
let stopped = false;
|
|
484
462
|
let running = false;
|
|
485
463
|
let pending = false;
|
|
486
464
|
let timer;
|
|
487
|
-
async function getAnalyze() {
|
|
488
|
-
if (cachedAnalyze) return cachedAnalyze;
|
|
489
|
-
const mod = await import("svelte-vitals");
|
|
490
|
-
const fn = (o) => mod.analyzeProject(o);
|
|
491
|
-
cachedAnalyze = fn;
|
|
492
|
-
return fn;
|
|
493
|
-
}
|
|
494
465
|
async function runOnce() {
|
|
495
466
|
if (stopped) return;
|
|
496
467
|
running = true;
|
|
497
468
|
opts.onStatusChange?.(true);
|
|
498
469
|
try {
|
|
499
|
-
const
|
|
500
|
-
const { results } = await analyze2({
|
|
470
|
+
const { results, failedRuleIds } = await analyze2({
|
|
501
471
|
cwd: opts.root,
|
|
502
472
|
treatDynamicAs: opts.treatDynamicAs,
|
|
503
473
|
metaComponents: opts.metaComponents,
|
|
@@ -505,7 +475,10 @@ function createAnalysisRunner(opts) {
|
|
|
505
475
|
failOn: opts.failOn,
|
|
506
476
|
parseCache
|
|
507
477
|
});
|
|
508
|
-
if (!stopped)
|
|
478
|
+
if (!stopped) {
|
|
479
|
+
if (failedRuleIds !== void 0) opts.onResults(results, failedRuleIds);
|
|
480
|
+
else opts.onResults(results);
|
|
481
|
+
}
|
|
509
482
|
} catch (err) {
|
|
510
483
|
if (!stopped) opts.onError(err);
|
|
511
484
|
} finally {
|
|
@@ -560,6 +533,7 @@ var CONFIG_BASENAMES = /* @__PURE__ */ new Set([
|
|
|
560
533
|
"vite.config.cts"
|
|
561
534
|
]);
|
|
562
535
|
var IGNORED_SEGMENTS = /* @__PURE__ */ new Set(["node_modules", ".svelte-kit", "build", "dist"]);
|
|
536
|
+
var warn = (line) => console.warn(terminalSafe(line));
|
|
563
537
|
function isRelevant(file, root) {
|
|
564
538
|
const rel = relative3(root, file);
|
|
565
539
|
if (rel === "" || rel.startsWith("..")) return false;
|
|
@@ -625,8 +599,8 @@ function svelteVitals(options = {}) {
|
|
|
625
599
|
if (!config.build.ssr) minifyFlag = { minify: config.build.minify, configFile: config.configFile };
|
|
626
600
|
},
|
|
627
601
|
async closeBundle() {
|
|
628
|
-
const pagesDir = options.prerenderDir ? options.prerenderDir :
|
|
629
|
-
const resolved = isAbsolute(pagesDir) ? pagesDir :
|
|
602
|
+
const pagesDir = options.prerenderDir ? options.prerenderDir : join3(root, DEFAULT_PRERENDER_DIR);
|
|
603
|
+
const resolved = isAbsolute(pagesDir) ? pagesDir : join3(root, pagesDir);
|
|
630
604
|
if (!existsSync(resolved)) return;
|
|
631
605
|
const resolvedConfig = await resolveConfig(root, options);
|
|
632
606
|
let result;
|
|
@@ -640,17 +614,17 @@ function svelteVitals(options = {}) {
|
|
|
640
614
|
resolvedConfig
|
|
641
615
|
);
|
|
642
616
|
} catch (err) {
|
|
643
|
-
|
|
617
|
+
warn(`svelte-vitals: skipped \u2014 analysis failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
644
618
|
return;
|
|
645
619
|
}
|
|
646
|
-
for (const w of result.warnings)
|
|
620
|
+
for (const w of result.warnings) warn(`svelte-vitals: ${w}`);
|
|
647
621
|
if (result.routeCount === 0) return;
|
|
648
622
|
if (options.report !== false) {
|
|
649
623
|
const out = options.report === "json" ? result.jsonReport : result.consoleReport;
|
|
650
624
|
console.log(out);
|
|
651
625
|
}
|
|
652
626
|
if (options.outFile) {
|
|
653
|
-
const outPath = isAbsolute(options.outFile) ? options.outFile :
|
|
627
|
+
const outPath = isAbsolute(options.outFile) ? options.outFile : join3(root, options.outFile);
|
|
654
628
|
await mkdir(dirname(outPath), { recursive: true });
|
|
655
629
|
await writeFile(outPath, result.jsonReport);
|
|
656
630
|
}
|
|
@@ -671,22 +645,26 @@ function svelteVitals(options = {}) {
|
|
|
671
645
|
try {
|
|
672
646
|
({ config, warnings } = await resolveConfig(uiRoot, options));
|
|
673
647
|
} catch (err) {
|
|
674
|
-
|
|
648
|
+
warn(
|
|
675
649
|
`svelte-vitals: config file invalid \u2014 dashboard using plugin options/defaults: ${err instanceof Error ? err.message : String(err)}`
|
|
676
650
|
);
|
|
677
651
|
config = mergeConfig(options, void 0);
|
|
678
652
|
warnings = [];
|
|
679
653
|
}
|
|
680
|
-
for (const w of warnings)
|
|
654
|
+
for (const w of warnings) warn(`svelte-vitals: ${w}`);
|
|
681
655
|
const store = createStore();
|
|
656
|
+
let staticFailedRuleIds = [];
|
|
682
657
|
const runner = createAnalysisRunner({
|
|
683
658
|
root: uiRoot,
|
|
684
659
|
treatDynamicAs: options.treatDynamicAs,
|
|
685
660
|
metaComponents: options.metaComponents,
|
|
686
661
|
rules: options.rules,
|
|
687
662
|
failOn: options.failOn,
|
|
688
|
-
onResults: (results) =>
|
|
689
|
-
|
|
663
|
+
onResults: (results, failedRuleIds) => {
|
|
664
|
+
store.setStatic(results);
|
|
665
|
+
staticFailedRuleIds = failedRuleIds ?? [];
|
|
666
|
+
},
|
|
667
|
+
onError: (err) => warn(`[svelte-vitals] dev analysis failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
690
668
|
onStatusChange: (analyzing) => store.setAnalyzing(analyzing)
|
|
691
669
|
});
|
|
692
670
|
runner.start();
|
|
@@ -697,7 +675,7 @@ function svelteVitals(options = {}) {
|
|
|
697
675
|
delete process.env.SVELTE_VITALS_UI;
|
|
698
676
|
runner.stop();
|
|
699
677
|
});
|
|
700
|
-
installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion());
|
|
678
|
+
installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion(), () => staticFailedRuleIds);
|
|
701
679
|
if (typeof server.printUrls === "function") {
|
|
702
680
|
const printUrls = server.printUrls.bind(server);
|
|
703
681
|
server.printUrls = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svelte-vitals/vite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
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,8 +42,8 @@
|
|
|
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.43.0",
|
|
46
|
+
"svelte-vitals": "0.47.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@sveltejs/kit": "^2.70.2",
|