@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 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's `<head>`**, and runs the same rule engine as the CLI (SEO, Performance, Correctness, Security, Architecture) — library-agnostic, because it inspects the real output. Fails the build when findings reach `failOn`.
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
- return { tags, htmlLang, headings, images };
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
- isLoopbackOrigin,
140
- isLoopbackHost
236
+ toOccurrenceMap,
237
+ collectRenderedHeads,
238
+ isLoopbackOrigin
141
239
  };
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  isLoopbackOrigin,
3
- parseHtmlHead
4
- } from "../chunk-2LDMPITM.js";
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
- async function postIngest(origin, route, results) {
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
- console.warn(
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
- body: JSON.stringify({ route, results })
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 { tags, htmlLang, headings: levels, images: imgs } = parseHtmlHead(html);
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) console.warn(`[svelte-vitals] rule ${f.id} failed and was skipped: ${f.message}`);
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
- console.warn("[svelte-vitals] dev analysis failed:", err);
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
- isLoopbackHost,
3
- isLoopbackOrigin,
4
- parseHtmlHead
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 join4, isAbsolute, dirname, relative as relative3, basename, sep as sep2 } from "path";
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 as readFile2 } from "fs/promises";
77
- import { join as join2 } from "path";
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(join2(cwd, p))));
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 readFile2(join2(cwd, "static/robots.txt"), "utf8"));
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 = join2(cwd, file);
70
+ const path = join(cwd, file);
108
71
  if (!await exists(path)) continue;
109
72
  try {
110
- return { file, source: await readFile2(path, "utf8") };
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 readFile3, access as access2 } from "fs/promises";
139
- import { join as join3 } from "path";
140
- import { glob as glob2 } from "tinyglobby";
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) => readFile3(path, "utf8"),
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) => glob2(pattern, { cwd, dot: false }),
157
- join: (...parts) => join3(...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(`rule ${f.id} failed and was skipped: ${f.message.split("\n")[0]}`);
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 readFile4 } from "fs/promises";
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 readFile4(configFile, "utf8"))?.line;
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 { buildJsonReport, safeHref } from "@svelte-vitals/core";
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(), config, meta)),
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) || !isLoopbackHost(host)) {
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
- store.set(route, results.filter(isResultLike));
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
- let cachedAnalyze = opts.analyze;
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 analyze2 = await getAnalyze();
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) opts.onResults(results);
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 : join4(root, DEFAULT_PRERENDER_DIR);
629
- const resolved = isAbsolute(pagesDir) ? pagesDir : join4(root, 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
- console.warn(`svelte-vitals: skipped \u2014 analysis failed: ${err instanceof Error ? err.message : String(err)}`);
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) console.warn(`svelte-vitals: ${w}`);
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 : join4(root, 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
- console.warn(
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) console.warn(`svelte-vitals: ${w}`);
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) => store.setStatic(results),
689
- onError: (err) => console.warn("[svelte-vitals] dev analysis failed:", err),
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.29.6",
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.41.1",
46
- "svelte-vitals": "0.45.1"
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",