svelte-vitals 0.14.0 → 0.17.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 +324 -8
- package/dist/{chunk-YMRN4VPN.js → chunk-VG647TPJ.js} +501 -27
- package/dist/index.d.ts +25 -1
- package/dist/index.js +5 -3
- package/package.json +5 -3
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-VG647TPJ.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,330 @@ 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 } : {}
|
|
72
|
+
},
|
|
73
|
+
warnings,
|
|
74
|
+
errors
|
|
75
|
+
};
|
|
76
|
+
}
|
|
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 mri from "mri";
|
|
83
|
+
import * as p from "@clack/prompts";
|
|
84
|
+
|
|
85
|
+
// src/install/clients.ts
|
|
86
|
+
import { join } from "path";
|
|
87
|
+
var MCP_ENTRY = { command: "npx", args: ["-y", "@svelte-vitals/mcp"] };
|
|
88
|
+
var CLIENTS = [
|
|
89
|
+
{
|
|
90
|
+
id: "claude-code",
|
|
91
|
+
label: "Claude Code",
|
|
92
|
+
scopes: ["project", "global"],
|
|
93
|
+
format: "json",
|
|
94
|
+
resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".mcp.json") : join(home, ".claude.json")
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "cursor",
|
|
98
|
+
label: "Cursor",
|
|
99
|
+
scopes: ["project", "global"],
|
|
100
|
+
format: "json",
|
|
101
|
+
resolvePath: (scope, cwd, home) => scope === "project" ? join(cwd, ".cursor", "mcp.json") : join(home, ".cursor", "mcp.json")
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "codex",
|
|
105
|
+
label: "Codex",
|
|
106
|
+
scopes: ["global"],
|
|
107
|
+
format: "toml",
|
|
108
|
+
resolvePath: (_scope, _cwd, home) => join(home, ".codex", "config.toml")
|
|
109
|
+
}
|
|
110
|
+
];
|
|
111
|
+
function clientById(id) {
|
|
112
|
+
return CLIENTS.find((c) => c.id === id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/install/merge.ts
|
|
116
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
117
|
+
var SERVER_KEY = "svelte-vitals";
|
|
118
|
+
function isPlainObject(v) {
|
|
119
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
120
|
+
}
|
|
121
|
+
function sameEntry(prior, entry) {
|
|
122
|
+
if (typeof prior !== "object" || prior === null) return false;
|
|
123
|
+
const o = prior;
|
|
124
|
+
return o.command === entry.command && Array.isArray(o.args) && o.args.length === entry.args.length && o.args.every((v, i) => v === entry.args[i]);
|
|
125
|
+
}
|
|
126
|
+
function statusFor(prior, entry, force, created) {
|
|
127
|
+
if (prior !== void 0) {
|
|
128
|
+
if (sameEntry(prior, entry)) return "exists";
|
|
129
|
+
return force ? "updated" : "skip";
|
|
130
|
+
}
|
|
131
|
+
return created ? "created" : "added";
|
|
132
|
+
}
|
|
133
|
+
function mergeJson(existing, entry, force) {
|
|
134
|
+
const created = existing === void 0;
|
|
135
|
+
const parsed = created ? {} : JSON.parse(existing);
|
|
136
|
+
if (!isPlainObject(parsed)) {
|
|
137
|
+
throw new Error("existing config is not a JSON object");
|
|
138
|
+
}
|
|
139
|
+
const root = parsed;
|
|
140
|
+
if (root.mcpServers !== void 0 && !isPlainObject(root.mcpServers)) {
|
|
141
|
+
throw new Error('existing config has a non-object "mcpServers" table');
|
|
142
|
+
}
|
|
143
|
+
const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
|
|
144
|
+
const status = statusFor(servers[SERVER_KEY], entry, force, created);
|
|
145
|
+
if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
|
|
146
|
+
servers[SERVER_KEY] = { command: entry.command, args: entry.args };
|
|
147
|
+
root.mcpServers = servers;
|
|
148
|
+
return { content: JSON.stringify(root, null, 2) + "\n", status };
|
|
149
|
+
}
|
|
150
|
+
function mergeToml(existing, entry, force) {
|
|
151
|
+
const created = existing === void 0;
|
|
152
|
+
const parsed = created ? {} : parseToml(existing);
|
|
153
|
+
if (!isPlainObject(parsed)) {
|
|
154
|
+
throw new Error("existing config is not a TOML table");
|
|
155
|
+
}
|
|
156
|
+
const root = parsed;
|
|
157
|
+
if (root.mcp_servers !== void 0 && !isPlainObject(root.mcp_servers)) {
|
|
158
|
+
throw new Error('existing config has a non-table "mcp_servers" section');
|
|
159
|
+
}
|
|
160
|
+
const servers = isPlainObject(root.mcp_servers) ? root.mcp_servers : {};
|
|
161
|
+
const status = statusFor(servers[SERVER_KEY], entry, force, created);
|
|
162
|
+
if (status === "exists" || status === "skip") return { content: existing, status: "exists" };
|
|
163
|
+
servers[SERVER_KEY] = { command: entry.command, args: entry.args };
|
|
164
|
+
root.mcp_servers = servers;
|
|
165
|
+
return { content: stringifyToml(root), status };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/install/index.ts
|
|
169
|
+
function planFor(client, scope, io, force) {
|
|
170
|
+
const path = client.resolvePath(scope, io.cwd, io.home);
|
|
171
|
+
const existing = io.readFile(path);
|
|
172
|
+
const merged = client.format === "toml" ? mergeToml(existing, MCP_ENTRY, force) : mergeJson(existing, MCP_ENTRY, force);
|
|
173
|
+
return { client, scope, path, status: merged.status, content: merged.content };
|
|
174
|
+
}
|
|
175
|
+
async function runInstall(flags, io, prompts) {
|
|
176
|
+
let ids;
|
|
177
|
+
if (flags.client && flags.client.length > 0) {
|
|
178
|
+
ids = flags.client;
|
|
179
|
+
} else if (io.isTTY) {
|
|
180
|
+
const configExists = (path) => {
|
|
181
|
+
try {
|
|
182
|
+
return io.readFile(path) !== void 0;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const detected = CLIENTS.filter((c) => c.scopes.some((s) => configExists(c.resolvePath(s, io.cwd, io.home)))).map(
|
|
188
|
+
(c) => c.id
|
|
189
|
+
);
|
|
190
|
+
const picked = await prompts.selectClients(CLIENTS, detected);
|
|
191
|
+
if (picked === null) {
|
|
192
|
+
io.log("Cancelled.");
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
ids = picked;
|
|
196
|
+
} else {
|
|
197
|
+
io.errorLog("svelte-vitals: no TTY; pass --client <claude-code,cursor,codex> to install non-interactively.");
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
const clients = ids.map(clientById).filter((c) => c !== void 0);
|
|
201
|
+
if (clients.length === 0) {
|
|
202
|
+
io.errorLog("svelte-vitals: no valid clients selected.");
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
const rows = [];
|
|
206
|
+
for (const client of clients) {
|
|
207
|
+
let scope;
|
|
208
|
+
if (client.scopes.length === 1) {
|
|
209
|
+
scope = client.scopes[0];
|
|
210
|
+
} else if (flags.scope) {
|
|
211
|
+
scope = flags.scope;
|
|
212
|
+
} else if (io.isTTY) {
|
|
213
|
+
const picked = await prompts.selectScope(client);
|
|
214
|
+
if (picked === null) {
|
|
215
|
+
io.log("Cancelled.");
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
scope = picked;
|
|
219
|
+
} else {
|
|
220
|
+
scope = "project";
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
rows.push(planFor(client, scope, io, flags.force ?? false));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const path = client.resolvePath(scope, io.cwd, io.home);
|
|
226
|
+
io.errorLog(
|
|
227
|
+
`svelte-vitals: could not parse existing config at ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
228
|
+
);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const planText = rows.map((r) => ` ${r.client.label} (${r.scope}) \u2192 ${r.path} [${r.status}]`).join("\n");
|
|
233
|
+
io.log("Plan:");
|
|
234
|
+
io.log(planText);
|
|
235
|
+
if (flags.dryRun) {
|
|
236
|
+
io.log("Dry run \u2014 no files written.");
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
if (!flags.yes && io.isTTY) {
|
|
240
|
+
const ok = await prompts.confirm(planText);
|
|
241
|
+
if (!ok) {
|
|
242
|
+
io.log("Cancelled.");
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let hadFailure = false;
|
|
247
|
+
for (const r of rows) {
|
|
248
|
+
if (r.status === "exists") {
|
|
249
|
+
io.log(`= ${r.client.label}: already configured (${r.path}) \u2014 use --force to overwrite.`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
io.writeFile(r.path, r.content);
|
|
254
|
+
io.log(`\u2713 ${r.client.label}: ${r.status} ${r.path}`);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
hadFailure = true;
|
|
257
|
+
io.errorLog(`svelte-vitals: failed to write ${r.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (hadFailure) return 2;
|
|
261
|
+
io.log("");
|
|
262
|
+
io.log("Done. Restart your client to load the svelte-vitals MCP server.");
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/install/args.ts
|
|
267
|
+
var VALID_CLIENTS = CLIENTS.map((c) => c.id);
|
|
268
|
+
function resolveInstallArgs(argv) {
|
|
269
|
+
const warnings = [];
|
|
270
|
+
const errors = [];
|
|
271
|
+
const rawClients = typeof argv.client === "string" ? argv.client.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
272
|
+
const client = [];
|
|
273
|
+
for (const c of rawClients) {
|
|
274
|
+
if (VALID_CLIENTS.includes(c)) {
|
|
275
|
+
if (!client.includes(c)) client.push(c);
|
|
276
|
+
} else {
|
|
277
|
+
warnings.push(`svelte-vitals: unknown --client '${c}'; expected claude-code|cursor|codex. Skipping.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (rawClients.length > 0 && client.length === 0) {
|
|
281
|
+
errors.push("svelte-vitals: no valid --client values; expected claude-code|cursor|codex.");
|
|
282
|
+
}
|
|
283
|
+
let scope;
|
|
284
|
+
const rawScope = argv.scope;
|
|
285
|
+
if (typeof rawScope === "string") {
|
|
286
|
+
if (rawScope === "project" || rawScope === "global") scope = rawScope;
|
|
287
|
+
else errors.push(`svelte-vitals: unknown --scope '${rawScope}'; expected project|global.`);
|
|
288
|
+
}
|
|
289
|
+
if (errors.length > 0) return { flags: null, warnings, errors };
|
|
290
|
+
return {
|
|
291
|
+
flags: {
|
|
292
|
+
...client.length > 0 ? { client } : {},
|
|
293
|
+
...scope ? { scope } : {},
|
|
294
|
+
yes: Boolean(argv.yes),
|
|
295
|
+
dryRun: Boolean(argv["dry-run"]),
|
|
296
|
+
force: Boolean(argv.force)
|
|
68
297
|
},
|
|
69
298
|
warnings,
|
|
70
299
|
errors
|
|
71
300
|
};
|
|
72
301
|
}
|
|
73
302
|
|
|
303
|
+
// src/install/cli.ts
|
|
304
|
+
var INSTALL_HELP = `svelte-vitals install \u2014 set up the svelte-vitals MCP server for your AI-agent clients
|
|
305
|
+
|
|
306
|
+
Usage:
|
|
307
|
+
svelte-vitals install [options]
|
|
308
|
+
|
|
309
|
+
Options:
|
|
310
|
+
--client <ids> Comma-separated: claude-code,cursor,codex (skips the interactive picker)
|
|
311
|
+
--scope <scope> project | global (applies to all selected clients; codex is always global)
|
|
312
|
+
--yes, -y Skip the confirmation prompt
|
|
313
|
+
--dry-run Print the planned changes and exit without writing
|
|
314
|
+
--force Overwrite an existing svelte-vitals entry
|
|
315
|
+
-h, --help Show this help`;
|
|
316
|
+
function realIO() {
|
|
317
|
+
return {
|
|
318
|
+
readFile: (path) => {
|
|
319
|
+
try {
|
|
320
|
+
return readFileSync(path, "utf8");
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err.code === "ENOENT") return void 0;
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
writeFile: (path, content) => {
|
|
327
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
328
|
+
writeFileSync(path, content);
|
|
329
|
+
},
|
|
330
|
+
cwd: process.cwd(),
|
|
331
|
+
home: homedir(),
|
|
332
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
333
|
+
log: (line) => console.log(line),
|
|
334
|
+
errorLog: (line) => console.error(line)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function clackPrompts() {
|
|
338
|
+
return {
|
|
339
|
+
selectClients: async (all, defaults) => {
|
|
340
|
+
const res = await p.multiselect({
|
|
341
|
+
message: "Which clients should svelte-vitals be installed for?",
|
|
342
|
+
options: all.map((c) => ({ value: c.id, label: c.label })),
|
|
343
|
+
initialValues: defaults,
|
|
344
|
+
required: true
|
|
345
|
+
});
|
|
346
|
+
return p.isCancel(res) ? null : res;
|
|
347
|
+
},
|
|
348
|
+
selectScope: async (client) => {
|
|
349
|
+
const res = await p.select({
|
|
350
|
+
message: `Scope for ${client.label}?`,
|
|
351
|
+
options: client.scopes.map((s) => ({ value: s, label: s })),
|
|
352
|
+
initialValue: client.scopes[0]
|
|
353
|
+
});
|
|
354
|
+
return p.isCancel(res) ? null : res;
|
|
355
|
+
},
|
|
356
|
+
confirm: async (planText) => {
|
|
357
|
+
const res = await p.confirm({ message: `Apply this plan?
|
|
358
|
+
${planText}` });
|
|
359
|
+
return p.isCancel(res) ? false : Boolean(res);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function runInstallCli(args) {
|
|
364
|
+
const argv = mri(args, {
|
|
365
|
+
boolean: ["yes", "dry-run", "force", "help"],
|
|
366
|
+
string: ["client", "scope"],
|
|
367
|
+
alias: { y: "yes", h: "help" }
|
|
368
|
+
});
|
|
369
|
+
if (argv.help) {
|
|
370
|
+
console.log(INSTALL_HELP);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
const { flags, warnings, errors } = resolveInstallArgs(argv);
|
|
374
|
+
for (const w of warnings) console.error(w);
|
|
375
|
+
for (const e of errors) console.error(e);
|
|
376
|
+
if (!flags) return 2;
|
|
377
|
+
return runInstall(flags, realIO(), clackPrompts());
|
|
378
|
+
}
|
|
379
|
+
|
|
74
380
|
// src/bin.ts
|
|
75
|
-
var HELP = `svelte-vitals \u2014 a SvelteKit
|
|
381
|
+
var HELP = `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
|
|
76
382
|
|
|
77
383
|
Usage:
|
|
78
384
|
svelte-vitals [path] [options]
|
|
385
|
+
svelte-vitals install Set up the MCP server for Claude Code / Cursor / Codex
|
|
79
386
|
|
|
80
387
|
Options:
|
|
81
388
|
--meta-components <names> Comma-separated component names that emit head metadata
|
|
82
389
|
--treat-dynamic-as <mode> pass | warn | fail (default: pass)
|
|
83
390
|
--route <glob> Only analyze routes matching this glob
|
|
391
|
+
--diff [ref] Report only findings in files changed vs ref (default HEAD; e.g. --diff main)
|
|
392
|
+
--staged Report only findings in files staged for commit (pre-commit gate)
|
|
84
393
|
--by-route Show per-route score breakdown in console output
|
|
85
394
|
--reporter <fmt> console | json | agent | sarif | github | html (auto: agent under AI-agent envs, github under GitHub Actions)
|
|
86
395
|
--out-file <path> Output path for --reporter html (default: svelte-vitals-report.html; '-' for stdout)
|
|
@@ -90,6 +399,7 @@ Options:
|
|
|
90
399
|
--min-health <0-100> Fail (exit 1) when the combined Health score is below this value
|
|
91
400
|
--rules <ids> Comma-separated rule ids to enable (all others disabled)
|
|
92
401
|
--ignore <ids> Comma-separated rule ids to disable
|
|
402
|
+
--no-color Disable ANSI color in console output
|
|
93
403
|
-h, --help Show this help
|
|
94
404
|
-v, --version Show version
|
|
95
405
|
|
|
@@ -99,9 +409,14 @@ Exit codes:
|
|
|
99
409
|
2 execution error (not a SvelteKit project / internal error)`;
|
|
100
410
|
var VERSION = readPackageVersion();
|
|
101
411
|
async function main() {
|
|
102
|
-
const
|
|
412
|
+
const rawArgs = process.argv.slice(2);
|
|
413
|
+
if (rawArgs[0] === "install") {
|
|
414
|
+
const code2 = await runInstallCli(rawArgs.slice(1));
|
|
415
|
+
process.exit(code2);
|
|
416
|
+
}
|
|
417
|
+
const argv = mri2(process.argv.slice(2), {
|
|
103
418
|
alias: { h: "help", v: "version" },
|
|
104
|
-
boolean: ["by-route", "json", "fail-on-warning"],
|
|
419
|
+
boolean: ["by-route", "json", "fail-on-warning", "staged", "no-color"],
|
|
105
420
|
string: [
|
|
106
421
|
"meta-components",
|
|
107
422
|
"treat-dynamic-as",
|
|
@@ -111,7 +426,8 @@ async function main() {
|
|
|
111
426
|
"rules",
|
|
112
427
|
"ignore",
|
|
113
428
|
"min-health",
|
|
114
|
-
"out-file"
|
|
429
|
+
"out-file",
|
|
430
|
+
"diff"
|
|
115
431
|
]
|
|
116
432
|
});
|
|
117
433
|
if (argv.help) {
|
|
@@ -136,7 +452,7 @@ async function main() {
|
|
|
136
452
|
}
|
|
137
453
|
minHealth = n;
|
|
138
454
|
}
|
|
139
|
-
const code = await run({ ...options, minHealth });
|
|
455
|
+
const code = await run({ ...options, minHealth, noColor: argv["no-color"] });
|
|
140
456
|
process.exit(code);
|
|
141
457
|
}
|
|
142
458
|
void main();
|
|
@@ -77,8 +77,11 @@ async function detectProject(rt, cwd) {
|
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
async function enumerateRoutePages(rt, cwd) {
|
|
80
|
-
const
|
|
81
|
-
|
|
80
|
+
const [plain, breakout] = await Promise.all([
|
|
81
|
+
rt.glob(`${ROUTES_DIR}/**/+page.svelte`, cwd),
|
|
82
|
+
rt.glob(`${ROUTES_DIR}/**/+page@*.svelte`, cwd)
|
|
83
|
+
]);
|
|
84
|
+
return [.../* @__PURE__ */ new Set([...plain, ...breakout])].sort();
|
|
82
85
|
}
|
|
83
86
|
async function existsAny(rt, cwd, paths) {
|
|
84
87
|
const found = await Promise.all(paths.map((p) => rt.exists(rt.join(cwd, p))));
|
|
@@ -363,6 +366,310 @@ function parseFile(source, filename) {
|
|
|
363
366
|
headings
|
|
364
367
|
};
|
|
365
368
|
}
|
|
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
|
+
function isDerivedDeclaration(node) {
|
|
423
|
+
const c = node?.callee;
|
|
424
|
+
if (c?.type === "Identifier") return c.name === "$derived";
|
|
425
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$derived") {
|
|
426
|
+
return c.property?.type === "Identifier" && c.property.name === "by";
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
function addBoundNames(id, acc) {
|
|
431
|
+
if (!id) return;
|
|
432
|
+
switch (id.type) {
|
|
433
|
+
case "Identifier":
|
|
434
|
+
acc.add(id.name);
|
|
435
|
+
break;
|
|
436
|
+
case "ObjectPattern":
|
|
437
|
+
for (const p of id.properties ?? []) {
|
|
438
|
+
if (p?.type === "Property") addBoundNames(p.value, acc);
|
|
439
|
+
else if (p?.type === "RestElement") addBoundNames(p.argument, acc);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
case "ArrayPattern":
|
|
443
|
+
for (const el of id.elements ?? []) addBoundNames(el, acc);
|
|
444
|
+
break;
|
|
445
|
+
case "AssignmentPattern":
|
|
446
|
+
addBoundNames(id.left, acc);
|
|
447
|
+
break;
|
|
448
|
+
case "RestElement":
|
|
449
|
+
addBoundNames(id.argument, acc);
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function rootObjectName(node) {
|
|
454
|
+
let cur = node;
|
|
455
|
+
while (cur?.type === "MemberExpression") cur = cur.object;
|
|
456
|
+
return cur?.type === "Identifier" ? cur.name : void 0;
|
|
457
|
+
}
|
|
458
|
+
function collectStateWrites(root, stateNames, acc) {
|
|
459
|
+
walkEstree(root, (n) => {
|
|
460
|
+
if (n?.type === "AssignmentExpression") {
|
|
461
|
+
if (n.left?.type === "Identifier" && stateNames.has(n.left.name)) acc.add(n.left.name);
|
|
462
|
+
else if (n.left?.type === "MemberExpression") {
|
|
463
|
+
const r = rootObjectName(n.left);
|
|
464
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
465
|
+
} else if (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern") {
|
|
466
|
+
const bound = /* @__PURE__ */ new Set();
|
|
467
|
+
addBoundNames(n.left, bound);
|
|
468
|
+
for (const name of bound) if (stateNames.has(name)) acc.add(name);
|
|
469
|
+
}
|
|
470
|
+
} else if (n?.type === "UpdateExpression") {
|
|
471
|
+
const r = rootObjectName(n.argument);
|
|
472
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
473
|
+
} else if (n?.type === "UnaryExpression" && n.operator === "delete") {
|
|
474
|
+
const r = rootObjectName(n.argument);
|
|
475
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
476
|
+
} else if (n?.type === "CallExpression") {
|
|
477
|
+
if (n.callee?.type === "MemberExpression") {
|
|
478
|
+
const r = rootObjectName(n.callee);
|
|
479
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
480
|
+
}
|
|
481
|
+
for (const a of n.arguments ?? []) {
|
|
482
|
+
const arg = a?.type === "SpreadElement" ? a.argument : a;
|
|
483
|
+
const r = rootObjectName(arg);
|
|
484
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
var COMPONENT_LIKE_TYPES = /* @__PURE__ */ new Set(["Component", "SvelteComponent", "SvelteSelf"]);
|
|
490
|
+
function collectTemplateEscapes(node, stateNames, acc) {
|
|
491
|
+
if (Array.isArray(node)) {
|
|
492
|
+
for (const c of node) collectTemplateEscapes(c, stateNames, acc);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
496
|
+
if (Array.isArray(node.attributes)) {
|
|
497
|
+
for (const attr of node.attributes) {
|
|
498
|
+
if (attr?.type === "BindDirective") {
|
|
499
|
+
const r = rootObjectName(attr.expression);
|
|
500
|
+
if (r && stateNames.has(r)) acc.add(r);
|
|
501
|
+
} else if (COMPONENT_LIKE_TYPES.has(node.type)) {
|
|
502
|
+
walkEstree(attr, (m) => {
|
|
503
|
+
if (m?.type === "Identifier" && stateNames.has(m.name)) acc.add(m.name);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const key of CHILD_NODE_KEYS) {
|
|
509
|
+
if (key in node) collectTemplateEscapes(node[key], stateNames, acc);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
var RUNE_NAMES = /* @__PURE__ */ new Set(["$state", "$derived", "$effect", "$props", "$bindable", "$inspect", "$host"]);
|
|
513
|
+
function bodyReadsReactive(fn, reactiveNames) {
|
|
514
|
+
let reads = false;
|
|
515
|
+
const IGNORED_KEYS = /* @__PURE__ */ new Set(["type", "start", "end", "loc", "range"]);
|
|
516
|
+
const visit = (n) => {
|
|
517
|
+
if (reads || !n) return;
|
|
518
|
+
if (Array.isArray(n)) {
|
|
519
|
+
for (const c of n) visit(c);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (typeof n !== "object" || typeof n.type !== "string") return;
|
|
523
|
+
if (n.type === "Identifier") {
|
|
524
|
+
if (reactiveNames.has(n.name) || n.name.startsWith("$") && !RUNE_NAMES.has(n.name)) reads = true;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (n.type === "CallExpression" && n.callee?.type === "Identifier") {
|
|
528
|
+
reads = true;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (n.type === "MemberExpression") {
|
|
532
|
+
visit(n.object);
|
|
533
|
+
if (n.computed) visit(n.property);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (n.type === "Property") {
|
|
537
|
+
if (n.computed) visit(n.key);
|
|
538
|
+
visit(n.value);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
for (const key of Object.keys(n)) {
|
|
542
|
+
if (!IGNORED_KEYS.has(key)) visit(n[key]);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
visit(fn.body);
|
|
546
|
+
return reads;
|
|
547
|
+
}
|
|
548
|
+
function bodyIsEmpty(fn) {
|
|
549
|
+
const body = fn?.body;
|
|
550
|
+
if (!body) return true;
|
|
551
|
+
if (body.type === "BlockStatement") return (body.body ?? []).length === 0;
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
var URL_ATTRS = ["href", "src", "action", "formaction"];
|
|
555
|
+
function collectSecurityFacts(node, source, htmlTags, jsUrls) {
|
|
556
|
+
if (Array.isArray(node)) {
|
|
557
|
+
for (const child of node) collectSecurityFacts(child, source, htmlTags, jsUrls);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (!node || typeof node !== "object") return;
|
|
561
|
+
if (node.type === "HtmlTag") htmlTags.push({ line: lineOf(source, node.start) });
|
|
562
|
+
if ((node.type === "RegularElement" || node.type === "SvelteElement") && Array.isArray(node.attributes)) {
|
|
563
|
+
for (const name of URL_ATTRS) {
|
|
564
|
+
const attr = findAttr(node.attributes, name);
|
|
565
|
+
if (!attr) continue;
|
|
566
|
+
const value = attrTextOf(attr);
|
|
567
|
+
if (value !== void 0 && /^\s*javascript:/i.test(value)) {
|
|
568
|
+
jsUrls.push({ line: lineOf(source, attr.start ?? node.start) });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const key of CHILD_NODE_KEYS) {
|
|
573
|
+
if (key in node) collectSecurityFacts(node[key], source, htmlTags, jsUrls);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function isPropsCall(node) {
|
|
577
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$props";
|
|
578
|
+
}
|
|
579
|
+
function countProps(program) {
|
|
580
|
+
let count = 0;
|
|
581
|
+
let seen = 0;
|
|
582
|
+
let uncountable = false;
|
|
583
|
+
walkEstree(program, (n) => {
|
|
584
|
+
if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(n.init)) return;
|
|
585
|
+
seen++;
|
|
586
|
+
const props = n.id?.type === "ObjectPattern" ? n.id.properties : void 0;
|
|
587
|
+
if (!Array.isArray(props) || props.some((p) => p?.type === "RestElement")) {
|
|
588
|
+
uncountable = true;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
count = props.filter((p) => p?.type === "Property").length;
|
|
592
|
+
});
|
|
593
|
+
return uncountable || seen > 1 ? 0 : count;
|
|
594
|
+
}
|
|
595
|
+
function countLines(source) {
|
|
596
|
+
if (source.length === 0) return 0;
|
|
597
|
+
return source.split("\n").length - (source.endsWith("\n") ? 1 : 0);
|
|
598
|
+
}
|
|
599
|
+
function collectImportSources(program, acc) {
|
|
600
|
+
walkEstree(program, (n) => {
|
|
601
|
+
if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") acc.push(n.source.value);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function isBareSpecifier(s) {
|
|
605
|
+
return !/^[./$#]/.test(s);
|
|
606
|
+
}
|
|
607
|
+
function collectNamespaceImports(program, source, acc) {
|
|
608
|
+
walkEstree(program, (n) => {
|
|
609
|
+
if (n.type !== "ImportDeclaration" || n.importKind === "type") return;
|
|
610
|
+
const spec = n.source?.value;
|
|
611
|
+
if (typeof spec !== "string" || !isBareSpecifier(spec)) return;
|
|
612
|
+
if (Array.isArray(n.specifiers) && n.specifiers.some((s) => s?.type === "ImportNamespaceSpecifier")) {
|
|
613
|
+
acc.push({ source: spec, line: lineOf(source, n.start) });
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
function parseComponentFacts(source, filename) {
|
|
618
|
+
const ast = parse(source, { modern: true, filename });
|
|
619
|
+
const eachBlocks = [];
|
|
620
|
+
collectEachBlocks(ast.fragment ?? ast, source, eachBlocks);
|
|
621
|
+
const htmlTags = [];
|
|
622
|
+
const javascriptUrls = [];
|
|
623
|
+
collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
|
|
624
|
+
const loc = countLines(source);
|
|
625
|
+
const imports = [];
|
|
626
|
+
const namespaceImports = [];
|
|
627
|
+
if (ast.module?.content) {
|
|
628
|
+
collectImportSources(ast.module.content, imports);
|
|
629
|
+
collectNamespaceImports(ast.module.content, source, namespaceImports);
|
|
630
|
+
}
|
|
631
|
+
const effects = [];
|
|
632
|
+
const constableStates = [];
|
|
633
|
+
let propCount = 0;
|
|
634
|
+
const program = ast.instance?.content;
|
|
635
|
+
if (program) {
|
|
636
|
+
collectImportSources(program, imports);
|
|
637
|
+
collectNamespaceImports(program, source, namespaceImports);
|
|
638
|
+
propCount = countProps(program);
|
|
639
|
+
const stateNames = /* @__PURE__ */ new Set();
|
|
640
|
+
const reactiveNames = /* @__PURE__ */ new Set();
|
|
641
|
+
const stateDecls = [];
|
|
642
|
+
walkEstree(program, (n) => {
|
|
643
|
+
if (n.type !== "VariableDeclarator" || !n.init) return;
|
|
644
|
+
if (isStateDeclaration(n.init) && n.id?.type === "Identifier") {
|
|
645
|
+
stateNames.add(n.id.name);
|
|
646
|
+
stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) });
|
|
647
|
+
}
|
|
648
|
+
if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init))
|
|
649
|
+
addBoundNames(n.id, reactiveNames);
|
|
650
|
+
});
|
|
651
|
+
walkEstree(program, (n) => {
|
|
652
|
+
if (n.type !== "CallExpression" || !isEffectCall(n)) return;
|
|
653
|
+
const fn = n.arguments?.[0];
|
|
654
|
+
const isFn = fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression";
|
|
655
|
+
effects.push({
|
|
656
|
+
line: lineOf(source, n.start),
|
|
657
|
+
assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false,
|
|
658
|
+
mountOnly: isFn ? !bodyIsEmpty(fn) && !bodyReadsReactive(fn, reactiveNames) : false
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
const writtenOrEscaped = /* @__PURE__ */ new Set();
|
|
662
|
+
collectStateWrites(program, stateNames, writtenOrEscaped);
|
|
663
|
+
if (ast.fragment) {
|
|
664
|
+
collectStateWrites(ast.fragment, stateNames, writtenOrEscaped);
|
|
665
|
+
collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped);
|
|
666
|
+
}
|
|
667
|
+
for (const d of stateDecls) {
|
|
668
|
+
if (!writtenOrEscaped.has(d.name)) constableStates.push(d);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports, constableStates };
|
|
672
|
+
}
|
|
366
673
|
|
|
367
674
|
// src/providers/source/adapters/svelte-meta-tags.ts
|
|
368
675
|
function findAttr2(attributes, name) {
|
|
@@ -519,27 +826,68 @@ var MAX_DEPTH = 5;
|
|
|
519
826
|
function isGroupSegment(segment) {
|
|
520
827
|
return /^\(.+\)$/.test(segment);
|
|
521
828
|
}
|
|
829
|
+
function dirOf(rel) {
|
|
830
|
+
const i = rel.lastIndexOf("/");
|
|
831
|
+
return i >= 0 ? rel.slice(0, i) : "";
|
|
832
|
+
}
|
|
833
|
+
function dirSegments(dir) {
|
|
834
|
+
const extra = dir.slice(ROUTES_DIR2.length);
|
|
835
|
+
return extra.length === 0 ? [] : extra.split("/").filter(Boolean);
|
|
836
|
+
}
|
|
837
|
+
function dirKey(segs) {
|
|
838
|
+
return segs.length === 0 ? ROUTES_DIR2 : `${ROUTES_DIR2}/${segs.join("/")}`;
|
|
839
|
+
}
|
|
840
|
+
function parseAt(rel) {
|
|
841
|
+
const file = rel.slice(rel.lastIndexOf("/") + 1);
|
|
842
|
+
const m = /^\+(?:page|layout)@(.*)\.svelte$/.exec(file);
|
|
843
|
+
return m ? m[1] : null;
|
|
844
|
+
}
|
|
845
|
+
function atTarget(rel, dirSegs, strictAncestor = false) {
|
|
846
|
+
const at = parseAt(rel);
|
|
847
|
+
if (at === null) return null;
|
|
848
|
+
if (at === "") return [];
|
|
849
|
+
const haystack = strictAncestor ? dirSegs.slice(0, -1) : dirSegs;
|
|
850
|
+
const i = haystack.lastIndexOf(at);
|
|
851
|
+
return i >= 0 ? dirSegs.slice(0, i + 1) : null;
|
|
852
|
+
}
|
|
522
853
|
function deriveRoute(pageRel) {
|
|
523
|
-
const
|
|
524
|
-
const segments = inner.length === 0 ? [] : inner.split("/").filter((s) => !isGroupSegment(s));
|
|
854
|
+
const segments = dirSegments(dirOf(pageRel)).filter((s) => !isGroupSegment(s));
|
|
525
855
|
return "/" + segments.join("/");
|
|
526
856
|
}
|
|
527
|
-
async function
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
for (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
|
|
857
|
+
async function collectLayouts(rt, cwd) {
|
|
858
|
+
const [plain, breakout] = await Promise.all([
|
|
859
|
+
rt.glob(`${ROUTES_DIR2}/**/+layout.svelte`, cwd),
|
|
860
|
+
rt.glob(`${ROUTES_DIR2}/**/+layout@*.svelte`, cwd)
|
|
861
|
+
]);
|
|
862
|
+
const map = /* @__PURE__ */ new Map();
|
|
863
|
+
for (const rel of [...plain, ...breakout]) map.set(dirOf(rel), rel);
|
|
864
|
+
return map;
|
|
865
|
+
}
|
|
866
|
+
function layoutAtOrAbove(segs, layouts) {
|
|
867
|
+
for (let j = segs.length; j >= 0; j--) {
|
|
868
|
+
const rel = layouts.get(dirKey(segs.slice(0, j)));
|
|
869
|
+
if (rel) return { segs: segs.slice(0, j), rel };
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
function chainFiles(pageRel, layouts) {
|
|
874
|
+
const pageSegs = dirSegments(dirOf(pageRel));
|
|
875
|
+
let dir = atTarget(pageRel, pageSegs) ?? pageSegs;
|
|
876
|
+
const chain = [];
|
|
877
|
+
const seen = /* @__PURE__ */ new Set();
|
|
878
|
+
while (dir !== null) {
|
|
879
|
+
const found = layoutAtOrAbove(dir, layouts);
|
|
880
|
+
if (!found || seen.has(found.rel)) break;
|
|
881
|
+
seen.add(found.rel);
|
|
882
|
+
chain.unshift(found.rel);
|
|
883
|
+
const reset = atTarget(found.rel, found.segs, true);
|
|
884
|
+
dir = reset !== null && reset.length < found.segs.length ? reset : found.segs.slice(0, -1);
|
|
885
|
+
if (dir.length === 0 && found.segs.length === 0) dir = null;
|
|
886
|
+
}
|
|
887
|
+
return [...chain.map((rel) => ({ rel, isPage: false })), { rel: pageRel, isPage: true }];
|
|
888
|
+
}
|
|
889
|
+
async function resolveRoute(rt, cwd, pageRel, config, layouts) {
|
|
890
|
+
const files = chainFiles(pageRel, layouts);
|
|
543
891
|
const composed = /* @__PURE__ */ new Map();
|
|
544
892
|
let broadOwn = false;
|
|
545
893
|
let broadInherited = false;
|
|
@@ -578,8 +926,8 @@ async function resolveRoute(rt, cwd, pageRel, config) {
|
|
|
578
926
|
};
|
|
579
927
|
}
|
|
580
928
|
async function collectRoutes(rt, cwd, config = defaultConfig) {
|
|
581
|
-
const pages = await enumerateRoutePages(rt, cwd);
|
|
582
|
-
const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config)));
|
|
929
|
+
const [pages, layouts] = await Promise.all([enumerateRoutePages(rt, cwd), collectLayouts(rt, cwd)]);
|
|
930
|
+
const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config, layouts)));
|
|
583
931
|
return {
|
|
584
932
|
heads: facts.map((f) => f.head),
|
|
585
933
|
images: facts.map((f) => f.images),
|
|
@@ -587,6 +935,32 @@ async function collectRoutes(rt, cwd, config = defaultConfig) {
|
|
|
587
935
|
};
|
|
588
936
|
}
|
|
589
937
|
|
|
938
|
+
// src/providers/source/components.ts
|
|
939
|
+
async function collectComponentFacts(rt, cwd) {
|
|
940
|
+
const files = await rt.glob("src/**/*.svelte", cwd);
|
|
941
|
+
return Promise.all(
|
|
942
|
+
files.sort().map(async (rel) => {
|
|
943
|
+
try {
|
|
944
|
+
const source = await rt.readFile(rt.join(cwd, rel));
|
|
945
|
+
return { file: rel, ...parseComponentFacts(source, rel) };
|
|
946
|
+
} catch {
|
|
947
|
+
return {
|
|
948
|
+
file: rel,
|
|
949
|
+
eachBlocks: [],
|
|
950
|
+
effects: [],
|
|
951
|
+
htmlTags: [],
|
|
952
|
+
javascriptUrls: [],
|
|
953
|
+
loc: 0,
|
|
954
|
+
propCount: 0,
|
|
955
|
+
imports: [],
|
|
956
|
+
namespaceImports: [],
|
|
957
|
+
constableStates: []
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
})
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
590
964
|
// src/version.ts
|
|
591
965
|
import { readFileSync } from "fs";
|
|
592
966
|
function readPackageVersion() {
|
|
@@ -627,6 +1001,69 @@ function isAutoDetectedGithub(explicit, env = process.env) {
|
|
|
627
1001
|
return !explicit && !isReporterName(env.SVELTE_VITALS_REPORTER) && !isAgentEnv(env) && isGithubActionsEnv(env);
|
|
628
1002
|
}
|
|
629
1003
|
|
|
1004
|
+
// src/changed-files.ts
|
|
1005
|
+
import { execFileSync } from "child_process";
|
|
1006
|
+
function git(args, cwd) {
|
|
1007
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n");
|
|
1008
|
+
}
|
|
1009
|
+
function getChangedFiles(cwd, opts) {
|
|
1010
|
+
try {
|
|
1011
|
+
const files = opts.staged ? git(["diff", "--name-only", "--cached", "--diff-filter=d"], cwd) : [
|
|
1012
|
+
...git(["diff", "--name-only", "--diff-filter=d", "--merge-base", opts.base ?? "HEAD"], cwd),
|
|
1013
|
+
...git(["ls-files", "--others", "--exclude-standard"], cwd)
|
|
1014
|
+
// untracked / new files
|
|
1015
|
+
];
|
|
1016
|
+
return new Set(files.map((s) => s.trim()).filter(Boolean));
|
|
1017
|
+
} catch {
|
|
1018
|
+
return void 0;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
function filterToChangedFiles(results, changed) {
|
|
1022
|
+
return results.filter((r) => r.location !== void 0 && changed.has(r.location));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/color.ts
|
|
1026
|
+
import { noColorPalette } from "@svelte-vitals/core";
|
|
1027
|
+
var wrap = (open, close = 0) => (s) => `\x1B[${open}m${s}\x1B[${close}m`;
|
|
1028
|
+
var ansiPalette = {
|
|
1029
|
+
bold: wrap(1, 22),
|
|
1030
|
+
dim: wrap(2, 22),
|
|
1031
|
+
red: wrap(31, 39),
|
|
1032
|
+
yellow: wrap(33, 39),
|
|
1033
|
+
green: wrap(32, 39),
|
|
1034
|
+
cyan: wrap(36, 39)
|
|
1035
|
+
};
|
|
1036
|
+
function colorEnabled(opts) {
|
|
1037
|
+
if (opts.noColorFlag) return false;
|
|
1038
|
+
if (opts.env.NO_COLOR !== void 0 && opts.env.NO_COLOR !== "") return false;
|
|
1039
|
+
const fc = opts.env.FORCE_COLOR;
|
|
1040
|
+
if (fc !== void 0 && fc !== "" && fc !== "0") return true;
|
|
1041
|
+
return opts.reporter === "console" && opts.isTTY;
|
|
1042
|
+
}
|
|
1043
|
+
var paletteFor = (enabled) => enabled ? ansiPalette : noColorPalette;
|
|
1044
|
+
|
|
1045
|
+
// src/spinner.ts
|
|
1046
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1047
|
+
function startSpinner(text, opts) {
|
|
1048
|
+
const stream = opts.stream ?? process.stderr;
|
|
1049
|
+
if (!opts.enabled) return { stop() {
|
|
1050
|
+
} };
|
|
1051
|
+
let i = 0;
|
|
1052
|
+
const tick = () => {
|
|
1053
|
+
stream.write(`\r${FRAMES[i % FRAMES.length]} ${text}`);
|
|
1054
|
+
i++;
|
|
1055
|
+
};
|
|
1056
|
+
tick();
|
|
1057
|
+
const timer = setInterval(tick, 80);
|
|
1058
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1059
|
+
return {
|
|
1060
|
+
stop() {
|
|
1061
|
+
clearInterval(timer);
|
|
1062
|
+
stream.write("\r\x1B[K");
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
630
1067
|
// src/rules-config.ts
|
|
631
1068
|
import { allRules } from "@svelte-vitals/core";
|
|
632
1069
|
var KNOWN_IDS = new Set(allRules.map((r) => r.id));
|
|
@@ -646,6 +1083,9 @@ function buildRulesConfig(allow, ignore) {
|
|
|
646
1083
|
}
|
|
647
1084
|
|
|
648
1085
|
// src/index.ts
|
|
1086
|
+
function spinnerEnabled(opts) {
|
|
1087
|
+
return opts.reporter === "console" && opts.stderrIsTTY && !isAutoDetectedAgent(opts.rawReporter, opts.env) && colorEnabled({ reporter: opts.reporter, isTTY: opts.stderrIsTTY, env: opts.env, noColorFlag: opts.noColorFlag });
|
|
1088
|
+
}
|
|
649
1089
|
function routeMatcher(glob) {
|
|
650
1090
|
if (!glob) return () => true;
|
|
651
1091
|
const body = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, " ").replace(/\*/g, "[^/]*").replace(/\/ $/g, "(?:/.*)?").replace(/^ \//g, "(?:.*/)?").replace(/ \//g, "(?:.*/)?").replace(/\/ /g, "(?:/.*)?").replace(/ /g, ".*");
|
|
@@ -668,8 +1108,12 @@ async function analyzeProject(opts = {}) {
|
|
|
668
1108
|
const images = collected.images.filter((i) => matches(i.route));
|
|
669
1109
|
const headings = collected.headings.filter((h) => matches(h.route));
|
|
670
1110
|
const project = await collectProjectFacts(rt, cwd);
|
|
1111
|
+
const components = opts.route ? [] : await collectComponentFacts(rt, cwd);
|
|
671
1112
|
const rules = selectRules(allRules2, config);
|
|
672
|
-
const results = applyRuleSeverities(
|
|
1113
|
+
const results = applyRuleSeverities(
|
|
1114
|
+
await runRules(rules, { heads, images, headings, components, project, config }),
|
|
1115
|
+
config
|
|
1116
|
+
);
|
|
673
1117
|
return { results, config, version: readPackageVersion() };
|
|
674
1118
|
}
|
|
675
1119
|
async function run(opts = {}) {
|
|
@@ -679,6 +1123,17 @@ async function run(opts = {}) {
|
|
|
679
1123
|
errorLog(`svelte-vitals: invalid minHealth '${opts.minHealth}'; expected a number 0-100.`);
|
|
680
1124
|
return 2;
|
|
681
1125
|
}
|
|
1126
|
+
const env = opts.env ?? process.env;
|
|
1127
|
+
const reporter = resolveReporter(opts.reporter, env);
|
|
1128
|
+
const spinner = startSpinner("Analyzing\u2026", {
|
|
1129
|
+
enabled: spinnerEnabled({
|
|
1130
|
+
reporter,
|
|
1131
|
+
rawReporter: opts.reporter,
|
|
1132
|
+
stderrIsTTY: opts.stderrIsTTY ?? !!process.stderr.isTTY,
|
|
1133
|
+
env,
|
|
1134
|
+
noColorFlag: opts.noColor
|
|
1135
|
+
})
|
|
1136
|
+
});
|
|
682
1137
|
let analysis;
|
|
683
1138
|
try {
|
|
684
1139
|
analysis = await analyzeProject({
|
|
@@ -690,6 +1145,7 @@ async function run(opts = {}) {
|
|
|
690
1145
|
rules: opts.rules
|
|
691
1146
|
});
|
|
692
1147
|
} catch (err) {
|
|
1148
|
+
spinner.stop();
|
|
693
1149
|
if (err instanceof ProjectError) {
|
|
694
1150
|
errorLog(err.message);
|
|
695
1151
|
return 2;
|
|
@@ -697,10 +1153,21 @@ async function run(opts = {}) {
|
|
|
697
1153
|
errorLog(`svelte-vitals: ${err instanceof Error ? err.message : String(err)}`);
|
|
698
1154
|
return 2;
|
|
699
1155
|
}
|
|
1156
|
+
spinner.stop();
|
|
700
1157
|
try {
|
|
701
|
-
const {
|
|
702
|
-
|
|
703
|
-
|
|
1158
|
+
const { config, version } = analysis;
|
|
1159
|
+
let results = analysis.results;
|
|
1160
|
+
if (opts.staged || opts.diffBase !== void 0) {
|
|
1161
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1162
|
+
const changed = opts.staged ? getChangedFiles(cwd, { staged: true }) : getChangedFiles(cwd, { base: opts.diffBase });
|
|
1163
|
+
if (changed === void 0) {
|
|
1164
|
+
errorLog(
|
|
1165
|
+
"svelte-vitals: could not determine changed files (not a git repo, git unavailable, or bad ref); analyzing all."
|
|
1166
|
+
);
|
|
1167
|
+
} else {
|
|
1168
|
+
results = filterToChangedFiles(results, changed);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
704
1171
|
if (reporter === "agent" && isAutoDetectedAgent(opts.reporter, env)) {
|
|
705
1172
|
errorLog(
|
|
706
1173
|
"svelte-vitals: agent reporter auto-selected (AI-agent env detected); override with --reporter console|json."
|
|
@@ -734,7 +1201,13 @@ async function run(opts = {}) {
|
|
|
734
1201
|
errorLog(`svelte-vitals: wrote report to ${path}`);
|
|
735
1202
|
}
|
|
736
1203
|
} else {
|
|
737
|
-
|
|
1204
|
+
const colorOn = colorEnabled({
|
|
1205
|
+
reporter,
|
|
1206
|
+
isTTY: opts.stdoutIsTTY ?? !!process.stdout.isTTY,
|
|
1207
|
+
env,
|
|
1208
|
+
noColorFlag: opts.noColor
|
|
1209
|
+
});
|
|
1210
|
+
log(formatConsoleReport(results, config, { byRoute: opts.byRoute ?? false, palette: paletteFor(colorOn) }));
|
|
738
1211
|
}
|
|
739
1212
|
const summary = summarize(results, config);
|
|
740
1213
|
const failBySeverity = hasFailureAtOrAbove(summary, config.failOn);
|
|
@@ -753,6 +1226,7 @@ export {
|
|
|
753
1226
|
findUnknownRuleIds,
|
|
754
1227
|
knownRuleIds,
|
|
755
1228
|
buildRulesConfig,
|
|
1229
|
+
spinnerEnabled,
|
|
756
1230
|
routeMatcher,
|
|
757
1231
|
analyzeProject,
|
|
758
1232
|
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-VG647TPJ.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.17.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,10 +39,12 @@
|
|
|
39
39
|
"dist"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^0.11.0",
|
|
42
43
|
"mri": "^1.2.0",
|
|
44
|
+
"smol-toml": "^1.7.0",
|
|
43
45
|
"svelte": "^5.56.3",
|
|
44
46
|
"tinyglobby": "^0.2.17",
|
|
45
|
-
"@svelte-vitals/core": "0.
|
|
47
|
+
"@svelte-vitals/core": "0.18.0"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"@types/node": "^24.7.0"
|