svelte-vitals 0.15.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +564 -8
- package/dist/{chunk-2NZBWTKF.js → chunk-NLQZ3CMZ.js} +130 -178
- package/dist/index.d.ts +25 -1
- package/dist/index.js +5 -3
- package/package.json +8 -5
package/dist/bin.js
CHANGED
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
knownRuleIds,
|
|
7
7
|
readPackageVersion,
|
|
8
8
|
run
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-NLQZ3CMZ.js";
|
|
10
10
|
|
|
11
11
|
// src/bin.ts
|
|
12
|
-
import
|
|
12
|
+
import mri2 from "mri";
|
|
13
13
|
|
|
14
14
|
// src/resolve-args.ts
|
|
15
15
|
var toList = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
@@ -26,6 +26,8 @@ function resolveArgs(argv) {
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
const route = typeof argv.route === "string" ? argv.route : void 0;
|
|
29
|
+
const diffBase = typeof argv.diff === "string" ? argv.diff || "HEAD" : void 0;
|
|
30
|
+
const staged = Boolean(argv.staged);
|
|
29
31
|
const allow = toList(argv.rules);
|
|
30
32
|
const ignore = toList(argv.ignore);
|
|
31
33
|
const unknown = findUnknownRuleIds([...allow, ...ignore]);
|
|
@@ -64,23 +66,570 @@ function resolveArgs(argv) {
|
|
|
64
66
|
outFile: typeof argv["out-file"] === "string" ? argv["out-file"] : void 0,
|
|
65
67
|
byRoute: Boolean(argv["by-route"]),
|
|
66
68
|
failOn,
|
|
67
|
-
rules: buildRulesConfig(allow, ignore)
|
|
69
|
+
rules: buildRulesConfig(allow, ignore),
|
|
70
|
+
...diffBase !== void 0 ? { diffBase } : {},
|
|
71
|
+
...staged ? { staged } : {}
|
|
68
72
|
},
|
|
69
73
|
warnings,
|
|
70
74
|
errors
|
|
71
75
|
};
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
// src/install/cli.ts
|
|
79
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
80
|
+
import { dirname } from "path";
|
|
81
|
+
import { homedir } from "os";
|
|
82
|
+
import { spawnSync } from "child_process";
|
|
83
|
+
import mri from "mri";
|
|
84
|
+
import * as p from "@clack/prompts";
|
|
85
|
+
|
|
86
|
+
// src/install/index.ts
|
|
87
|
+
import { join as join3 } from "path";
|
|
88
|
+
|
|
89
|
+
// src/install/clients.ts
|
|
90
|
+
import { join } from "path";
|
|
91
|
+
var MCP_ENTRY = { command: "npx", args: ["-y", "@svelte-vitals/mcp"] };
|
|
92
|
+
var CLIENTS = [
|
|
93
|
+
{
|
|
94
|
+
id: "claude-code",
|
|
95
|
+
label: "Claude Code",
|
|
96
|
+
scopes: ["project", "global"],
|
|
97
|
+
format: "json",
|
|
98
|
+
resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".mcp.json") : join(home, ".claude.json")
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "cursor",
|
|
102
|
+
label: "Cursor",
|
|
103
|
+
scopes: ["project", "global"],
|
|
104
|
+
format: "json",
|
|
105
|
+
resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".cursor", "mcp.json") : join(home, ".cursor", "mcp.json")
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "codex",
|
|
109
|
+
label: "Codex",
|
|
110
|
+
scopes: ["global"],
|
|
111
|
+
format: "toml",
|
|
112
|
+
resolvePath: (_scope, _cwd, home) => join(home, ".codex", "config.toml")
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
function clientById(id) {
|
|
116
|
+
return CLIENTS.find((c) => c.id === id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/install/merge.ts
|
|
120
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
121
|
+
var SERVER_KEY = "svelte-vitals";
|
|
122
|
+
function isPlainObject(v) {
|
|
123
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
124
|
+
}
|
|
125
|
+
function sameEntry(prior, entry) {
|
|
126
|
+
if (typeof prior !== "object" || prior === null) return false;
|
|
127
|
+
const o = prior;
|
|
128
|
+
return o.command === entry.command && Array.isArray(o.args) && o.args.length === entry.args.length && o.args.every((v, i) => v === entry.args[i]);
|
|
129
|
+
}
|
|
130
|
+
function statusFor(prior, entry, force, created) {
|
|
131
|
+
if (prior !== void 0) {
|
|
132
|
+
if (sameEntry(prior, entry)) return "exists";
|
|
133
|
+
return force ? "updated" : "skip";
|
|
134
|
+
}
|
|
135
|
+
return created ? "created" : "added";
|
|
136
|
+
}
|
|
137
|
+
function mergeJson(existing, entry, force) {
|
|
138
|
+
const created = existing === void 0;
|
|
139
|
+
const parsed = created ? {} : JSON.parse(existing);
|
|
140
|
+
if (!isPlainObject(parsed)) {
|
|
141
|
+
throw new Error("existing config is not a JSON object");
|
|
142
|
+
}
|
|
143
|
+
const root = parsed;
|
|
144
|
+
if (root.mcpServers !== void 0 && !isPlainObject(root.mcpServers)) {
|
|
145
|
+
throw new Error('existing config has a non-object "mcpServers" table');
|
|
146
|
+
}
|
|
147
|
+
const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
|
|
148
|
+
const status = statusFor(servers[SERVER_KEY], entry, force, created);
|
|
149
|
+
if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
|
|
150
|
+
servers[SERVER_KEY] = { command: entry.command, args: entry.args };
|
|
151
|
+
root.mcpServers = servers;
|
|
152
|
+
return { content: JSON.stringify(root, null, 2) + "\n", status };
|
|
153
|
+
}
|
|
154
|
+
function mergeToml(existing, entry, force) {
|
|
155
|
+
const created = existing === void 0;
|
|
156
|
+
const parsed = created ? {} : parseToml(existing);
|
|
157
|
+
if (!isPlainObject(parsed)) {
|
|
158
|
+
throw new Error("existing config is not a TOML table");
|
|
159
|
+
}
|
|
160
|
+
const root = parsed;
|
|
161
|
+
if (root.mcp_servers !== void 0 && !isPlainObject(root.mcp_servers)) {
|
|
162
|
+
throw new Error('existing config has a non-table "mcp_servers" section');
|
|
163
|
+
}
|
|
164
|
+
const servers = isPlainObject(root.mcp_servers) ? root.mcp_servers : {};
|
|
165
|
+
const status = statusFor(servers[SERVER_KEY], entry, force, created);
|
|
166
|
+
if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
|
|
167
|
+
servers[SERVER_KEY] = { command: entry.command, args: entry.args };
|
|
168
|
+
root.mcp_servers = servers;
|
|
169
|
+
return { content: stringifyToml(root), status };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/install/vite-targets.ts
|
|
173
|
+
var VITE_TARGETS = [
|
|
174
|
+
{
|
|
175
|
+
id: "vite-plugin",
|
|
176
|
+
label: "Vite plugin (build gate)",
|
|
177
|
+
hint: "Fails `vite build` when prerendered pages cross the SEO/Performance threshold"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "vite-dev-overlay",
|
|
181
|
+
label: "Dev overlay",
|
|
182
|
+
hint: "Live warnings in `vite dev` only \u2014 never fails a build or CI"
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
function viteTargetById(id) {
|
|
186
|
+
return VITE_TARGETS.find((t) => t.id === id);
|
|
187
|
+
}
|
|
188
|
+
function isViteTargetId(id) {
|
|
189
|
+
return VITE_TARGETS.some((t) => t.id === id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/install/codemod-vite-config.ts
|
|
193
|
+
import { parseModule, generateCode, builders, MagicastError } from "magicast";
|
|
194
|
+
var MANUAL_SNIPPET = `import { svelteVitals } from '@svelte-vitals/vite';
|
|
195
|
+
// add svelteVitals() to your \`plugins\` array`;
|
|
196
|
+
function codemodViteConfig(existing) {
|
|
197
|
+
if (existing === void 0) {
|
|
198
|
+
return { status: "manual", snippet: MANUAL_SNIPPET };
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const mod = parseModule(existing);
|
|
202
|
+
const def = mod.exports.default;
|
|
203
|
+
const configObj = def?.$type === "function-call" ? def.$args[0] : def;
|
|
204
|
+
if (!configObj || configObj.$type !== "object" || configObj.plugins?.$type !== "array") {
|
|
205
|
+
return { status: "manual", snippet: MANUAL_SNIPPET };
|
|
206
|
+
}
|
|
207
|
+
const already = configObj.plugins.find(
|
|
208
|
+
(p2) => p2?.$type === "function-call" && p2?.$callee === "svelteVitals"
|
|
209
|
+
);
|
|
210
|
+
if (already !== void 0) {
|
|
211
|
+
return { status: "exists" };
|
|
212
|
+
}
|
|
213
|
+
if (!mod.imports.svelteVitals) {
|
|
214
|
+
mod.imports.$append({ imported: "svelteVitals", local: "svelteVitals", from: "@svelte-vitals/vite" });
|
|
215
|
+
}
|
|
216
|
+
configObj.plugins.unshift(builders.functionCall("svelteVitals"));
|
|
217
|
+
return { status: "added", content: generateCode(mod, { format: { objectCurlySpacing: true } }).code };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof MagicastError) {
|
|
220
|
+
return { status: "manual", snippet: MANUAL_SNIPPET };
|
|
221
|
+
}
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/install/codemod-hooks.ts
|
|
227
|
+
import { parseModule as parseModule2, generateCode as generateCode2, builders as builders2, MagicastError as MagicastError2 } from "magicast";
|
|
228
|
+
var FRESH_HANDLE = `import { svelteVitalsHandle } from '@svelte-vitals/vite/hooks';
|
|
229
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
230
|
+
|
|
231
|
+
export const handle = sequence(svelteVitalsHandle());
|
|
232
|
+
`;
|
|
233
|
+
var MANUAL_SNIPPET2 = `import { svelteVitalsHandle } from '@svelte-vitals/vite/hooks';
|
|
234
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
235
|
+
// wrap your existing \`handle\` in sequence(yourHandle, svelteVitalsHandle())`;
|
|
236
|
+
function addImports(mod) {
|
|
237
|
+
if (!mod.imports.sequence) {
|
|
238
|
+
mod.imports.$append({ imported: "sequence", local: "sequence", from: "@sveltejs/kit/hooks" });
|
|
239
|
+
}
|
|
240
|
+
if (!mod.imports.svelteVitalsHandle) {
|
|
241
|
+
mod.imports.$append({
|
|
242
|
+
imported: "svelteVitalsHandle",
|
|
243
|
+
local: "svelteVitalsHandle",
|
|
244
|
+
from: "@svelte-vitals/vite/hooks"
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function codemodHooksServer(existing) {
|
|
249
|
+
if (existing === void 0) {
|
|
250
|
+
return { status: "created", content: FRESH_HANDLE };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const mod = parseModule2(existing);
|
|
254
|
+
const handle = mod.exports.handle;
|
|
255
|
+
if (handle === void 0) {
|
|
256
|
+
addImports(mod);
|
|
257
|
+
mod.exports.handle = builders2.functionCall("sequence", builders2.functionCall("svelteVitalsHandle"));
|
|
258
|
+
return {
|
|
259
|
+
status: "added",
|
|
260
|
+
content: generateCode2(mod, { format: { objectCurlySpacing: true, quote: "single" } }).code
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (handle.$type === "function-call" && handle.$callee === "sequence") {
|
|
264
|
+
const already = handle.$args.find(
|
|
265
|
+
(a) => a?.$type === "function-call" && a?.$callee === "svelteVitalsHandle"
|
|
266
|
+
);
|
|
267
|
+
if (already !== void 0) {
|
|
268
|
+
return { status: "exists" };
|
|
269
|
+
}
|
|
270
|
+
if (!mod.imports.svelteVitalsHandle) {
|
|
271
|
+
mod.imports.$append({
|
|
272
|
+
imported: "svelteVitalsHandle",
|
|
273
|
+
local: "svelteVitalsHandle",
|
|
274
|
+
from: "@svelte-vitals/vite/hooks"
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
handle.$args.push(builders2.functionCall("svelteVitalsHandle"));
|
|
278
|
+
return {
|
|
279
|
+
status: "added",
|
|
280
|
+
content: generateCode2(mod, { format: { objectCurlySpacing: true, quote: "single" } }).code
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
addImports(mod);
|
|
284
|
+
mod.exports.handle = builders2.functionCall("sequence", handle, builders2.functionCall("svelteVitalsHandle"));
|
|
285
|
+
return {
|
|
286
|
+
status: "updated",
|
|
287
|
+
content: generateCode2(mod, { format: { objectCurlySpacing: true, quote: "single" } }).code
|
|
288
|
+
};
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err instanceof MagicastError2) {
|
|
291
|
+
return { status: "manual", snippet: MANUAL_SNIPPET2 };
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/install/package-manager.ts
|
|
298
|
+
import { join as join2 } from "path";
|
|
299
|
+
var LOCKFILE_TO_PM = {
|
|
300
|
+
"pnpm-lock.yaml": "pnpm",
|
|
301
|
+
"yarn.lock": "yarn",
|
|
302
|
+
"bun.lock": "bun",
|
|
303
|
+
"bun.lockb": "bun"
|
|
304
|
+
};
|
|
305
|
+
function detectPackageManager(io) {
|
|
306
|
+
for (const [file, pm] of Object.entries(LOCKFILE_TO_PM)) {
|
|
307
|
+
if (io.readFile(join2(io.cwd, file)) !== void 0) return pm;
|
|
308
|
+
}
|
|
309
|
+
return "npm";
|
|
310
|
+
}
|
|
311
|
+
function hasVitePackage(io) {
|
|
312
|
+
const raw = io.readFile(join2(io.cwd, "package.json"));
|
|
313
|
+
if (raw === void 0) return false;
|
|
314
|
+
try {
|
|
315
|
+
const pkg = JSON.parse(raw);
|
|
316
|
+
return Boolean(pkg.dependencies?.["@svelte-vitals/vite"] || pkg.devDependencies?.["@svelte-vitals/vite"]);
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function installCommand(pm) {
|
|
322
|
+
const action = pm === "npm" ? "install" : "add";
|
|
323
|
+
return { command: pm, args: [action, "-D", "@svelte-vitals/vite"] };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/install/index.ts
|
|
327
|
+
function planForClient(client, scope, io, force) {
|
|
328
|
+
const path = client.resolvePath(scope, io.cwd, io.home);
|
|
329
|
+
const existing = io.readFile(path);
|
|
330
|
+
const merged = client.format === "toml" ? mergeToml(existing, MCP_ENTRY, force) : mergeJson(existing, MCP_ENTRY, force);
|
|
331
|
+
return { id: client.id, label: client.label, scope, path, status: merged.status, content: merged.content };
|
|
332
|
+
}
|
|
333
|
+
function resolveCandidate(io, candidates) {
|
|
334
|
+
for (const rel of candidates) {
|
|
335
|
+
const path = join3(io.cwd, rel);
|
|
336
|
+
const content = io.readFile(path);
|
|
337
|
+
if (content !== void 0) return { path, content };
|
|
338
|
+
}
|
|
339
|
+
return { path: join3(io.cwd, candidates[0]), content: void 0 };
|
|
340
|
+
}
|
|
341
|
+
function planForVitePlugin(io) {
|
|
342
|
+
const { path, content } = resolveCandidate(io, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]);
|
|
343
|
+
const result = codemodViteConfig(content);
|
|
344
|
+
return { id: "vite-plugin", label: viteTargetById("vite-plugin").label, path, ...result };
|
|
345
|
+
}
|
|
346
|
+
function planForDevOverlay(io) {
|
|
347
|
+
const { path, content } = resolveCandidate(io, ["src/hooks.server.ts", "src/hooks.server.js"]);
|
|
348
|
+
const result = codemodHooksServer(content);
|
|
349
|
+
return { id: "vite-dev-overlay", label: viteTargetById("vite-dev-overlay").label, path, ...result };
|
|
350
|
+
}
|
|
351
|
+
function indent(text) {
|
|
352
|
+
return text.split("\n").map((l) => ` ${l}`).join("\n");
|
|
353
|
+
}
|
|
354
|
+
function rowLine(r) {
|
|
355
|
+
const head = ` ${r.label}${r.scope ? ` (${r.scope})` : ""} \u2192 ${r.path} [${r.status}]`;
|
|
356
|
+
return r.status === "manual" && r.snippet ? `${head}
|
|
357
|
+
${indent(r.snippet)}` : head;
|
|
358
|
+
}
|
|
359
|
+
async function runInstall(flags, io, prompts) {
|
|
360
|
+
let ids;
|
|
361
|
+
if (flags.client && flags.client.length > 0) {
|
|
362
|
+
ids = flags.client;
|
|
363
|
+
} else if (io.isTTY) {
|
|
364
|
+
const configExists = (path) => {
|
|
365
|
+
try {
|
|
366
|
+
return io.readFile(path) !== void 0;
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const detectedClients = CLIENTS.filter(
|
|
372
|
+
(c) => c.scopes.some((s) => configExists(c.resolvePath(s, io.cwd, io.home)))
|
|
373
|
+
).map((c) => c.id);
|
|
374
|
+
const viteConfigExists = ["vite.config.ts", "vite.config.js", "vite.config.mjs"].some(
|
|
375
|
+
(f) => configExists(join3(io.cwd, f))
|
|
376
|
+
);
|
|
377
|
+
const detected = [...detectedClients, ...viteConfigExists ? VITE_TARGETS.map((t) => t.id) : []];
|
|
378
|
+
const options = [
|
|
379
|
+
...CLIENTS.map((c) => ({ id: c.id, label: c.label })),
|
|
380
|
+
...VITE_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint }))
|
|
381
|
+
];
|
|
382
|
+
const picked = await prompts.selectClients(options, detected);
|
|
383
|
+
if (picked === null) {
|
|
384
|
+
io.log("Cancelled.");
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
ids = picked;
|
|
388
|
+
} else {
|
|
389
|
+
io.errorLog(
|
|
390
|
+
"svelte-vitals: no TTY; pass --client <claude-code,cursor,codex,vite-plugin,vite-dev-overlay> to install non-interactively."
|
|
391
|
+
);
|
|
392
|
+
return 2;
|
|
393
|
+
}
|
|
394
|
+
const clients = ids.map(clientById).filter((c) => c !== void 0);
|
|
395
|
+
const viteIds = ids.filter(isViteTargetId);
|
|
396
|
+
if (clients.length === 0 && viteIds.length === 0) {
|
|
397
|
+
io.errorLog("svelte-vitals: no valid clients or targets selected.");
|
|
398
|
+
return 2;
|
|
399
|
+
}
|
|
400
|
+
const rows = [];
|
|
401
|
+
for (const client of clients) {
|
|
402
|
+
let scope;
|
|
403
|
+
if (client.scopes.length === 1) {
|
|
404
|
+
scope = client.scopes[0];
|
|
405
|
+
} else if (flags.scope) {
|
|
406
|
+
scope = flags.scope;
|
|
407
|
+
} else if (io.isTTY) {
|
|
408
|
+
const picked = await prompts.selectScope(client);
|
|
409
|
+
if (picked === null) {
|
|
410
|
+
io.log("Cancelled.");
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
scope = picked;
|
|
414
|
+
} else {
|
|
415
|
+
scope = "project";
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
rows.push(planForClient(client, scope, io, flags.force ?? false));
|
|
419
|
+
} catch (err) {
|
|
420
|
+
const path = client.resolvePath(scope, io.cwd, io.home);
|
|
421
|
+
io.errorLog(
|
|
422
|
+
`svelte-vitals: could not parse existing config at ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
423
|
+
);
|
|
424
|
+
return 2;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const viteId of viteIds) {
|
|
428
|
+
rows.push(viteId === "vite-plugin" ? planForVitePlugin(io) : planForDevOverlay(io));
|
|
429
|
+
}
|
|
430
|
+
const planText = rows.map(rowLine).join("\n");
|
|
431
|
+
io.log("Plan:");
|
|
432
|
+
io.log(planText);
|
|
433
|
+
if (flags.dryRun) {
|
|
434
|
+
io.log("Dry run \u2014 no files written.");
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
if (!flags.yes && io.isTTY) {
|
|
438
|
+
const ok = await prompts.confirm(planText);
|
|
439
|
+
if (!ok) {
|
|
440
|
+
io.log("Cancelled.");
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
let hadFailure = false;
|
|
445
|
+
let viteWasWritten = false;
|
|
446
|
+
for (const r of rows) {
|
|
447
|
+
if (r.status === "exists") {
|
|
448
|
+
const hint = isViteTargetId(r.id) ? "" : " \u2014 use --force to overwrite";
|
|
449
|
+
io.log(`= ${r.label}: already configured (${r.path})${hint}.`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (r.status === "manual") {
|
|
453
|
+
io.log(`! ${r.label}: couldn't safely modify ${r.path} \u2014 add this by hand:
|
|
454
|
+
${indent(r.snippet ?? "")}`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
io.writeFile(r.path, r.content ?? "");
|
|
459
|
+
io.log(`\u2713 ${r.label}: ${r.status} ${r.path}`);
|
|
460
|
+
if (isViteTargetId(r.id)) viteWasWritten = true;
|
|
461
|
+
} catch (err) {
|
|
462
|
+
hadFailure = true;
|
|
463
|
+
io.errorLog(`svelte-vitals: failed to write ${r.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (viteWasWritten && io.runCommand && !hasVitePackage(io)) {
|
|
467
|
+
const pm = detectPackageManager(io);
|
|
468
|
+
const { command, args } = installCommand(pm);
|
|
469
|
+
io.log(`Installing @svelte-vitals/vite via ${pm}...`);
|
|
470
|
+
const code = io.runCommand(command, args, io.cwd);
|
|
471
|
+
if (code !== 0) {
|
|
472
|
+
io.errorLog(
|
|
473
|
+
`svelte-vitals: failed to install @svelte-vitals/vite (${command} ${args.join(" ")} exited ${code}). Install it manually.`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (hadFailure) return 2;
|
|
478
|
+
io.log("");
|
|
479
|
+
if (clients.length > 0) io.log("Restart your client to load the svelte-vitals MCP server.");
|
|
480
|
+
if (viteWasWritten) io.log("Restart `vite dev` (or your build) to pick up the change.");
|
|
481
|
+
io.log("Done.");
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/install/args.ts
|
|
486
|
+
var VALID_TARGETS = [...CLIENTS.map((c) => c.id), ...VITE_TARGETS.map((t) => t.id)];
|
|
487
|
+
var EXPECTED_TARGETS = VALID_TARGETS.join("|");
|
|
488
|
+
function resolveInstallArgs(argv) {
|
|
489
|
+
const warnings = [];
|
|
490
|
+
const errors = [];
|
|
491
|
+
const rawClients = typeof argv.client === "string" ? argv.client.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
492
|
+
const client = [];
|
|
493
|
+
for (const c of rawClients) {
|
|
494
|
+
if (VALID_TARGETS.includes(c)) {
|
|
495
|
+
if (!client.includes(c)) client.push(c);
|
|
496
|
+
} else {
|
|
497
|
+
warnings.push(`svelte-vitals: unknown --client '${c}'; expected ${EXPECTED_TARGETS}. Skipping.`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (rawClients.length > 0 && client.length === 0) {
|
|
501
|
+
errors.push(`svelte-vitals: no valid --client values; expected ${EXPECTED_TARGETS}.`);
|
|
502
|
+
}
|
|
503
|
+
let scope;
|
|
504
|
+
const rawScope = argv.scope;
|
|
505
|
+
if (typeof rawScope === "string") {
|
|
506
|
+
if (rawScope === "project" || rawScope === "global") scope = rawScope;
|
|
507
|
+
else errors.push(`svelte-vitals: unknown --scope '${rawScope}'; expected project|global.`);
|
|
508
|
+
}
|
|
509
|
+
if (errors.length > 0) return { flags: null, warnings, errors };
|
|
510
|
+
return {
|
|
511
|
+
flags: {
|
|
512
|
+
...client.length > 0 ? { client } : {},
|
|
513
|
+
...scope ? { scope } : {},
|
|
514
|
+
yes: Boolean(argv.yes),
|
|
515
|
+
dryRun: Boolean(argv["dry-run"]),
|
|
516
|
+
force: Boolean(argv.force)
|
|
517
|
+
},
|
|
518
|
+
warnings,
|
|
519
|
+
errors
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/install/cli.ts
|
|
524
|
+
var INSTALL_HELP = `svelte-vitals install \u2014 set up the svelte-vitals MCP server for your AI-agent clients
|
|
525
|
+
|
|
526
|
+
Usage:
|
|
527
|
+
svelte-vitals install [options]
|
|
528
|
+
|
|
529
|
+
Options:
|
|
530
|
+
--client <ids> Comma-separated: claude-code,cursor,codex,vite-plugin,vite-dev-overlay (skips the interactive picker)
|
|
531
|
+
vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-dev-overlay
|
|
532
|
+
wires up the dev-overlay hook in src/hooks.server.{ts,js}. --force does not apply
|
|
533
|
+
to either \u2014 an existing registration is always left as-is.
|
|
534
|
+
--scope <scope> project | global (applies to all selected clients; codex is always global)
|
|
535
|
+
--yes, -y Skip the confirmation prompt
|
|
536
|
+
--dry-run Print the planned changes and exit without writing
|
|
537
|
+
--force Overwrite an existing svelte-vitals entry
|
|
538
|
+
-h, --help Show this help`;
|
|
539
|
+
function realIO() {
|
|
540
|
+
return {
|
|
541
|
+
readFile: (path) => {
|
|
542
|
+
try {
|
|
543
|
+
return readFileSync(path, "utf8");
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (err.code === "ENOENT") return void 0;
|
|
546
|
+
throw err;
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
writeFile: (path, content) => {
|
|
550
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
551
|
+
writeFileSync(path, content);
|
|
552
|
+
},
|
|
553
|
+
cwd: process.cwd(),
|
|
554
|
+
home: homedir(),
|
|
555
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
556
|
+
log: (line) => console.log(line),
|
|
557
|
+
errorLog: (line) => console.error(line),
|
|
558
|
+
runCommand: (command, args, cwd) => {
|
|
559
|
+
const result = spawnSync(command, args, {
|
|
560
|
+
cwd,
|
|
561
|
+
stdio: "inherit",
|
|
562
|
+
shell: process.platform === "win32",
|
|
563
|
+
timeout: 12e4
|
|
564
|
+
});
|
|
565
|
+
if (result.error) {
|
|
566
|
+
console.error(`svelte-vitals: ${command} failed to start: ${result.error.message}`);
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
if (result.signal) {
|
|
570
|
+
console.error(`svelte-vitals: ${command} was terminated (${result.signal}) \u2014 it may have timed out.`);
|
|
571
|
+
return 1;
|
|
572
|
+
}
|
|
573
|
+
return result.status ?? 1;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function clackPrompts() {
|
|
578
|
+
return {
|
|
579
|
+
selectClients: async (all, defaults) => {
|
|
580
|
+
const res = await p.multiselect({
|
|
581
|
+
message: "Which clients/targets should svelte-vitals be installed for?",
|
|
582
|
+
options: all.map((o) => ({ value: o.id, label: o.label, hint: o.hint })),
|
|
583
|
+
initialValues: defaults,
|
|
584
|
+
required: true
|
|
585
|
+
});
|
|
586
|
+
return p.isCancel(res) ? null : res;
|
|
587
|
+
},
|
|
588
|
+
selectScope: async (client) => {
|
|
589
|
+
const res = await p.select({
|
|
590
|
+
message: `Scope for ${client.label}?`,
|
|
591
|
+
options: client.scopes.map((s) => ({ value: s, label: s })),
|
|
592
|
+
initialValue: client.scopes[0]
|
|
593
|
+
});
|
|
594
|
+
return p.isCancel(res) ? null : res;
|
|
595
|
+
},
|
|
596
|
+
confirm: async (planText) => {
|
|
597
|
+
const res = await p.confirm({ message: `Apply this plan?
|
|
598
|
+
${planText}` });
|
|
599
|
+
return p.isCancel(res) ? false : Boolean(res);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async function runInstallCli(args) {
|
|
604
|
+
const argv = mri(args, {
|
|
605
|
+
boolean: ["yes", "dry-run", "force", "help"],
|
|
606
|
+
string: ["client", "scope"],
|
|
607
|
+
alias: { y: "yes", h: "help" }
|
|
608
|
+
});
|
|
609
|
+
if (argv.help) {
|
|
610
|
+
console.log(INSTALL_HELP);
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
const { flags, warnings, errors } = resolveInstallArgs(argv);
|
|
614
|
+
for (const w of warnings) console.error(w);
|
|
615
|
+
for (const e of errors) console.error(e);
|
|
616
|
+
if (!flags) return 2;
|
|
617
|
+
return runInstall(flags, realIO(), clackPrompts());
|
|
618
|
+
}
|
|
619
|
+
|
|
74
620
|
// src/bin.ts
|
|
75
|
-
var HELP = `svelte-vitals \u2014 a SvelteKit
|
|
621
|
+
var HELP = `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
|
|
76
622
|
|
|
77
623
|
Usage:
|
|
78
624
|
svelte-vitals [path] [options]
|
|
625
|
+
svelte-vitals install Set up the MCP server for Claude Code / Cursor / Codex
|
|
79
626
|
|
|
80
627
|
Options:
|
|
81
628
|
--meta-components <names> Comma-separated component names that emit head metadata
|
|
82
629
|
--treat-dynamic-as <mode> pass | warn | fail (default: pass)
|
|
83
630
|
--route <glob> Only analyze routes matching this glob
|
|
631
|
+
--diff [ref] Report only findings in files changed vs ref (default HEAD; e.g. --diff main)
|
|
632
|
+
--staged Report only findings in files staged for commit (pre-commit gate)
|
|
84
633
|
--by-route Show per-route score breakdown in console output
|
|
85
634
|
--reporter <fmt> console | json | agent | sarif | github | html (auto: agent under AI-agent envs, github under GitHub Actions)
|
|
86
635
|
--out-file <path> Output path for --reporter html (default: svelte-vitals-report.html; '-' for stdout)
|
|
@@ -90,6 +639,7 @@ Options:
|
|
|
90
639
|
--min-health <0-100> Fail (exit 1) when the combined Health score is below this value
|
|
91
640
|
--rules <ids> Comma-separated rule ids to enable (all others disabled)
|
|
92
641
|
--ignore <ids> Comma-separated rule ids to disable
|
|
642
|
+
--no-color Disable ANSI color in console output
|
|
93
643
|
-h, --help Show this help
|
|
94
644
|
-v, --version Show version
|
|
95
645
|
|
|
@@ -99,9 +649,14 @@ Exit codes:
|
|
|
99
649
|
2 execution error (not a SvelteKit project / internal error)`;
|
|
100
650
|
var VERSION = readPackageVersion();
|
|
101
651
|
async function main() {
|
|
102
|
-
const
|
|
652
|
+
const rawArgs = process.argv.slice(2);
|
|
653
|
+
if (rawArgs[0] === "install") {
|
|
654
|
+
const code2 = await runInstallCli(rawArgs.slice(1));
|
|
655
|
+
process.exit(code2);
|
|
656
|
+
}
|
|
657
|
+
const argv = mri2(process.argv.slice(2), {
|
|
103
658
|
alias: { h: "help", v: "version" },
|
|
104
|
-
boolean: ["by-route", "json", "fail-on-warning"],
|
|
659
|
+
boolean: ["by-route", "json", "fail-on-warning", "staged", "no-color"],
|
|
105
660
|
string: [
|
|
106
661
|
"meta-components",
|
|
107
662
|
"treat-dynamic-as",
|
|
@@ -111,7 +666,8 @@ async function main() {
|
|
|
111
666
|
"rules",
|
|
112
667
|
"ignore",
|
|
113
668
|
"min-health",
|
|
114
|
-
"out-file"
|
|
669
|
+
"out-file",
|
|
670
|
+
"diff"
|
|
115
671
|
]
|
|
116
672
|
});
|
|
117
673
|
if (argv.help) {
|
|
@@ -136,7 +692,7 @@ async function main() {
|
|
|
136
692
|
}
|
|
137
693
|
minHealth = n;
|
|
138
694
|
}
|
|
139
|
-
const code = await run({ ...options, minHealth });
|
|
695
|
+
const code = await run({ ...options, minHealth, noColor: argv["no-color"] });
|
|
140
696
|
process.exit(code);
|
|
141
697
|
}
|
|
142
698
|
void main();
|
|
@@ -124,6 +124,15 @@ async function collectProjectFacts(rt, cwd) {
|
|
|
124
124
|
|
|
125
125
|
// src/providers/source/parse.ts
|
|
126
126
|
import { parse } from "svelte/compiler";
|
|
127
|
+
import {
|
|
128
|
+
CHILD_NODE_KEYS,
|
|
129
|
+
lineOf,
|
|
130
|
+
findAttr,
|
|
131
|
+
valueFromNodes,
|
|
132
|
+
textFromNodes,
|
|
133
|
+
attrText,
|
|
134
|
+
attrValue
|
|
135
|
+
} from "@svelte-vitals/core";
|
|
127
136
|
|
|
128
137
|
// src/providers/source/imports.ts
|
|
129
138
|
function addImportsFromProgram(program, map) {
|
|
@@ -150,58 +159,6 @@ function collectImports(ast) {
|
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
// src/providers/source/parse.ts
|
|
153
|
-
var CHILD_NODE_KEYS = [
|
|
154
|
-
"fragment",
|
|
155
|
-
"nodes",
|
|
156
|
-
"consequent",
|
|
157
|
-
"alternate",
|
|
158
|
-
"body",
|
|
159
|
-
"pending",
|
|
160
|
-
"then",
|
|
161
|
-
"catch",
|
|
162
|
-
"fallback"
|
|
163
|
-
];
|
|
164
|
-
function valueFromNodes(nodes) {
|
|
165
|
-
if (!Array.isArray(nodes)) return "absent";
|
|
166
|
-
if (nodes.some((n) => n?.type === "ExpressionTag")) return "dynamic";
|
|
167
|
-
const text = nodes.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
168
|
-
return text.trim().length > 0 ? "static" : "absent";
|
|
169
|
-
}
|
|
170
|
-
function textFromNodes(nodes) {
|
|
171
|
-
if (!Array.isArray(nodes) || nodes.some((n) => n?.type === "ExpressionTag")) return void 0;
|
|
172
|
-
const text = nodes.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
173
|
-
return text.trim().length > 0 ? text : void 0;
|
|
174
|
-
}
|
|
175
|
-
function attrText(attributes, name) {
|
|
176
|
-
const attr = findAttr(attributes, name);
|
|
177
|
-
if (!attr) return void 0;
|
|
178
|
-
const v = attr.value;
|
|
179
|
-
if (v === true) return "";
|
|
180
|
-
if (Array.isArray(v)) {
|
|
181
|
-
return v.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
182
|
-
}
|
|
183
|
-
return void 0;
|
|
184
|
-
}
|
|
185
|
-
function attrValue(attributes, name) {
|
|
186
|
-
const attr = findAttr(attributes, name);
|
|
187
|
-
if (!attr) return "absent";
|
|
188
|
-
const v = attr.value;
|
|
189
|
-
if (v === true) return "absent";
|
|
190
|
-
if (Array.isArray(v)) return valueFromNodes(v);
|
|
191
|
-
if (v && v.type === "ExpressionTag") return "dynamic";
|
|
192
|
-
return "absent";
|
|
193
|
-
}
|
|
194
|
-
function lineOf(source, offset) {
|
|
195
|
-
if (typeof offset !== "number" || offset < 0) return 0;
|
|
196
|
-
let line = 1;
|
|
197
|
-
const end = Math.min(offset, source.length);
|
|
198
|
-
for (let i = 0; i < end; i++) if (source[i] === "\n") line++;
|
|
199
|
-
return line;
|
|
200
|
-
}
|
|
201
|
-
function findAttr(attributes, name) {
|
|
202
|
-
if (!Array.isArray(attributes)) return void 0;
|
|
203
|
-
return attributes.find((a) => a?.type === "Attribute" && a.name === name);
|
|
204
|
-
}
|
|
205
162
|
function collectSvelteHeads(node, acc) {
|
|
206
163
|
if (Array.isArray(node)) {
|
|
207
164
|
for (const child of node) collectSvelteHeads(child, acc);
|
|
@@ -279,19 +236,6 @@ function tagsFromHead(head) {
|
|
|
279
236
|
}
|
|
280
237
|
return tags;
|
|
281
238
|
}
|
|
282
|
-
function attrValueOf(attr) {
|
|
283
|
-
const v = attr?.value;
|
|
284
|
-
if (v === true) return "absent";
|
|
285
|
-
if (Array.isArray(v)) return valueFromNodes(v);
|
|
286
|
-
if (v && v.type === "ExpressionTag") return "dynamic";
|
|
287
|
-
return "absent";
|
|
288
|
-
}
|
|
289
|
-
function attrTextOf(attr) {
|
|
290
|
-
const v = attr?.value;
|
|
291
|
-
if (!Array.isArray(v) || v.some((n) => n?.type === "ExpressionTag")) return void 0;
|
|
292
|
-
const text = v.filter((n) => n?.type === "Text").map((n) => String(n.data ?? "")).join("");
|
|
293
|
-
return text.trim().length > 0 ? text : void 0;
|
|
294
|
-
}
|
|
295
239
|
function collectComponents(node, acc) {
|
|
296
240
|
if (Array.isArray(node)) {
|
|
297
241
|
for (const child of node) collectComponents(child, acc);
|
|
@@ -366,111 +310,9 @@ function parseFile(source, filename) {
|
|
|
366
310
|
headings
|
|
367
311
|
};
|
|
368
312
|
}
|
|
369
|
-
function isConstantListEach(node) {
|
|
370
|
-
const expr = node?.expression;
|
|
371
|
-
return expr?.type === "ArrayExpression" && Array.isArray(expr.elements) && !expr.elements.some((el) => el?.type === "SpreadElement");
|
|
372
|
-
}
|
|
373
|
-
function collectEachBlocks(node, source, acc) {
|
|
374
|
-
if (Array.isArray(node)) {
|
|
375
|
-
for (const child of node) collectEachBlocks(child, source, acc);
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
if (!node || typeof node !== "object") return;
|
|
379
|
-
if (node.type === "EachBlock" && !isConstantListEach(node)) {
|
|
380
|
-
acc.push({ hasKey: node.key != null, line: lineOf(source, node.start) });
|
|
381
|
-
}
|
|
382
|
-
for (const key of CHILD_NODE_KEYS) {
|
|
383
|
-
if (key in node) collectEachBlocks(node[key], source, acc);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
function walkEstree(node, visit) {
|
|
387
|
-
if (Array.isArray(node)) {
|
|
388
|
-
for (const child of node) walkEstree(child, visit);
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
392
|
-
visit(node);
|
|
393
|
-
for (const key of Object.keys(node)) {
|
|
394
|
-
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
395
|
-
walkEstree(node[key], visit);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
function isEffectCall(node) {
|
|
399
|
-
const c = node?.callee;
|
|
400
|
-
if (c?.type === "Identifier") return c.name === "$effect";
|
|
401
|
-
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$effect") {
|
|
402
|
-
return c.property?.type === "Identifier" && c.property.name === "pre";
|
|
403
|
-
}
|
|
404
|
-
return false;
|
|
405
|
-
}
|
|
406
|
-
function isStateDeclaration(node) {
|
|
407
|
-
const c = node?.callee;
|
|
408
|
-
if (c?.type === "Identifier") return c.name === "$state";
|
|
409
|
-
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$state") {
|
|
410
|
-
return c.property?.type === "Identifier" && (c.property.name === "raw" || c.property.name === "frozen");
|
|
411
|
-
}
|
|
412
|
-
return false;
|
|
413
|
-
}
|
|
414
|
-
function bodyOnlyAssignsState(fn, stateNames) {
|
|
415
|
-
const isStateAssign = (expr) => expr?.type === "AssignmentExpression" && expr.operator === "=" && expr.left?.type === "Identifier" && stateNames.has(expr.left.name);
|
|
416
|
-
const body = fn?.body;
|
|
417
|
-
if (!body) return false;
|
|
418
|
-
if (body.type !== "BlockStatement") return isStateAssign(body);
|
|
419
|
-
if (body.body.length === 0) return false;
|
|
420
|
-
return body.body.every((s) => s?.type === "ExpressionStatement" && isStateAssign(s.expression));
|
|
421
|
-
}
|
|
422
|
-
var URL_ATTRS = ["href", "src", "action", "formaction"];
|
|
423
|
-
function collectSecurityFacts(node, source, htmlTags, jsUrls) {
|
|
424
|
-
if (Array.isArray(node)) {
|
|
425
|
-
for (const child of node) collectSecurityFacts(child, source, htmlTags, jsUrls);
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
if (!node || typeof node !== "object") return;
|
|
429
|
-
if (node.type === "HtmlTag") htmlTags.push({ line: lineOf(source, node.start) });
|
|
430
|
-
if ((node.type === "RegularElement" || node.type === "SvelteElement") && Array.isArray(node.attributes)) {
|
|
431
|
-
for (const name of URL_ATTRS) {
|
|
432
|
-
const attr = findAttr(node.attributes, name);
|
|
433
|
-
if (!attr) continue;
|
|
434
|
-
const value = attrTextOf(attr);
|
|
435
|
-
if (value !== void 0 && /^\s*javascript:/i.test(value)) {
|
|
436
|
-
jsUrls.push({ line: lineOf(source, attr.start ?? node.start) });
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
for (const key of CHILD_NODE_KEYS) {
|
|
441
|
-
if (key in node) collectSecurityFacts(node[key], source, htmlTags, jsUrls);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
function parseComponentFacts(source, filename) {
|
|
445
|
-
const ast = parse(source, { modern: true, filename });
|
|
446
|
-
const eachBlocks = [];
|
|
447
|
-
collectEachBlocks(ast.fragment ?? ast, source, eachBlocks);
|
|
448
|
-
const htmlTags = [];
|
|
449
|
-
const javascriptUrls = [];
|
|
450
|
-
collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
|
|
451
|
-
const effects = [];
|
|
452
|
-
const program = ast.instance?.content;
|
|
453
|
-
if (program) {
|
|
454
|
-
const stateNames = /* @__PURE__ */ new Set();
|
|
455
|
-
walkEstree(program, (n) => {
|
|
456
|
-
if (n.type === "VariableDeclarator" && n.init && isStateDeclaration(n.init) && n.id?.type === "Identifier") {
|
|
457
|
-
stateNames.add(n.id.name);
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
|
-
walkEstree(program, (n) => {
|
|
461
|
-
if (n.type !== "CallExpression" || !isEffectCall(n)) return;
|
|
462
|
-
const fn = n.arguments?.[0];
|
|
463
|
-
const isFn = fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression";
|
|
464
|
-
effects.push({
|
|
465
|
-
line: lineOf(source, n.start),
|
|
466
|
-
assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false
|
|
467
|
-
});
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
return { eachBlocks, effects, htmlTags, javascriptUrls };
|
|
471
|
-
}
|
|
472
313
|
|
|
473
314
|
// src/providers/source/adapters/svelte-meta-tags.ts
|
|
315
|
+
import { attrValueOf, attrTextOf } from "@svelte-vitals/core";
|
|
474
316
|
function findAttr2(attributes, name) {
|
|
475
317
|
return attributes.find((a) => a?.type === "Attribute" && a.name === name);
|
|
476
318
|
}
|
|
@@ -508,6 +350,7 @@ var svelteMetaTagsAdapter = {
|
|
|
508
350
|
};
|
|
509
351
|
|
|
510
352
|
// src/providers/source/adapters/svelte-seo.ts
|
|
353
|
+
import { attrValueOf as attrValueOf2, attrTextOf as attrTextOf2 } from "@svelte-vitals/core";
|
|
511
354
|
function findAttr3(attributes, name) {
|
|
512
355
|
return attributes.find((a) => a?.type === "Attribute" && a.name === name);
|
|
513
356
|
}
|
|
@@ -520,18 +363,18 @@ var svelteSeoAdapter = {
|
|
|
520
363
|
const attrs = use.attributes;
|
|
521
364
|
const title = findAttr3(attrs, "title");
|
|
522
365
|
if (title) {
|
|
523
|
-
const value =
|
|
524
|
-
const text = value === "static" ?
|
|
366
|
+
const value = attrValueOf2(title);
|
|
367
|
+
const text = value === "static" ? attrTextOf2(title) : void 0;
|
|
525
368
|
tags.push({ kind: "title", value, ...text !== void 0 ? { text } : {} });
|
|
526
369
|
}
|
|
527
370
|
const description = findAttr3(attrs, "description");
|
|
528
371
|
if (description) {
|
|
529
|
-
const value =
|
|
530
|
-
const text = value === "static" ?
|
|
372
|
+
const value = attrValueOf2(description);
|
|
373
|
+
const text = value === "static" ? attrTextOf2(description) : void 0;
|
|
531
374
|
tags.push({ kind: "meta", name: "description", value, ...text !== void 0 ? { text } : {} });
|
|
532
375
|
}
|
|
533
376
|
const canonical = findAttr3(attrs, "canonical");
|
|
534
|
-
if (canonical) tags.push({ kind: "link", rel: "canonical", value:
|
|
377
|
+
if (canonical) tags.push({ kind: "link", rel: "canonical", value: attrValueOf2(canonical) });
|
|
535
378
|
const openGraph = findAttr3(attrs, "openGraph");
|
|
536
379
|
const broad = use.hasSpread || Boolean(openGraph);
|
|
537
380
|
return { tags, broad };
|
|
@@ -735,6 +578,7 @@ async function collectRoutes(rt, cwd, config = defaultConfig) {
|
|
|
735
578
|
}
|
|
736
579
|
|
|
737
580
|
// src/providers/source/components.ts
|
|
581
|
+
import { parseComponentFacts } from "@svelte-vitals/core";
|
|
738
582
|
async function collectComponentFacts(rt, cwd) {
|
|
739
583
|
const files = await rt.glob("src/**/*.svelte", cwd);
|
|
740
584
|
return Promise.all(
|
|
@@ -743,7 +587,19 @@ async function collectComponentFacts(rt, cwd) {
|
|
|
743
587
|
const source = await rt.readFile(rt.join(cwd, rel));
|
|
744
588
|
return { file: rel, ...parseComponentFacts(source, rel) };
|
|
745
589
|
} catch {
|
|
746
|
-
return {
|
|
590
|
+
return {
|
|
591
|
+
file: rel,
|
|
592
|
+
eachBlocks: [],
|
|
593
|
+
effects: [],
|
|
594
|
+
htmlTags: [],
|
|
595
|
+
javascriptUrls: [],
|
|
596
|
+
loc: 0,
|
|
597
|
+
propCount: 0,
|
|
598
|
+
imports: [],
|
|
599
|
+
namespaceImports: [],
|
|
600
|
+
constableStates: [],
|
|
601
|
+
suppressions: []
|
|
602
|
+
};
|
|
747
603
|
}
|
|
748
604
|
})
|
|
749
605
|
);
|
|
@@ -789,6 +645,69 @@ function isAutoDetectedGithub(explicit, env = process.env) {
|
|
|
789
645
|
return !explicit && !isReporterName(env.SVELTE_VITALS_REPORTER) && !isAgentEnv(env) && isGithubActionsEnv(env);
|
|
790
646
|
}
|
|
791
647
|
|
|
648
|
+
// src/changed-files.ts
|
|
649
|
+
import { execFileSync } from "child_process";
|
|
650
|
+
function git(args, cwd) {
|
|
651
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n");
|
|
652
|
+
}
|
|
653
|
+
function getChangedFiles(cwd, opts) {
|
|
654
|
+
try {
|
|
655
|
+
const files = opts.staged ? git(["diff", "--name-only", "--cached", "--diff-filter=d"], cwd) : [
|
|
656
|
+
...git(["diff", "--name-only", "--diff-filter=d", "--merge-base", opts.base ?? "HEAD"], cwd),
|
|
657
|
+
...git(["ls-files", "--others", "--exclude-standard"], cwd)
|
|
658
|
+
// untracked / new files
|
|
659
|
+
];
|
|
660
|
+
return new Set(files.map((s) => s.trim()).filter(Boolean));
|
|
661
|
+
} catch {
|
|
662
|
+
return void 0;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function filterToChangedFiles(results, changed) {
|
|
666
|
+
return results.filter((r) => r.location !== void 0 && changed.has(r.location));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/color.ts
|
|
670
|
+
import { noColorPalette } from "@svelte-vitals/core";
|
|
671
|
+
var wrap = (open, close = 0) => (s) => `\x1B[${open}m${s}\x1B[${close}m`;
|
|
672
|
+
var ansiPalette = {
|
|
673
|
+
bold: wrap(1, 22),
|
|
674
|
+
dim: wrap(2, 22),
|
|
675
|
+
red: wrap(31, 39),
|
|
676
|
+
yellow: wrap(33, 39),
|
|
677
|
+
green: wrap(32, 39),
|
|
678
|
+
cyan: wrap(36, 39)
|
|
679
|
+
};
|
|
680
|
+
function colorEnabled(opts) {
|
|
681
|
+
if (opts.noColorFlag) return false;
|
|
682
|
+
if (opts.env.NO_COLOR !== void 0 && opts.env.NO_COLOR !== "") return false;
|
|
683
|
+
const fc = opts.env.FORCE_COLOR;
|
|
684
|
+
if (fc !== void 0 && fc !== "" && fc !== "0") return true;
|
|
685
|
+
return opts.reporter === "console" && opts.isTTY;
|
|
686
|
+
}
|
|
687
|
+
var paletteFor = (enabled) => enabled ? ansiPalette : noColorPalette;
|
|
688
|
+
|
|
689
|
+
// src/spinner.ts
|
|
690
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
691
|
+
function startSpinner(text, opts) {
|
|
692
|
+
const stream = opts.stream ?? process.stderr;
|
|
693
|
+
if (!opts.enabled) return { stop() {
|
|
694
|
+
} };
|
|
695
|
+
let i = 0;
|
|
696
|
+
const tick = () => {
|
|
697
|
+
stream.write(`\r${FRAMES[i % FRAMES.length]} ${text}`);
|
|
698
|
+
i++;
|
|
699
|
+
};
|
|
700
|
+
tick();
|
|
701
|
+
const timer = setInterval(tick, 80);
|
|
702
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
703
|
+
return {
|
|
704
|
+
stop() {
|
|
705
|
+
clearInterval(timer);
|
|
706
|
+
stream.write("\r\x1B[K");
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
792
711
|
// src/rules-config.ts
|
|
793
712
|
import { allRules } from "@svelte-vitals/core";
|
|
794
713
|
var KNOWN_IDS = new Set(allRules.map((r) => r.id));
|
|
@@ -808,6 +727,9 @@ function buildRulesConfig(allow, ignore) {
|
|
|
808
727
|
}
|
|
809
728
|
|
|
810
729
|
// src/index.ts
|
|
730
|
+
function spinnerEnabled(opts) {
|
|
731
|
+
return opts.reporter === "console" && opts.stderrIsTTY && !isAutoDetectedAgent(opts.rawReporter, opts.env) && colorEnabled({ reporter: opts.reporter, isTTY: opts.stderrIsTTY, env: opts.env, noColorFlag: opts.noColorFlag });
|
|
732
|
+
}
|
|
811
733
|
function routeMatcher(glob) {
|
|
812
734
|
if (!glob) return () => true;
|
|
813
735
|
const body = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, " ").replace(/\*/g, "[^/]*").replace(/\/ $/g, "(?:/.*)?").replace(/^ \//g, "(?:.*/)?").replace(/ \//g, "(?:.*/)?").replace(/\/ /g, "(?:/.*)?").replace(/ /g, ".*");
|
|
@@ -845,6 +767,17 @@ async function run(opts = {}) {
|
|
|
845
767
|
errorLog(`svelte-vitals: invalid minHealth '${opts.minHealth}'; expected a number 0-100.`);
|
|
846
768
|
return 2;
|
|
847
769
|
}
|
|
770
|
+
const env = opts.env ?? process.env;
|
|
771
|
+
const reporter = resolveReporter(opts.reporter, env);
|
|
772
|
+
const spinner = startSpinner("Analyzing\u2026", {
|
|
773
|
+
enabled: spinnerEnabled({
|
|
774
|
+
reporter,
|
|
775
|
+
rawReporter: opts.reporter,
|
|
776
|
+
stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY,
|
|
777
|
+
env,
|
|
778
|
+
noColorFlag: opts.noColor
|
|
779
|
+
})
|
|
780
|
+
});
|
|
848
781
|
let analysis;
|
|
849
782
|
try {
|
|
850
783
|
analysis = await analyzeProject({
|
|
@@ -856,6 +789,7 @@ async function run(opts = {}) {
|
|
|
856
789
|
rules: opts.rules
|
|
857
790
|
});
|
|
858
791
|
} catch (err) {
|
|
792
|
+
spinner.stop();
|
|
859
793
|
if (err instanceof ProjectError) {
|
|
860
794
|
errorLog(err.message);
|
|
861
795
|
return 2;
|
|
@@ -863,10 +797,21 @@ async function run(opts = {}) {
|
|
|
863
797
|
errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`);
|
|
864
798
|
return 2;
|
|
865
799
|
}
|
|
800
|
+
spinner.stop();
|
|
866
801
|
try {
|
|
867
|
-
const {
|
|
868
|
-
|
|
869
|
-
|
|
802
|
+
const { config, version } = analysis;
|
|
803
|
+
let results = analysis.results;
|
|
804
|
+
if (opts.staged || opts.diffBase !== void 0) {
|
|
805
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
806
|
+
const changed = opts.staged ? getChangedFiles(cwd, { staged: true }) : getChangedFiles(cwd, { base: opts.diffBase });
|
|
807
|
+
if (changed === void 0) {
|
|
808
|
+
errorLog(
|
|
809
|
+
"svelte-vitals: could not determine changed files (not a git repo, git unavailable, or bad ref); analyzing all."
|
|
810
|
+
);
|
|
811
|
+
} else {
|
|
812
|
+
results = filterToChangedFiles(results, changed);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
870
815
|
if (reporter === "agent" && isAutoDetectedAgent(opts.reporter, env)) {
|
|
871
816
|
errorLog(
|
|
872
817
|
"svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json."
|
|
@@ -900,7 +845,13 @@ async function run(opts = {}) {
|
|
|
900
845
|
errorLog(`svelte-vitals: wrote report to ${path}`);
|
|
901
846
|
}
|
|
902
847
|
} else {
|
|
903
|
-
|
|
848
|
+
const colorOn = colorEnabled({
|
|
849
|
+
reporter,
|
|
850
|
+
isTTY: opts.stdoutIsTTY ?? !!process.stdout.isTTY,
|
|
851
|
+
env,
|
|
852
|
+
noColorFlag: opts.noColor
|
|
853
|
+
});
|
|
854
|
+
log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false, palette: paletteFor(colorOn) }));
|
|
904
855
|
}
|
|
905
856
|
const summary = summarize(results, config);
|
|
906
857
|
const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
|
|
@@ -919,6 +870,7 @@ export {
|
|
|
919
870
|
findUnknownRuleIds,
|
|
920
871
|
knownRuleIds,
|
|
921
872
|
buildRulesConfig,
|
|
873
|
+
spinnerEnabled,
|
|
922
874
|
routeMatcher,
|
|
923
875
|
analyzeProject,
|
|
924
876
|
run
|
package/dist/index.d.ts
CHANGED
|
@@ -39,7 +39,31 @@ interface RunOptions {
|
|
|
39
39
|
outFile?: string;
|
|
40
40
|
/** Injected file writer for --reporter html (defaults to node:fs writeFileSync). Mainly for tests. */
|
|
41
41
|
writeFile?: (path: string, content: string) => void;
|
|
42
|
+
/** Report only findings in files changed vs the merge-base with this ref ('HEAD' = uncommitted). Undefined = no gating. */
|
|
43
|
+
diffBase?: string;
|
|
44
|
+
/** Report only findings in files staged for commit. Takes precedence over `diffBase`. */
|
|
45
|
+
staged?: boolean;
|
|
46
|
+
/** Disable ANSI color in console output. */
|
|
47
|
+
noColor?: boolean;
|
|
48
|
+
/** Override stdout TTY detection (tests). */
|
|
49
|
+
stdoutIsTTY?: boolean;
|
|
50
|
+
/** Override stderr TTY detection (tests). */
|
|
51
|
+
stderrIsTTY?: boolean;
|
|
42
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether the "Analyzing…" spinner should run. Unlike color, the spinner animates
|
|
55
|
+
* with carriage returns and escape codes, so it needs a real interactive stderr —
|
|
56
|
+
* `FORCE_COLOR` must NOT force it on in a non-TTY stderr (e.g. CI), where `\r` would
|
|
57
|
+
* clutter the log. So gate on `stderrIsTTY` unconditionally, then reuse the color
|
|
58
|
+
* gating (NO_COLOR / --no-color / agent env) with that same TTY value.
|
|
59
|
+
*/
|
|
60
|
+
declare function spinnerEnabled(opts: {
|
|
61
|
+
reporter: ReporterName;
|
|
62
|
+
rawReporter: ReporterName | undefined;
|
|
63
|
+
stderrIsTTY: boolean;
|
|
64
|
+
env: NodeJS.ProcessEnv;
|
|
65
|
+
noColorFlag?: boolean;
|
|
66
|
+
}): boolean;
|
|
43
67
|
declare function routeMatcher(glob: string | undefined): (route: string) => boolean;
|
|
44
68
|
interface AnalyzeOptions {
|
|
45
69
|
cwd?: string;
|
|
@@ -67,4 +91,4 @@ declare function analyzeProject(opts?: AnalyzeOptions): Promise<AnalyzeResult>;
|
|
|
67
91
|
*/
|
|
68
92
|
declare function run(opts?: RunOptions): Promise<number>;
|
|
69
93
|
|
|
70
|
-
export { type AnalyzeOptions, type AnalyzeResult, ProjectError, type RunOptions, analyzeProject, buildRulesConfig, findUnknownRuleIds, knownRuleIds, routeMatcher, run };
|
|
94
|
+
export { type AnalyzeOptions, type AnalyzeResult, ProjectError, type RunOptions, analyzeProject, buildRulesConfig, findUnknownRuleIds, knownRuleIds, routeMatcher, run, spinnerEnabled };
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
findUnknownRuleIds,
|
|
6
6
|
knownRuleIds,
|
|
7
7
|
routeMatcher,
|
|
8
|
-
run
|
|
9
|
-
|
|
8
|
+
run,
|
|
9
|
+
spinnerEnabled
|
|
10
|
+
} from "./chunk-NLQZ3CMZ.js";
|
|
10
11
|
export {
|
|
11
12
|
ProjectError,
|
|
12
13
|
analyzeProject,
|
|
@@ -14,5 +15,6 @@ export {
|
|
|
14
15
|
findUnknownRuleIds,
|
|
15
16
|
knownRuleIds,
|
|
16
17
|
routeMatcher,
|
|
17
|
-
run
|
|
18
|
+
run,
|
|
19
|
+
spinnerEnabled
|
|
18
20
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-vitals",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "A SvelteKit SEO checker — not a runtime Web Vitals reporter. Static analysis of your routes' head metadata.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/oekazuma/svelte-vitals#readme",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=18"
|
|
26
|
+
"node": ">=18.20.8"
|
|
27
27
|
},
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"bin": {
|
|
@@ -39,13 +39,16 @@
|
|
|
39
39
|
"dist"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^1.6.0",
|
|
43
|
+
"magicast": "^0.5.3",
|
|
42
44
|
"mri": "^1.2.0",
|
|
43
|
-
"
|
|
45
|
+
"smol-toml": "^1.7.0",
|
|
46
|
+
"svelte": "^5.56.4",
|
|
44
47
|
"tinyglobby": "^0.2.17",
|
|
45
|
-
"@svelte-vitals/core": "0.
|
|
48
|
+
"@svelte-vitals/core": "0.19.0"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
|
-
"@types/node": "^24.
|
|
51
|
+
"@types/node": "^24.13.2"
|
|
49
52
|
},
|
|
50
53
|
"scripts": {
|
|
51
54
|
"build": "tsup",
|