@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/hooks/index.d.ts +9 -9
- package/dist/hooks/index.js +103 -100
- package/dist/index.d.ts +29 -29
- package/dist/index.js +715 -674
- package/dist/loopback-dIwCgCTG.js +321 -0
- package/package.json +5 -5
- package/dist/chunk-KII3SU3J.js +0 -258
package/dist/index.js
CHANGED
|
@@ -1,730 +1,771 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
};
|
|
770
|
+
//#endregion
|
|
771
|
+
export { svelteVitals };
|