@svelte-vitals/vite 0.13.1 → 0.15.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.
@@ -10,8 +10,10 @@ interface SvelteVitalsHookOptions {
10
10
  }
11
11
 
12
12
  /**
13
- * SvelteKit `handle` that prints SEO warnings for each visited page's rendered `<head>`,
14
- * in dev only. Add it to `src/hooks.server.ts`, e.g. `sequence(svelteVitalsHandle())`.
13
+ * SvelteKit `handle` that analyzes each visited page's rendered `<head>`, in dev only,
14
+ * and (when the live dashboard is enabled) feeds the results in — upgrading that
15
+ * route's dashboard findings from static (source-only) to `measured` (real rendered
16
+ * HTML). Add it to `src/hooks.server.ts`, e.g. `sequence(svelteVitalsHandle())`.
15
17
  */
16
18
  declare function svelteVitalsHandle(options?: SvelteVitalsHookOptions): Handle;
17
19
 
@@ -15,22 +15,9 @@ import {
15
15
 
16
16
  // src/hooks/format.ts
17
17
  import { isPenalized, effectiveSeverity } from "@svelte-vitals/core";
18
- var GLYPH = { critical: "\u2717", warning: "\u26A0", info: "\xB7" };
19
- var RANK = { critical: 0, warning: 1, info: 2 };
20
18
  function penalized(results, config) {
21
19
  return results.filter((r) => isPenalized(r.detection, config.treatDynamicAs));
22
20
  }
23
- function formatDevReport(route, results, config) {
24
- const failing = penalized(results, config).sort(
25
- (a, b) => RANK[effectiveSeverity(a, config)] - RANK[effectiveSeverity(b, config)] || a.id.localeCompare(b.id)
26
- );
27
- if (failing.length === 0) return "";
28
- const lines = [`[svelte-vitals] ${route}`];
29
- for (const r of failing) {
30
- lines.push(` ${GLYPH[effectiveSeverity(r, config)]} ${r.id} ${r.message}`);
31
- }
32
- return lines.join("\n");
33
- }
34
21
  function findingSignature(results, config) {
35
22
  return penalized(results, config).map((r) => `${r.id}:${effectiveSeverity(r, config)}:${r.detection.presence}:${r.detection.value}`).sort().join("|");
36
23
  }
@@ -54,7 +41,7 @@ async function postIngest(origin, route, results) {
54
41
  } catch {
55
42
  }
56
43
  }
57
- async function analyzeAndWarn(html, route, origin, rules, config, lastSignature) {
44
+ async function analyzeAndIngest(html, route, origin, rules, config, lastSignature) {
58
45
  try {
59
46
  const { tags, htmlLang, headings: levels, images: imgs } = parseHtmlHead(html);
60
47
  const head = { route, source: "rendered", tags, file: route };
@@ -70,8 +57,6 @@ async function analyzeAndWarn(html, route, origin, rules, config, lastSignature)
70
57
  const signature = findingSignature(results, config);
71
58
  if (lastSignature.get(route) === signature) return;
72
59
  lastSignature.set(route, signature);
73
- const report = formatDevReport(route, results, config);
74
- if (report) console.warn(report);
75
60
  if (globalThis.process?.env?.SVELTE_VITALS_UI) void postIngest(origin, route, results);
76
61
  } catch (err) {
77
62
  if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) {
@@ -93,7 +78,7 @@ function svelteVitalsHandle(options = {}) {
93
78
  transformPageChunk: ({ html, done }) => {
94
79
  buffer += html;
95
80
  if (done)
96
- void analyzeAndWarn(
81
+ void analyzeAndIngest(
97
82
  buffer,
98
83
  event.route.id ?? event.url.pathname,
99
84
  event.url.origin,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Plugin } from 'vite';
2
- import { TreatDynamicAs, RuleSetting, Severity } from '@svelte-vitals/core';
2
+ import { TreatDynamicAs, RuleSetting, Severity, Category } from '@svelte-vitals/core';
3
3
 
4
4
  interface SvelteVitalsOptions {
5
5
  /** Project root (defaults to the Vite config root / cwd). */
@@ -9,13 +9,20 @@ interface SvelteVitalsOptions {
9
9
  rules?: Record<string, RuleSetting>;
10
10
  /** Minimum severity that fails the build (default: 'critical'). */
11
11
  failOn?: Severity;
12
+ /** Per-category weights for the combined Health score shown in the JSON/console report (flag > config file > default 1 each). */
13
+ weights?: Partial<Record<Category, number>>;
12
14
  /** Report output (default: 'console'). */
13
15
  report?: 'console' | 'json' | false;
14
16
  /** Write the JSON report to this path. */
15
17
  outFile?: string;
16
18
  /** Override the prerendered-pages directory (default: .svelte-kit/output/prerendered/pages). */
17
19
  prerenderDir?: string;
18
- /** Serve a live dashboard at /__svelte-vitals/ during `vite dev` (requires svelteVitalsHandle in hooks.server.ts). */
20
+ /**
21
+ * Serve a live dashboard at /__svelte-vitals/ during `vite dev` (add
22
+ * svelteVitalsHandle to hooks.server.ts for accurate, per-route `measured` results
23
+ * as you browse — the dashboard still works without it, from whole-project static
24
+ * analysis alone). Default: `true`. Pass `false` to keep only the build-time gate.
25
+ */
19
26
  ui?: boolean;
20
27
  }
21
28
  /** svelte-vitals Vite/SvelteKit plugin. */
package/dist/index.js CHANGED
@@ -7,8 +7,9 @@ import {
7
7
  // src/plugin.ts
8
8
  import { existsSync } from "fs";
9
9
  import { writeFile, mkdir } from "fs/promises";
10
- import { join as join4, isAbsolute, dirname, relative, basename, sep } from "path";
10
+ import { join as join4, isAbsolute, dirname, relative as relative2, basename, sep as sep2 } from "path";
11
11
  import { defineConfig as defineConfig2 } from "@svelte-vitals/core";
12
+ import { loadConfigFile as loadConfigFile2 } from "svelte-vitals";
12
13
 
13
14
  // src/analyze.ts
14
15
  import {
@@ -23,6 +24,7 @@ import {
23
24
  formatJsonReport,
24
25
  defineConfig
25
26
  } from "@svelte-vitals/core";
27
+ import { loadConfigFile } from "svelte-vitals";
26
28
 
27
29
  // src/providers/rendered/collect.ts
28
30
  import { readFile } from "fs/promises";
@@ -143,11 +145,16 @@ function readCoreVersion() {
143
145
 
144
146
  // src/analyze.ts
145
147
  async function analyze(prerenderPagesDir, cwd, options) {
148
+ const loaded = await loadConfigFile(cwd);
149
+ const fileConfig = loaded?.config;
150
+ const warnings = loaded?.warnings ?? [];
151
+ const weights = options.weights ?? fileConfig?.weights;
146
152
  const config = defineConfig({
147
- treatDynamicAs: options.treatDynamicAs ?? "pass",
148
- metaComponents: options.metaComponents ?? [],
149
- rules: options.rules ?? {},
150
- failOn: options.failOn ?? "critical"
153
+ treatDynamicAs: options.treatDynamicAs ?? fileConfig?.treatDynamicAs ?? "pass",
154
+ metaComponents: options.metaComponents ?? fileConfig?.metaComponents ?? [],
155
+ rules: options.rules ?? fileConfig?.rules ?? {},
156
+ failOn: options.failOn ?? fileConfig?.failOn ?? "critical",
157
+ ...weights !== void 0 ? { weights } : {}
151
158
  });
152
159
  const { heads, headings, images, htmlLang } = await collectRenderedHeads(prerenderPagesDir);
153
160
  const project = await collectRenderedProject(cwd, htmlLang);
@@ -171,7 +178,8 @@ Scanned ${components.length} component(s) under src/ for Correctness/Security/Ar
171
178
  jsonReport,
172
179
  routeCount: heads.length,
173
180
  failed,
174
- failOn: config.failOn
181
+ failOn: config.failOn,
182
+ warnings
175
183
  };
176
184
  }
177
185
 
@@ -210,8 +218,9 @@ body{background:var(--ground);color:var(--ink);font-family:var(--sans);line-heig
210
218
  .dv-app{display:grid;grid-template-rows:auto 1fr;grid-template-columns:280px 1fr;grid-template-areas:"top top" "side main";height:100vh}
211
219
  .dv-topbar{grid-area:top;border-bottom:1px solid var(--line);background:var(--panel)}
212
220
  .dv-topbar-inner{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 20px}
213
- .dv-brand{display:flex;align-items:baseline;gap:8px;font-weight:700;font-size:16px}
214
- .dv-brand .bolt{color:var(--accent)}
221
+ .dv-brand{display:flex;align-items:center;background:none;border:none;padding:0;cursor:pointer;border-radius:6px}
222
+ .dv-brand svg{height:28px;width:auto;display:block}
223
+ .dv-brand:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
215
224
  .dv-meta{font-family:var(--mono);font-size:12px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap}
216
225
  .dv-status{display:flex;align-items:center;gap:10px}
217
226
  .dv-analyzing{font-size:12px;color:var(--accent);font-weight:600}
@@ -268,6 +277,9 @@ body{background:var(--ground);color:var(--ink);font-family:var(--sans);line-heig
268
277
  .dv-sev-critical{color:var(--poor)}
269
278
  .dv-sev-warning{color:var(--warn)}
270
279
  .dv-sev-info{color:var(--faint)}
280
+ .dv-f-route{display:block;font:inherit;font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--accent);background:none;border:none;padding:0;margin:8px 0 0;cursor:pointer;text-align:left}
281
+ .dv-f-route:hover{text-decoration:underline}
282
+ .dv-f-route:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
271
283
  .dv-f-loc{font-family:var(--mono);font-size:12.5px;color:var(--muted);margin:8px 0 0}
272
284
  .dv-f-rec{font-size:14px;margin:10px 0 0}
273
285
  .dv-fix{margin:12px 0 0;background:var(--code-bg);border-radius:8px;overflow:hidden}
@@ -293,6 +305,12 @@ var DASHBOARD_SCRIPT = `
293
305
  var BAND_COLOR = { good: '#2fa968', warn: '#e8a317', poor: '#e5484d' };
294
306
  function scoreBand(score) { return score >= 90 ? 'good' : score >= 50 ? 'warn' : 'poor'; }
295
307
 
308
+ // Same mark as the docs site's hero wordmark (docs/public/wordmark.svg) \u2014 an inline
309
+ // copy, not an <img src>, since the dashboard is a single self-contained HTML response
310
+ // with no other static assets to serve alongside it. Fixed brand colors (not CSS custom
311
+ // properties), matching the docs usage: the wordmark reads the same in both themes.
312
+ var WORDMARK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 380 56" role="img" aria-labelledby="dv-wordmark-title"><title id="dv-wordmark-title">svelte-vitals</title><defs><clipPath id="dv-wordmark-clip"><rect x="2" y="2" width="52" height="52" rx="14"/></clipPath></defs><rect x="2" y="2" width="52" height="52" rx="14" fill="#FF3E00"/><polyline clip-path="url(#dv-wordmark-clip)" points="4,28 15,28 17.5,23.5 20,28 23,28 26,7 29,49 32,28 35,28 37,24.5 39.5,28 52,28" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><text x="70" y="38" font-family="ui-sans-serif, system-ui, -apple-system, \\'Segoe UI\\', Roboto, sans-serif" font-size="30" font-weight="700" fill="#FF3E00">svelte-vitals</text></svg>';
313
+
296
314
  function h(tag, attrs, kids) {
297
315
  var n = document.createElement(tag);
298
316
  if (attrs) {
@@ -418,12 +436,18 @@ var DASHBOARD_SCRIPT = `
418
436
  if (sb) sb.classList.toggle('open');
419
437
  }
420
438
 
439
+ function brandEl() {
440
+ var el = h('button', { type: 'button', class: 'dv-brand', 'aria-label': 'Go to Overview', onclick: function () { selectItem('overview'); } }, []);
441
+ el.innerHTML = WORDMARK_SVG;
442
+ return el;
443
+ }
444
+
421
445
  function renderTopbar() {
422
446
  var s = state.snapshot;
423
447
  var findings = s.report.routes.reduce(function (n, r) { return n + r.issues.length; }, 0) + s.report.siteIssues.length;
424
448
  var kids = [
425
449
  h('button', { type: 'button', class: 'dv-menu-toggle', 'aria-label': 'Toggle route list', onclick: toggleSidebar, text: '\u2261' }, []),
426
- h('div', { class: 'dv-brand' }, [h('span', { class: 'bolt', text: '\u21AF' }, []), document.createTextNode('svelte-vitals')]),
450
+ brandEl(),
427
451
  h('div', { class: 'dv-meta' }, [
428
452
  h('span', { text: 'v' + s.meta.version }, []),
429
453
  s.meta.coreVersion ? h('span', { title: '@svelte-vitals/core version', text: 'core v' + s.meta.coreVersion }, []) : null,
@@ -567,7 +591,9 @@ var DASHBOARD_SCRIPT = `
567
591
  return f === 'all' || issue.severity === f || issue.category === f;
568
592
  }
569
593
 
570
- function renderFinding(issue) {
594
+ var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
595
+
596
+ function renderFinding(issue, route) {
571
597
  var kids = [
572
598
  h('div', { class: 'dv-f-head' }, [
573
599
  h('span', { class: 'dv-ruleid', text: issue.id }, []),
@@ -575,6 +601,9 @@ var DASHBOARD_SCRIPT = `
575
601
  h('span', { class: 'dv-sev-tag dv-sev-' + issue.severity, text: issue.severity }, [])
576
602
  ])
577
603
  ];
604
+ if (route) {
605
+ kids.push(h('button', { type: 'button', class: 'dv-f-route', onclick: function () { selectItem(route); }, text: route }, []));
606
+ }
578
607
  if (issue.location) {
579
608
  kids.push(h('p', { class: 'dv-f-loc', text: issue.location + (issue.line !== undefined ? ':' + issue.line : '') }, []));
580
609
  }
@@ -631,11 +660,23 @@ var DASHBOARD_SCRIPT = `
631
660
  ]);
632
661
  });
633
662
  var chips = renderFilterChips(s.report.categories);
634
- var siteFindings = s.report.siteIssues.filter(passesFilter);
635
- var siteChecks = s.report.siteIssues.length
636
- ? h('section', { class: 'dv-section' }, [h('h2', { text: 'Site checks' }, [])].concat(siteFindings.map(renderFinding)))
637
- : null;
638
- return h('div', { class: 'dv-overview' }, [gauge, h('div', { class: 'dv-cats' }, cats), chips, siteChecks].filter(Boolean));
663
+ var totalCount = s.report.routes.reduce(function (n, r) { return n + r.issues.length; }, 0) + s.report.siteIssues.length;
664
+ var entries = [];
665
+ s.report.routes.forEach(function (r) {
666
+ r.issues.forEach(function (issue) { entries.push({ issue: issue, route: r.route }); });
667
+ });
668
+ s.report.siteIssues.forEach(function (issue) { entries.push({ issue: issue, route: null }); });
669
+ entries = entries.filter(function (e) { return passesFilter(e.issue); });
670
+ entries.sort(function (a, b) {
671
+ var sd = SEVERITY_ORDER[a.issue.severity] - SEVERITY_ORDER[b.issue.severity];
672
+ if (sd !== 0) return sd;
673
+ return (a.route || '').localeCompare(b.route || '');
674
+ });
675
+ var body = entries.length
676
+ ? entries.map(function (e) { return renderFinding(e.issue, e.route); })
677
+ : [h('p', { class: 'dv-empty', text: totalCount ? 'No issues match the current filter.' : 'No issues found \u2014 nice work!' }, [])];
678
+ var findings = h('section', { class: 'dv-section' }, [h('h2', { text: 'Findings' }, [])].concat(body));
679
+ return h('div', { class: 'dv-overview' }, [gauge, h('div', { class: 'dv-cats' }, cats), chips, findings].filter(Boolean));
639
680
  }
640
681
 
641
682
  function renderRouteDetail(route) {
@@ -648,7 +689,9 @@ var DASHBOARD_SCRIPT = `
648
689
  ].filter(Boolean));
649
690
  var chips = renderFilterChips(state.snapshot.report.categories);
650
691
  var findings = route.issues.filter(passesFilter);
651
- var body = findings.length ? findings.map(renderFinding) : [h('p', { class: 'dv-empty', text: 'No issues match the current filter.' }, [])];
692
+ var body = findings.length
693
+ ? findings.map(function (issue) { return renderFinding(issue); })
694
+ : [h('p', { class: 'dv-empty', text: 'No issues match the current filter.' }, [])];
652
695
  return h('div', { class: 'dv-route-detail' }, [header, chips].concat(body));
653
696
  }
654
697
 
@@ -886,9 +929,11 @@ function createStore() {
886
929
  }
887
930
 
888
931
  // src/ui/analysis.ts
932
+ import { relative, sep } from "path";
889
933
  function createAnalysisRunner(opts) {
890
934
  const debounceMs = opts.debounceMs ?? 500;
891
935
  let cachedAnalyze = opts.analyze;
936
+ const parseCache = /* @__PURE__ */ new Map();
892
937
  let stopped = false;
893
938
  let running = false;
894
939
  let pending = false;
@@ -911,7 +956,8 @@ function createAnalysisRunner(opts) {
911
956
  treatDynamicAs: opts.treatDynamicAs,
912
957
  metaComponents: opts.metaComponents,
913
958
  rules: opts.rules,
914
- failOn: opts.failOn
959
+ failOn: opts.failOn,
960
+ parseCache
915
961
  });
916
962
  if (!stopped) opts.onResults(results);
917
963
  } catch (err) {
@@ -930,8 +976,10 @@ function createAnalysisRunner(opts) {
930
976
  if (stopped) return;
931
977
  void runOnce();
932
978
  },
933
- notifyChange() {
979
+ notifyChange(file) {
934
980
  if (stopped) return;
981
+ const rel = relative(opts.root, file).split(sep).join("/");
982
+ parseCache.delete(rel);
935
983
  if (timer !== void 0) clearTimeout(timer);
936
984
  timer = setTimeout(() => {
937
985
  timer = void 0;
@@ -961,9 +1009,9 @@ var CONFIG_BASENAMES = /* @__PURE__ */ new Set([
961
1009
  ]);
962
1010
  var IGNORED_SEGMENTS = /* @__PURE__ */ new Set(["node_modules", ".svelte-kit", "build", "dist"]);
963
1011
  function isRelevant(file, root) {
964
- const rel = relative(root, file);
1012
+ const rel = relative2(root, file);
965
1013
  if (rel === "" || rel.startsWith("..")) return false;
966
- const segments = rel.split(sep);
1014
+ const segments = rel.split(sep2);
967
1015
  if (segments.some((s) => IGNORED_SEGMENTS.has(s))) return false;
968
1016
  if (CONFIG_BASENAMES.has(basename(file))) return true;
969
1017
  return segments[0] === "src" || segments[0] === "static";
@@ -989,6 +1037,7 @@ function svelteVitals(options = {}) {
989
1037
  console.warn(`svelte-vitals: skipped \u2014 analysis failed: ${err instanceof Error ? err.message : String(err)}`);
990
1038
  return;
991
1039
  }
1040
+ for (const w of result.warnings) console.warn(`svelte-vitals: ${w}`);
992
1041
  if (result.routeCount === 0) return;
993
1042
  if (options.report !== false) {
994
1043
  const out = options.report === "json" ? result.jsonReport : result.consoleReport;
@@ -1004,20 +1053,25 @@ function svelteVitals(options = {}) {
1004
1053
  }
1005
1054
  }
1006
1055
  };
1007
- if (!options.ui) return buildPlugin;
1056
+ if (options.ui === false) return buildPlugin;
1008
1057
  const uiPlugin = {
1009
1058
  name: "svelte-vitals:ui",
1010
1059
  apply: "serve",
1011
- configureServer(server) {
1060
+ async configureServer(server) {
1012
1061
  process.env.SVELTE_VITALS_UI = "1";
1062
+ const uiRoot = options.cwd ?? server.config.root;
1063
+ const loaded = await loadConfigFile2(uiRoot);
1064
+ const fileConfig = loaded?.config;
1065
+ for (const w of loaded?.warnings ?? []) console.warn(`svelte-vitals: ${w}`);
1066
+ const weights = options.weights ?? fileConfig?.weights;
1013
1067
  const config = defineConfig2({
1014
- treatDynamicAs: options.treatDynamicAs ?? "pass",
1015
- metaComponents: options.metaComponents ?? [],
1016
- rules: options.rules ?? {},
1017
- failOn: options.failOn ?? "critical"
1068
+ treatDynamicAs: options.treatDynamicAs ?? fileConfig?.treatDynamicAs ?? "pass",
1069
+ metaComponents: options.metaComponents ?? fileConfig?.metaComponents ?? [],
1070
+ rules: options.rules ?? fileConfig?.rules ?? {},
1071
+ failOn: options.failOn ?? fileConfig?.failOn ?? "critical",
1072
+ ...weights !== void 0 ? { weights } : {}
1018
1073
  });
1019
1074
  const store = createStore();
1020
- const uiRoot = options.cwd ?? server.config.root;
1021
1075
  const runner = createAnalysisRunner({
1022
1076
  root: uiRoot,
1023
1077
  treatDynamicAs: options.treatDynamicAs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-vitals/vite",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Vite/SvelteKit plugin for svelte-vitals — analyzes prerendered HTML during vite build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,12 +42,12 @@
42
42
  "esm-env": "^1.2.2",
43
43
  "node-html-parser": "^9.0.0",
44
44
  "tinyglobby": "^0.2.17",
45
- "@svelte-vitals/core": "0.23.0",
46
- "svelte-vitals": "0.23.0"
45
+ "@svelte-vitals/core": "0.24.0",
46
+ "svelte-vitals": "0.25.0"
47
47
  },
48
48
  "peerDependencies": {
49
- "@sveltejs/kit": "^2.69.1",
50
- "vite": "^8.1.3"
49
+ "@sveltejs/kit": "^2.69.2",
50
+ "vite": "^8.1.4"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@sveltejs/kit": {
@@ -55,10 +55,11 @@
55
55
  }
56
56
  },
57
57
  "devDependencies": {
58
- "@sveltejs/kit": "^2.69.1",
59
- "@types/node": "^24.13.2",
58
+ "@sveltejs/kit": "^2.69.2",
59
+ "@types/node": "^24.13.3",
60
+ "jsdom": "^29.1.1",
60
61
  "svelte": "^5.56.4",
61
- "vite": "^8.1.3"
62
+ "vite": "^8.1.4"
62
63
  },
63
64
  "scripts": {
64
65
  "build": "tsup",