@svelte-vitals/vite 0.32.1 → 0.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,730 +1,771 @@
1
- import {
2
- collectRenderedHeads,
3
- isLoopbackOrigin
4
- } from "./chunk-KII3SU3J.js";
5
-
6
- // src/plugin.ts
7
- import { existsSync } from "fs";
8
- import { writeFile, mkdir } from "fs/promises";
9
- import { join as join3, isAbsolute, dirname, relative as relative3, basename, sep as sep2 } from "path";
10
- import { CATEGORIES as CATEGORIES2 } from "@svelte-vitals/core";
11
- import {
12
- defaultConfig,
13
- resolveRuleOptions,
14
- shouldSkipRangeCheck,
15
- terminalSafe,
16
- validateRuleSetting
17
- } from "@svelte-vitals/core/internal";
18
- import { CONFIG_FILENAMES, findUnknownRuleIds, knownRuleIds, ruleOptionsSpec } from "svelte-vitals";
19
-
20
- // src/analyze.ts
21
- import { defineConfig } from "@svelte-vitals/core";
22
- import {
23
- allRules,
24
- selectRules,
25
- applyRuleSeverities,
26
- applyOverrides,
27
- applyInlineDirectives,
28
- unknownDirectiveIds,
29
- runRules,
30
- withFailedRulesOff,
31
- formatFailedRuleWarning,
32
- skippedFileWarnings,
33
- computeScore,
34
- summarize,
35
- hasFailureAtOrAbove,
36
- formatConsoleReport,
37
- formatJsonReport
38
- } from "@svelte-vitals/core/internal";
39
- import { loadConfigFile } from "svelte-vitals";
40
-
41
- // src/providers/rendered/project.ts
42
- import { access, readFile } from "fs/promises";
43
- import { join } from "path";
44
- import "@svelte-vitals/core";
45
- import {
46
- ROBOTS_SOURCE_PATHS,
47
- SITEMAP_SOURCE_PATHS,
48
- SVELTE_CONFIG_FILES,
49
- VITE_CONFIG_FILES,
50
- resolveKitAliases,
51
- resolveKitPathsBase
52
- } from "@svelte-vitals/core/internal";
1
+ import { n as collectRenderedHeads, t as isLoopbackOrigin } from "./loopback-dIwCgCTG.js";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { CATEGORIES, defineConfig } from "@svelte-vitals/core";
6
+ import { ROBOTS_SOURCE_PATHS, SITEMAP_SOURCE_PATHS, SVELTE_CONFIG_FILES, VITE_CONFIG_FILES, allRules, applyInlineDirectives, applyOverrides, applyRuleSeverities, buildJsonReport, collectComponentFacts, collectKitModuleFacts, collectSourceFiles, collectSuppressions, computeScore, defaultConfig, findMinifyDisabled, formatConsoleReport, formatFailedRuleWarning, formatJsonReport, hasFailureAtOrAbove, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveRuleOptions, runRules, safeHref, selectRules, shouldSkipRangeCheck, skippedFileWarnings, summarize, terminalSafe, unknownDirectiveIds, validateRuleSetting, withFailedRulesOff, withReadLimit } from "@svelte-vitals/core/internal";
7
+ import { CONFIG_FILENAMES, analyzeProject, findUnknownRuleIds, knownRuleIds, loadConfigFile, ruleOptionsSpec } from "svelte-vitals";
8
+ import { glob } from "tinyglobby";
9
+ //#region src/providers/rendered/project.ts
53
10
  async function exists(path) {
54
- try {
55
- await access(path);
56
- return true;
57
- } catch {
58
- return false;
59
- }
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
60
17
  }
61
18
  async function existsAny(cwd, paths) {
62
- const found = await Promise.all(paths.map((p) => exists(join(cwd, p))));
63
- return found.some(Boolean);
19
+ return (await Promise.all(paths.map((p) => exists(join(cwd, p))))).some(Boolean);
64
20
  }
65
21
  async function robotsRefsSitemap(cwd) {
66
- try {
67
- return /^\s*sitemap:/im.test(await readFile(join(cwd, "static/robots.txt"), "utf8"));
68
- } catch {
69
- return void 0;
70
- }
22
+ try {
23
+ return /^\s*sitemap:/im.test(await readFile(join(cwd, "static/robots.txt"), "utf8"));
24
+ } catch {
25
+ return;
26
+ }
71
27
  }
28
+ /** First existing config candidate with its source — same "only the first is loaded" rule as the CLI provider. */
72
29
  async function readFirstConfig(cwd, files) {
73
- for (const file of files) {
74
- const path = join(cwd, file);
75
- if (!await exists(path)) continue;
76
- try {
77
- return { file, source: await readFile(path, "utf8") };
78
- } catch {
79
- return void 0;
80
- }
81
- }
82
- return void 0;
30
+ for (const file of files) {
31
+ const path = join(cwd, file);
32
+ if (!await exists(path)) continue;
33
+ try {
34
+ return {
35
+ file,
36
+ source: await readFile(path, "utf8")
37
+ };
38
+ } catch {
39
+ return;
40
+ }
41
+ }
83
42
  }
43
+ /** Project facts for plugin mode: robots/sitemap from source, htmlLang from rendered HTML. */
84
44
  async function collectRenderedProject(cwd, htmlLang) {
85
- const [hasRobotsTxt, hasSitemap, viteConfig, svelteConfig] = await Promise.all([
86
- existsAny(cwd, ROBOTS_SOURCE_PATHS),
87
- existsAny(cwd, SITEMAP_SOURCE_PATHS),
88
- readFirstConfig(cwd, VITE_CONFIG_FILES),
89
- readFirstConfig(cwd, SVELTE_CONFIG_FILES)
90
- ]);
91
- const robotsReferencesSitemap = await robotsRefsSitemap(cwd);
92
- const kitPathsBase = resolveKitPathsBase(viteConfig, svelteConfig);
93
- const kitAliases = resolveKitAliases(viteConfig, svelteConfig);
94
- return {
95
- hasRobotsTxt,
96
- hasSitemap,
97
- htmlLang,
98
- ...robotsReferencesSitemap !== void 0 ? { robotsReferencesSitemap } : {},
99
- ...kitPathsBase ? { kitPathsBase } : {},
100
- ...kitAliases ? { kitAliases } : {}
101
- };
45
+ const [hasRobotsTxt, hasSitemap, viteConfig, svelteConfig] = await Promise.all([
46
+ existsAny(cwd, ROBOTS_SOURCE_PATHS),
47
+ existsAny(cwd, SITEMAP_SOURCE_PATHS),
48
+ readFirstConfig(cwd, VITE_CONFIG_FILES),
49
+ readFirstConfig(cwd, SVELTE_CONFIG_FILES)
50
+ ]);
51
+ const robotsReferencesSitemap = await robotsRefsSitemap(cwd);
52
+ const kitPathsBase = resolveKitPathsBase(viteConfig, svelteConfig);
53
+ const kitAliases = resolveKitAliases(viteConfig, svelteConfig);
54
+ return {
55
+ hasRobotsTxt,
56
+ hasSitemap,
57
+ htmlLang,
58
+ ...robotsReferencesSitemap !== void 0 ? { robotsReferencesSitemap } : {},
59
+ ...kitPathsBase ? { kitPathsBase } : {},
60
+ ...kitAliases ? { kitAliases } : {}
61
+ };
102
62
  }
103
-
104
- // src/providers/source/components.ts
105
- import { readFile as readFile2, access as access2 } from "fs/promises";
106
- import { join as join2 } from "path";
107
- import { glob } from "tinyglobby";
108
- import {
109
- collectComponentFacts as collect,
110
- collectKitModuleFacts as collectKit,
111
- collectSourceFiles as collectFiles,
112
- withReadLimit
113
- } from "@svelte-vitals/core/internal";
114
- var boundedRead = withReadLimit((path) => readFile2(path, "utf8"));
115
- var nodeRuntime = {
116
- readFile: (path) => boundedRead(path),
117
- async exists(path) {
118
- try {
119
- await access2(path);
120
- return true;
121
- } catch {
122
- return false;
123
- }
124
- },
125
- glob: (pattern, cwd) => glob(pattern, { cwd, dot: false }),
126
- join: (...parts) => join2(...parts)
63
+ //#endregion
64
+ //#region src/providers/source/components.ts
65
+ /**
66
+ * Node-backed Runtime adapter (design §8). vite always runs in Node, so no
67
+ * swappable runtime is needed here — this just satisfies the interface the
68
+ * shared core implementation expects.
69
+ */
70
+ const boundedRead = withReadLimit((path) => readFile(path, "utf8"));
71
+ const nodeRuntime = {
72
+ readFile: (path) => boundedRead(path),
73
+ async exists(path) {
74
+ try {
75
+ await access(path);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+ glob: (pattern, cwd) => glob(pattern, {
82
+ cwd,
83
+ dot: false
84
+ }),
85
+ join: (...parts) => join(...parts)
127
86
  };
128
- function collectComponentFacts(root) {
129
- return collect(nodeRuntime, root);
87
+ /**
88
+ * Scan every `.svelte` component under `src/` for Correctness/Security/Architecture/
89
+ * Bundle-Performance/Accessibility facts (build mode only). Implementation lives in
90
+ * `@svelte-vitals/core` (plans/003) and is shared with the CLI package.
91
+ */
92
+ function collectComponentFacts$1(root) {
93
+ return collectComponentFacts(nodeRuntime, root);
130
94
  }
131
- function collectKitModuleFacts(root, aliases) {
132
- return collectKit(nodeRuntime, root, aliases);
95
+ /** Scan SvelteKit route/hooks files for SSR shared-state facts (security/handler-state-write, security/server-module-state, security/shared-state-import; build mode only). */
96
+ function collectKitModuleFacts$1(root, aliases) {
97
+ return collectKitModuleFacts(nodeRuntime, root, aliases);
133
98
  }
134
- function collectSourceFiles(root) {
135
- return collectFiles(nodeRuntime, root);
99
+ /** Every file under `src/` for directory-shaped Architecture rules (build mode only). */
100
+ function collectSourceFiles$1(root) {
101
+ return collectSourceFiles(nodeRuntime, root);
136
102
  }
137
-
138
- // src/version.ts
139
- import { readFileSync } from "fs";
103
+ //#endregion
104
+ //#region src/version.ts
105
+ /** Read this package's version from its own package.json so the report never drifts. */
140
106
  function readPackageVersion() {
141
- try {
142
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
143
- return pkg.version ?? "0.0.0";
144
- } catch {
145
- return "0.0.0";
146
- }
107
+ try {
108
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
109
+ } catch {
110
+ return "0.0.0";
111
+ }
147
112
  }
113
+ /**
114
+ * Read the *actually resolved* `@svelte-vitals/core` version — the rule engine
115
+ * that determines which findings get reported. `@svelte-vitals/vite` and the
116
+ * `svelte-vitals` CLI are versioned independently and can end up depending on
117
+ * different core versions (e.g. a lockfile/registry cooldown resolving a CLI
118
+ * `@latest` install down to an older release than what this plugin depends on);
119
+ * surfacing this in the live dashboard lets users directly compare "rule engine
120
+ * version" against the CLI's `--version` output instead of guessing from
121
+ * unrelated outer package version numbers. `@svelte-vitals/core` stays an
122
+ * external (unbundled) dependency in the tsup build, so this resolves against
123
+ * the real installed package at runtime, not whatever was bundled in.
124
+ */
148
125
  function readCoreVersion() {
149
- try {
150
- const entry = import.meta.resolve("@svelte-vitals/core");
151
- const pkg = JSON.parse(readFileSync(new URL("../package.json", entry), "utf8"));
152
- return pkg.version ?? "0.0.0";
153
- } catch {
154
- return "0.0.0";
155
- }
126
+ try {
127
+ const entry = import.meta.resolve("@svelte-vitals/core");
128
+ return JSON.parse(readFileSync(new URL("../package.json", entry), "utf8")).version ?? "0.0.0";
129
+ } catch {
130
+ return "0.0.0";
131
+ }
156
132
  }
157
-
158
- // src/analyze.ts
133
+ //#endregion
134
+ //#region src/analyze.ts
135
+ /**
136
+ * Merge plugin options over an optional loaded config file, per-field precedence (an
137
+ * explicit `options` value wins, otherwise the file, otherwise the built-in default).
138
+ * Extracted so the dev-server fallback (plugin.ts) can build an options-only config
139
+ * without re-reading the config file.
140
+ */
159
141
  function mergeConfig(options, fileConfig) {
160
- const weights = options.weights ?? fileConfig?.weights;
161
- const overrides = options.overrides ?? fileConfig?.overrides;
162
- return defineConfig({
163
- treatDynamicAs: options.treatDynamicAs ?? fileConfig?.treatDynamicAs ?? "pass",
164
- metaComponents: options.metaComponents ?? fileConfig?.metaComponents ?? [],
165
- rules: options.rules ?? fileConfig?.rules ?? {},
166
- failOn: options.failOn ?? fileConfig?.failOn ?? "critical",
167
- ...weights !== void 0 ? { weights } : {},
168
- ...overrides !== void 0 ? { overrides } : {}
169
- });
142
+ const weights = options.weights ?? fileConfig?.weights;
143
+ const overrides = options.overrides ?? fileConfig?.overrides;
144
+ return defineConfig({
145
+ treatDynamicAs: options.treatDynamicAs ?? fileConfig?.treatDynamicAs ?? "pass",
146
+ metaComponents: options.metaComponents ?? fileConfig?.metaComponents ?? [],
147
+ rules: options.rules ?? fileConfig?.rules ?? {},
148
+ failOn: options.failOn ?? fileConfig?.failOn ?? "critical",
149
+ ...weights !== void 0 ? { weights } : {},
150
+ ...overrides !== void 0 ? { overrides } : {}
151
+ });
170
152
  }
153
+ /**
154
+ * Resolve the effective config the same way the CLI's `analyzeProject` does — per-field
155
+ * precedence: an explicit `options` value wins, otherwise `svelte-vitals.config.*` in
156
+ * `cwd`, otherwise the built-in default. Shared by build-mode `analyze()` and the dev
157
+ * dashboard (plugin.ts). `warnings` are the config file's non-fatal issues. Throws if
158
+ * the config file itself is invalid (unknown rule id, malformed `overrides`, …) —
159
+ * callers decide whether that's fatal (build) or a fall-back-to-defaults warning (dev).
160
+ */
171
161
  async function resolveConfig(cwd, options) {
172
- const loaded = await loadConfigFile(cwd);
173
- const config = mergeConfig(options, loaded?.config);
174
- return { config, warnings: loaded?.warnings ?? [] };
162
+ const loaded = await loadConfigFile(cwd);
163
+ return {
164
+ config: mergeConfig(options, loaded?.config),
165
+ warnings: loaded?.warnings ?? []
166
+ };
175
167
  }
168
+ /**
169
+ * Collect prerendered heads + project facts + component facts, run the core pipeline, and
170
+ * format reports. Config precedence: see `resolveConfig`. Pass `resolved` when the caller
171
+ * already resolved config itself (build mode resolves it outside `analyze`'s try/catch so a
172
+ * config-file validation error fails the build instead of being caught as an analysis error).
173
+ */
176
174
  async function analyze(prerenderPagesDir, cwd, options, extraProjectFacts, resolved) {
177
- const { config, warnings } = resolved ?? await resolveConfig(cwd, options);
178
- const [{ heads, headings, images, a11y, htmlLang }, components, sourceFiles] = await Promise.all([
179
- collectRenderedHeads(prerenderPagesDir),
180
- collectComponentFacts(cwd),
181
- collectSourceFiles(cwd)
182
- ]);
183
- const project = {
184
- ...await collectRenderedProject(cwd, htmlLang),
185
- ...extraProjectFacts
186
- };
187
- const kitModules = await collectKitModuleFacts(cwd, project.kitAliases);
188
- const selected = selectRules(allRules, config);
189
- if (selected.some((r) => r.id === "a11y/unverified-id-ref")) {
190
- warnings.push(
191
- "a11y/unverified-id-ref has no effect in rendered mode \u2014 the prerendered document is always fully resolved."
192
- );
193
- }
194
- const {
195
- results: rawResults,
196
- examined,
197
- failedRules
198
- } = await runRules(selected, {
199
- heads,
200
- headings,
201
- images,
202
- a11y,
203
- project,
204
- components,
205
- config,
206
- kitModules,
207
- sourceFiles
208
- });
209
- const directives = /* @__PURE__ */ new Map();
210
- for (const c of components) directives.set(c.file, c.suppressions ?? []);
211
- for (const m of kitModules) directives.set(m.file, m.suppressions ?? []);
212
- if (project.viteMinifyDisabled?.file)
213
- directives.set(project.viteMinifyDisabled.file, project.viteMinifyDisabled.suppressions ?? []);
214
- const results = applyInlineDirectives(
215
- applyOverrides(applyRuleSeverities(rawResults, config), config),
216
- directives,
217
- selected,
218
- config
219
- );
220
- warnings.push(...skippedFileWarnings([...components, ...kitModules]));
221
- warnings.push(...unknownDirectiveIds(directives, allRules));
222
- for (const f of failedRules) warnings.push(formatFailedRuleWarning(f));
223
- const scoringConfig = withFailedRulesOff(
224
- config,
225
- failedRules.map((f) => f.id)
226
- );
227
- const { score } = computeScore(results, scoringConfig);
228
- const summary = summarize(results, scoringConfig);
229
- const failed = hasFailureAtOrAbove(summary, scoringConfig.failOn);
230
- const coverageNote = `Analyzed ${heads.length} prerendered route(s). SSR/dynamic routes are not covered \u2014 run \`npx svelte-vitals\` for those.
175
+ const { config, warnings } = resolved ?? await resolveConfig(cwd, options);
176
+ const [{ heads, headings, images, a11y, htmlLang }, components, sourceFiles] = await Promise.all([
177
+ collectRenderedHeads(prerenderPagesDir),
178
+ collectComponentFacts$1(cwd),
179
+ collectSourceFiles$1(cwd)
180
+ ]);
181
+ const project = {
182
+ ...await collectRenderedProject(cwd, htmlLang),
183
+ ...extraProjectFacts
184
+ };
185
+ const kitModules = await collectKitModuleFacts$1(cwd, project.kitAliases);
186
+ const selected = selectRules(allRules, config);
187
+ 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.");
188
+ const { results: rawResults, examined, failedRules } = await runRules(selected, {
189
+ heads,
190
+ headings,
191
+ images,
192
+ a11y,
193
+ project,
194
+ components,
195
+ config,
196
+ kitModules,
197
+ sourceFiles
198
+ });
199
+ const directives = /* @__PURE__ */ new Map();
200
+ for (const c of components) directives.set(c.file, c.suppressions ?? []);
201
+ for (const m of kitModules) directives.set(m.file, m.suppressions ?? []);
202
+ if (project.viteMinifyDisabled?.file) directives.set(project.viteMinifyDisabled.file, project.viteMinifyDisabled.suppressions ?? []);
203
+ const results = applyInlineDirectives(applyOverrides(applyRuleSeverities(rawResults, config), config), directives, selected, config);
204
+ warnings.push(...skippedFileWarnings([...components, ...kitModules]));
205
+ warnings.push(...unknownDirectiveIds(directives, allRules));
206
+ for (const f of failedRules) warnings.push(formatFailedRuleWarning(f));
207
+ const scoringConfig = withFailedRulesOff(config, failedRules.map((f) => f.id));
208
+ const { score } = computeScore(results, scoringConfig);
209
+ const summary = summarize(results, scoringConfig);
210
+ const failed = hasFailureAtOrAbove(summary, scoringConfig.failOn);
211
+ const coverageNote = `Analyzed ${heads.length} prerendered route(s). SSR/dynamic routes are not covered — run \`npx svelte-vitals\` for those.
231
212
  Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Accessibility/Bundle findings.`;
232
- const consoleReport = formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n";
233
- const jsonReport = formatJsonReport(
234
- results,
235
- scoringConfig,
236
- { version: readPackageVersion() },
237
- selected.map((r) => r.id),
238
- examined
239
- );
240
- return {
241
- score,
242
- summary,
243
- results,
244
- consoleReport,
245
- jsonReport,
246
- routeCount: heads.length,
247
- failed,
248
- failOn: scoringConfig.failOn,
249
- warnings
250
- };
213
+ return {
214
+ score,
215
+ summary,
216
+ results,
217
+ consoleReport: formatConsoleReport(results, scoringConfig, { mode: "rendered / plugin" }) + "\n" + coverageNote + "\n",
218
+ jsonReport: formatJsonReport(results, scoringConfig, { version: readPackageVersion() }, selected.map((r) => r.id), examined),
219
+ routeCount: heads.length,
220
+ failed,
221
+ failOn: scoringConfig.failOn,
222
+ warnings
223
+ };
251
224
  }
252
-
253
- // src/minify-flag.ts
254
- import { readFile as readFile3 } from "fs/promises";
255
- import { relative } from "path";
256
- import {
257
- collectSuppressions,
258
- findMinifyDisabled
259
- } from "@svelte-vitals/core/internal";
225
+ //#endregion
226
+ //#region src/minify-flag.ts
227
+ /**
228
+ * performance/minify-disabled fact from the evaluated Vite user config (the value the plugin's
229
+ * `config` hook sees) — exact, so it also catches function-form/conditional configs the CLI's
230
+ * literal-only static pass skips.
231
+ * The config source is re-parsed only to locate the line. `file` is never
232
+ * fabricated: unset for an inline programmatic config (no `configFile` at
233
+ * all), and always a posix-relative path to `root` (may start with `../` in
234
+ * monorepos) — never an absolute path. `line` is set only when the literal
235
+ * `minify: false` could be located in that file; a dynamic config that still
236
+ * resolves to `minify: false` (or an unreadable file) omits it, since the
237
+ * resolved value already proved the finding without needing a line.
238
+ */
260
239
  async function resolveMinifyDisabled(minify, configFile, root) {
261
- if (minify !== false) return void 0;
262
- if (!configFile) return {};
263
- const rel = relative(root, configFile).split("\\").join("/");
264
- const file = rel === "" ? configFile.split("\\").join("/") : rel;
265
- let line;
266
- let suppressions;
267
- try {
268
- const source = await readFile3(configFile, "utf8");
269
- line = findMinifyDisabled(source)?.line;
270
- suppressions = collectSuppressions(source);
271
- } catch {
272
- }
273
- return {
274
- file,
275
- ...line !== void 0 ? { line } : {},
276
- ...suppressions !== void 0 ? { suppressions } : {}
277
- };
240
+ if (minify !== false) return void 0;
241
+ if (!configFile) return {};
242
+ const rel = relative(root, configFile).split("\\").join("/");
243
+ const file = rel === "" ? configFile.split("\\").join("/") : rel;
244
+ let line;
245
+ let suppressions;
246
+ try {
247
+ const source = await readFile(configFile, "utf8");
248
+ line = findMinifyDisabled(source)?.line;
249
+ suppressions = collectSuppressions(source);
250
+ } catch {}
251
+ return {
252
+ file,
253
+ ...line !== void 0 ? { line } : {},
254
+ ...suppressions !== void 0 ? { suppressions } : {}
255
+ };
278
256
  }
279
-
280
- // src/ui/middleware.ts
281
- import { CATEGORIES } from "@svelte-vitals/core";
282
- import { renderAppShell } from "@svelte-vitals/core/internal";
283
-
284
- // src/ui/snapshot.ts
285
- import "@svelte-vitals/core";
286
- import { buildJsonReport, safeHref, withFailedRulesOff as withFailedRulesOff2 } from "@svelte-vitals/core/internal";
257
+ //#endregion
258
+ //#region src/ui/snapshot.ts
259
+ /**
260
+ * `docsUrl` on an ingested (live) result flows into `/data.json` responses, which the
261
+ * client script renders into an <a href> without going through `renderAppShell`'s own
262
+ * sanitizer — so sanitize here too, server-side, covering both the embedded first paint
263
+ * and every refetch.
264
+ */
287
265
  function sanitizeDocsUrl(issue) {
288
- if (issue.docsUrl === void 0) return issue;
289
- if (safeHref(issue.docsUrl) !== null) return issue;
290
- return { ...issue, docsUrl: void 0 };
266
+ if (issue.docsUrl === void 0) return issue;
267
+ if (safeHref(issue.docsUrl) !== null) return issue;
268
+ return {
269
+ ...issue,
270
+ docsUrl: void 0
271
+ };
291
272
  }
292
273
  function sanitizeReport(report) {
293
- return {
294
- ...report,
295
- routes: report.routes.map((route) => ({ ...route, issues: route.issues.map(sanitizeDocsUrl) })),
296
- siteIssues: report.siteIssues.map(sanitizeDocsUrl)
297
- };
274
+ return {
275
+ ...report,
276
+ routes: report.routes.map((route) => ({
277
+ ...route,
278
+ issues: route.issues.map(sanitizeDocsUrl)
279
+ })),
280
+ siteIssues: report.siteIssues.map(sanitizeDocsUrl)
281
+ };
298
282
  }
283
+ /**
284
+ * Build the payload shared by the dashboard shell's embedded JSON and the /data.json endpoint.
285
+ * `config` is never swapped for another config — plugin-option `weights`/`overrides` must
286
+ * survive every request. `staticFailedRuleIds` (the whole-project runner's crashed-rule ids)
287
+ * and the store's live-layer union (`store.failedRuleIds()`) are both layered onto `config` via
288
+ * `withFailedRulesOff`, the same correction the CLI and build mode apply, so a rule that crashed
289
+ * on either layer scores as not-run instead of inflating Health.
290
+ */
299
291
  function buildSnapshot(store, config, meta, staticFailedRuleIds) {
300
- const failedRuleIds = [.../* @__PURE__ */ new Set([...staticFailedRuleIds ?? [], ...store.failedRuleIds()])];
301
- const scoringConfig = withFailedRulesOff2(config, failedRuleIds);
302
- return {
303
- // No rule-id list threaded through: `report.rules` is seeded from `store.snapshot()`
304
- // alone here, so presence means "produced a result", not "was selected" — unlike the
305
- // `json` reporter (design doc 2026-08-03-json-rule-evidence-design.md, Not in scope).
306
- report: sanitizeReport(buildJsonReport(store.snapshot(), scoringConfig, meta)),
307
- badges: store.badges(),
308
- analyzing: store.isAnalyzing(),
309
- sequence: store.sequence(),
310
- live: true,
311
- meta
312
- };
292
+ const failedRuleIds = [.../* @__PURE__ */ new Set([...staticFailedRuleIds ?? [], ...store.failedRuleIds()])];
293
+ const scoringConfig = withFailedRulesOff(config, failedRuleIds);
294
+ return {
295
+ report: sanitizeReport(buildJsonReport(store.snapshot(), scoringConfig, meta)),
296
+ badges: store.badges(),
297
+ analyzing: store.isAnalyzing(),
298
+ sequence: store.sequence(),
299
+ live: true,
300
+ meta
301
+ };
313
302
  }
314
-
315
- // src/ui/middleware.ts
316
- var SEVERITIES = /* @__PURE__ */ new Set(["critical", "warning", "info"]);
317
- var CATEGORY_SET = new Set(CATEGORIES);
303
+ //#endregion
304
+ //#region src/ui/middleware.ts
305
+ const SEVERITIES = /* @__PURE__ */ new Set([
306
+ "critical",
307
+ "warning",
308
+ "info"
309
+ ]);
310
+ const CATEGORY_SET = new Set(CATEGORIES);
318
311
  function isOptionalString(v) {
319
- return v === void 0 || typeof v === "string";
312
+ return v === void 0 || typeof v === "string";
320
313
  }
314
+ /**
315
+ * Every field the dashboard renderer dereferences, so malformed ingest can't crash it:
316
+ * `escapeHtml` needs strings for `id`/`message`/`category`/`location`/`recommendation`/
317
+ * `docsUrl`/`fix.*`/`route`, `effectiveSeverity`/`classify` read `detection.presence|value`
318
+ * and `severity`, and `line` is interpolated as a number. Optional fields must be absent
319
+ * or well-typed — `category: 123` would pass `?? 'seo'` and then throw inside `escapeHtml`.
320
+ * Real engine findings always satisfy all of this.
321
+ */
321
322
  function isResultLike(x) {
322
- if (typeof x !== "object" || x === null) return false;
323
- const r = x;
324
- const d = r.detection;
325
- const f = r.fix;
326
- 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);
323
+ if (typeof x !== "object" || x === null) return false;
324
+ const r = x;
325
+ const d = r.detection;
326
+ const f = r.fix;
327
+ 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);
327
328
  }
329
+ /**
330
+ * Mount the dev UI at /__svelte-vitals/ : GET / (dashboard), POST /ingest, GET /events (SSE).
331
+ * `store` is created and owned by the ui plugin (plugin.ts) so the analysis runner can also
332
+ * write to it (`store.setStatic`) — this middleware only reads it and writes the live layer.
333
+ */
328
334
  function installUiMiddleware(server, config, version, store, coreVersion, getStaticFailedRuleIds) {
329
- const clients = /* @__PURE__ */ new Set();
330
- store.subscribe(() => {
331
- for (const res of clients) {
332
- try {
333
- res.write("event: update\ndata: {}\n\n");
334
- } catch {
335
- clients.delete(res);
336
- }
337
- }
338
- });
339
- server.httpServer?.once("close", () => {
340
- for (const res of clients) res.end();
341
- clients.clear();
342
- });
343
- server.middlewares.use("/__svelte-vitals", (req, res) => {
344
- const url = req.url ?? "/";
345
- const origin = req.headers.origin;
346
- const host = req.headers.host;
347
- if (typeof origin === "string" && !isLoopbackOrigin(origin) || host === void 0 || !isLoopbackOrigin(`http://${host}`)) {
348
- req.resume();
349
- res.statusCode = 403;
350
- res.end("svelte-vitals dev UI is only available from localhost");
351
- return;
352
- }
353
- if (req.method === "POST" && url.startsWith("/ingest")) {
354
- const chunks = [];
355
- req.on("data", (c) => chunks.push(c));
356
- req.on("end", () => {
357
- try {
358
- const { route, results, failedRuleIds } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
359
- if (typeof route === "string" && Array.isArray(results)) {
360
- const failedIds = Array.isArray(failedRuleIds) ? failedRuleIds.filter((id) => typeof id === "string") : [];
361
- store.set(route, results.filter(isResultLike), failedIds);
362
- }
363
- } catch {
364
- }
365
- res.statusCode = 204;
366
- res.end();
367
- });
368
- return;
369
- }
370
- if (url.startsWith("/events")) {
371
- res.writeHead(200, {
372
- "Content-Type": "text/event-stream",
373
- "Cache-Control": "no-cache",
374
- Connection: "keep-alive"
375
- });
376
- res.write("\n");
377
- clients.add(res);
378
- req.on("close", () => clients.delete(res));
379
- return;
380
- }
381
- if (url.startsWith("/data.json")) {
382
- try {
383
- const snapshot = buildSnapshot(store, config, { version, coreVersion }, getStaticFailedRuleIds?.());
384
- res.setHeader("Content-Type", "application/json");
385
- res.end(JSON.stringify(snapshot));
386
- } catch {
387
- res.statusCode = 500;
388
- res.end("{}");
389
- }
390
- return;
391
- }
392
- try {
393
- const html = renderAppShell(buildSnapshot(store, config, { version, coreVersion }, getStaticFailedRuleIds?.()));
394
- res.setHeader("Content-Type", "text/html");
395
- res.end(html);
396
- } catch {
397
- res.statusCode = 500;
398
- res.end("svelte-vitals dashboard failed to render");
399
- }
400
- });
335
+ const clients = /* @__PURE__ */ new Set();
336
+ store.subscribe(() => {
337
+ for (const res of clients) try {
338
+ res.write("event: update\ndata: {}\n\n");
339
+ } catch {
340
+ clients.delete(res);
341
+ }
342
+ });
343
+ server.httpServer?.once("close", () => {
344
+ for (const res of clients) res.end();
345
+ clients.clear();
346
+ });
347
+ server.middlewares.use("/__svelte-vitals", (req, res) => {
348
+ const url = req.url ?? "/";
349
+ const origin = req.headers.origin;
350
+ const host = req.headers.host;
351
+ if (typeof origin === "string" && !isLoopbackOrigin(origin) || host === void 0 || !isLoopbackOrigin(`http://${host}`)) {
352
+ req.resume();
353
+ res.statusCode = 403;
354
+ res.end("svelte-vitals dev UI is only available from localhost");
355
+ return;
356
+ }
357
+ if (req.method === "POST" && url.startsWith("/ingest")) {
358
+ const chunks = [];
359
+ req.on("data", (c) => chunks.push(c));
360
+ req.on("end", () => {
361
+ try {
362
+ const { route, results, failedRuleIds } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
363
+ if (typeof route === "string" && Array.isArray(results)) {
364
+ const failedIds = Array.isArray(failedRuleIds) ? failedRuleIds.filter((id) => typeof id === "string") : [];
365
+ store.set(route, results.filter(isResultLike), failedIds);
366
+ }
367
+ } catch {}
368
+ res.statusCode = 204;
369
+ res.end();
370
+ });
371
+ return;
372
+ }
373
+ if (url.startsWith("/events")) {
374
+ res.writeHead(200, {
375
+ "Content-Type": "text/event-stream",
376
+ "Cache-Control": "no-cache",
377
+ Connection: "keep-alive"
378
+ });
379
+ res.write("\n");
380
+ clients.add(res);
381
+ req.on("close", () => clients.delete(res));
382
+ return;
383
+ }
384
+ if (url.startsWith("/data.json")) {
385
+ try {
386
+ const snapshot = buildSnapshot(store, config, {
387
+ version,
388
+ coreVersion
389
+ }, getStaticFailedRuleIds?.());
390
+ res.setHeader("Content-Type", "application/json");
391
+ res.end(JSON.stringify(snapshot));
392
+ } catch {
393
+ res.statusCode = 500;
394
+ res.end("{}");
395
+ }
396
+ return;
397
+ }
398
+ try {
399
+ const html = renderAppShell(buildSnapshot(store, config, {
400
+ version,
401
+ coreVersion
402
+ }, getStaticFailedRuleIds?.()));
403
+ res.setHeader("Content-Type", "text/html");
404
+ res.end(html);
405
+ } catch {
406
+ res.statusCode = 500;
407
+ res.end("svelte-vitals dashboard failed to render");
408
+ }
409
+ });
401
410
  }
402
-
403
- // src/ui/store.ts
411
+ //#endregion
412
+ //#region src/ui/store.ts
413
+ /**
414
+ * Merge rule (design doc §2): for a route with a live result set, static results whose
415
+ * rule id appears in the live payload are replaced by the live ones — the handle reports
416
+ * passing as well as failing results, so the rule ids present in the payload ARE the
417
+ * evaluated set. Everything else — component/site-scoped findings (no route) and
418
+ * unvisited routes — keeps the static result untouched. Pure function so it is
419
+ * unit-testable without the store's mutable state.
420
+ */
404
421
  function composeSnapshot(staticResults, liveByRoute) {
405
- const staticByRoute = /* @__PURE__ */ new Map();
406
- const routeless = [];
407
- for (const r of staticResults) {
408
- if (r.route) {
409
- const bucket = staticByRoute.get(r.route);
410
- if (bucket) bucket.push(r);
411
- else staticByRoute.set(r.route, [r]);
412
- } else {
413
- routeless.push(r);
414
- }
415
- }
416
- const out = [...routeless];
417
- for (const [route, live] of liveByRoute) {
418
- const liveIds = new Set(live.map((r) => r.id));
419
- const staticForRoute = staticByRoute.get(route) ?? [];
420
- out.push(...staticForRoute.filter((r) => !liveIds.has(r.id)), ...live);
421
- staticByRoute.delete(route);
422
- }
423
- for (const remaining of staticByRoute.values()) out.push(...remaining);
424
- return out;
422
+ const staticByRoute = /* @__PURE__ */ new Map();
423
+ const routeless = [];
424
+ for (const r of staticResults) if (r.route) {
425
+ const bucket = staticByRoute.get(r.route);
426
+ if (bucket) bucket.push(r);
427
+ else staticByRoute.set(r.route, [r]);
428
+ } else routeless.push(r);
429
+ const out = [...routeless];
430
+ for (const [route, live] of liveByRoute) {
431
+ const liveIds = new Set(live.map((r) => r.id));
432
+ const staticForRoute = staticByRoute.get(route) ?? [];
433
+ out.push(...staticForRoute.filter((r) => !liveIds.has(r.id)), ...live);
434
+ staticByRoute.delete(route);
435
+ }
436
+ for (const remaining of staticByRoute.values()) out.push(...remaining);
437
+ return out;
425
438
  }
439
+ /** Per-route provenance for the merged view: 'measured' where a live layer exists, else 'static'. */
426
440
  function composeBadges(staticResults, liveByRoute) {
427
- const badges = {};
428
- for (const r of staticResults) {
429
- if (r.route) badges[r.route] = "static";
430
- }
431
- for (const route of liveByRoute.keys()) badges[route] = "measured";
432
- return badges;
441
+ const badges = {};
442
+ for (const r of staticResults) if (r.route) badges[r.route] = "static";
443
+ for (const route of liveByRoute.keys()) badges[route] = "measured";
444
+ return badges;
433
445
  }
434
446
  function createStore() {
435
- let staticResults = [];
436
- const liveByRoute = /* @__PURE__ */ new Map();
437
- const liveFailedByRoute = /* @__PURE__ */ new Map();
438
- const subs = /* @__PURE__ */ new Set();
439
- let analyzing = false;
440
- let seq = 0;
441
- function notify() {
442
- seq += 1;
443
- for (const fn of subs) fn();
444
- }
445
- return {
446
- set(route, results, failedRuleIds) {
447
- liveByRoute.set(
448
- route,
449
- results.map((r) => r.route ? r : { ...r, route })
450
- );
451
- if (failedRuleIds && failedRuleIds.length > 0) liveFailedByRoute.set(route, failedRuleIds);
452
- else liveFailedByRoute.delete(route);
453
- notify();
454
- },
455
- setStatic(results) {
456
- staticResults = results;
457
- notify();
458
- },
459
- setAnalyzing(next) {
460
- analyzing = next;
461
- notify();
462
- },
463
- isAnalyzing() {
464
- return analyzing;
465
- },
466
- snapshot() {
467
- return composeSnapshot(staticResults, liveByRoute);
468
- },
469
- badges() {
470
- return composeBadges(staticResults, liveByRoute);
471
- },
472
- failedRuleIds() {
473
- return [...new Set([...liveFailedByRoute.values()].flat())].sort();
474
- },
475
- sequence() {
476
- return seq;
477
- },
478
- subscribe(fn) {
479
- subs.add(fn);
480
- return () => subs.delete(fn);
481
- }
482
- };
447
+ let staticResults = [];
448
+ const liveByRoute = /* @__PURE__ */ new Map();
449
+ const liveFailedByRoute = /* @__PURE__ */ new Map();
450
+ const subs = /* @__PURE__ */ new Set();
451
+ let analyzing = false;
452
+ let seq = 0;
453
+ function notify() {
454
+ seq += 1;
455
+ for (const fn of subs) fn();
456
+ }
457
+ return {
458
+ set(route, results, failedRuleIds) {
459
+ liveByRoute.set(route, results.map((r) => r.route ? r : {
460
+ ...r,
461
+ route
462
+ }));
463
+ if (failedRuleIds && failedRuleIds.length > 0) liveFailedByRoute.set(route, failedRuleIds);
464
+ else liveFailedByRoute.delete(route);
465
+ notify();
466
+ },
467
+ setStatic(results) {
468
+ staticResults = results;
469
+ notify();
470
+ },
471
+ setAnalyzing(next) {
472
+ analyzing = next;
473
+ notify();
474
+ },
475
+ isAnalyzing() {
476
+ return analyzing;
477
+ },
478
+ snapshot() {
479
+ return composeSnapshot(staticResults, liveByRoute);
480
+ },
481
+ badges() {
482
+ return composeBadges(staticResults, liveByRoute);
483
+ },
484
+ failedRuleIds() {
485
+ return [...new Set([...liveFailedByRoute.values()].flat())].sort();
486
+ },
487
+ sequence() {
488
+ return seq;
489
+ },
490
+ subscribe(fn) {
491
+ subs.add(fn);
492
+ return () => subs.delete(fn);
493
+ }
494
+ };
483
495
  }
484
-
485
- // src/ui/analysis.ts
486
- import { relative as relative2, sep } from "path";
487
- import { analyzeProject } from "svelte-vitals";
496
+ //#endregion
497
+ //#region src/ui/analysis.ts
498
+ /**
499
+ * Owns the dev-dashboard's whole-project analysis lifecycle: an async run at startup,
500
+ * a debounced re-run on source changes, and coalescing so a change that arrives mid-run
501
+ * produces exactly one follow-up run rather than one per change (design doc §1).
502
+ */
488
503
  function createAnalysisRunner(opts) {
489
- const debounceMs = opts.debounceMs ?? 500;
490
- const analyze2 = opts.analyze ?? analyzeProject;
491
- const parseCache = /* @__PURE__ */ new Map();
492
- let stopped = false;
493
- let running = false;
494
- let pending = false;
495
- let timer;
496
- async function runOnce() {
497
- if (stopped) return;
498
- running = true;
499
- opts.onStatusChange?.(true);
500
- try {
501
- const { results, failedRuleIds } = await analyze2({
502
- cwd: opts.root,
503
- treatDynamicAs: opts.treatDynamicAs,
504
- metaComponents: opts.metaComponents,
505
- rules: opts.rules,
506
- failOn: opts.failOn,
507
- parseCache
508
- });
509
- if (!stopped) {
510
- if (failedRuleIds !== void 0) opts.onResults(results, failedRuleIds);
511
- else opts.onResults(results);
512
- }
513
- } catch (err) {
514
- if (!stopped) opts.onError(err);
515
- } finally {
516
- running = false;
517
- opts.onStatusChange?.(false);
518
- if (!stopped && pending) {
519
- pending = false;
520
- void runOnce();
521
- }
522
- }
523
- }
524
- return {
525
- start() {
526
- if (stopped) return;
527
- void runOnce();
528
- },
529
- notifyChange(file) {
530
- if (stopped) return;
531
- const rel = relative2(opts.root, file).split(sep).join("/");
532
- parseCache.delete(rel);
533
- if (timer !== void 0) clearTimeout(timer);
534
- timer = setTimeout(() => {
535
- timer = void 0;
536
- if (stopped) return;
537
- if (running) pending = true;
538
- else void runOnce();
539
- }, debounceMs);
540
- },
541
- stop() {
542
- stopped = true;
543
- if (timer !== void 0) {
544
- clearTimeout(timer);
545
- timer = void 0;
546
- }
547
- pending = false;
548
- }
549
- };
504
+ const debounceMs = opts.debounceMs ?? 500;
505
+ const analyze = opts.analyze ?? analyzeProject;
506
+ const parseCache = /* @__PURE__ */ new Map();
507
+ let stopped = false;
508
+ let running = false;
509
+ let pending = false;
510
+ let timer;
511
+ async function runOnce() {
512
+ if (stopped) return;
513
+ running = true;
514
+ opts.onStatusChange?.(true);
515
+ try {
516
+ const { results, failedRuleIds } = await analyze({
517
+ cwd: opts.root,
518
+ treatDynamicAs: opts.treatDynamicAs,
519
+ metaComponents: opts.metaComponents,
520
+ rules: opts.rules,
521
+ failOn: opts.failOn,
522
+ parseCache
523
+ });
524
+ if (!stopped) if (failedRuleIds !== void 0) opts.onResults(results, failedRuleIds);
525
+ else opts.onResults(results);
526
+ } catch (err) {
527
+ if (!stopped) opts.onError(err);
528
+ } finally {
529
+ running = false;
530
+ opts.onStatusChange?.(false);
531
+ if (!stopped && pending) {
532
+ pending = false;
533
+ runOnce();
534
+ }
535
+ }
536
+ }
537
+ return {
538
+ start() {
539
+ if (stopped) return;
540
+ runOnce();
541
+ },
542
+ notifyChange(file) {
543
+ if (stopped) return;
544
+ const rel = relative(opts.root, file).split(sep).join("/");
545
+ parseCache.delete(rel);
546
+ if (timer !== void 0) clearTimeout(timer);
547
+ timer = setTimeout(() => {
548
+ timer = void 0;
549
+ if (stopped) return;
550
+ if (running) pending = true;
551
+ else runOnce();
552
+ }, debounceMs);
553
+ },
554
+ stop() {
555
+ stopped = true;
556
+ if (timer !== void 0) {
557
+ clearTimeout(timer);
558
+ timer = void 0;
559
+ }
560
+ pending = false;
561
+ }
562
+ };
550
563
  }
551
-
552
- // src/plugin.ts
553
- var CONFIG_BASENAMES = /* @__PURE__ */ new Set([
554
- "svelte.config.js",
555
- "svelte.config.ts",
556
- // The svelte-vitals config names come from the CLI's own loader list, so the watcher
557
- // can never drift from what analyze() actually loads.
558
- ...CONFIG_FILENAMES,
559
- "vite.config.js",
560
- "vite.config.mjs",
561
- "vite.config.ts",
562
- "vite.config.cjs",
563
- "vite.config.mts",
564
- "vite.config.cts"
564
+ //#endregion
565
+ //#region src/plugin.ts
566
+ const CONFIG_BASENAMES = /* @__PURE__ */ new Set([
567
+ "svelte.config.js",
568
+ "svelte.config.ts",
569
+ ...CONFIG_FILENAMES,
570
+ "vite.config.js",
571
+ "vite.config.mjs",
572
+ "vite.config.ts",
573
+ "vite.config.cjs",
574
+ "vite.config.mts",
575
+ "vite.config.cts"
565
576
  ]);
566
- var IGNORED_SEGMENTS = /* @__PURE__ */ new Set(["node_modules", ".svelte-kit", "build", "dist"]);
567
- var warn = (line) => console.warn(terminalSafe(line));
577
+ const IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
578
+ "node_modules",
579
+ ".svelte-kit",
580
+ "build",
581
+ "dist"
582
+ ]);
583
+ /** Analyzed-repo-derived strings (rule messages, config warnings) can carry raw terminal escapes — sanitize at this sink boundary, not per interpolation. */
584
+ const warn = (line) => console.warn(terminalSafe(line));
585
+ /**
586
+ * Whether a `server.watcher` event on `file` should trigger a dev-dashboard re-analysis:
587
+ * anything under `src/` or `static/` (the default SvelteKit layout this dashboard assumes),
588
+ * or a `svelte.config.*` / `svelte-vitals.config.*` / `vite.config.*` at any depth (editing
589
+ * the Vite config can change the performance/minify-disabled fact) — excluding build/dependency
590
+ * output so their churn never triggers a spurious re-run. Exported for tests.
591
+ */
568
592
  function isRelevant(file, root) {
569
- const rel = relative3(root, file);
570
- if (rel === "" || rel.startsWith("..")) return false;
571
- const segments = rel.split(sep2);
572
- if (segments.some((s) => IGNORED_SEGMENTS.has(s))) return false;
573
- if (CONFIG_BASENAMES.has(basename(file))) return true;
574
- return segments[0] === "src" || segments[0] === "static";
593
+ const rel = relative(root, file);
594
+ if (rel === "" || rel.startsWith("..")) return false;
595
+ const segments = rel.split(sep);
596
+ if (segments.some((s) => IGNORED_SEGMENTS.has(s))) return false;
597
+ if (CONFIG_BASENAMES.has(basename(file))) return true;
598
+ return segments[0] === "src" || segments[0] === "static";
575
599
  }
576
- var DEFAULT_PRERENDER_DIR = ".svelte-kit/output/prerendered/pages";
600
+ const DEFAULT_PRERENDER_DIR = ".svelte-kit/output/prerendered/pages";
601
+ /**
602
+ * Validate the plugin's `rules` option the same way the CLI's config-file loader
603
+ * validates a config file's `rules` map (design 2026-07-26, Finding 4): an unknown
604
+ * rule id, an invalid `severity`, an unrecognized key in the object form, or an
605
+ * unknown/mistyped/out-of-range option is fatal, not silently dropped.
606
+ * `options.rules` never passes through `loadConfigFile` (that only validates
607
+ * `svelte-vitals.config.*`), so this is the only check a plugin-option `rules`
608
+ * value gets. Unknown ids are rejected first — as the CLI does — so a typo'd id
609
+ * with options reports "unknown rule id", not a misleading "takes no options"
610
+ * (2026-07-26 second review, Finding D). The setting shape itself is checked by
611
+ * core's `validateRuleSetting`, shared with the CLI loader: TypeScript already
612
+ * catches a bad severity for TS consumers, but a `vite.config.js` gets no such
613
+ * help, and a silently-inert typo is the failure this whole check exists for.
614
+ */
577
615
  function validateRulesOption(rules) {
578
- if (!rules) return;
579
- const unknown = findUnknownRuleIds(Object.keys(rules));
580
- if (unknown.length > 0) {
581
- throw new Error(
582
- `svelte-vitals: invalid \`rules\` option \u2014 unknown rule id(s): ${unknown.join(", ")}. Known rule ids: ${knownRuleIds().join(", ")}`
583
- );
584
- }
585
- const errors = [];
586
- for (const [id, setting] of Object.entries(rules)) {
587
- errors.push(...validateRuleSetting(`rules.${id}`, id, setting, ruleOptionsSpec(id), { allowOptions: true }));
588
- }
589
- if (errors.length > 0) throw new Error(`svelte-vitals: invalid \`rules\` option \u2014 ${errors.join(" ")}`);
616
+ if (!rules) return;
617
+ const unknown = findUnknownRuleIds(Object.keys(rules));
618
+ if (unknown.length > 0) throw new Error(`svelte-vitals: invalid \`rules\` option — unknown rule id(s): ${unknown.join(", ")}. Known rule ids: ${knownRuleIds().join(", ")}`);
619
+ const errors = [];
620
+ for (const [id, setting] of Object.entries(rules)) errors.push(...validateRuleSetting(`rules.${id}`, id, setting, ruleOptionsSpec(id), { allowOptions: true }));
621
+ if (errors.length > 0) throw new Error(`svelte-vitals: invalid \`rules\` option — ${errors.join(" ")}`);
590
622
  }
623
+ /**
624
+ * Validate the plugin's `overrides` option (2026-07-26 second review, Finding C):
625
+ * previously only `options.rules` was validated, so a typo inside
626
+ * `overrides[].rules[id].options` — the field the changeset advertises as the
627
+ * per-path home for options — silently dropped the option instead of failing
628
+ * loudly. Same fatal/known-id/carve-out rules as the CLI's config-file loader
629
+ * (both funnel through core's `validateRuleSetting`): unknown rule ids (rejected
630
+ * before options, per Finding D) or categories are fatal; a category key may
631
+ * carry a severity but never `options`; a rule-id key's options are validated
632
+ * against the baseline resolved from `rules`, so an override that only narrows
633
+ * one side of an already-widened range isn't falsely rejected (Finding A) —
634
+ * including when the widening comes from another `overrides[]` entry rather than
635
+ * the global `rules` layer (Finding A, third pass; see `shouldSkipRangeCheck`).
636
+ *
637
+ * `rulesLayerKnown` is false when the plugin got no `rules` option: `analyze()`
638
+ * then resolves the global layer from `svelte-vitals.config.*` instead
639
+ * (per-field precedence, `analyze.ts`), and that file can only be read
640
+ * asynchronously — after this synchronous construction-time check. A config file
641
+ * widening `max` while an override narrows `min` is a valid combination, so
642
+ * judging the override against the built-in default alone would hard-fail a
643
+ * `vite build` over a config that is correct at run time. The min/max
644
+ * cross-check is therefore skipped in that case; every other check (unknown id,
645
+ * unknown key, type, bounds) is unaffected, and `analyze()` still validates the
646
+ * config file itself through `loadConfigFile`.
647
+ */
591
648
  function validateOverridesOption(overrides, rules) {
592
- if (!overrides) return;
593
- const rulesLayerKnown = rules !== void 0;
594
- const baseConfig = { ...defaultConfig, rules: rules ?? {} };
595
- const errors = [];
596
- overrides.forEach((entry, i) => {
597
- const entryRules = entry.rules ?? {};
598
- const nonCategoryKeys = Object.keys(entryRules).filter((k) => !CATEGORIES2.includes(k));
599
- const unknown = findUnknownRuleIds(nonCategoryKeys);
600
- if (unknown.length > 0) {
601
- throw new Error(
602
- `svelte-vitals: invalid \`overrides\` option \u2014 overrides[${i}]: unknown rule id(s) or categories: ${unknown.join(", ")}. Known categories: ${CATEGORIES2.join(", ")}. Known rule ids: ${knownRuleIds().join(", ")}`
603
- );
604
- }
605
- for (const [key, setting] of Object.entries(entryRules)) {
606
- const isCategory = CATEGORIES2.includes(key);
607
- const baseline = isCategory ? void 0 : resolveRuleOptions(key, ruleOptionsSpec(key), baseConfig);
608
- errors.push(
609
- ...validateRuleSetting(`overrides[${i}].rules.${key}`, key, setting, ruleOptionsSpec(key), {
610
- allowOptions: !isCategory,
611
- ...baseline !== void 0 ? { baseline } : {},
612
- skipRangeCheck: !rulesLayerKnown || shouldSkipRangeCheck(overrides, i, key, setting)
613
- })
614
- );
615
- }
616
- });
617
- if (errors.length > 0) throw new Error(`svelte-vitals: invalid \`overrides\` option \u2014 ${errors.join(" ")}`);
649
+ if (!overrides) return;
650
+ const rulesLayerKnown = rules !== void 0;
651
+ const baseConfig = {
652
+ ...defaultConfig,
653
+ rules: rules ?? {}
654
+ };
655
+ const errors = [];
656
+ overrides.forEach((entry, i) => {
657
+ const entryRules = entry.rules ?? {};
658
+ const nonCategoryKeys = Object.keys(entryRules).filter((k) => !CATEGORIES.includes(k));
659
+ const unknown = findUnknownRuleIds(nonCategoryKeys);
660
+ if (unknown.length > 0) throw new Error(`svelte-vitals: invalid \`overrides\` option — overrides[${i}]: unknown rule id(s) or categories: ${unknown.join(", ")}. Known categories: ${CATEGORIES.join(", ")}. Known rule ids: ${knownRuleIds().join(", ")}`);
661
+ for (const [key, setting] of Object.entries(entryRules)) {
662
+ const isCategory = CATEGORIES.includes(key);
663
+ const baseline = isCategory ? void 0 : resolveRuleOptions(key, ruleOptionsSpec(key), baseConfig);
664
+ errors.push(...validateRuleSetting(`overrides[${i}].rules.${key}`, key, setting, ruleOptionsSpec(key), {
665
+ allowOptions: !isCategory,
666
+ ...baseline !== void 0 ? { baseline } : {},
667
+ skipRangeCheck: !rulesLayerKnown || shouldSkipRangeCheck(overrides, i, key, setting)
668
+ }));
669
+ }
670
+ });
671
+ if (errors.length > 0) throw new Error(`svelte-vitals: invalid \`overrides\` option — ${errors.join(" ")}`);
618
672
  }
673
+ /** svelte-vitals Vite/SvelteKit plugin. */
619
674
  function svelteVitals(options = {}) {
620
- validateRulesOption(options.rules);
621
- validateOverridesOption(options.overrides, options.rules);
622
- let root = options.cwd ?? process.cwd();
623
- let minify;
624
- let configFile;
625
- const buildPlugin = {
626
- name: "svelte-vitals",
627
- apply: "build",
628
- enforce: "post",
629
- config(userConfig) {
630
- minify = userConfig.build?.minify;
631
- },
632
- configResolved(config) {
633
- if (!options.cwd) root = config.root;
634
- configFile = config.configFile;
635
- },
636
- async closeBundle() {
637
- const pagesDir = options.prerenderDir ? options.prerenderDir : join3(root, DEFAULT_PRERENDER_DIR);
638
- const resolved = isAbsolute(pagesDir) ? pagesDir : join3(root, pagesDir);
639
- if (!existsSync(resolved)) return;
640
- const resolvedConfig = await resolveConfig(root, options);
641
- let result;
642
- try {
643
- const viteMinifyDisabled = await resolveMinifyDisabled(minify, configFile, root);
644
- result = await analyze(
645
- resolved,
646
- root,
647
- options,
648
- viteMinifyDisabled ? { viteMinifyDisabled } : void 0,
649
- resolvedConfig
650
- );
651
- } catch (err) {
652
- warn(`svelte-vitals: skipped \u2014 analysis failed: ${err instanceof Error ? err.message : String(err)}`);
653
- return;
654
- }
655
- for (const w of result.warnings) warn(`svelte-vitals: ${w}`);
656
- if (result.routeCount === 0) return;
657
- if (options.report !== false) {
658
- const out = options.report === "json" ? result.jsonReport : result.consoleReport;
659
- console.log(out);
660
- }
661
- if (options.outFile) {
662
- const outPath = isAbsolute(options.outFile) ? options.outFile : join3(root, options.outFile);
663
- await mkdir(dirname(outPath), { recursive: true });
664
- await writeFile(outPath, result.jsonReport);
665
- }
666
- if (result.failed) {
667
- throw new Error(`svelte-vitals: build failed \u2014 findings at or above "${result.failOn}".`);
668
- }
669
- }
670
- };
671
- if (options.ui === false) return buildPlugin;
672
- const uiPlugin = {
673
- name: "svelte-vitals:ui",
674
- apply: "serve",
675
- async configureServer(server) {
676
- process.env.SVELTE_VITALS_UI = "1";
677
- const uiRoot = options.cwd ?? server.config.root;
678
- let config;
679
- let warnings;
680
- try {
681
- ({ config, warnings } = await resolveConfig(uiRoot, options));
682
- } catch (err) {
683
- warn(
684
- `svelte-vitals: config file invalid \u2014 dashboard using plugin options/defaults: ${err instanceof Error ? err.message : String(err)}`
685
- );
686
- config = mergeConfig(options, void 0);
687
- warnings = [];
688
- }
689
- for (const w of warnings) warn(`svelte-vitals: ${w}`);
690
- const store = createStore();
691
- let staticFailedRuleIds = [];
692
- const runner = createAnalysisRunner({
693
- root: uiRoot,
694
- treatDynamicAs: options.treatDynamicAs,
695
- metaComponents: options.metaComponents,
696
- rules: options.rules,
697
- failOn: options.failOn,
698
- onResults: (results, failedRuleIds) => {
699
- store.setStatic(results);
700
- staticFailedRuleIds = failedRuleIds ?? [];
701
- },
702
- onError: (err) => warn(`[svelte-vitals] dev analysis failed: ${err instanceof Error ? err.message : String(err)}`),
703
- onStatusChange: (analyzing) => store.setAnalyzing(analyzing)
704
- });
705
- runner.start();
706
- server.watcher?.on("all", (_event, file) => {
707
- if (isRelevant(file, uiRoot)) runner.notifyChange(file);
708
- });
709
- server.httpServer?.once("close", () => {
710
- delete process.env.SVELTE_VITALS_UI;
711
- runner.stop();
712
- });
713
- installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion(), () => staticFailedRuleIds);
714
- if (typeof server.printUrls === "function") {
715
- const printUrls = server.printUrls.bind(server);
716
- server.printUrls = () => {
717
- printUrls();
718
- const printed = server.resolvedUrls?.local?.[0] ?? server.resolvedUrls?.network?.[0];
719
- if (printed) {
720
- console.log(` \u279C svelte-vitals: ${new URL(printed).origin}/__svelte-vitals/`);
721
- }
722
- };
723
- }
724
- }
725
- };
726
- return [buildPlugin, uiPlugin];
675
+ validateRulesOption(options.rules);
676
+ validateOverridesOption(options.overrides, options.rules);
677
+ let root = options.cwd ?? process.cwd();
678
+ let minify;
679
+ let configFile;
680
+ const buildPlugin = {
681
+ name: "svelte-vitals",
682
+ apply: "build",
683
+ enforce: "post",
684
+ config(userConfig) {
685
+ minify = userConfig.build?.minify;
686
+ },
687
+ configResolved(config) {
688
+ if (!options.cwd) root = config.root;
689
+ configFile = config.configFile;
690
+ },
691
+ async closeBundle() {
692
+ const pagesDir = options.prerenderDir ? options.prerenderDir : join(root, DEFAULT_PRERENDER_DIR);
693
+ const resolved = isAbsolute(pagesDir) ? pagesDir : join(root, pagesDir);
694
+ if (!existsSync(resolved)) return;
695
+ const resolvedConfig = await resolveConfig(root, options);
696
+ let result;
697
+ try {
698
+ const viteMinifyDisabled = await resolveMinifyDisabled(minify, configFile, root);
699
+ result = await analyze(resolved, root, options, viteMinifyDisabled ? { viteMinifyDisabled } : void 0, resolvedConfig);
700
+ } catch (err) {
701
+ warn(`svelte-vitals: skipped — analysis failed: ${err instanceof Error ? err.message : String(err)}`);
702
+ return;
703
+ }
704
+ for (const w of result.warnings) warn(`svelte-vitals: ${w}`);
705
+ if (result.routeCount === 0) return;
706
+ if (options.report !== false) {
707
+ const out = options.report === "json" ? result.jsonReport : result.consoleReport;
708
+ console.log(out);
709
+ }
710
+ if (options.outFile) {
711
+ const outPath = isAbsolute(options.outFile) ? options.outFile : join(root, options.outFile);
712
+ await mkdir(dirname(outPath), { recursive: true });
713
+ await writeFile(outPath, result.jsonReport);
714
+ }
715
+ if (result.failed) throw new Error(`svelte-vitals: build failed — findings at or above "${result.failOn}".`);
716
+ }
717
+ };
718
+ if (options.ui === false) return buildPlugin;
719
+ return [buildPlugin, {
720
+ name: "svelte-vitals:ui",
721
+ apply: "serve",
722
+ async configureServer(server) {
723
+ process.env.SVELTE_VITALS_UI = "1";
724
+ const uiRoot = options.cwd ?? server.config.root;
725
+ let config;
726
+ let warnings;
727
+ try {
728
+ ({config, warnings} = await resolveConfig(uiRoot, options));
729
+ } catch (err) {
730
+ warn(`svelte-vitals: config file invalid — dashboard using plugin options/defaults: ${err instanceof Error ? err.message : String(err)}`);
731
+ config = mergeConfig(options, void 0);
732
+ warnings = [];
733
+ }
734
+ for (const w of warnings) warn(`svelte-vitals: ${w}`);
735
+ const store = createStore();
736
+ let staticFailedRuleIds = [];
737
+ const runner = createAnalysisRunner({
738
+ root: uiRoot,
739
+ treatDynamicAs: options.treatDynamicAs,
740
+ metaComponents: options.metaComponents,
741
+ rules: options.rules,
742
+ failOn: options.failOn,
743
+ onResults: (results, failedRuleIds) => {
744
+ store.setStatic(results);
745
+ staticFailedRuleIds = failedRuleIds ?? [];
746
+ },
747
+ onError: (err) => warn(`[svelte-vitals] dev analysis failed: ${err instanceof Error ? err.message : String(err)}`),
748
+ onStatusChange: (analyzing) => store.setAnalyzing(analyzing)
749
+ });
750
+ runner.start();
751
+ server.watcher?.on("all", (_event, file) => {
752
+ if (isRelevant(file, uiRoot)) runner.notifyChange(file);
753
+ });
754
+ server.httpServer?.once("close", () => {
755
+ delete process.env.SVELTE_VITALS_UI;
756
+ runner.stop();
757
+ });
758
+ installUiMiddleware(server, config, readPackageVersion(), store, readCoreVersion(), () => staticFailedRuleIds);
759
+ if (typeof server.printUrls === "function") {
760
+ const printUrls = server.printUrls.bind(server);
761
+ server.printUrls = () => {
762
+ printUrls();
763
+ const printed = server.resolvedUrls?.local?.[0] ?? server.resolvedUrls?.network?.[0];
764
+ if (printed) console.log(` ➜ svelte-vitals: ${new URL(printed).origin}/__svelte-vitals/`);
765
+ };
766
+ }
767
+ }
768
+ }];
727
769
  }
728
- export {
729
- svelteVitals
730
- };
770
+ //#endregion
771
+ export { svelteVitals };