svelte-vitals 0.18.0 → 0.19.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 CHANGED
@@ -6,12 +6,55 @@ import {
6
6
  knownRuleIds,
7
7
  readPackageVersion,
8
8
  run
9
- } from "./chunk-NLQZ3CMZ.js";
9
+ } from "./chunk-ZE3M3T6U.js";
10
10
 
11
11
  // src/bin.ts
12
12
  import mri2 from "mri";
13
13
 
14
14
  // src/resolve-args.ts
15
+ var CATEGORIES = ["seo", "performance", "correctness", "security", "architecture"];
16
+ function parseWeights(raw, errors) {
17
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
18
+ const weights = {};
19
+ const unknownCategories = [];
20
+ const invalidValues = [];
21
+ for (const pair of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
22
+ const eq = pair.indexOf("=");
23
+ if (eq === -1) {
24
+ invalidValues.push(pair);
25
+ continue;
26
+ }
27
+ const category = pair.slice(0, eq).trim().toLowerCase();
28
+ const valueRaw = pair.slice(eq + 1).trim();
29
+ if (!CATEGORIES.includes(category)) {
30
+ unknownCategories.push(category);
31
+ continue;
32
+ }
33
+ if (valueRaw === "") {
34
+ invalidValues.push(pair);
35
+ continue;
36
+ }
37
+ const value = Number(valueRaw);
38
+ if (!Number.isFinite(value) || value < 0) {
39
+ invalidValues.push(pair);
40
+ continue;
41
+ }
42
+ weights[category] = value;
43
+ }
44
+ if (unknownCategories.length > 0) {
45
+ errors.push(`svelte-vitals: unknown category(ies) in --weights: ${unknownCategories.join(", ")}`);
46
+ errors.push(`Known categories: ${CATEGORIES.join(", ")}`);
47
+ }
48
+ if (invalidValues.length > 0) {
49
+ errors.push(
50
+ `svelte-vitals: invalid --weights entry(ies): ${invalidValues.join(", ")}; expected category=number with a finite number >= 0.`
51
+ );
52
+ }
53
+ if (unknownCategories.length === 0 && invalidValues.length === 0 && Object.keys(weights).length === 0) {
54
+ errors.push("svelte-vitals: --weights was passed but contains no category=number pairs.");
55
+ }
56
+ return weights;
57
+ }
15
58
  var toList = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
16
59
  function resolveArgs(argv) {
17
60
  const warnings = [];
@@ -55,6 +98,9 @@ function resolveArgs(argv) {
55
98
  );
56
99
  }
57
100
  const failOn = argv["fail-on-warning"] ? "warning" : failOnValid ? failOnRaw : void 0;
101
+ const weights = parseWeights(argv.weights, errors);
102
+ const rulesConfig = buildRulesConfig(allow, ignore);
103
+ const rules = Object.keys(rulesConfig).length > 0 ? rulesConfig : void 0;
58
104
  if (errors.length > 0) return { options: null, warnings, errors };
59
105
  return {
60
106
  options: {
@@ -66,7 +112,8 @@ function resolveArgs(argv) {
66
112
  outFile: typeof argv["out-file"] === "string" ? argv["out-file"] : void 0,
67
113
  byRoute: Boolean(argv["by-route"]),
68
114
  failOn,
69
- rules: buildRulesConfig(allow, ignore),
115
+ rules,
116
+ ...weights !== void 0 ? { weights } : {},
70
117
  ...diffBase !== void 0 ? { diffBase } : {},
71
118
  ...staged ? { staged } : {}
72
119
  },
@@ -639,10 +686,14 @@ Options:
639
686
  --min-health <0-100> Fail (exit 1) when the combined Health score is below this value
640
687
  --rules <ids> Comma-separated rule ids to enable (all others disabled)
641
688
  --ignore <ids> Comma-separated rule ids to disable
689
+ --weights <pairs> Per-category Health weight overrides, e.g. seo=2,performance=1 (unlisted categories default to 1)
642
690
  --no-color Disable ANSI color in console output
643
691
  -h, --help Show this help
644
692
  -v, --version Show version
645
693
 
694
+ Config file:
695
+ svelte-vitals.config.{mjs,js,ts} in the analyzed directory; flags override it.
696
+
646
697
  Exit codes:
647
698
  0 no failing findings
648
699
  1 critical finding present (or --fail-on threshold reached)
@@ -667,7 +718,8 @@ async function main() {
667
718
  "ignore",
668
719
  "min-health",
669
720
  "out-file",
670
- "diff"
721
+ "diff",
722
+ "weights"
671
723
  ]
672
724
  });
673
725
  if (argv.help) {
@@ -122,12 +122,145 @@ async function collectProjectFacts(rt, cwd) {
122
122
  };
123
123
  }
124
124
 
125
+ // src/providers/source/adapters/svelte-meta-tags.ts
126
+ import { attrValueOf, attrTextOf } from "@svelte-vitals/core";
127
+
128
+ // src/providers/source/adapters/meta-object.ts
129
+ function exprValue(node) {
130
+ if (!node) return "absent";
131
+ if (node.type === "Literal") {
132
+ if (typeof node.value === "string") return node.value.trim().length > 0 ? "static" : "absent";
133
+ return "static";
134
+ }
135
+ return "dynamic";
136
+ }
137
+ function resolveMetaObject(attr, keyMap) {
138
+ if (!attr) return { tags: [], opaque: false };
139
+ const expr = attr.value?.type === "ExpressionTag" ? attr.value.expression : void 0;
140
+ if (!expr || expr.type !== "ObjectExpression") return { tags: [], opaque: true };
141
+ const tags = [];
142
+ let opaque = false;
143
+ for (const prop of expr.properties ?? []) {
144
+ if (prop?.type !== "Property" || prop.computed) {
145
+ opaque = true;
146
+ continue;
147
+ }
148
+ const key = prop.key?.name ?? prop.key?.value;
149
+ const make = typeof key === "string" ? keyMap[key] : void 0;
150
+ if (make) tags.push(make(exprValue(prop.value)));
151
+ }
152
+ return { tags, opaque };
153
+ }
154
+ var OPEN_GRAPH_KEYS = {
155
+ title: (value) => ({ kind: "meta", property: "og:title", value }),
156
+ description: (value) => ({ kind: "meta", property: "og:description", value }),
157
+ url: (value) => ({ kind: "meta", property: "og:url", value }),
158
+ images: (value) => ({ kind: "meta", property: "og:image", value }),
159
+ type: (value) => ({ kind: "meta", property: "og:type", value })
160
+ };
161
+ var TWITTER_KEYS = {
162
+ cardType: (value) => ({ kind: "meta", name: "twitter:card", value }),
163
+ card: (value) => ({ kind: "meta", name: "twitter:card", value })
164
+ };
165
+
166
+ // src/providers/source/adapters/svelte-meta-tags.ts
167
+ function findAttr(attributes, name) {
168
+ return attributes.find((a) => a?.type === "Attribute" && a.name === name);
169
+ }
170
+ var svelteMetaTagsAdapter = {
171
+ match(info) {
172
+ if (info.source === "svelte-meta-tags") return info.imported === "MetaTags";
173
+ if (info.source === "svelte-meta-tags/MetaTags.svelte") return info.imported === "default";
174
+ return false;
175
+ },
176
+ resolve(use) {
177
+ const tags = [];
178
+ const attrs = use.attributes;
179
+ const titleAttr = findAttr(attrs, "title");
180
+ const templateAttr = findAttr(attrs, "titleTemplate");
181
+ const title = titleAttr ?? templateAttr;
182
+ if (title) {
183
+ const value = attrValueOf(title);
184
+ const text = titleAttr && !templateAttr && value === "static" ? attrTextOf(titleAttr) : void 0;
185
+ tags.push({ kind: "title", value, ...text !== void 0 ? { text } : {} });
186
+ }
187
+ const description = findAttr(attrs, "description");
188
+ if (description) {
189
+ const value = attrValueOf(description);
190
+ const text = value === "static" ? attrTextOf(description) : void 0;
191
+ tags.push({ kind: "meta", name: "description", value, ...text !== void 0 ? { text } : {} });
192
+ }
193
+ const canonical = findAttr(attrs, "canonical");
194
+ if (canonical) tags.push({ kind: "link", rel: "canonical", value: attrValueOf(canonical) });
195
+ const robots = findAttr(attrs, "robots");
196
+ if (robots) tags.push({ kind: "meta", name: "robots", value: attrValueOf(robots) });
197
+ const og = resolveMetaObject(findAttr(attrs, "openGraph"), OPEN_GRAPH_KEYS);
198
+ const tw = resolveMetaObject(findAttr(attrs, "twitter"), TWITTER_KEYS);
199
+ tags.push(...og.tags, ...tw.tags);
200
+ const broad = use.hasSpread || og.opaque || tw.opaque;
201
+ return { tags, broad };
202
+ }
203
+ };
204
+
205
+ // src/providers/source/adapters/svelte-meta-tags-jsonld.ts
206
+ var svelteMetaTagsJsonLdAdapter = {
207
+ match(info) {
208
+ if (info.source === "svelte-meta-tags") return info.imported === "JsonLd";
209
+ if (info.source === "svelte-meta-tags/JsonLd.svelte") return info.imported === "default";
210
+ return false;
211
+ },
212
+ resolve() {
213
+ const tags = [{ kind: "jsonld", value: "dynamic" }];
214
+ return { tags, broad: false };
215
+ }
216
+ };
217
+
218
+ // src/providers/source/adapters/svelte-seo.ts
219
+ import { attrValueOf as attrValueOf2, attrTextOf as attrTextOf2 } from "@svelte-vitals/core";
220
+ function findAttr2(attributes, name) {
221
+ return attributes.find((a) => a?.type === "Attribute" && a.name === name);
222
+ }
223
+ var svelteSeoAdapter = {
224
+ match(info) {
225
+ return info.source === "svelte-seo" && info.imported === "default";
226
+ },
227
+ resolve(use) {
228
+ const tags = [];
229
+ const attrs = use.attributes;
230
+ const title = findAttr2(attrs, "title");
231
+ if (title) {
232
+ const value = attrValueOf2(title);
233
+ const text = value === "static" ? attrTextOf2(title) : void 0;
234
+ tags.push({ kind: "title", value, ...text !== void 0 ? { text } : {} });
235
+ }
236
+ const description = findAttr2(attrs, "description");
237
+ if (description) {
238
+ const value = attrValueOf2(description);
239
+ const text = value === "static" ? attrTextOf2(description) : void 0;
240
+ tags.push({ kind: "meta", name: "description", value, ...text !== void 0 ? { text } : {} });
241
+ }
242
+ const canonical = findAttr2(attrs, "canonical");
243
+ if (canonical) tags.push({ kind: "link", rel: "canonical", value: attrValueOf2(canonical) });
244
+ const og = resolveMetaObject(findAttr2(attrs, "openGraph"), OPEN_GRAPH_KEYS);
245
+ const tw = resolveMetaObject(findAttr2(attrs, "twitter"), TWITTER_KEYS);
246
+ tags.push(...og.tags, ...tw.tags);
247
+ const broad = use.hasSpread || og.opaque || tw.opaque;
248
+ return { tags, broad };
249
+ }
250
+ };
251
+
252
+ // src/providers/source/adapters/index.ts
253
+ var builtinAdapters = [svelteMetaTagsAdapter, svelteMetaTagsJsonLdAdapter, svelteSeoAdapter];
254
+ function findAdapter(info) {
255
+ return builtinAdapters.find((adapter) => adapter.match(info));
256
+ }
257
+
125
258
  // src/providers/source/parse.ts
126
259
  import { parse } from "svelte/compiler";
127
260
  import {
128
261
  CHILD_NODE_KEYS,
129
262
  lineOf,
130
- findAttr,
263
+ findAttr as findAttr3,
131
264
  valueFromNodes,
132
265
  textFromNodes,
133
266
  attrText,
@@ -203,9 +336,9 @@ function tagsFromHead(head) {
203
336
  });
204
337
  } else if (node.name === "link") {
205
338
  const rel = attrText(node.attributes, "rel");
206
- const hasAs = findAttr(node.attributes, "as") !== void 0;
339
+ const hasAs = findAttr3(node.attributes, "as") !== void 0;
207
340
  const asLiteral = attrText(node.attributes, "as");
208
- const hasCrossorigin = findAttr(node.attributes, "crossorigin") !== void 0;
341
+ const hasCrossorigin = findAttr3(node.attributes, "crossorigin") !== void 0;
209
342
  const hreflang = attrText(node.attributes, "hreflang");
210
343
  const href = attrText(node.attributes, "href");
211
344
  tags.push({
@@ -228,7 +361,7 @@ function tagsFromHead(head) {
228
361
  } else {
229
362
  const src = attrText(node.attributes, "src");
230
363
  if (src) {
231
- const blocking = findAttr(node.attributes, "defer") === void 0 && findAttr(node.attributes, "async") === void 0 && type !== "module";
364
+ const blocking = findAttr3(node.attributes, "defer") === void 0 && findAttr3(node.attributes, "async") === void 0 && type !== "module";
232
365
  tags.push({ kind: "script", value: "static", href: src, ...blocking ? { blocking: true } : {} });
233
366
  }
234
367
  }
@@ -264,13 +397,13 @@ function collectImages(node, source, acc) {
264
397
  const attrs = node.attributes ?? [];
265
398
  const hasSpread = attrs.some((a) => a?.type === "SpreadAttribute");
266
399
  acc.push({
267
- hasWidth: hasSpread || Boolean(findAttr(attrs, "width")),
268
- hasHeight: hasSpread || Boolean(findAttr(attrs, "height")),
269
- hasLoading: hasSpread || Boolean(findAttr(attrs, "loading")),
270
- hasAlt: hasSpread || Boolean(findAttr(attrs, "alt")),
400
+ hasWidth: hasSpread || Boolean(findAttr3(attrs, "width")),
401
+ hasHeight: hasSpread || Boolean(findAttr3(attrs, "height")),
402
+ hasLoading: hasSpread || Boolean(findAttr3(attrs, "loading")),
403
+ hasAlt: hasSpread || Boolean(findAttr3(attrs, "alt")),
271
404
  // A literal loading="lazy" only — a spread or dynamic loading={…} must not be flagged.
272
405
  lazy: attrText(attrs, "loading") === "lazy",
273
- hasSrcset: hasSpread || Boolean(findAttr(attrs, "srcset")),
406
+ hasSrcset: hasSpread || Boolean(findAttr3(attrs, "srcset")),
274
407
  line: lineOf(source, node.start)
275
408
  });
276
409
  }
@@ -311,90 +444,27 @@ function parseFile(source, filename) {
311
444
  };
312
445
  }
313
446
 
314
- // src/providers/source/adapters/svelte-meta-tags.ts
315
- import { attrValueOf, attrTextOf } from "@svelte-vitals/core";
316
- function findAttr2(attributes, name) {
317
- return attributes.find((a) => a?.type === "Attribute" && a.name === name);
318
- }
319
- var svelteMetaTagsAdapter = {
320
- match(info) {
321
- if (info.source === "svelte-meta-tags") return info.imported === "MetaTags";
322
- if (info.source === "svelte-meta-tags/MetaTags.svelte") return info.imported === "default";
323
- return false;
324
- },
325
- resolve(use) {
326
- const tags = [];
327
- const attrs = use.attributes;
328
- const titleAttr = findAttr2(attrs, "title");
329
- const templateAttr = findAttr2(attrs, "titleTemplate");
330
- const title = titleAttr ?? templateAttr;
331
- if (title) {
332
- const value = attrValueOf(title);
333
- const text = titleAttr && !templateAttr && value === "static" ? attrTextOf(titleAttr) : void 0;
334
- tags.push({ kind: "title", value, ...text !== void 0 ? { text } : {} });
335
- }
336
- const description = findAttr2(attrs, "description");
337
- if (description) {
338
- const value = attrValueOf(description);
339
- const text = value === "static" ? attrTextOf(description) : void 0;
340
- tags.push({ kind: "meta", name: "description", value, ...text !== void 0 ? { text } : {} });
341
- }
342
- const canonical = findAttr2(attrs, "canonical");
343
- if (canonical) tags.push({ kind: "link", rel: "canonical", value: attrValueOf(canonical) });
344
- const robots = findAttr2(attrs, "robots");
345
- if (robots) tags.push({ kind: "meta", name: "robots", value: attrValueOf(robots) });
346
- const openGraph = findAttr2(attrs, "openGraph");
347
- const broad = use.hasSpread || Boolean(openGraph);
348
- return { tags, broad };
349
- }
350
- };
351
-
352
- // src/providers/source/adapters/svelte-seo.ts
353
- import { attrValueOf as attrValueOf2, attrTextOf as attrTextOf2 } from "@svelte-vitals/core";
354
- function findAttr3(attributes, name) {
355
- return attributes.find((a) => a?.type === "Attribute" && a.name === name);
356
- }
357
- var svelteSeoAdapter = {
358
- match(info) {
359
- return info.source === "svelte-seo" && info.imported === "default";
360
- },
361
- resolve(use) {
362
- const tags = [];
363
- const attrs = use.attributes;
364
- const title = findAttr3(attrs, "title");
365
- if (title) {
366
- const value = attrValueOf2(title);
367
- const text = value === "static" ? attrTextOf2(title) : void 0;
368
- tags.push({ kind: "title", value, ...text !== void 0 ? { text } : {} });
369
- }
370
- const description = findAttr3(attrs, "description");
371
- if (description) {
372
- const value = attrValueOf2(description);
373
- const text = value === "static" ? attrTextOf2(description) : void 0;
374
- tags.push({ kind: "meta", name: "description", value, ...text !== void 0 ? { text } : {} });
375
- }
376
- const canonical = findAttr3(attrs, "canonical");
377
- if (canonical) tags.push({ kind: "link", rel: "canonical", value: attrValueOf2(canonical) });
378
- const openGraph = findAttr3(attrs, "openGraph");
379
- const broad = use.hasSpread || Boolean(openGraph);
380
- return { tags, broad };
447
+ // src/providers/source/resolve.ts
448
+ function readAndParse(rt, cwd, rel, cache) {
449
+ let hit = cache.get(rel);
450
+ if (!hit) {
451
+ hit = rt.readFile(rt.join(cwd, rel)).then((source) => parseFile(source, rel));
452
+ cache.set(rel, hit);
381
453
  }
382
- };
383
-
384
- // src/providers/source/adapters/index.ts
385
- var builtinAdapters = [svelteMetaTagsAdapter, svelteSeoAdapter];
386
- function findAdapter(info) {
387
- return builtinAdapters.find((adapter) => adapter.match(info));
454
+ return hit;
388
455
  }
389
-
390
- // src/providers/source/resolve.ts
391
456
  var BROAD_KINDS = [
392
457
  { kind: "title", value: "dynamic" },
393
458
  { kind: "meta", name: "description", value: "dynamic" },
394
459
  { kind: "link", rel: "canonical", value: "dynamic" },
395
460
  { kind: "meta", property: "og:title", value: "dynamic" },
461
+ { kind: "meta", property: "og:description", value: "dynamic" },
396
462
  { kind: "meta", property: "og:image", value: "dynamic" },
463
+ { kind: "meta", property: "og:url", value: "dynamic" },
464
+ { kind: "meta", name: "twitter:card", value: "dynamic" },
397
465
  { kind: "meta", name: "robots", value: "dynamic" }
466
+ // jsonld is intentionally omitted: structured data is a distinct concern, not a
467
+ // meta-tag family a broad meta source implies (JsonLd has its own adapter).
398
468
  ];
399
469
  function tagKey(tag) {
400
470
  switch (tag.kind) {
@@ -431,7 +501,7 @@ function resolveComponentPath(source, fromFileRel) {
431
501
  if (/\.[^/]+$/.test(path)) return void 0;
432
502
  return `${path}.svelte`;
433
503
  }
434
- async function resolveFileTags(rt, cwd, fileRel, parsed, config, depth, visited) {
504
+ async function resolveFileTags(rt, cwd, fileRel, parsed, config, depth, visited, cache = /* @__PURE__ */ new Map()) {
435
505
  const tags = [...parsed.headTags];
436
506
  let broad = false;
437
507
  for (const use of parsed.components) {
@@ -451,9 +521,9 @@ async function resolveFileTags(rt, cwd, fileRel, parsed, config, depth, visited)
451
521
  if (childRel && depth > 0 && !visited.has(childRel)) {
452
522
  const abs = rt.join(cwd, childRel);
453
523
  if (await rt.exists(abs)) {
454
- const childParsed = parseFile(await rt.readFile(abs), childRel);
524
+ const childParsed = await readAndParse(rt, cwd, childRel, cache);
455
525
  const childVisited = new Set(visited).add(childRel);
456
- const child = await resolveFileTags(rt, cwd, childRel, childParsed, config, depth - 1, childVisited);
526
+ const child = await resolveFileTags(rt, cwd, childRel, childParsed, config, depth - 1, childVisited, cache);
457
527
  tags.push(...child.tags);
458
528
  broad = broad || child.broad;
459
529
  }
@@ -528,7 +598,7 @@ function chainFiles(pageRel, layouts) {
528
598
  }
529
599
  return [...chain.map((rel) => ({ rel, isPage: false })), { rel: pageRel, isPage: true }];
530
600
  }
531
- async function resolveRoute(rt, cwd, pageRel, config, layouts) {
601
+ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache) {
532
602
  const files = chainFiles(pageRel, layouts);
533
603
  const composed = /* @__PURE__ */ new Map();
534
604
  let broadOwn = false;
@@ -536,15 +606,14 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts) {
536
606
  const images = [];
537
607
  const headings = [];
538
608
  for (const { rel, isPage } of files) {
539
- const source = await rt.readFile(rt.join(cwd, rel));
540
- const parsed = parseFile(source, rel);
609
+ const parsed = await readAndParse(rt, cwd, rel, cache);
541
610
  for (const img of parsed.images) {
542
611
  images.push({ ...img, file: rel });
543
612
  }
544
613
  for (const heading of parsed.headings) {
545
614
  headings.push({ ...heading, file: rel });
546
615
  }
547
- const resolved = await resolveFileTags(rt, cwd, rel, parsed, config, MAX_DEPTH, /* @__PURE__ */ new Set([rel]));
616
+ const resolved = await resolveFileTags(rt, cwd, rel, parsed, config, MAX_DEPTH, /* @__PURE__ */ new Set([rel]), cache);
548
617
  for (const tag of resolved.tags) {
549
618
  composed.set(tagKey(tag), { ...tag, presence: isPage ? "own" : "inherited", file: rel });
550
619
  }
@@ -569,7 +638,8 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts) {
569
638
  }
570
639
  async function collectRoutes(rt, cwd, config = defaultConfig) {
571
640
  const [pages, layouts] = await Promise.all([enumerateRoutePages(rt, cwd), collectLayouts(rt, cwd)]);
572
- const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config, layouts)));
641
+ const cache = /* @__PURE__ */ new Map();
642
+ const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config, layouts, cache)));
573
643
  return {
574
644
  heads: facts.map((f) => f.head),
575
645
  images: facts.map((f) => f.images),
@@ -578,32 +648,7 @@ async function collectRoutes(rt, cwd, config = defaultConfig) {
578
648
  }
579
649
 
580
650
  // src/providers/source/components.ts
581
- import { parseComponentFacts } from "@svelte-vitals/core";
582
- async function collectComponentFacts(rt, cwd) {
583
- const files = await rt.glob("src/**/*.svelte", cwd);
584
- return Promise.all(
585
- files.sort().map(async (rel) => {
586
- try {
587
- const source = await rt.readFile(rt.join(cwd, rel));
588
- return { file: rel, ...parseComponentFacts(source, rel) };
589
- } catch {
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
- };
603
- }
604
- })
605
- );
606
- }
651
+ import { collectComponentFacts } from "@svelte-vitals/core";
607
652
 
608
653
  // src/version.ts
609
654
  import { readFileSync } from "fs";
@@ -652,8 +697,8 @@ function git(args, cwd) {
652
697
  }
653
698
  function getChangedFiles(cwd, opts) {
654
699
  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),
700
+ const files = opts.staged ? git(["diff", "--name-only", "--relative", "--cached", "--diff-filter=d"], cwd) : [
701
+ ...git(["diff", "--name-only", "--relative", "--diff-filter=d", "--merge-base", opts.base ?? "HEAD"], cwd),
657
702
  ...git(["ls-files", "--others", "--exclude-standard"], cwd)
658
703
  // untracked / new files
659
704
  ];
@@ -708,6 +753,11 @@ function startSpinner(text, opts) {
708
753
  };
709
754
  }
710
755
 
756
+ // src/config-file.ts
757
+ import { existsSync } from "fs";
758
+ import { join as join2 } from "path";
759
+ import { pathToFileURL } from "url";
760
+
711
761
  // src/rules-config.ts
712
762
  import { allRules } from "@svelte-vitals/core";
713
763
  var KNOWN_IDS = new Set(allRules.map((r) => r.id));
@@ -726,7 +776,106 @@ function buildRulesConfig(allow, ignore) {
726
776
  return rules;
727
777
  }
728
778
 
779
+ // src/config-file.ts
780
+ var CONFIG_FILENAMES = ["svelte-vitals.config.mjs", "svelte-vitals.config.js", "svelte-vitals.config.ts"];
781
+ var CATEGORIES = ["seo", "performance", "correctness", "security", "architecture"];
782
+ var TREAT_DYNAMIC_AS_VALUES = ["pass", "warn", "fail"];
783
+ var FAIL_ON_VALUES = ["critical", "warning", "info"];
784
+ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["treatDynamicAs", "metaComponents", "rules", "failOn", "weights"]);
785
+ function isPlainObject(value) {
786
+ return typeof value === "object" && value !== null && !Array.isArray(value);
787
+ }
788
+ function isMissingExtensionLoaderError(err) {
789
+ return err instanceof Error && ("code" in err && err.code === "ERR_UNKNOWN_FILE_EXTENSION" || /Unknown file extension/.test(err.message));
790
+ }
791
+ function validateConfigFile(raw, path) {
792
+ const warnings = [];
793
+ const config = {};
794
+ for (const key of Object.keys(raw)) {
795
+ if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
796
+ warnings.push(`${path}: unknown config key '${key}' ignored.`);
797
+ }
798
+ }
799
+ if (raw.treatDynamicAs !== void 0) {
800
+ if (TREAT_DYNAMIC_AS_VALUES.includes(raw.treatDynamicAs)) {
801
+ config.treatDynamicAs = raw.treatDynamicAs;
802
+ } else {
803
+ warnings.push(
804
+ `${path}: unknown treatDynamicAs '${String(raw.treatDynamicAs)}'; expected pass|warn|fail. Ignoring.`
805
+ );
806
+ }
807
+ }
808
+ if (raw.failOn !== void 0) {
809
+ if (FAIL_ON_VALUES.includes(raw.failOn)) {
810
+ config.failOn = raw.failOn;
811
+ } else {
812
+ warnings.push(`${path}: unknown failOn '${String(raw.failOn)}'; expected critical|warning|info. Ignoring.`);
813
+ }
814
+ }
815
+ if (raw.metaComponents !== void 0) {
816
+ if (Array.isArray(raw.metaComponents) && raw.metaComponents.every((c) => typeof c === "string")) {
817
+ config.metaComponents = raw.metaComponents;
818
+ } else {
819
+ warnings.push(`${path}: metaComponents must be an array of strings. Ignoring.`);
820
+ }
821
+ }
822
+ if (raw.rules !== void 0) {
823
+ if (!isPlainObject(raw.rules)) {
824
+ throw new Error(`${path}: rules must be an object of rule-id \u2192 setting.`);
825
+ }
826
+ const rules = raw.rules;
827
+ const unknown = findUnknownRuleIds(Object.keys(rules));
828
+ if (unknown.length > 0) {
829
+ throw new Error(
830
+ `${path}: unknown rule id(s) in rules: ${unknown.join(", ")}. Known rule ids: ${knownRuleIds().join(", ")}`
831
+ );
832
+ }
833
+ config.rules = rules;
834
+ }
835
+ if (raw.weights !== void 0) {
836
+ if (!isPlainObject(raw.weights)) {
837
+ throw new Error(`${path}: weights must be an object of category \u2192 number.`);
838
+ }
839
+ const weights = {};
840
+ for (const [rawCat, w] of Object.entries(raw.weights)) {
841
+ const cat = rawCat.toLowerCase();
842
+ if (!CATEGORIES.includes(cat)) {
843
+ throw new Error(`${path}: unknown category '${rawCat}' in weights. Known categories: ${CATEGORIES.join(", ")}`);
844
+ }
845
+ if (typeof w !== "number" || !Number.isFinite(w) || w < 0) {
846
+ throw new Error(`${path}: invalid weight for '${cat}': ${String(w)}; expected a finite number >= 0.`);
847
+ }
848
+ weights[cat] = w;
849
+ }
850
+ config.weights = weights;
851
+ }
852
+ return { config, warnings };
853
+ }
854
+ async function loadConfigFile(cwd) {
855
+ const found = CONFIG_FILENAMES.map((name) => join2(cwd, name)).find((path) => existsSync(path));
856
+ if (!found) return void 0;
857
+ let mod;
858
+ try {
859
+ mod = await import(pathToFileURL(found).href);
860
+ } catch (err) {
861
+ if (found.endsWith(".ts") && isMissingExtensionLoaderError(err)) {
862
+ throw new Error(
863
+ `could not load ${found} \u2014 this Node runtime does not support TypeScript config files without a flag. Native type-stripping is unflagged from Node 22.18 / 23.6+: upgrade Node to 22.18+, re-run with --experimental-strip-types, or rename the file to .mjs/.js.`,
864
+ { cause: err }
865
+ );
866
+ }
867
+ throw err;
868
+ }
869
+ if (!isPlainObject(mod.default)) {
870
+ throw new Error(
871
+ `${found} must have a default export that is a plain object (e.g. \`export default defineConfig({...})\` or a plain object literal).`
872
+ );
873
+ }
874
+ return validateConfigFile(mod.default, found);
875
+ }
876
+
729
877
  // src/index.ts
878
+ import { defineConfig as defineConfig2 } from "@svelte-vitals/core";
730
879
  function spinnerEnabled(opts) {
731
880
  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
881
  }
@@ -739,11 +888,16 @@ function routeMatcher(glob) {
739
888
  async function analyzeProject(opts = {}) {
740
889
  const cwd = opts.cwd ?? process.cwd();
741
890
  const rt = createNodeRuntime();
891
+ const loaded = await loadConfigFile(cwd);
892
+ const file = loaded?.config;
893
+ const warnings = loaded?.warnings ?? [];
894
+ const weights = opts.weights ?? file?.weights;
742
895
  const config = defineConfig({
743
- treatDynamicAs: opts.treatDynamicAs ?? "pass",
744
- metaComponents: opts.metaComponents ?? [],
745
- rules: opts.rules ?? {},
746
- failOn: opts.failOn ?? "critical"
896
+ treatDynamicAs: opts.treatDynamicAs ?? file?.treatDynamicAs ?? "pass",
897
+ metaComponents: opts.metaComponents ?? file?.metaComponents ?? [],
898
+ rules: opts.rules ?? file?.rules ?? {},
899
+ failOn: opts.failOn ?? file?.failOn ?? "critical",
900
+ ...weights !== void 0 ? { weights } : {}
747
901
  });
748
902
  await detectProject(rt, cwd);
749
903
  const matches = routeMatcher(opts.route);
@@ -758,7 +912,7 @@ async function analyzeProject(opts = {}) {
758
912
  await runRules(rules, { heads, images, headings, components, project, config }),
759
913
  config
760
914
  );
761
- return { results, config, version: readPackageVersion() };
915
+ return { results, config, version: readPackageVersion(), warnings };
762
916
  }
763
917
  async function run(opts = {}) {
764
918
  const log = opts.log ?? ((line) => console.log(line));
@@ -786,7 +940,8 @@ async function run(opts = {}) {
786
940
  treatDynamicAs: opts.treatDynamicAs,
787
941
  route: opts.route,
788
942
  failOn: opts.failOn,
789
- rules: opts.rules
943
+ rules: opts.rules,
944
+ weights: opts.weights
790
945
  });
791
946
  } catch (err) {
792
947
  spinner.stop();
@@ -798,6 +953,7 @@ async function run(opts = {}) {
798
953
  return 2;
799
954
  }
800
955
  spinner.stop();
956
+ for (const w of analysis.warnings) errorLog(`svelte-vitals: ${w}`);
801
957
  try {
802
958
  const { config, version } = analysis;
803
959
  let results = analysis.results;
@@ -870,8 +1026,10 @@ export {
870
1026
  findUnknownRuleIds,
871
1027
  knownRuleIds,
872
1028
  buildRulesConfig,
1029
+ loadConfigFile,
873
1030
  spinnerEnabled,
874
1031
  routeMatcher,
875
1032
  analyzeProject,
876
- run
1033
+ run,
1034
+ defineConfig2 as defineConfig
877
1035
  };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { RuleSetting, Severity, Result, Config } from '@svelte-vitals/core';
1
+ import { RuleSetting, Config, Severity, Category, Result } from '@svelte-vitals/core';
2
+ export { defineConfig } from '@svelte-vitals/core';
2
3
 
3
4
  type ReporterName = 'console' | 'json' | 'agent' | 'sarif' | 'github' | 'html';
4
5
 
@@ -19,6 +20,31 @@ declare function knownRuleIds(): string[];
19
20
  */
20
21
  declare function buildRulesConfig(allow: string[], ignore: string[]): Record<string, RuleSetting>;
21
22
 
23
+ /** Result of loading and validating a config file. */
24
+ interface LoadedConfigFile {
25
+ /** The file's contents after validation; invalid optional fields are dropped (see `warnings`). */
26
+ config: Partial<Config>;
27
+ /** Non-fatal issues: unknown top-level keys, invalid enum values (the field is ignored). */
28
+ warnings: string[];
29
+ }
30
+ /**
31
+ * Find and load `svelte-vitals.config.{mjs,js,ts}` from `cwd` (only `cwd`, no
32
+ * upward search). Returns `undefined` when no candidate file exists.
33
+ *
34
+ * Loader mechanism (design doc §2): plain native `import()`. `.mjs`/`.js` always
35
+ * work (zero dependencies, no Node-version dependency). `.ts` depends on the host
36
+ * Node's TypeScript type-stripping support: unflagged in Node 23.6.0, backported
37
+ * to 22.18.0; on 22.13–22.17 (this repo's floor is >=22.13.0) it requires
38
+ * `--experimental-strip-types` and otherwise fails with
39
+ * `ERR_UNKNOWN_FILE_EXTENSION` — this is caught here and rethrown as a
40
+ * descriptive, actionable error instead of surfacing Node's raw error.
41
+ *
42
+ * Throws when: the file exists but has no usable default export; (`.ts` only)
43
+ * the host Node can't load TypeScript without a flag; or the loaded config
44
+ * fails validation (unknown rule ids in `rules`, invalid `weights` entries).
45
+ */
46
+ declare function loadConfigFile(cwd: string): Promise<LoadedConfigFile | undefined>;
47
+
22
48
  interface RunOptions {
23
49
  cwd?: string;
24
50
  log?: (line: string) => void;
@@ -31,6 +57,8 @@ interface RunOptions {
31
57
  byRoute?: boolean;
32
58
  failOn?: Severity;
33
59
  rules?: Record<string, RuleSetting>;
60
+ /** Per-category weights for the combined Health score (flag > config file > default 1 each). */
61
+ weights?: Partial<Record<Category, number>>;
34
62
  /** Override process.env for reporter auto-detection (mainly useful in tests). */
35
63
  env?: NodeJS.ProcessEnv;
36
64
  /** Fail (exit 1) when the combined Health score is below this value (0–100). */
@@ -73,16 +101,26 @@ interface AnalyzeOptions {
73
101
  route?: string;
74
102
  failOn?: Severity;
75
103
  rules?: Record<string, RuleSetting>;
104
+ /** Per-category weights for the combined Health score (flag > config file > default 1 each). */
105
+ weights?: Partial<Record<Category, number>>;
76
106
  }
77
107
  interface AnalyzeResult {
78
108
  results: Result[];
79
109
  config: Config;
80
110
  version: string;
111
+ /** Non-fatal config-file issues (unknown top-level keys, invalid enum values). Empty when no config file or none found. */
112
+ warnings: string[];
81
113
  }
82
114
  /**
83
115
  * Run static-mode analysis and return the structured findings + resolved config.
84
- * Throws ProjectError when `cwd` is not a SvelteKit project. Shared by the CLI's
85
- * run() and by @svelte-vitals/mcp (issue #24).
116
+ * Throws ProjectError when `cwd` is not a SvelteKit project. Also throws when a
117
+ * `svelte-vitals.config.{mjs,js,ts}` file in `cwd` fails to load or fails
118
+ * validation (unknown rule ids in `rules`, invalid `weights` entries) — see
119
+ * `loadConfigFile`. Shared by the CLI's run() and by @svelte-vitals/mcp (issue #24).
120
+ *
121
+ * Config precedence is per field: an explicit option here wins, otherwise the
122
+ * config file's value is used, otherwise the built-in default (design doc
123
+ * 2026-07-05-config-file-design.md §3).
86
124
  */
87
125
  declare function analyzeProject(opts?: AnalyzeOptions): Promise<AnalyzeResult>;
88
126
  /**
@@ -91,4 +129,4 @@ declare function analyzeProject(opts?: AnalyzeOptions): Promise<AnalyzeResult>;
91
129
  */
92
130
  declare function run(opts?: RunOptions): Promise<number>;
93
131
 
94
- export { type AnalyzeOptions, type AnalyzeResult, ProjectError, type RunOptions, analyzeProject, buildRulesConfig, findUnknownRuleIds, knownRuleIds, routeMatcher, run, spinnerEnabled };
132
+ export { type AnalyzeOptions, type AnalyzeResult, type LoadedConfigFile, ProjectError, type RunOptions, analyzeProject, buildRulesConfig, findUnknownRuleIds, knownRuleIds, loadConfigFile, routeMatcher, run, spinnerEnabled };
package/dist/index.js CHANGED
@@ -2,18 +2,22 @@ import {
2
2
  ProjectError,
3
3
  analyzeProject,
4
4
  buildRulesConfig,
5
+ defineConfig,
5
6
  findUnknownRuleIds,
6
7
  knownRuleIds,
8
+ loadConfigFile,
7
9
  routeMatcher,
8
10
  run,
9
11
  spinnerEnabled
10
- } from "./chunk-NLQZ3CMZ.js";
12
+ } from "./chunk-ZE3M3T6U.js";
11
13
  export {
12
14
  ProjectError,
13
15
  analyzeProject,
14
16
  buildRulesConfig,
17
+ defineConfig,
15
18
  findUnknownRuleIds,
16
19
  knownRuleIds,
20
+ loadConfigFile,
17
21
  routeMatcher,
18
22
  run,
19
23
  spinnerEnabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.18.0",
3
+ "version": "0.19.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.20.8"
26
+ "node": ">=22.13.0"
27
27
  },
28
28
  "sideEffects": false,
29
29
  "bin": {
@@ -39,13 +39,13 @@
39
39
  "dist"
40
40
  ],
41
41
  "dependencies": {
42
- "@clack/prompts": "^1.6.0",
42
+ "@clack/prompts": "^1.7.0",
43
43
  "magicast": "^0.5.3",
44
44
  "mri": "^1.2.0",
45
45
  "smol-toml": "^1.7.0",
46
46
  "svelte": "^5.56.4",
47
47
  "tinyglobby": "^0.2.17",
48
- "@svelte-vitals/core": "0.19.0"
48
+ "@svelte-vitals/core": "0.20.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.13.2"