svelte-vitals 0.24.0 → 0.25.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
@@ -7,7 +7,7 @@ import {
7
7
  readCoreVersion,
8
8
  readPackageVersion,
9
9
  run
10
- } from "./chunk-TCZ6OF6J.js";
10
+ } from "./chunk-OCLDCX4Y.js";
11
11
 
12
12
  // src/bin.ts
13
13
  import mri3 from "mri";
@@ -135,6 +135,11 @@ function resolveArgs(argv) {
135
135
  const verbose = Boolean(argv["verbose"]);
136
136
  const noColor = argv.color === false;
137
137
  const noAnimation = argv.animation === false;
138
+ const noSuppressions = argv.suppressions === false;
139
+ const updateSuppressions = Boolean(argv["update-suppressions"]);
140
+ if (updateSuppressions && noSuppressions) {
141
+ errors.push("svelte-vitals: --update-suppressions and --no-suppressions cannot be used together.");
142
+ }
138
143
  const rulesConfig = buildRulesConfig(allow, ignore);
139
144
  const rules = Object.keys(rulesConfig).length > 0 ? rulesConfig : void 0;
140
145
  if (errors.length > 0) return { options: null, warnings, errors };
@@ -160,7 +165,9 @@ function resolveArgs(argv) {
160
165
  ...noAnimation ? { noAnimation } : {},
161
166
  ...diffBase !== void 0 ? { diffBase } : {},
162
167
  ...staged ? { staged } : {},
163
- ...baselineRef !== void 0 ? { baseline: baselineRef } : {}
168
+ ...baselineRef !== void 0 ? { baseline: baselineRef } : {},
169
+ ...noSuppressions ? { noSuppressions } : {},
170
+ ...updateSuppressions ? { updateSuppressions } : {}
164
171
  },
165
172
  warnings,
166
173
  errors
@@ -303,6 +310,22 @@ function isAgentTargetId(id) {
303
310
  return AGENT_TARGETS.some((t) => t.id === id);
304
311
  }
305
312
 
313
+ // src/install/config-targets.ts
314
+ var CONFIG_TARGETS = [
315
+ {
316
+ id: "config-file",
317
+ label: "Config file",
318
+ hint: "Scaffolds svelte-vitals.config.mjs with every option commented out",
319
+ relPath: "svelte-vitals.config.mjs"
320
+ }
321
+ ];
322
+ function configTargetById(id) {
323
+ return CONFIG_TARGETS.find((t) => t.id === id);
324
+ }
325
+ function isConfigTargetId(id) {
326
+ return CONFIG_TARGETS.some((t) => t.id === id);
327
+ }
328
+
306
329
  // src/install/skill-content.ts
307
330
  import { allRules, docsUrlFor } from "@svelte-vitals/core";
308
331
  var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture"];
@@ -368,6 +391,19 @@ alwaysApply: false
368
391
  ${sharedBody(version)}`;
369
392
  }
370
393
 
394
+ // src/install/config-content.ts
395
+ function buildConfigFileTemplate() {
396
+ return `// svelte-vitals config file \u2014 https://oekazuma.github.io/svelte-vitals/guides/configuration/
397
+ export default {
398
+ // treatDynamicAs: 'pass', // 'pass' | 'warn' | 'fail' \u2014 how {data.title}-style dynamic values are scored
399
+ // metaComponents: ['Seo'], // component names that resolve SEO tags into <head>
400
+ // rules: {}, // e.g. { SEO001: 'off' } to disable a rule
401
+ // failOn: 'critical', // 'critical' | 'warning' | 'info'
402
+ // weights: {} // e.g. { seo: 2 } \u2014 per-category weight for the combined Health score
403
+ };
404
+ `;
405
+ }
406
+
371
407
  // src/install/codemod-vite-config.ts
372
408
  import { parseModule, generateCode, builders, MagicastError } from "magicast";
373
409
  var MANUAL_SNIPPET = `import { svelteVitals } from '@svelte-vitals/vite';
@@ -543,6 +579,13 @@ function planForAgentTarget(target, io, force, version) {
543
579
  const status = existing === void 0 ? "created" : force ? "updated" : "exists";
544
580
  return { id: target.id, label: target.label, path, status, content };
545
581
  }
582
+ function planForConfigTarget(target, io, force) {
583
+ const path = join3(io.cwd, target.relPath);
584
+ const existing = io.readFile(path);
585
+ const content = buildConfigFileTemplate();
586
+ const status = existing === void 0 ? "created" : force ? "updated" : "exists";
587
+ return { id: target.id, label: target.label, path, status, content };
588
+ }
546
589
  function indent(text) {
547
590
  return text.split("\n").map((l) => ` ${l}`).join("\n");
548
591
  }
@@ -551,7 +594,57 @@ function rowLine(r) {
551
594
  return r.status === "manual" && r.snippet ? `${head}
552
595
  ${indent(r.snippet)}` : head;
553
596
  }
597
+ async function runRefresh(io, flags, version) {
598
+ let hadFailure = false;
599
+ const rows = [];
600
+ for (const target of AGENT_TARGETS) {
601
+ const path = join3(io.cwd, target.relPath);
602
+ try {
603
+ if (io.readFile(path) === void 0) continue;
604
+ rows.push(planForAgentTarget(
605
+ target,
606
+ io,
607
+ /* force */
608
+ true,
609
+ version
610
+ ));
611
+ } catch (err) {
612
+ hadFailure = true;
613
+ io.errorLog(`svelte-vitals: failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
614
+ }
615
+ }
616
+ if (rows.length === 0) {
617
+ if (hadFailure) return 2;
618
+ io.errorLog(
619
+ "svelte-vitals: no generated agent files found \u2014 run `svelte-vitals install --client claude-skill,cursor-rules` first."
620
+ );
621
+ return 0;
622
+ }
623
+ const planText = rows.map(rowLine).join("\n");
624
+ io.log("Plan:");
625
+ io.log(planText);
626
+ if (flags.dryRun) {
627
+ io.log("Dry run \u2014 no files written.");
628
+ return hadFailure ? 2 : 0;
629
+ }
630
+ for (const r of rows) {
631
+ try {
632
+ io.writeFile(r.path, r.content ?? "");
633
+ io.log(`\u2713 ${r.label}: ${r.status} ${r.path}`);
634
+ } catch (err) {
635
+ hadFailure = true;
636
+ io.errorLog(`svelte-vitals: failed to write ${r.path}: ${err instanceof Error ? err.message : String(err)}`);
637
+ }
638
+ }
639
+ if (hadFailure) return 2;
640
+ io.log("");
641
+ io.log(`\u2713 refreshed ${rows.length} file(s).`);
642
+ return 0;
643
+ }
554
644
  async function runInstall(flags, io, prompts, version = "0.0.0") {
645
+ if (flags.refresh) {
646
+ return runRefresh(io, flags, version);
647
+ }
555
648
  let ids;
556
649
  if (flags.client && flags.client.length > 0) {
557
650
  ids = flags.client;
@@ -583,7 +676,8 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
583
676
  const options = [
584
677
  ...CLIENTS.map((c) => ({ id: c.id, label: c.label })),
585
678
  ...VITE_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
586
- ...AGENT_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint }))
679
+ ...AGENT_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint })),
680
+ ...CONFIG_TARGETS.map((t) => ({ id: t.id, label: t.label, hint: t.hint }))
587
681
  ];
588
682
  const picked = await prompts.selectClients(options, detected);
589
683
  if (picked === null) {
@@ -593,14 +687,15 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
593
687
  ids = picked;
594
688
  } else {
595
689
  io.errorLog(
596
- "svelte-vitals: no TTY; pass --client <claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules> to install non-interactively."
690
+ "svelte-vitals: no TTY; pass --client <claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules,config-file> to install non-interactively."
597
691
  );
598
692
  return 2;
599
693
  }
600
694
  const clients = ids.map(clientById).filter((c) => c !== void 0);
601
695
  const viteIds = ids.filter(isViteTargetId);
602
696
  const agentIds = ids.filter(isAgentTargetId);
603
- if (clients.length === 0 && viteIds.length === 0 && agentIds.length === 0) {
697
+ const configIds = ids.filter(isConfigTargetId);
698
+ if (clients.length === 0 && viteIds.length === 0 && agentIds.length === 0 && configIds.length === 0) {
604
699
  io.errorLog("svelte-vitals: no valid clients or targets selected.");
605
700
  return 2;
606
701
  }
@@ -638,6 +733,10 @@ async function runInstall(flags, io, prompts, version = "0.0.0") {
638
733
  const target = agentTargetById(agentId);
639
734
  rows.push(planForAgentTarget(target, io, flags.force ?? false, version));
640
735
  }
736
+ for (const configId of configIds) {
737
+ const target = configTargetById(configId);
738
+ rows.push(planForConfigTarget(target, io, flags.force ?? false));
739
+ }
641
740
  const planText = rows.map(rowLine).join("\n");
642
741
  io.log("Plan:");
643
742
  io.log(planText);
@@ -726,14 +825,22 @@ function resolveInstallArgs(argv) {
726
825
  if (rawScope === "project" || rawScope === "global") scope = rawScope;
727
826
  else errors.push(`svelte-vitals: unknown --scope '${rawScope}'; expected project|global.`);
728
827
  }
828
+ const refresh = Boolean(argv.refresh);
829
+ if (refresh && rawClients.length > 0) {
830
+ errors.push("svelte-vitals: --refresh regenerates existing files and cannot be combined with --client.");
831
+ }
729
832
  if (errors.length > 0) return { flags: null, warnings, errors };
833
+ if (refresh && (scope !== void 0 || Boolean(argv.yes) || Boolean(argv.force))) {
834
+ warnings.push("svelte-vitals: --scope, --yes, and --force are ignored with --refresh.");
835
+ }
730
836
  return {
731
837
  flags: {
732
838
  ...client.length > 0 ? { client } : {},
733
839
  ...scope ? { scope } : {},
734
840
  yes: Boolean(argv.yes),
735
841
  dryRun: Boolean(argv["dry-run"]),
736
- force: Boolean(argv.force)
842
+ force: Boolean(argv.force),
843
+ ...refresh ? { refresh: true } : {}
737
844
  },
738
845
  warnings,
739
846
  errors
@@ -747,7 +854,7 @@ Usage:
747
854
  svelte-vitals install [options]
748
855
 
749
856
  Options:
750
- --client <ids> Comma-separated: claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules
857
+ --client <ids> Comma-separated: claude-code,cursor,codex,vite-plugin,vite-hooks,claude-skill,cursor-rules,config-file
751
858
  (skips the interactive picker)
752
859
  vite-plugin registers the build-mode plugin in vite.config.{ts,js,mjs}; vite-hooks
753
860
  wires up the svelteVitalsHandle hook in src/hooks.server.{ts,js}, which improves the
@@ -756,10 +863,15 @@ Options:
756
863
  claude-skill writes a Claude Code skill (.claude/skills/svelte-vitals/SKILL.md); cursor-rules
757
864
  writes a Cursor rules file (.cursor/rules/svelte-vitals.mdc). Both are generated from the
758
865
  current rule set and support --force to regenerate.
866
+ config-file scaffolds svelte-vitals.config.mjs with every option commented out;
867
+ supports --force to regenerate.
759
868
  --scope <scope> project | global (applies to all selected clients; codex is always global)
760
869
  --yes, -y Skip the confirmation prompt
761
870
  --dry-run Print the planned changes and exit without writing
762
871
  --force Overwrite an existing svelte-vitals entry
872
+ --refresh Regenerate existing agent skill/rules files with the current rule set
873
+ (claude-skill / cursor-rules). Only regenerates files already present on
874
+ disk \u2014 it never creates one. Cannot be combined with --client.
763
875
  -h, --help Show this help`;
764
876
  function realIO() {
765
877
  return {
@@ -827,7 +939,7 @@ ${planText}` });
827
939
  }
828
940
  async function runInstallCli(args) {
829
941
  const argv = mri(args, {
830
- boolean: ["yes", "dry-run", "force", "help"],
942
+ boolean: ["yes", "dry-run", "force", "refresh", "help"],
831
943
  string: ["client", "scope"],
832
944
  alias: { y: "yes", h: "help" }
833
945
  });
@@ -884,22 +996,56 @@ function buildWorkflowYaml(opts) {
884
996
  ].join("\n");
885
997
  }
886
998
 
999
+ // src/ci/upgrade.ts
1000
+ var ACTION_USES_LINE = /^(?<indent>\s*-\s*uses:\s*(?:&\S+\s+)?oekazuma\/svelte-vitals\/packages\/action@)(?<ref>[^\s#]+)(?<comment>\s*#.*)?$/;
1001
+ function upgradeActionPin(content, sha, version) {
1002
+ const lines = content.split("\n");
1003
+ let replaced = 0;
1004
+ let from;
1005
+ const next = lines.map((line) => {
1006
+ const eol = line.endsWith("\r") ? "\r" : "";
1007
+ const bare = eol ? line.slice(0, -1) : line;
1008
+ const match = ACTION_USES_LINE.exec(bare);
1009
+ if (!match || !match.groups) return line;
1010
+ const { indent: indent2, ref } = match.groups;
1011
+ if (indent2 === void 0 || ref === void 0) return line;
1012
+ if (ref === sha) return line;
1013
+ if (from === void 0) {
1014
+ const commentMatch = /#\s*@svelte-vitals\/action@(\S+)/.exec(match.groups.comment ?? "");
1015
+ from = commentMatch ? commentMatch[1] : ref.slice(0, 7);
1016
+ }
1017
+ replaced += 1;
1018
+ return `${indent2}${sha} # @svelte-vitals/action@${version}${eol}`;
1019
+ });
1020
+ if (replaced === 0) {
1021
+ const hasAnyReference = lines.some((line) => ACTION_USES_LINE.test(line.endsWith("\r") ? line.slice(0, -1) : line));
1022
+ return { status: hasAnyReference ? "up-to-date" : "no-reference" };
1023
+ }
1024
+ return { status: "upgraded", content: next.join("\n"), replaced, from };
1025
+ }
1026
+
887
1027
  // src/ci/action-pin.generated.ts
888
- var ACTION_SHA = "c9257b68ce2c4006f3631345f66c05342c715fdd";
889
- var ACTION_VERSION = "0.2.3";
1028
+ var ACTION_SHA = "2c21acbad36c5360228bbb0790dbb4a06b7c773a";
1029
+ var ACTION_VERSION = "0.3.0";
890
1030
 
891
1031
  // src/ci/cli.ts
892
1032
  var CI_HELP = `svelte-vitals ci \u2014 scaffold CI integration
893
1033
 
894
1034
  Usage:
895
1035
  svelte-vitals ci install [options]
1036
+ svelte-vitals ci upgrade [--dry-run]
896
1037
 
897
1038
  Adds a GitHub Actions workflow (${WORKFLOW_PATH}) that calls the \`@svelte-vitals/action\`
898
1039
  GitHub Action on pull requests: inline annotations, a job summary, and a sticky PR
899
1040
  comment with the findings.
900
1041
 
1042
+ \`ci upgrade\` rewrites only the pinned \`@svelte-vitals/action\` reference in an existing
1043
+ workflow to the pin bundled with this CLI, leaving the rest of the file (and any other
1044
+ pins, like actions/checkout) untouched. To pick up the latest pin, run
1045
+ \`npx svelte-vitals@latest ci upgrade\`.
1046
+
901
1047
  Options:
902
- --force Overwrite an existing workflow file
1048
+ --force Overwrite an existing workflow file (install only)
903
1049
  --dry-run Print the plan and exit without writing
904
1050
  -h, --help Show this help`;
905
1051
  async function runCiCli(args, io = realIO()) {
@@ -908,6 +1054,9 @@ async function runCiCli(args, io = realIO()) {
908
1054
  io.log(CI_HELP);
909
1055
  return 0;
910
1056
  }
1057
+ if (sub === "upgrade") {
1058
+ return runCiUpgrade(args.slice(1), io);
1059
+ }
911
1060
  if (sub !== "install") {
912
1061
  io.log(CI_HELP);
913
1062
  return 2;
@@ -945,6 +1094,44 @@ async function runCiCli(args, io = realIO()) {
945
1094
  io.log("Done. Commit the workflow file and open a PR to see it in action.");
946
1095
  return 0;
947
1096
  }
1097
+ async function runCiUpgrade(args, io) {
1098
+ const argv = mri2(args, {
1099
+ boolean: ["dry-run", "help"],
1100
+ alias: { h: "help" }
1101
+ });
1102
+ if (argv.help) {
1103
+ io.log(CI_HELP);
1104
+ return 0;
1105
+ }
1106
+ const path = join4(io.cwd, WORKFLOW_PATH);
1107
+ const existing = io.readFile(path);
1108
+ if (existing === void 0) {
1109
+ io.errorLog(`svelte-vitals: no ${WORKFLOW_PATH} found \u2014 run \`svelte-vitals ci install\` first.`);
1110
+ return 2;
1111
+ }
1112
+ const outcome = upgradeActionPin(existing, ACTION_SHA, ACTION_VERSION);
1113
+ if (outcome.status === "no-reference") {
1114
+ io.errorLog(`svelte-vitals: no @svelte-vitals/action reference found in ${WORKFLOW_PATH}.`);
1115
+ return 2;
1116
+ }
1117
+ if (outcome.status === "up-to-date") {
1118
+ io.log(`= already up to date (@svelte-vitals/action@${ACTION_VERSION}).`);
1119
+ return 0;
1120
+ }
1121
+ if (argv["dry-run"]) {
1122
+ io.log(`Would upgrade @svelte-vitals/action: ${outcome.from} \u2192 ${ACTION_VERSION} (${outcome.replaced} line(s)).`);
1123
+ io.log("Dry run \u2014 no files written.");
1124
+ return 0;
1125
+ }
1126
+ try {
1127
+ io.writeFile(path, outcome.content ?? existing);
1128
+ } catch (err) {
1129
+ io.errorLog(`svelte-vitals: failed to write ${WORKFLOW_PATH}: ${err instanceof Error ? err.message : String(err)}`);
1130
+ return 2;
1131
+ }
1132
+ io.log(`\u2713 upgraded @svelte-vitals/action: ${outcome.from} \u2192 ${ACTION_VERSION} (${outcome.replaced} line(s)).`);
1133
+ return 0;
1134
+ }
948
1135
 
949
1136
  // src/bin.ts
950
1137
  var HELP = `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
@@ -953,6 +1140,7 @@ Usage:
953
1140
  svelte-vitals [path] [options]
954
1141
  svelte-vitals install Set up the MCP server, Vite integration, or agent skills/rules
955
1142
  svelte-vitals ci install Add a GitHub Actions PR gate (annotations + summary comment)
1143
+ svelte-vitals ci upgrade Refresh the pinned @svelte-vitals/action in an existing workflow
956
1144
 
957
1145
  Options:
958
1146
  --meta-components <names> Comma-separated component names that emit head metadata
@@ -961,6 +1149,8 @@ Options:
961
1149
  --diff [ref] Report only findings in files changed vs ref (default HEAD; e.g. --diff main)
962
1150
  --staged Report only findings in files staged for commit (pre-commit gate)
963
1151
  --baseline <ref> Report only findings not present at ref (compare against e.g. origin/main)
1152
+ --update-suppressions Write svelte-vitals-suppressions.json accepting all current findings (introduce gates on legacy projects)
1153
+ --no-suppressions Ignore svelte-vitals-suppressions.json for this run
964
1154
  --by-route Show per-route score breakdown in console output
965
1155
  --reporter <fmt> console | json | agent | sarif | github | html | md (auto: agent under AI-agent envs, github under GitHub Actions)
966
1156
  --out-file <path> Output path for --reporter html (default: svelte-vitals-report.html; '-' for stdout)
@@ -1004,7 +1194,7 @@ async function main() {
1004
1194
  }
1005
1195
  const argv = mri3(process.argv.slice(2), {
1006
1196
  alias: { h: "help", v: "version" },
1007
- boolean: ["by-route", "staged", "score", "verbose"],
1197
+ boolean: ["by-route", "staged", "score", "verbose", "update-suppressions"],
1008
1198
  string: [
1009
1199
  "meta-components",
1010
1200
  "treat-dynamic-as",
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
- import { mkdirSync, writeFileSync } from "fs";
3
- import { dirname as dirname2, join as join5 } from "path";
2
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
3
+ import { dirname as dirname2, join as join6 } from "path";
4
4
  import {
5
5
  allRules as allRules2,
6
6
  runRules,
@@ -637,9 +637,8 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache) {
637
637
  headings: { route, headings }
638
638
  };
639
639
  }
640
- async function collectRoutes(rt, cwd, config = defaultConfig) {
640
+ async function collectRoutes(rt, cwd, config = defaultConfig, cache = /* @__PURE__ */ new Map()) {
641
641
  const [pages, layouts] = await Promise.all([enumerateRoutePages(rt, cwd), collectLayouts(rt, cwd)]);
642
- const cache = /* @__PURE__ */ new Map();
643
642
  const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config, layouts, cache)));
644
643
  return {
645
644
  heads: facts.map((f) => f.head),
@@ -793,6 +792,104 @@ function filterToNewFindings(results, baselineResults) {
793
792
  return results.filter((r) => !baselineKeys.has(findingKey(r)));
794
793
  }
795
794
 
795
+ // src/suppressions.ts
796
+ import { readFileSync as readFileSync2, writeFileSync } from "fs";
797
+ import { join as join4 } from "path";
798
+ import { isPenalized } from "@svelte-vitals/core";
799
+ var SUPPRESSIONS_FILE = "svelte-vitals-suppressions.json";
800
+ function isPlainObject(value) {
801
+ return typeof value === "object" && value !== null && !Array.isArray(value);
802
+ }
803
+ function loadSuppressions(cwd) {
804
+ const path = join4(cwd, SUPPRESSIONS_FILE);
805
+ let raw;
806
+ try {
807
+ raw = readFileSync2(path, "utf8");
808
+ } catch {
809
+ return void 0;
810
+ }
811
+ let parsed;
812
+ try {
813
+ parsed = JSON.parse(raw);
814
+ } catch (err) {
815
+ throw new Error(
816
+ `invalid ${SUPPRESSIONS_FILE}: not valid JSON (${err instanceof Error ? err.message : String(err)}).`,
817
+ { cause: err }
818
+ );
819
+ }
820
+ if (!isPlainObject(parsed)) {
821
+ throw new Error(`invalid ${SUPPRESSIONS_FILE}: expected a top-level JSON object.`);
822
+ }
823
+ if (parsed.version !== 1) {
824
+ throw new Error(`invalid ${SUPPRESSIONS_FILE}: expected "version": 1, got ${JSON.stringify(parsed.version)}.`);
825
+ }
826
+ if (!Array.isArray(parsed.suppressions)) {
827
+ throw new Error(`invalid ${SUPPRESSIONS_FILE}: "suppressions" must be an array.`);
828
+ }
829
+ const entries = [];
830
+ parsed.suppressions.forEach((entry, i) => {
831
+ if (!isPlainObject(entry) || typeof entry.id !== "string") {
832
+ throw new Error(`invalid ${SUPPRESSIONS_FILE}: suppressions[${i}] must be an object with a string "id".`);
833
+ }
834
+ entries.push({
835
+ id: entry.id,
836
+ ...typeof entry.route === "string" ? { route: entry.route } : {},
837
+ ...typeof entry.location === "string" ? { location: entry.location } : {}
838
+ });
839
+ });
840
+ return entries;
841
+ }
842
+ function toEntry(r) {
843
+ return {
844
+ id: r.id,
845
+ ...r.route !== void 0 ? { route: r.route } : {},
846
+ ...r.location !== void 0 ? { location: r.location } : {}
847
+ };
848
+ }
849
+ function compareEntries(a, b) {
850
+ if (a.id !== b.id) return a.id < b.id ? -1 : 1;
851
+ const ar = a.route ?? "";
852
+ const br = b.route ?? "";
853
+ if (ar !== br) return ar < br ? -1 : 1;
854
+ const al = a.location ?? "";
855
+ const bl = b.location ?? "";
856
+ if (al !== bl) return al < bl ? -1 : 1;
857
+ return 0;
858
+ }
859
+ function writeSuppressions(cwd, results, config) {
860
+ const seen = /* @__PURE__ */ new Set();
861
+ const entries = [];
862
+ for (const r of results) {
863
+ if (!isPenalized(r.detection, config.treatDynamicAs)) continue;
864
+ const entry = toEntry(r);
865
+ const key = findingKey(entry);
866
+ if (seen.has(key)) continue;
867
+ seen.add(key);
868
+ entries.push(entry);
869
+ }
870
+ entries.sort(compareEntries);
871
+ const path = join4(cwd, SUPPRESSIONS_FILE);
872
+ writeFileSync(path, JSON.stringify({ version: 1, suppressions: entries }, null, 2) + "\n");
873
+ return entries.length;
874
+ }
875
+ function applySuppressions(results, entries, config) {
876
+ const keys = new Set(entries.map((e) => findingKey(e)));
877
+ const usedKeys = /* @__PURE__ */ new Set();
878
+ const kept = [];
879
+ let suppressed = 0;
880
+ for (const r of results) {
881
+ const key = findingKey(r);
882
+ if (keys.has(key) && isPenalized(r.detection, config.treatDynamicAs)) {
883
+ suppressed++;
884
+ usedKeys.add(key);
885
+ continue;
886
+ }
887
+ kept.push(r);
888
+ }
889
+ const stale = [...keys].filter((k) => !usedKeys.has(k)).length;
890
+ return { results: kept, suppressed, stale };
891
+ }
892
+
796
893
  // src/color.ts
797
894
  import { noColorPalette } from "@svelte-vitals/core";
798
895
  var wrap = (open, close = 0) => (s) => `\x1B[${open}m${s}\x1B[${close}m`;
@@ -857,7 +954,7 @@ var FACE_WINK_BOTH = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2
857
954
  var FACE_WINK_ONE = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF < \u2502", "\u2502 \u2500\u2500 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
858
955
  var FACE_CONTENT = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF \u25CF \u2502", "\u2502 \u25E1\u25E1 \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
859
956
  var FACE_HAPPY = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 \u25CF \u25CF \u2502", "\u2502 \u2570\u2500\u2500\u256F \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
860
- var FACE_ECSTATIC = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 ^ ^ \u2502", "\u2502 \u2570\u2500\u2500\u2500\u256F \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
957
+ var FACE_ECSTATIC = ["\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E", "\u2502 ^ ^ \u2502", "\u2502 \u2570\u2500\u2500\u256F \u2502", "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"];
861
958
  var REACTION_FACES = {
862
959
  content: FACE_CONTENT,
863
960
  happy: FACE_HAPPY,
@@ -988,7 +1085,7 @@ async function playMascotGreeting(opts) {
988
1085
 
989
1086
  // src/config-file.ts
990
1087
  import { existsSync as existsSync2 } from "fs";
991
- import { join as join4 } from "path";
1088
+ import { join as join5 } from "path";
992
1089
  import { pathToFileURL } from "url";
993
1090
 
994
1091
  // src/rules-config.ts
@@ -1015,7 +1112,7 @@ var CATEGORIES = ["seo", "performance", "correctness", "security", "architecture
1015
1112
  var TREAT_DYNAMIC_AS_VALUES = ["pass", "warn", "fail"];
1016
1113
  var FAIL_ON_VALUES = ["critical", "warning", "info"];
1017
1114
  var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["treatDynamicAs", "metaComponents", "rules", "failOn", "weights"]);
1018
- function isPlainObject(value) {
1115
+ function isPlainObject2(value) {
1019
1116
  return typeof value === "object" && value !== null && !Array.isArray(value);
1020
1117
  }
1021
1118
  function isMissingExtensionLoaderError(err) {
@@ -1053,7 +1150,7 @@ function validateConfigFile(raw, path) {
1053
1150
  }
1054
1151
  }
1055
1152
  if (raw.rules !== void 0) {
1056
- if (!isPlainObject(raw.rules)) {
1153
+ if (!isPlainObject2(raw.rules)) {
1057
1154
  throw new Error(`${path}: rules must be an object of rule-id \u2192 setting.`);
1058
1155
  }
1059
1156
  const rules = raw.rules;
@@ -1066,7 +1163,7 @@ function validateConfigFile(raw, path) {
1066
1163
  config.rules = rules;
1067
1164
  }
1068
1165
  if (raw.weights !== void 0) {
1069
- if (!isPlainObject(raw.weights)) {
1166
+ if (!isPlainObject2(raw.weights)) {
1070
1167
  throw new Error(`${path}: weights must be an object of category \u2192 number.`);
1071
1168
  }
1072
1169
  const weights = {};
@@ -1085,7 +1182,7 @@ function validateConfigFile(raw, path) {
1085
1182
  return { config, warnings };
1086
1183
  }
1087
1184
  async function loadConfigFile(cwd) {
1088
- const found = CONFIG_FILENAMES.map((name) => join4(cwd, name)).find((path) => existsSync2(path));
1185
+ const found = CONFIG_FILENAMES.map((name) => join5(cwd, name)).find((path) => existsSync2(path));
1089
1186
  if (!found) return void 0;
1090
1187
  let mod;
1091
1188
  try {
@@ -1099,7 +1196,7 @@ async function loadConfigFile(cwd) {
1099
1196
  }
1100
1197
  throw err;
1101
1198
  }
1102
- if (!isPlainObject(mod.default)) {
1199
+ if (!isPlainObject2(mod.default)) {
1103
1200
  throw new Error(
1104
1201
  `${found} must have a default export that is a plain object (e.g. \`export default defineConfig({...})\` or a plain object literal).`
1105
1202
  );
@@ -1189,7 +1286,7 @@ async function analyzeProject(opts = {}) {
1189
1286
  });
1190
1287
  await detectProject(rt, cwd);
1191
1288
  const matches = routeMatcher(opts.route);
1192
- const collected = await collectRoutes(rt, cwd, config);
1289
+ const collected = await collectRoutes(rt, cwd, config, opts.parseCache);
1193
1290
  const heads = collected.heads.filter((h) => matches(h.route));
1194
1291
  const images = collected.images.filter((i) => matches(i.route));
1195
1292
  const headings = collected.headings.filter((h) => matches(h.route));
@@ -1233,6 +1330,18 @@ async function applyScope(results, opts) {
1233
1330
  }
1234
1331
  }
1235
1332
  }
1333
+ if (!opts.noSuppressions && opts.config) {
1334
+ const entries = loadSuppressions(opts.cwd);
1335
+ if (entries !== void 0) {
1336
+ const { results: afterSuppressions, suppressed, stale } = applySuppressions(scoped, entries, opts.config);
1337
+ scoped = afterSuppressions;
1338
+ if (suppressed > 0 || stale > 0) {
1339
+ errorLog(
1340
+ `svelte-vitals: ${suppressed} finding(s) suppressed by ${SUPPRESSIONS_FILE}` + (stale > 0 ? ` (${stale} stale entr${stale === 1 ? "y" : "ies"} \u2014 re-run --update-suppressions to prune)` : "") + "."
1341
+ );
1342
+ }
1343
+ }
1344
+ }
1236
1345
  return scoped;
1237
1346
  }
1238
1347
  async function run(opts = {}) {
@@ -1302,7 +1411,7 @@ async function run(opts = {}) {
1302
1411
  errorLog(`svelte-vitals: pass one as a path, e.g. \`npx svelte-vitals ${apps[0]}\`.`);
1303
1412
  return 2;
1304
1413
  }
1305
- cwd = join5(cwd, chosen);
1414
+ cwd = join6(cwd, chosen);
1306
1415
  try {
1307
1416
  analysis = await analyzeProject({
1308
1417
  cwd,
@@ -1331,11 +1440,18 @@ async function run(opts = {}) {
1331
1440
  for (const w of analysis.warnings) errorLog(`svelte-vitals: ${w}`);
1332
1441
  try {
1333
1442
  const { config, version } = analysis;
1443
+ if (opts.updateSuppressions) {
1444
+ const count = writeSuppressions(cwd, analysis.results, config);
1445
+ errorLog(`svelte-vitals: wrote ${count} suppression(s) to ${SUPPRESSIONS_FILE}.`);
1446
+ return 0;
1447
+ }
1334
1448
  const results = await applyScope(analysis.results, {
1335
1449
  cwd,
1450
+ config,
1336
1451
  staged: opts.staged,
1337
1452
  diffBase: opts.diffBase,
1338
1453
  baseline: opts.baseline,
1454
+ noSuppressions: opts.noSuppressions,
1339
1455
  errorLog,
1340
1456
  analyzeOpts: {
1341
1457
  metaComponents: opts.metaComponents,
@@ -1378,7 +1494,7 @@ async function run(opts = {}) {
1378
1494
  const path = opts.outFile || "svelte-vitals-report.html";
1379
1495
  const write = opts.writeFile ?? ((p, c) => {
1380
1496
  mkdirSync(dirname2(p), { recursive: true });
1381
- writeFileSync(p, c);
1497
+ writeFileSync2(p, c);
1382
1498
  });
1383
1499
  write(path, html);
1384
1500
  errorLog(`svelte-vitals: wrote report to ${path}`);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,54 @@
1
- import { RuleSetting, Config, Severity, Category, Result } from '@svelte-vitals/core';
1
+ import { HeadTag, RuleSetting, Config, Severity, Category, Result } from '@svelte-vitals/core';
2
2
  export { defineConfig } from '@svelte-vitals/core';
3
3
 
4
+ /** A resolved import binding: which module, and which export ('default' for default imports). */
5
+ interface ImportInfo {
6
+ source: string;
7
+ imported: string;
8
+ }
9
+ /** local identifier -> import binding. */
10
+ type ImportMap = Map<string, ImportInfo>;
11
+
12
+ /** A head tag parsed from one file, before layout-chain presence is assigned. */
13
+ type ParsedTag = Omit<HeadTag, 'presence' | 'file'>;
14
+ type Node = any;
15
+ interface ComponentUse {
16
+ name: string;
17
+ attributes: Node[];
18
+ hasSpread: boolean;
19
+ }
20
+ interface ParsedImage {
21
+ hasWidth: boolean;
22
+ hasHeight: boolean;
23
+ hasLoading: boolean;
24
+ hasAlt: boolean;
25
+ lazy: boolean;
26
+ hasSrcset: boolean;
27
+ /** 1-based source line, or 0 if unknown. */
28
+ line: number;
29
+ }
30
+ /** A page-body heading (<h1>–<h6>) parsed from one file (SEO027). */
31
+ interface ParsedHeading {
32
+ /** Heading level 1–6. */
33
+ level: number;
34
+ /** 1-based source line, or 0 if unknown. */
35
+ line: number;
36
+ }
37
+ interface ParsedFile {
38
+ headTags: ParsedTag[];
39
+ components: ComponentUse[];
40
+ imports: ImportMap;
41
+ images: ParsedImage[];
42
+ headings: ParsedHeading[];
43
+ }
44
+
45
+ /**
46
+ * Per-run read+parse memo, keyed by project-root-relative path (as normalized by
47
+ * chainFiles / resolveComponentPath). Shared across routes so a file imported by
48
+ * many pages (a root layout, a common $lib component) is only parsed once per run.
49
+ */
50
+ type ParseCache = Map<string, Promise<ParsedFile>>;
51
+
4
52
  type ReporterName = 'console' | 'json' | 'agent' | 'sarif' | 'github' | 'html' | 'md';
5
53
 
6
54
  /** Thrown when the target directory is not a SvelteKit project (CLI maps to exit 2). */
@@ -77,6 +125,10 @@ interface RunOptions {
77
125
  staged?: boolean;
78
126
  /** Report only findings not present when analyzing this git ref (e.g. the PR base). */
79
127
  baseline?: string;
128
+ /** Disable applying svelte-vitals-suppressions.json for this run. */
129
+ noSuppressions?: boolean;
130
+ /** Analyze, then (re)write svelte-vitals-suppressions.json with all currently penalized findings and exit 0. */
131
+ updateSuppressions?: boolean;
80
132
  /** Disable ANSI color in console output. */
81
133
  noColor?: boolean;
82
134
  /** Override stdout TTY detection (tests). */
@@ -127,6 +179,18 @@ interface AnalyzeOptions {
127
179
  weights?: Partial<Record<Category, number>>;
128
180
  /** Restrict analysis to rules in these categories (applied after rules/ignore selection). */
129
181
  categories?: Category[];
182
+ /**
183
+ * Reuse this parse cache across multiple `analyzeProject` calls instead of
184
+ * starting fresh each time — the vite dev dashboard passes a long-lived cache
185
+ * and invalidates only the changed file's entry between re-analyses, so
186
+ * unchanged routes/layouts are never re-read or re-parsed. This only covers
187
+ * the route/layout (head-resolution) parse path via `collectRoutes` —
188
+ * `collectComponentFacts` (Correctness facts) is unaffected and still scans
189
+ * every component on each call. Callers that don't need cross-call reuse
190
+ * (the CLI's `run()`, MCP, the Action — each analyzes once per process) can
191
+ * omit this; a fresh cache is created automatically.
192
+ */
193
+ parseCache?: ParseCache;
130
194
  }
131
195
  interface AnalyzeResult {
132
196
  results: Result[];
@@ -152,14 +216,25 @@ interface ApplyScopeOptions {
152
216
  staged?: boolean;
153
217
  diffBase?: string;
154
218
  baseline?: string;
219
+ /**
220
+ * Resolved config, needed to decide which findings count as "penalized" when
221
+ * applying svelte-vitals-suppressions.json (`isPenalized`). Suppression
222
+ * application is skipped entirely when omitted, keeping such callers'
223
+ * behavior unchanged (the CLI and @svelte-vitals/action both pass it).
224
+ */
225
+ config?: Config;
226
+ /** Disable applying svelte-vitals-suppressions.json for this run. */
227
+ noSuppressions?: boolean;
155
228
  errorLog?: (line: string) => void;
156
229
  analyzeOpts?: AnalyzeOptions;
157
230
  }
158
231
  /**
159
232
  * Narrow `results` to what a PR gate cares about: `--staged`/`--diff` restrict to
160
- * changed files, `--baseline` drops findings that already existed at that ref. Shared
161
- * by `run()` and `@svelte-vitals/action` (issue #154) so the git-diff/baseline
162
- * orchestration lives in exactly one place.
233
+ * changed files, `--baseline` drops findings that already existed at that ref,
234
+ * and (last) svelte-vitals-suppressions.json drops findings that were explicitly
235
+ * accepted via `--update-suppressions`. Shared by `run()` and
236
+ * `@svelte-vitals/action` (issue #154) so the git-diff/baseline orchestration
237
+ * lives in exactly one place.
163
238
  */
164
239
  declare function applyScope(results: Result[], opts: ApplyScopeOptions): Promise<Result[]>;
165
240
  /**
@@ -168,4 +243,4 @@ declare function applyScope(results: Result[], opts: ApplyScopeOptions): Promise
168
243
  */
169
244
  declare function run(opts?: RunOptions): Promise<number>;
170
245
 
171
- export { type AnalyzeOptions, type AnalyzeResult, type ApplyScopeOptions, type LoadedConfigFile, ProjectError, type RunOptions, analyzeProject, applyScope, buildRulesConfig, findUnknownRuleIds, knownRuleIds, loadConfigFile, routeMatcher, run, spinnerEnabled };
246
+ export { type AnalyzeOptions, type AnalyzeResult, type ApplyScopeOptions, type LoadedConfigFile, type ParseCache, ProjectError, type RunOptions, analyzeProject, applyScope, buildRulesConfig, findUnknownRuleIds, knownRuleIds, loadConfigFile, routeMatcher, run, spinnerEnabled };
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  routeMatcher,
11
11
  run,
12
12
  spinnerEnabled
13
- } from "./chunk-TCZ6OF6J.js";
13
+ } from "./chunk-OCLDCX4Y.js";
14
14
  export {
15
15
  ProjectError,
16
16
  analyzeProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",
@@ -46,7 +46,7 @@
46
46
  "smol-toml": "^1.7.0",
47
47
  "svelte": "^5.56.4",
48
48
  "tinyglobby": "^0.2.17",
49
- "@svelte-vitals/core": "0.23.0"
49
+ "@svelte-vitals/core": "0.24.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^24.13.3"