@svelte-vitals/vite 0.54.5 → 0.54.7

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
@@ -34,7 +34,9 @@ export default {
34
34
  - `cwd`: project root (defaults to the Vite config root).
35
35
  - `ui`: serve the live dev dashboard (default `true`); pass `false` to keep only the build-time gate.
36
36
 
37
- At build time the HTML check covers prerendered routes only; the source scan applies project-wide. SSR and dynamic routes have no build output, so nothing checks their rendered HTML here. The `svelte-vitals` CLI covers them by source analysis, and browsing them during `vite dev` gets the live dashboard a rendered reading.
37
+ A `svelte-vitals.config.*` and a `svelte-vitals-suppressions.json` in the project root are read automatically (explicit options win over the config file). Only source-scan suppression entries apply; route-level entries are skipped on every plugin surface (rendered route findings anchor to the built HTML) and the plugin says how many.
38
+
39
+ At build time the HTML check covers prerendered routes only; the source scan applies project-wide. Routes that are not prerendered (server-rendered, or dynamic routes without prerendered entries) have no build output, so nothing checks their rendered HTML here. A prerendered route with `ssr = false` has build output, but only the empty app shell, so the HTML check skips it too (a `+layout` with `ssr = false` skips its subtree) and prints one warning listing the skipped routes. The `svelte-vitals` CLI covers them by source analysis, and browsing them during `vite dev` gets the live dashboard a rendered reading.
38
40
 
39
41
  See [Live dashboard](https://oekazuma.github.io/svelte-vitals/guides/dev-dashboard/) and [Plugin mode](https://oekazuma.github.io/svelte-vitals/guides/plugin-mode/) for the full picture.
40
42
 
@@ -14,6 +14,6 @@ interface SvelteVitalsHookOptions {
14
14
  * route's dashboard findings from static (source-only) to `measured` (real rendered
15
15
  * HTML). Add it to `src/hooks.server.ts`, e.g. `sequence(svelteVitalsHandle())`.
16
16
  */
17
- declare function svelteVitalsHandle(options?: SvelteVitalsHookOptions): Handle;
17
+ export declare function svelteVitalsHandle(options?: SvelteVitalsHookOptions): Handle;
18
18
  //#endregion
19
- export { type SvelteVitalsHookOptions, svelteVitalsHandle };
19
+ export type { SvelteVitalsHookOptions };
package/dist/index.d.ts CHANGED
@@ -28,6 +28,6 @@ interface SvelteVitalsOptions {
28
28
  ui?: boolean;
29
29
  }
30
30
  /** svelte-vitals Vite/SvelteKit plugin. */
31
- declare function svelteVitals(options?: SvelteVitalsOptions): Plugin | Plugin[];
31
+ export declare function svelteVitals(options?: SvelteVitalsOptions): Plugin | Plugin[];
32
32
  //#endregion
33
- export { type SvelteVitalsOptions, svelteVitals };
33
+ export type { SvelteVitalsOptions };
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
5
  import { CATEGORIES, defineConfig } from "@svelte-vitals/core";
6
6
  import { ROBOTS_SOURCE_PATHS, SITEMAP_SOURCE_PATHS, SVELTE_CONFIG_FILES, VITE_CONFIG_FILES, addFactsDirectives, allRules, buildJsonReport, collectComponentFacts, collectKitModuleFacts, collectSourceFiles, collectSuppressions, computeScore, defaultConfig, findMinifyDisabled, formatConsoleReport, formatFailedRuleWarning, formatJsonReport, hasFailureAtOrAbove, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveRuleOptions, runAnalysis, safeHref, selectRules, shouldSkipRangeCheck, skippedFileWarnings, summarize, terminalSafe, unknownDirectiveIds, validateRuleSetting, withFailedRulesOff } from "@svelte-vitals/core/internal";
7
- import { CONFIG_FILENAMES, analyzeProject, createNodeRuntime, findUnknownRuleIds, knownRuleIds, loadConfigFile, registryTag, ruleOptionsSpec } from "svelte-vitals";
7
+ import { CONFIG_FILENAMES, SUPPRESSIONS_FILE, analyzeProject, applySuppressions, createNodeRuntime, findUnknownRuleIds, knownRuleIds, loadConfigFile, loadSuppressions, registryTag, ruleOptionsSpec } from "svelte-vitals";
8
8
  //#region src/providers/rendered/collect.ts
9
9
  /** Map a prerendered HTML path (relative to pages/, POSIX) to its route. */
10
10
  function deriveRouteFromHtmlPath(relPath) {
@@ -157,6 +157,29 @@ function collectSourceFiles$1(root) {
157
157
  return collectSourceFiles(nodeRuntime, root);
158
158
  }
159
159
  //#endregion
160
+ //#region src/suppressions.ts
161
+ /**
162
+ * Apply `svelte-vitals-suppressions.json` the way every plugin surface must: source-scan entries
163
+ * only. A route-level entry (route starting with `/`) is written by the CLI against
164
+ * `src/routes/…/+page.svelte`, while the build gate's rendered findings anchor to the built HTML
165
+ * and the dashboard's live layer to the route itself — the gate could never match it, and the
166
+ * dashboard's static layer would drop it only until the route was browsed and the live result
167
+ * re-surfaced it. Skipping them everywhere keeps Health identical across surfaces; the caller
168
+ * gets one line per fact to print. Stale entries are not counted: pruning stays with the CLI.
169
+ */
170
+ function applySourceSuppressions(results, entries, config) {
171
+ const notices = [];
172
+ const source = entries.filter((e) => !e.route?.startsWith("/"));
173
+ const routeLevel = entries.length - source.length;
174
+ const applied = applySuppressions(results, source, config);
175
+ if (applied.suppressed > 0) notices.push(`${applied.suppressed} finding(s) suppressed by ${SUPPRESSIONS_FILE}.`);
176
+ if (routeLevel > 0) notices.push(`${routeLevel} route-level ${routeLevel === 1 ? "entry does" : "entries do"} not apply to the plugin (${SUPPRESSIONS_FILE}) — rendered route findings anchor to the built HTML; use \`overrides\` for those.`);
177
+ return {
178
+ results: applied.results,
179
+ notices
180
+ };
181
+ }
182
+ //#endregion
160
183
  //#region src/version.ts
161
184
  /** Read this package's version from its own package.json so the report never drifts. */
162
185
  function readPackageVersion() {
@@ -222,24 +245,75 @@ async function resolveConfig(cwd, options) {
222
245
  };
223
246
  }
224
247
  /**
248
+ * `ssr` is a page option: only `+page`/`+layout` modules (universal or server) can set it. Same
249
+ * file set as `PAGE_OPTION_FILE_RE` in core's `rules/seo/ssr-disabled.ts`, which reports the fact.
250
+ */
251
+ const PAGE_OPTION_FILE_RE = /^src\/routes\/(.*?)\/?\+(page|layout)(?:\.server)?\.[jt]s$/;
252
+ function escapeRegExp(s) {
253
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
254
+ }
255
+ /** One route directory segment as a pattern over the rendered (concrete) route path, leading `/` included. */
256
+ function segmentPattern(segment) {
257
+ if (/^\[\.\.\.[^\]]+\]$/.test(segment)) return "(?:/.*)?";
258
+ if (/^\[\[[^\]]+\]\]$/.test(segment)) return "(?:/[^/]+)?";
259
+ return "/" + segment.split(/\[[^\]]+\]/).map(escapeRegExp).join("[^/]+");
260
+ }
261
+ /**
262
+ * Routes whose prerendered HTML is the app shell rather than the page: SvelteKit still writes a
263
+ * file for an `ssr = false` route, but its `<head>` and body only exist once the client renders,
264
+ * so every rendered head/heading/landmark check would report the page as empty. A `+layout`
265
+ * covers its whole subtree, a `+page` only its own route; `(group)` directories are not URL
266
+ * segments. Two ceilings: a child route re-enabling `ssr = true` under a disabled layout is not
267
+ * tracked, and Kit's route precedence is not modelled, so a `[param]` route also claims a literal
268
+ * sibling (`/docs/[slug]` matches a prerendered `/docs/intro`) — the warning lists the skipped
269
+ * routes so a wrong skip is visible.
270
+ */
271
+ function ssrDisabledRouteMatcher(kitModules) {
272
+ const patterns = [];
273
+ for (const m of kitModules) {
274
+ const match = m.ssrDisabled ? PAGE_OPTION_FILE_RE.exec(m.file) : null;
275
+ if (!match) continue;
276
+ const path = match[1].split("/").filter((s) => s !== "" && !/^\(.+\)$/.test(s)).map(segmentPattern).join("");
277
+ patterns.push(new RegExp(match[2] === "layout" ? `^${path}(?:/.*)?$` : `^${path}$`));
278
+ }
279
+ return (route) => {
280
+ const r = route === "/" ? "" : route;
281
+ return patterns.some((p) => p.test(r));
282
+ };
283
+ }
284
+ /**
225
285
  * Collect prerendered heads + project facts + component facts, run the core pipeline, and
226
286
  * format reports. Config precedence: see `resolveConfig`. Pass `resolved` when the caller
227
- * already resolved config itself (build mode resolves it outside `analyze`'s try/catch so a
228
- * config-file validation error fails the build instead of being caught as an analysis error).
287
+ * already resolved config and loaded `svelte-vitals-suppressions.json` itself (build mode does
288
+ * both outside `analyze`'s try/catch so a validation error fails the build instead of being
289
+ * caught as an analysis error); `suppressions: undefined` means the file does not exist.
229
290
  */
230
291
  async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resolved) {
231
292
  const { config, warnings } = resolved ?? await resolveConfig(cwd, options);
232
- const [{ heads, headings, images, a11y, htmlLang }, components, sourceFiles] = await Promise.all([
293
+ const suppressions = resolved ? resolved.suppressions : loadSuppressions(cwd);
294
+ const [rendered, components, sourceFiles] = await Promise.all([
233
295
  collectRenderedHeads(prerenderPagesDir),
234
296
  collectComponentFacts$1(cwd),
235
297
  collectSourceFiles$1(cwd)
236
298
  ]);
237
299
  const project = {
238
- ...await collectRenderedProject(cwd, htmlLang),
300
+ ...await collectRenderedProject(cwd, rendered.htmlLang),
239
301
  ...extraProjectFacts
240
302
  };
241
303
  const kitModules = await collectKitModuleFacts$1(cwd, project.kitAliases);
304
+ const isShell = ssrDisabledRouteMatcher(kitModules);
305
+ const keep = (items) => items.filter((i) => !isShell(i.route));
306
+ const heads = keep(rendered.heads);
307
+ const headings = keep(rendered.headings);
308
+ const images = keep(rendered.images);
309
+ const a11y = keep(rendered.a11y);
310
+ const shells = rendered.heads.filter((h) => isShell(h.route)).map((h) => h.route).sort();
242
311
  const selected = selectRules(allRules, config);
312
+ if (shells.length > 0) {
313
+ const shown = shells.slice(0, 10);
314
+ const list = shells.length > shown.length ? `${shown.join(", ")}, … and ${shells.length - shown.length} more` : shown.join(", ");
315
+ warnings.push(`skipped ${shells.length} prerendered route(s) with ssr = false — their HTML is the app shell, not the page; run \`npx svelte-vitals\` to check them from source: ${list}`);
316
+ }
243
317
  if (selected.some((r) => r.id === "a11y/unverified-id-ref")) warnings.push("a11y/unverified-id-ref has no effect in rendered mode — the prerendered document is always fully resolved.");
244
318
  const directives = /* @__PURE__ */ new Map();
245
319
  addFactsDirectives(directives, {
@@ -247,7 +321,7 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
247
321
  kitModules,
248
322
  viteMinifyDisabled: project.viteMinifyDisabled
249
323
  });
250
- const { results, examined, failedRules, scoringConfig } = await runAnalysis(selected, {
324
+ const analysis = await runAnalysis(selected, {
251
325
  heads,
252
326
  headings,
253
327
  images,
@@ -258,21 +332,30 @@ async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resol
258
332
  kitModules,
259
333
  sourceFiles
260
334
  }, directives);
335
+ const { examined, failedRules, scoringConfig } = analysis;
336
+ let results = analysis.results;
337
+ if (suppressions !== void 0) {
338
+ const applied = applySourceSuppressions(results, suppressions, config);
339
+ results = applied.results;
340
+ warnings.push(...applied.notices);
341
+ }
261
342
  warnings.push(...skippedFileWarnings([...components, ...kitModules]));
262
343
  warnings.push(...unknownDirectiveIds(directives, allRules));
263
344
  for (const f of failedRules) warnings.push(formatFailedRuleWarning(f));
264
345
  const { score } = computeScore(results, scoringConfig);
265
346
  const summary = summarize(results, scoringConfig);
266
347
  const failed = hasFailureAtOrAbove(summary, scoringConfig.failOn);
267
- const coverageNote = `Analyzed ${heads.length} prerendered route(s). SSR/dynamic routes are not covered — run \`npx svelte-vitals\` for those.
348
+ const coverageNote = `Analyzed ${heads.length} prerendered route(s)${shells.length > 0 ? ` (skipped ${shells.length} with ssr = false)` : ""}. SSR/dynamic routes are not covered — run \`npx svelte-vitals\` for those.
268
349
  Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Accessibility/Bundle findings.`;
350
+ const consoleReport = formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n";
351
+ const jsonReport = formatJsonReport(results, scoringConfig, { version: readPackageVersion() }, selected.map((r) => r.id), examined);
269
352
  return {
270
353
  score,
271
354
  summary,
272
355
  results,
273
- consoleReport: formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n",
274
- jsonReport: formatJsonReport(results, scoringConfig, { version: readPackageVersion() }, selected.map((r) => r.id), examined),
275
- routeCount: heads.length,
356
+ consoleReport,
357
+ jsonReport,
358
+ routeCount: rendered.heads.length,
276
359
  failed,
277
360
  failOn: scoringConfig.failOn,
278
361
  warnings
@@ -586,6 +669,26 @@ function createStore() {
586
669
  //#endregion
587
670
  //#region src/ui/analysis.ts
588
671
  /**
672
+ * Apply `svelte-vitals-suppressions.json` to the whole-project layer, same entries as the build
673
+ * gate (see `applySourceSuppressions`). A malformed file is a warning here, not an error: dev
674
+ * must keep serving. Returns the results unchanged when the file is absent or `config` is
675
+ * unknown (an injected `analyze` that returns no config).
676
+ */
677
+ function suppress(root, results, config, warnings) {
678
+ if (!config) return results;
679
+ let entries;
680
+ try {
681
+ entries = loadSuppressions(root);
682
+ } catch (err) {
683
+ warnings.push(`${err instanceof Error ? err.message : String(err)} — ${SUPPRESSIONS_FILE} ignored.`);
684
+ return results;
685
+ }
686
+ if (entries === void 0) return results;
687
+ const applied = applySourceSuppressions(results, entries, config);
688
+ warnings.push(...applied.notices);
689
+ return applied.results;
690
+ }
691
+ /**
589
692
  * Owns the dev-dashboard's whole-project analysis lifecycle: an async run at startup,
590
693
  * a debounced re-run on source changes, and coalescing so a change that arrives mid-run
591
694
  * produces exactly one follow-up run rather than one per change (design doc §1).
@@ -612,10 +715,13 @@ function createAnalysisRunner(opts) {
612
715
  failOn: opts.failOn,
613
716
  parseCache
614
717
  });
615
- const { results, failedRuleIds } = result;
616
- if (!stopped) if (failedRuleIds !== void 0) opts.onResults(results, failedRuleIds);
617
- else opts.onResults(results);
618
- const warnings = result.warnings ?? [];
718
+ const { failedRuleIds } = result;
719
+ const warnings = [...result.warnings ?? []];
720
+ const results = suppress(opts.root, result.results, result.config, warnings);
721
+ if (!stopped) {
722
+ if (failedRuleIds !== void 0) opts.onResults(results, failedRuleIds);
723
+ else opts.onResults(results);
724
+ }
619
725
  const key = warnings.join("\n");
620
726
  if (!stopped && warnings.length > 0 && key !== lastWarningsKey) opts.onWarnings?.(warnings);
621
727
  lastWarningsKey = key;
@@ -665,6 +771,7 @@ const CONFIG_BASENAMES = /* @__PURE__ */ new Set([
665
771
  "svelte.config.js",
666
772
  "svelte.config.ts",
667
773
  ...CONFIG_FILENAMES,
774
+ SUPPRESSIONS_FILE,
668
775
  "vite.config.js",
669
776
  "vite.config.mjs",
670
777
  "vite.config.ts",
@@ -790,7 +897,10 @@ function svelteVitals(options = {}) {
790
897
  const pagesDir = options.prerenderDir ? options.prerenderDir : join(root, DEFAULT_PRERENDER_DIR);
791
898
  const resolved = isAbsolute(pagesDir) ? pagesDir : join(root, pagesDir);
792
899
  if (!existsSync(resolved)) return;
793
- const resolvedConfig = await resolveConfig(root, options);
900
+ const resolvedConfig = {
901
+ ...await resolveConfig(root, options),
902
+ suppressions: loadSuppressions(root)
903
+ };
794
904
  let result;
795
905
  try {
796
906
  const viteMinifyDisabled = await resolveMinifyDisabled(minify, configFile, root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/vite",
3
- "version": "0.54.5",
3
+ "version": "0.54.7",
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",
@@ -40,13 +40,13 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "esm-env": "^1.2.2",
43
- "node-html-parser": "^9.0.3"
43
+ "node-html-parser": "^9.0.4"
44
44
  },
45
45
  "peerDependencies": {
46
- "@svelte-vitals/core": "^0.54.5",
46
+ "@svelte-vitals/core": "^0.54.7",
47
47
  "@sveltejs/kit": "^2.70.3",
48
- "svelte-vitals": "^0.54.5",
49
- "vite": "^8.2.2"
48
+ "svelte-vitals": "^0.54.7",
49
+ "vite": "^8.3.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@sveltejs/kit": {
@@ -54,13 +54,13 @@
54
54
  }
55
55
  },
56
56
  "devDependencies": {
57
- "@svelte-vitals/core": "0.54.5",
57
+ "@svelte-vitals/core": "0.54.7",
58
58
  "@sveltejs/kit": "^2.70.3",
59
- "@types/node": "^24.13.3",
60
- "happy-dom": "^20.13.2",
59
+ "@types/node": "^24.13.4",
60
+ "happy-dom": "^20.14.3",
61
61
  "svelte": "^5.57.0",
62
- "svelte-vitals": "0.54.5",
63
- "vite": "^8.2.2"
62
+ "svelte-vitals": "0.54.7",
63
+ "vite": "^8.3.0"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsdown",