svelte-vitals 0.46.0 → 0.47.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![MIT](https://img.shields.io/npm/l/svelte-vitals)](https://opensource.org/licenses/MIT)
5
5
 
6
6
  > **A static SvelteKit code-health scanner — not a runtime Web Vitals reporter.**
7
- > Diagnose your project's SEO, Performance, Correctness, Security, and Architecture health by statically analyzing your source code, before it ships. No browser, no build server, no headless Chrome.
7
+ > Diagnose your project's SEO, Performance, Correctness, Security, Architecture, and Accessibility health by statically analyzing your source code, before it ships. No browser, no build server, no headless Chrome.
8
8
  >
9
9
  > **ESM-only** (Node 22.13+). Ships ES modules only; `require()` is unsupported by design.
10
10
 
package/dist/bin.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runAnalyzeCliGunshi
4
- } from "./chunk-Z2DBOC5X.js";
5
- import "./chunk-FVN7R2YK.js";
6
- import "./chunk-MUIOPL5F.js";
4
+ } from "./chunk-MZO3HVTN.js";
5
+ import "./chunk-5UQ6P2M2.js";
6
+ import "./chunk-43FGGQQZ.js";
7
7
  import "./chunk-M5KM5SV7.js";
8
8
  import {
9
9
  consoleIO
@@ -21,23 +21,23 @@ async function runCli(argv, io = consoleIO, env = process.env) {
21
21
  try {
22
22
  const locale = resolveLocale(env);
23
23
  if (argv[0] === "complete") {
24
- const { runCompleteCliGunshi } = await import("./complete-7TW5PTE6.js");
24
+ const { runCompleteCliGunshi } = await import("./complete-BDKGAUQX.js");
25
25
  return { code: await runCompleteCliGunshi(argv, io), exit: "natural" };
26
26
  }
27
27
  if (argv[0] === "docs") {
28
- const { runDocsCliGunshi } = await import("./docs-WRZT4ZJN.js");
28
+ const { runDocsCliGunshi } = await import("./docs-XNKZQBBZ.js");
29
29
  return { code: await runDocsCliGunshi(argv.slice(1), io, locale), exit: "natural" };
30
30
  }
31
31
  if (argv[0] === "explain") {
32
- const { runExplainCliGunshi } = await import("./explain-W3BU3CYJ.js");
32
+ const { runExplainCliGunshi } = await import("./explain-YPWGQE5N.js");
33
33
  return { code: await runExplainCliGunshi(argv.slice(1), io, locale), exit: "natural" };
34
34
  }
35
35
  if (argv[0] === "install") {
36
- const { runInstallCliGunshi } = await import("./install-EP364SRT.js");
36
+ const { runInstallCliGunshi } = await import("./install-HE76S4ID.js");
37
37
  return { code: await runInstallCliGunshi(argv.slice(1), io, locale), exit: "immediate" };
38
38
  }
39
39
  if (argv[0] === "ci") {
40
- const { runCiCliGunshi } = await import("./ci-TFTS5QAU.js");
40
+ const { runCiCliGunshi } = await import("./ci-J7PGFHK3.js");
41
41
  const code = await runCiCliGunshi(argv.slice(1), { ...realIO(), log: io.log, errorLog: io.errorLog }, locale);
42
42
  return { code, exit: "immediate" };
43
43
  }
@@ -8,7 +8,7 @@ import {
8
8
 
9
9
  // src/resolve-args.ts
10
10
  import { parseArgs } from "util";
11
- var CATEGORIES = ["seo", "performance", "correctness", "security", "architecture"];
11
+ var CATEGORIES = ["seo", "performance", "correctness", "security", "architecture", "a11y"];
12
12
  var FAIL_ON_VALUES = ["critical", "warning", "info"];
13
13
  function isFailOnValue(value) {
14
14
  return typeof value === "string" && FAIL_ON_VALUES.includes(value);
@@ -146,10 +146,28 @@ function detectHtmlLang(html) {
146
146
  const value = match[1] ?? match[2] ?? match[3] ?? "";
147
147
  return { presence: "own", value: value.trim().length > 0 ? "static" : "absent" };
148
148
  }
149
- async function detectAppHtmlLang(rt, cwd) {
149
+ function detectAppHtmlIds(html) {
150
+ const markup = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<script[\s\S]*?<\/script\s*>/gi, "").replace(/<style[\s\S]*?<\/style\s*>/gi, "");
151
+ const found = markup.matchAll(/(?<![\w-])id\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>{][^\s"'>]*))/gi);
152
+ return [...new Set([...found].map((m) => m[1] ?? m[2] ?? m[3] ?? "").filter(Boolean))];
153
+ }
154
+ async function detectAppHtmlFacts(rt, cwd) {
150
155
  const appHtmlPath = rt.join(cwd, "src/app.html");
151
- if (!await rt.exists(appHtmlPath)) return { presence: "none", value: "absent" };
152
- return detectHtmlLang(await rt.readFile(appHtmlPath));
156
+ if (!await rt.exists(appHtmlPath)) return { htmlLang: { presence: "none", value: "absent" } };
157
+ let content;
158
+ try {
159
+ content = await rt.readFile(appHtmlPath);
160
+ } catch {
161
+ return { htmlLang: { presence: "none", value: "absent" } };
162
+ }
163
+ return {
164
+ htmlLang: detectHtmlLang(content),
165
+ // Comments are stripped first, then a simple anchored match — a starred group over a lazy
166
+ // [\s\S]*? is ambiguous across iterations and backtracks exponentially on a comment run
167
+ // with no doctype (measured: ~45 leading comments hang the process).
168
+ appHtmlDoctype: /^\s*<!doctype\s+html/i.test(content.replace(/<!--[\s\S]*?-->/g, "")),
169
+ appHtmlIds: detectAppHtmlIds(content)
170
+ };
153
171
  }
154
172
  async function robotsRefsSitemap(rt, cwd) {
155
173
  const p = rt.join(cwd, "static/robots.txt");
@@ -196,10 +214,10 @@ async function detectKitConfigFacts(rt, cwd) {
196
214
  };
197
215
  }
198
216
  async function collectProjectFacts(rt, cwd) {
199
- const [hasRobotsTxt, hasSitemap, htmlLang, viteMinifyDisabled, kitConfig] = await Promise.all([
217
+ const [hasRobotsTxt, hasSitemap, appHtmlFacts, viteMinifyDisabled, kitConfig] = await Promise.all([
200
218
  existsAny(rt, cwd, ROBOTS_SOURCE_PATHS),
201
219
  existsAny(rt, cwd, SITEMAP_SOURCE_PATHS),
202
- detectAppHtmlLang(rt, cwd),
220
+ detectAppHtmlFacts(rt, cwd),
203
221
  detectViteMinifyDisabled(rt, cwd),
204
222
  detectKitConfigFacts(rt, cwd)
205
223
  ]);
@@ -207,7 +225,7 @@ async function collectProjectFacts(rt, cwd) {
207
225
  return {
208
226
  hasRobotsTxt,
209
227
  hasSitemap,
210
- htmlLang,
228
+ ...appHtmlFacts,
211
229
  ...robotsReferencesSitemap !== void 0 ? { robotsReferencesSitemap } : {},
212
230
  ...viteMinifyDisabled ? { viteMinifyDisabled } : {},
213
231
  ...kitConfig
@@ -222,7 +240,7 @@ import {
222
240
  } from "@svelte-vitals/core";
223
241
 
224
242
  // src/providers/source/routes.ts
225
- import { defaultConfig } from "@svelte-vitals/core";
243
+ import { defaultConfig, foldOccurrences, isTopFragment } from "@svelte-vitals/core";
226
244
 
227
245
  // src/providers/source/resolve.ts
228
246
  import { resolveRepoLocalPath } from "@svelte-vitals/core";
@@ -369,7 +387,13 @@ import {
369
387
  valueFromNodes,
370
388
  textFromNodes,
371
389
  attrText,
372
- attrValue
390
+ attrTextOf as attrTextOf3,
391
+ attrValue,
392
+ attrValueOf as attrValueOf3,
393
+ decodeFragmentId,
394
+ splitTokens,
395
+ LANDMARK_ROLES,
396
+ IDREF_ATTRS
373
397
  } from "@svelte-vitals/core";
374
398
 
375
399
  // src/providers/source/imports.ts
@@ -557,6 +581,137 @@ function collectHeadings(node, source, acc) {
557
581
  if (key in node) collectHeadings(childOf(node, key), source, acc);
558
582
  }
559
583
  }
584
+ var LANDMARK_TAGS = { main: "main", header: "banner", footer: "contentinfo" };
585
+ var IDREF_ATTR_SET = new Set(IDREF_ATTRS);
586
+ function collectA11y(fragment, source) {
587
+ const nodes = [];
588
+ let groups = 0;
589
+ let slotInLandmark;
590
+ let unknowableContent = false;
591
+ const emit = (ctx, node) => {
592
+ const inLandmark = ctx.landmarks.at(-1);
593
+ nodes.push({ ...node, repeatable: ctx.repeatable, path: ctx.path, ...inLandmark ? { inLandmark } : {} });
594
+ };
595
+ const noteSpread = (node) => {
596
+ const attributes = node.attributes;
597
+ if (Array.isArray(attributes) && attributes.some((a) => a.type === "SpreadAttribute")) {
598
+ unknowableContent = true;
599
+ }
600
+ };
601
+ const walk = (node, ctx) => {
602
+ if (Array.isArray(node)) {
603
+ for (const child of node) walk(child, ctx);
604
+ return;
605
+ }
606
+ if (!node || typeof node !== "object") return;
607
+ switch (node.type) {
608
+ case "SvelteHead":
609
+ return;
610
+ // head content never renders into the body
611
+ case "HtmlTag":
612
+ unknowableContent = true;
613
+ return;
614
+ case "IfBlock":
615
+ walkIfChain(node, ctx, groups++, 0);
616
+ return;
617
+ case "AwaitBlock": {
618
+ const group = groups++;
619
+ walk(node.pending, { ...ctx, path: [...ctx.path, { group, branch: 0 }] });
620
+ walk(node.then, { ...ctx, path: [...ctx.path, { group, branch: 1 }] });
621
+ walk(node.catch, { ...ctx, path: [...ctx.path, { group, branch: 2 }] });
622
+ return;
623
+ }
624
+ case "EachBlock":
625
+ walk(node.body, { ...ctx, repeatable: true });
626
+ walk(node.fallback, ctx);
627
+ return;
628
+ case "SnippetBlock":
629
+ walk(node.body, { ...ctx, repeatable: true });
630
+ return;
631
+ // <svelte:element> has a dynamic tag (so no tag-derived landmark) but its literal id/idref
632
+ // attributes are real — dropping them would make no-missing-id-ref report phantom misses.
633
+ case "RegularElement":
634
+ case "SvelteElement":
635
+ walkElement(node, ctx);
636
+ return;
637
+ case "Component":
638
+ case "SvelteComponent":
639
+ case "SvelteSelf":
640
+ noteSpread(node);
641
+ emit(ctx, { kind: "component", key: node.name, line: lineOf(source, node.start) });
642
+ walk(node.fragment, { ...ctx, elementDepth: ctx.elementDepth + 1 });
643
+ return;
644
+ case "SlotElement":
645
+ noteSpread(node);
646
+ slotInLandmark ??= ctx.landmarks.at(-1);
647
+ walk(node.fragment, ctx);
648
+ return;
649
+ case "RenderTag":
650
+ if (isChildrenRender(node)) slotInLandmark ??= ctx.landmarks.at(-1);
651
+ return;
652
+ default:
653
+ noteSpread(node);
654
+ for (const key of CHILD_NODE_KEYS) {
655
+ if (key in node) walk(childOf(node, key), ctx);
656
+ }
657
+ }
658
+ };
659
+ const walkIfChain = (node, ctx, group, branch) => {
660
+ walk(node.consequent, { ...ctx, path: [...ctx.path, { group, branch }] });
661
+ if (!node.alternate) return;
662
+ const rest = node.alternate.nodes.filter((n) => n.type !== "Text" || n.data.trim() !== "");
663
+ const chained = rest.length === 1 && rest[0].type === "IfBlock" && rest[0].elseif ? rest[0] : void 0;
664
+ if (chained) walkIfChain(chained, ctx, group, branch + 1);
665
+ else walk(node.alternate, { ...ctx, path: [...ctx.path, { group, branch: branch + 1 }] });
666
+ };
667
+ const walkElement = (node, ctx) => {
668
+ noteSpread(node);
669
+ const line = lineOf(source, node.start);
670
+ const attrs = node.attributes;
671
+ const roleAttr = findAttr3(attrs, "role");
672
+ const role = roleAttr ? splitTokens(attrTextOf3(roleAttr))[0] : void 0;
673
+ const landmark = roleAttr ? role && LANDMARK_ROLES.has(role) ? role : void 0 : LANDMARK_TAGS[node.name];
674
+ if (landmark) {
675
+ const headerFooter = !roleAttr && node.name !== "main";
676
+ emit(ctx, {
677
+ kind: "landmark",
678
+ key: landmark,
679
+ line,
680
+ ...headerFooter ? { topLevel: ctx.elementDepth === 0 } : {}
681
+ });
682
+ }
683
+ for (const attr of node.attributes) {
684
+ if (attr.type !== "Attribute") continue;
685
+ if (attr.name === "id") {
686
+ const v = attrValueOf3(attr);
687
+ if (v === "dynamic") emit(ctx, { kind: "id", key: "", line });
688
+ else if (v === "static") emit(ctx, { kind: "id", key: attrTextOf3(attr), line });
689
+ } else if (attr.name === "href") {
690
+ const href = attrTextOf3(attr);
691
+ if (href?.startsWith("#") && href.length > 1) {
692
+ emit(ctx, { kind: "idref", key: decodeFragmentId(href.slice(1)), line, attr: "href" });
693
+ }
694
+ } else if (IDREF_ATTR_SET.has(attr.name)) {
695
+ for (const token of splitTokens(attrTextOf3(attr))) {
696
+ emit(ctx, { kind: "idref", key: token, line, attr: attr.name });
697
+ }
698
+ }
699
+ }
700
+ const literalTag = node.type === "SvelteElement" ? node.tag.type === "Literal" && typeof node.tag.value === "string" ? node.tag.value : void 0 : node.name;
701
+ if (literalTag === "template") return;
702
+ walk(node.fragment, {
703
+ ...ctx,
704
+ elementDepth: ctx.elementDepth + 1,
705
+ landmarks: landmark ? [...ctx.landmarks, landmark] : ctx.landmarks
706
+ });
707
+ };
708
+ walk(fragment, { path: [], repeatable: false, landmarks: [], elementDepth: 0 });
709
+ return { nodes, ...slotInLandmark ? { slotInLandmark } : {}, unknowableContent };
710
+ }
711
+ function isChildrenRender(node) {
712
+ const call = node.expression.type === "ChainExpression" ? node.expression.expression : node.expression;
713
+ return call.callee.type === "Identifier" && call.callee.name === "children";
714
+ }
560
715
  function parseFile(source, filename) {
561
716
  const ast = parse(source, { modern: true, filename });
562
717
  const heads = [];
@@ -572,7 +727,8 @@ function parseFile(source, filename) {
572
727
  components,
573
728
  imports: collectImports(ast),
574
729
  images,
575
- headings
730
+ headings,
731
+ a11y: collectA11y(ast.fragment, source)
576
732
  };
577
733
  }
578
734
 
@@ -730,8 +886,64 @@ function chainFiles(pageRel, layouts) {
730
886
  }
731
887
  return [...chain.map((rel) => ({ rel, isPage: false })), { rel: pageRel, isPage: true }];
732
888
  }
733
- async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases) {
889
+ function groupSpan(nodes) {
890
+ let max = -1;
891
+ for (const node of nodes) {
892
+ for (const step of node.path) if (step.group > max) max = step.group;
893
+ }
894
+ return max + 1;
895
+ }
896
+ function offsetPath(path, base) {
897
+ return base === 0 ? path : path.map((step) => ({ group: step.group + base, branch: step.branch }));
898
+ }
899
+ async function composeA11y(ctx, fileRel, parsed, depth, visited, chain) {
900
+ const { rt, cwd, state } = ctx;
901
+ if (parsed.a11y.unknowableContent) state.fullyResolved = false;
902
+ const base = state.nextGroup;
903
+ state.nextGroup += groupSpan(parsed.a11y.nodes);
904
+ const composed = [];
905
+ for (const node of parsed.a11y.nodes) {
906
+ const path = offsetPath(node.path, base);
907
+ if (node.kind !== "component") {
908
+ composed.push({ ...node, path, file: fileRel, chain });
909
+ continue;
910
+ }
911
+ const info = ctx.config.metaComponents.includes(node.key) ? void 0 : parsed.imports.get(node.key);
912
+ const childRel = info ? resolveComponentPath(info.source, fileRel, ctx.aliases) : void 0;
913
+ if (!childRel || depth <= 0 || visited.has(childRel) || !await rt.exists(rt.join(cwd, childRel))) {
914
+ state.fullyResolved = false;
915
+ continue;
916
+ }
917
+ const childParsed = await readAndParse(rt, cwd, childRel, ctx.cache);
918
+ const child = await composeA11y(ctx, childRel, childParsed, depth - 1, new Set(visited).add(childRel), false);
919
+ for (const inner of child) {
920
+ composed.push({ ...inner, path: [...path, ...inner.path], repeatable: node.repeatable || inner.repeatable });
921
+ }
922
+ }
923
+ return composed;
924
+ }
925
+ function countsAsLandmark(node) {
926
+ return node.topLevel === void 0 || node.chain && node.topLevel === true;
927
+ }
928
+ function representativeOrder(chainOrder) {
929
+ return (a, b) => {
930
+ const rankA = a.chain ? chainOrder.get(a.file) ?? 0 : chainOrder.size;
931
+ const rankB = b.chain ? chainOrder.get(b.file) ?? 0 : chainOrder.size;
932
+ if (rankA !== rankB) return rankA - rankB;
933
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
934
+ return a.line - b.line;
935
+ };
936
+ }
937
+ function representatives(nodes, chainOrder) {
938
+ const folded = foldOccurrences(nodes);
939
+ const order = representativeOrder(chainOrder);
940
+ return Object.fromEntries(
941
+ [...folded].map(([key, list]) => [key, list.sort(order).map(({ file, line }) => ({ file, line }))])
942
+ );
943
+ }
944
+ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases, appHtmlIds) {
734
945
  const files = chainFiles(pageRel, layouts);
946
+ const chainOrder = new Map(files.map((f, i) => [f.rel, i]));
735
947
  const composed = /* @__PURE__ */ new Map();
736
948
  const jsonldTags = [];
737
949
  let broadOwn = false;
@@ -739,8 +951,20 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases) {
739
951
  const images = [];
740
952
  const headings = [];
741
953
  const componentHeadings = [];
954
+ const a11yCtx = { rt, cwd, config, cache, aliases, state: { nextGroup: 0, fullyResolved: true } };
955
+ const a11yNodes = [];
956
+ const nestedLandmarks = [];
957
+ let slotLandmark;
742
958
  for (const { rel, isPage } of files) {
743
959
  const parsed = await readAndParse(rt, cwd, rel, cache);
960
+ const contributed = await composeA11y(a11yCtx, rel, parsed, MAX_DEPTH, /* @__PURE__ */ new Set([rel]), true);
961
+ for (const node of contributed) {
962
+ if (!node.chain || node.kind !== "landmark" || !countsAsLandmark(node) || node.repeatable) continue;
963
+ const within = node.inLandmark ?? slotLandmark;
964
+ if (within) nestedLandmarks.push({ kind: node.key, within, file: node.file, line: node.line });
965
+ }
966
+ slotLandmark = parsed.a11y.slotInLandmark ?? slotLandmark;
967
+ a11yNodes.push(...contributed);
744
968
  for (const img of parsed.images) {
745
969
  images.push({ ...img, file: rel });
746
970
  }
@@ -766,20 +990,40 @@ async function resolveRoute(rt, cwd, pageRel, config, layouts, cache, aliases) {
766
990
  if (!composed.has(key)) composed.set(key, { ...tag, presence });
767
991
  }
768
992
  }
993
+ const idNodes = a11yNodes.filter((n) => n.kind === "id");
994
+ if (idNodes.some((n) => n.key === "")) a11yCtx.state.fullyResolved = false;
995
+ const literalIds = idNodes.filter((n) => n.key !== "");
769
996
  const route = deriveRoute(pageRel);
770
997
  return {
771
998
  head: { route, source: "static", tags: [...composed.values(), ...jsonldTags], file: pageRel },
772
999
  images: { route, images },
773
- headings: { route, headings, componentHeadings }
1000
+ headings: { route, headings, componentHeadings },
1001
+ a11y: {
1002
+ route,
1003
+ landmarks: representatives(
1004
+ a11yNodes.filter((n) => n.kind === "landmark" && countsAsLandmark(n)),
1005
+ chainOrder
1006
+ ),
1007
+ nestedLandmarks,
1008
+ ids: representatives(literalIds, chainOrder),
1009
+ // `href="#top"` scrolls to the document top with no element of that id, so it is
1010
+ // never a missing reference (HTML's "top of the document" fragment).
1011
+ idRefs: a11yNodes.filter((n) => n.kind === "idref" && !(n.attr === "href" && isTopFragment(n.key))).map((n) => ({ id: n.key, attr: n.attr ?? "", file: n.file, line: n.line })),
1012
+ idCandidates: [.../* @__PURE__ */ new Set([...literalIds.map((n) => n.key), ...appHtmlIds ?? []])],
1013
+ fullyResolved: a11yCtx.state.fullyResolved
1014
+ }
774
1015
  };
775
1016
  }
776
- async function collectRoutes(rt, cwd, config = defaultConfig, cache = /* @__PURE__ */ new Map(), aliases) {
1017
+ async function collectRoutes(rt, cwd, config = defaultConfig, cache = /* @__PURE__ */ new Map(), aliases, appHtmlIds) {
777
1018
  const [pages, layouts] = await Promise.all([enumerateRoutePages(rt, cwd), collectLayouts(rt, cwd)]);
778
- const facts = await Promise.all(pages.map((page) => resolveRoute(rt, cwd, page, config, layouts, cache, aliases)));
1019
+ const facts = await Promise.all(
1020
+ pages.map((page) => resolveRoute(rt, cwd, page, config, layouts, cache, aliases, appHtmlIds))
1021
+ );
779
1022
  return {
780
1023
  heads: facts.map((f) => f.head),
781
1024
  images: facts.map((f) => f.images),
782
- headings: facts.map((f) => f.headings)
1025
+ headings: facts.map((f) => f.headings),
1026
+ a11y: facts.map((f) => f.a11y)
783
1027
  };
784
1028
  }
785
1029
 
@@ -796,7 +1040,7 @@ async function collectAll(rt, cwd, config, opts = {}) {
796
1040
  const matches = routeMatcher(opts.route);
797
1041
  const project = await collectProjectFacts(rt, cwd);
798
1042
  const [collected, components, kitModules, sourceFiles] = await Promise.all([
799
- collectRoutes(rt, cwd, config, opts.parseCache, project.kitAliases),
1043
+ collectRoutes(rt, cwd, config, opts.parseCache, project.kitAliases, project.appHtmlIds),
800
1044
  // Component (Correctness) facts are file-scoped with no route attribution yet, so a
801
1045
  // route-filtered run skips them rather than reporting unrelated components (#68 review);
802
1046
  // kitModules is skipped for the same reason.
@@ -811,7 +1055,8 @@ async function collectAll(rt, cwd, config, opts = {}) {
811
1055
  const heads = collected.heads.filter((h) => matches(h.route));
812
1056
  const images = collected.images.filter((i) => matches(i.route));
813
1057
  const headings = collected.headings.filter((h) => matches(h.route));
814
- return { heads, images, headings, project, components, kitModules, sourceFiles };
1058
+ const a11y = collected.a11y.filter((a) => matches(a.route));
1059
+ return { heads, images, headings, a11y, project, components, kitModules, sourceFiles };
815
1060
  }
816
1061
 
817
1062
  // src/changed-files.ts
@@ -1319,10 +1564,15 @@ async function analyzeProject(opts = {}) {
1319
1564
  ...await checkVersionFloor(rt, cwd),
1320
1565
  ...overridesOffWarnings(opts.allowRules, config.overrides)
1321
1566
  ];
1322
- const { heads, images, headings, project, components, kitModules, sourceFiles } = await collectAll(rt, cwd, config, {
1323
- route: opts.route,
1324
- parseCache: opts.parseCache
1325
- });
1567
+ const { heads, images, headings, a11y, project, components, kitModules, sourceFiles } = await collectAll(
1568
+ rt,
1569
+ cwd,
1570
+ config,
1571
+ {
1572
+ route: opts.route,
1573
+ parseCache: opts.parseCache
1574
+ }
1575
+ );
1326
1576
  const selected = selectRules(allRules2, config);
1327
1577
  const rules = opts.categories ? selected.filter((r) => opts.categories.includes(r.category)) : selected;
1328
1578
  const {
@@ -1333,6 +1583,7 @@ async function analyzeProject(opts = {}) {
1333
1583
  heads,
1334
1584
  images,
1335
1585
  headings,
1586
+ a11y,
1336
1587
  components,
1337
1588
  project,
1338
1589
  config,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  toList
3
- } from "./chunk-MUIOPL5F.js";
3
+ } from "./chunk-43FGGQQZ.js";
4
4
  import {
5
5
  CONFIG_FILENAMES,
6
6
  discoverApps,
@@ -110,13 +110,14 @@ function isKind(id, kind) {
110
110
 
111
111
  // src/install/skill-content.ts
112
112
  import { allRules, docsUrlFor } from "@svelte-vitals/core";
113
- var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture"];
113
+ var CATEGORY_ORDER = ["seo", "performance", "correctness", "security", "architecture", "a11y"];
114
114
  var CATEGORY_LABELS = {
115
115
  seo: "SEO",
116
116
  performance: "Performance",
117
117
  correctness: "Correctness",
118
118
  security: "Security",
119
- architecture: "Architecture"
119
+ architecture: "Architecture",
120
+ a11y: "Accessibility"
120
121
  };
121
122
  function oneLine(text) {
122
123
  return text.replace(/\r?\n+/g, " ").trim();
@@ -151,7 +152,7 @@ function sharedBody(version) {
151
152
 
152
153
  ## When to use
153
154
 
154
- Use this whenever you are writing or reviewing SvelteKit route files (\`+page.svelte\`, \`+layout.svelte\`) or components in this project \u2014 svelte-vitals statically checks SEO, performance, correctness, security, and architecture patterns.
155
+ Use this whenever you are writing or reviewing SvelteKit route files (\`+page.svelte\`, \`+layout.svelte\`) or components in this project \u2014 svelte-vitals statically checks SEO, performance, correctness, security, architecture, and accessibility patterns.
155
156
 
156
157
  ## Playbook
157
158
 
@@ -168,7 +169,7 @@ ${ruleDigest()}
168
169
  function buildSkillMarkdown(version) {
169
170
  const frontmatter = `---
170
171
  name: svelte-vitals
171
- description: Use when writing or reviewing SvelteKit routes/components \u2014 svelte-vitals rule knowledge (SEO, performance, correctness, security, architecture) and how to run the scanner.
172
+ description: Use when writing or reviewing SvelteKit routes/components \u2014 svelte-vitals rule knowledge (SEO, performance, correctness, security, architecture, accessibility) and how to run the scanner.
172
173
  ---`;
173
174
  return `${frontmatter}
174
175
 
@@ -176,7 +177,7 @@ ${sharedBody(version)}`;
176
177
  }
177
178
  function buildCursorRules(version) {
178
179
  const frontmatter = `---
179
- description: svelte-vitals code-health rules for SvelteKit (SEO, performance, correctness, security, architecture)
180
+ description: svelte-vitals code-health rules for SvelteKit (SEO, performance, correctness, security, architecture, accessibility)
180
181
  globs: ["**/*.svelte", "src/routes/**"]
181
182
  alwaysApply: false
182
183
  ---`;
@@ -281,7 +282,8 @@ Get the machine map before applying judgment:
281
282
  \`<title>\`/meta (SEO), image-heavy routes (Performance), forms and
282
283
  \`{@html}\` usage (Security), large or unkeyed list-rendering routes
283
284
  (Correctness), route/component files that have grown large or deeply
284
- nested (Architecture).
285
+ nested (Architecture), interactive controls and forms with unclear
286
+ labeling or ARIA usage (Accessibility).
285
287
  - **Leverage map** (the judgment the scan lacks): which routes are
286
288
  high-traffic/public/indexed (a marketing page, a product listing) versus
287
289
  low-traffic or gated (an internal admin tool, a rarely visited settings
@@ -290,10 +292,10 @@ Get the machine map before applying judgment:
290
292
 
291
293
  ### Phase 2 \u2014 Audit (parallel)
292
294
 
293
- Audit against svelte-vitals' five categories: SEO, Performance, Correctness,
294
- Security, Architecture (see the rule catalog below for the full "hunt for"
295
- list per category, generated from svelte-vitals' own rule metadata \u2014 always
296
- in sync, never invented).
295
+ Audit against svelte-vitals' six categories: SEO, Performance, Correctness,
296
+ Security, Architecture, Accessibility (see the rule catalog below for the
297
+ full "hunt for" list per category, generated from svelte-vitals' own rule
298
+ metadata \u2014 always in sync, never invented).
297
299
 
298
300
  For anything beyond a small project, fan out read-only subagents \u2014 one per
299
301
  category. Each subagent prompt must include: the recon facts (stack,
@@ -311,8 +313,8 @@ Depth follows effort level (default \`standard\`):
311
313
  | Effort | Coverage | Subagents | Findings |
312
314
  | ---------- | -------------------------------------- | --------- | ----------------------------- |
313
315
  | \`quick\` | Highest-traffic/public routes only | 0\u20131 | ~5, HIGH severity only |
314
- | \`standard\` | All routes and components | \u22645 | Full table |
315
- | \`deep\` | Whole project incl. rarely-hit routes | 5 | Full table + LOW polish items |
316
+ | \`standard\` | All routes and components | \u22646 | Full table |
317
+ | \`deep\` | Whole project incl. rarely-hit routes | 6 | Full table + LOW polish items |
316
318
 
317
319
  ### Phase 3 \u2014 Vet, prioritize, confirm
318
320
 
@@ -408,6 +410,11 @@ category the rule catalog above can't cover:
408
410
  complex, well-organized page (leave it \u2014 don't split just to satisfy a
409
411
  metric). Look for duplicated \`<svelte:head>\` boilerplate that a shared
410
412
  layout or meta component would remove.
413
+ - **Accessibility** \u2014 svelte-vitals checks static markup (ARIA validity,
414
+ landmarks, ids, labels); it can't drive a keyboard or screen reader. Hunt
415
+ for illogical tab order, missing visible focus styles, color contrast
416
+ below WCAG thresholds, and modal/menu components that don't trap or
417
+ restore focus \u2014 all invisible to a static scan.
411
418
 
412
419
  ## Plan template
413
420
 
@@ -421,7 +428,7 @@ target state.
421
428
  - **Status**: TODO
422
429
  - **Commit**: <output of \`git rev-parse --short HEAD\` when written>
423
430
  - **Severity**: HIGH | MEDIUM | LOW
424
- - **Category**: SEO | Performance | Correctness | Security | Architecture
431
+ - **Category**: SEO | Performance | Correctness | Security | Architecture | Accessibility
425
432
  - **Rule**: <RULEID> | Beyond the scan
426
433
  - **Estimated scope**: <n files, rough size>
427
434
 
@@ -491,7 +498,7 @@ adapted to this file \u2014 never approximated from memory.
491
498
  | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
492
499
  | bare | Full workflow: recon \u2192 audit all categories \u2192 vet \u2192 confirm \u2192 plans |
493
500
  | \`quick\` / \`deep\` | Adjust audit effort (see table); composes with a category focus |
494
- | a category focus (\`seo\`, \`performance\`, \`correctness\`, \`security\`, \`architecture\`) | Recon + audit that category only |
501
+ | a category focus (\`seo\`, \`performance\`, \`correctness\`, \`security\`, \`architecture\`, \`accessibility\`) | Recon + audit that category only |
495
502
  | \`plan <description>\` | Skip the audit; recon just enough to specify, then write a single plan for the described improvement |
496
503
  | \`execute <plan>\` | Dispatch an executor subagent to implement the plan in an isolated worktree, then review its diff against svelte-vitals (\`--diff --reporter agent\`) and render a verdict |
497
504
  | \`reconcile\` | Re-check \`plans/\` against the current code: mark done plans DONE, refresh stale \`file:line\`/route references, retire fixed findings |
@@ -1143,7 +1150,7 @@ var INSTALL_ARGS = {
1143
1150
  help: { type: "boolean", short: "h", description: "Show this help" }
1144
1151
  };
1145
1152
  async function buildInstallHelpText(installCommand2, locale) {
1146
- const ja = locale === "ja" ? await import("./ja-BCE5T3H2.js") : void 0;
1153
+ const ja = locale === "ja" ? await import("./ja-GAQSBSXF.js") : void 0;
1147
1154
  const optionsSection = stripAutoVersionLine(
1148
1155
  await localizedOptionsSection(
1149
1156
  installCommand2,
@@ -16,7 +16,7 @@ var JA_ARG_DESCRIPTIONS = {
16
16
  "min-health": "\u7D44\u307F\u5408\u308F\u305B\u305F Health \u30B9\u30B3\u30A2\u304C\u3053\u306E\u5024\u3092\u4E0B\u56DE\u308C\u3070\u5931\u6557\uFF08\u7D42\u4E86\u30B3\u30FC\u30C9 1\u30010\u301C100\uFF09",
17
17
  rules: "\u6709\u52B9\u306B\u3059\u308B\u30EB\u30FC\u30EB ID\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\u3001\u4ED6\u306F\u3059\u3079\u3066\u7121\u52B9\uFF09",
18
18
  ignore: "\u7121\u52B9\u306B\u3059\u308B\u30EB\u30FC\u30EB ID\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\uFF09",
19
- category: "\u89E3\u6790\u5BFE\u8C61\u30AB\u30C6\u30B4\u30EA\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\uFF09: seo | performance | correctness | security | architecture",
19
+ category: "\u89E3\u6790\u5BFE\u8C61\u30AB\u30C6\u30B4\u30EA\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\uFF09: seo | performance | correctness | security | architecture | a11y",
20
20
  weights: "\u30AB\u30C6\u30B4\u30EA\u3054\u3068\u306E Health \u91CD\u307F\u4E0A\u66F8\u304D\u3002\u4F8B: seo=2,performance=1\uFF08\u6307\u5B9A\u306E\u306A\u3044\u30AB\u30C6\u30B4\u30EA\u306F\u30C7\u30D5\u30A9\u30EB\u30C8\u5024 1\uFF09",
21
21
  score: "\u7D44\u307F\u5408\u308F\u305B\u305F Health \u30B9\u30B3\u30A2\u306E\u307F\u3092\u51FA\u529B\uFF08--min-health \u3068\u4F75\u7528\u3057\u3066\u30B2\u30FC\u30C8\u306B\u5229\u7528\u53EF\u80FD\uFF09",
22
22
  noColor: "\u30B3\u30F3\u30BD\u30FC\u30EB\u51FA\u529B\u306E ANSI \u30AB\u30E9\u30FC\u3092\u7121\u52B9\u5316",
@@ -50,7 +50,7 @@ var JA_ARG_DESCRIPTIONS = {
50
50
  }
51
51
  };
52
52
  function rootHelpJa(optionsSection) {
53
- return `svelte-vitals \u2014 \u6C7A\u5B9A\u8AD6\u7684\u306A SvelteKit \u30B3\u30FC\u30C9\u30D8\u30EB\u30B9\u30C1\u30A7\u30C3\u30AB\u30FC\uFF08SEO\u30FB\u30D1\u30D5\u30A9\u30FC\u30DE\u30F3\u30B9\u30FB\u6B63\u78BA\u6027\u30FB\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30FB\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\uFF09
53
+ return `svelte-vitals \u2014 \u6C7A\u5B9A\u8AD6\u7684\u306A SvelteKit \u30B3\u30FC\u30C9\u30D8\u30EB\u30B9\u30C1\u30A7\u30C3\u30AB\u30FC\uFF08SEO\u30FB\u30D1\u30D5\u30A9\u30FC\u30DE\u30F3\u30B9\u30FB\u6B63\u78BA\u6027\u30FB\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30FB\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30FB\u30A2\u30AF\u30BB\u30B7\u30D3\u30EA\u30C6\u30A3\uFF09
54
54
 
55
55
  \u4F7F\u7528\u65B9\u6CD5:
56
56
  svelte-vitals [path] [options]
@@ -86,7 +86,7 @@ var CI_UPGRADE_ARGS = { "dry-run": CI_ARGS["dry-run"], help: CI_ARGS.help };
86
86
  var KNOWN_LONG_FLAGS = /* @__PURE__ */ new Set(["force", "dry-run", "help"]);
87
87
  var KNOWN_SHORT_FLAGS = /* @__PURE__ */ new Set(["h"]);
88
88
  async function buildCiHelpText(ciArgsCommand, locale) {
89
- const ja = locale === "ja" ? await import("./ja-BCE5T3H2.js") : void 0;
89
+ const ja = locale === "ja" ? await import("./ja-GAQSBSXF.js") : void 0;
90
90
  const optionsSection = stripAutoVersionLine(
91
91
  await localizedOptionsSection(ciArgsCommand, "svelte-vitals ci", locale, ja?.JA_ARG_DESCRIPTIONS.ci ?? {})
92
92
  );
@@ -62,7 +62,7 @@ var EXPLAIN_ARGS = {
62
62
  help: { type: "boolean", short: "h", description: "Show this help" }
63
63
  };
64
64
  async function buildExplainHelpText(explainCommand, locale) {
65
- const ja = locale === "ja" ? await import("./ja-BCE5T3H2.js") : void 0;
65
+ const ja = locale === "ja" ? await import("./ja-GAQSBSXF.js") : void 0;
66
66
  const optionsSection = stripAutoVersionLine(
67
67
  await localizedOptionsSection(
68
68
  explainCommand,
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  run
3
- } from "./chunk-FVN7R2YK.js";
3
+ } from "./chunk-5UQ6P2M2.js";
4
4
  import {
5
5
  VALUE_FLAGS,
6
6
  parseRunArgs,
7
7
  resolveArgs
8
- } from "./chunk-MUIOPL5F.js";
8
+ } from "./chunk-43FGGQQZ.js";
9
9
  import {
10
10
  readCoreVersion,
11
11
  readPackageVersion
@@ -90,7 +90,7 @@ var ROOT_ARGS = {
90
90
  ignore: { type: "string", description: "Comma-separated rule ids to disable" },
91
91
  category: {
92
92
  type: "string",
93
- description: "Comma-separated categories to analyze: seo | performance | correctness | security | architecture"
93
+ description: "Comma-separated categories to analyze: seo | performance | correctness | security | architecture | a11y"
94
94
  },
95
95
  weights: {
96
96
  type: "string",
@@ -127,7 +127,7 @@ function neutralizeBareDiffAndBaseline(argv) {
127
127
  }
128
128
  async function buildHelpText(rootCommand, locale) {
129
129
  if (locale === "ja") {
130
- const { JA_ARG_DESCRIPTIONS, rootHelpJa } = await import("./ja-BCE5T3H2.js");
130
+ const { JA_ARG_DESCRIPTIONS, rootHelpJa } = await import("./ja-GAQSBSXF.js");
131
131
  const optionsSection2 = await localizedOptionsSection(
132
132
  rootCommand,
133
133
  "svelte-vitals",
@@ -137,7 +137,7 @@ async function buildHelpText(rootCommand, locale) {
137
137
  return rootHelpJa(optionsSection2);
138
138
  }
139
139
  const optionsSection = await localizedOptionsSection(rootCommand, "svelte-vitals", locale, {});
140
- return `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture)
140
+ return `svelte-vitals \u2014 a deterministic SvelteKit code-health scanner (SEO \xB7 performance \xB7 correctness \xB7 security \xB7 architecture \xB7 accessibility)
141
141
 
142
142
  Usage:
143
143
  svelte-vitals [path] [options]
@@ -31,7 +31,7 @@ var EMBEDDED_DOCS = [
31
31
  name: "config",
32
32
  title: "The config file",
33
33
  description: "Where svelte-vitals.config lives, every top-level option, how to disable or re-grade a rule, and how to scope rules to routes or files.",
34
- body: "# The config file\n\n## Where it lives\n\nIn the **analyzed directory only** \u2014 no upward search. First match wins:\n\n1. `svelte-vitals.config.mjs`\n2. `svelte-vitals.config.js`\n3. `svelte-vitals.config.ts`\n\nNo file means built-in defaults. `svelte-vitals install --client config-file` scaffolds one with\nevery option commented out.\n\n```js\n// svelte-vitals.config.mjs\nexport default {\n treatDynamicAs: 'warn',\n metaComponents: ['Seo'],\n rules: { 'seo/json-ld': 'off' },\n failOn: 'warning',\n weights: { seo: 2 }\n};\n```\n\nA `.ts` config can `import { defineConfig } from 'svelte-vitals'` for type-checking, but that is a\n**runtime** import: it needs svelte-vitals as a declared dependency and Node 22.18+ (or 23.6+).\nA plain `export default {}` in `.mjs` behaves identically and always works.\n\n## Options\n\n| Option | Type | Default |\n| ---------------- | -------------------------------------------------------------- | ------------------ |\n| `treatDynamicAs` | `'pass' \\| 'warn' \\| 'fail'` | `'pass'` |\n| `metaComponents` | `string[]` | `[]` |\n| `rules` | `Record<ruleId, 'off' \\| Severity \\| { severity?, options? }>` | `{}` |\n| `failOn` | `'critical' \\| 'warning' \\| 'info'` | `'critical'` |\n| `weights` | `Partial<Record<Category, number>>` | every category `1` |\n| `overrides` | `RuleOverride[]` | (none) |\n\n`Severity` is `'critical' | 'warning' | 'info'`. `Category` is `'seo' | 'performance' |\n'correctness' | 'security' | 'architecture'`. A weight of `0` drops a category from the Health\naverage; setting every category to `0` is an error (exit `2`).\n\n## Turning a rule off or down\n\n```js\nexport default {\n rules: {\n 'seo/json-ld': 'off', // remove its findings entirely\n 'architecture/prop-count': 'info' // keep it, stop it failing the build\n }\n};\n```\n\nMany rules take options, so check whether the finding is a **threshold disagreement** rather than\na defect first. `svelte-vitals explain <rule-id>` prints each option's name, default, bounds, and\nmerge semantics (`integer` replaces, `string-list` appends, `string-map` is spread over).\n\n```js\nexport default {\n rules: {\n 'architecture/prop-count': { options: { max: 12 } }\n }\n};\n```\n\n## Scoping to routes or files (`overrides`)\n\n`rules` applies everywhere; `overrides` applies only where it matches \u2014 typically routes that\nare deliberately not public.\n\n```js\nexport default {\n overrides: [\n { files: 'src/routes/(app)/**', rules: { seo: 'off' } },\n { route: '/admin/**', rules: { 'seo/title-presence': 'info' } }\n ]\n};\n```\n\nEach entry needs `rules` (keys are rule ids **or** category names) plus at least one of:\n\n- **`route`** \u2014 glob(s) against the route id as reported (`/blog/[slug]`). SvelteKit `(group)`\n segments are **not** in the route id, so use `files` to target a group.\n- **`files`** \u2014 glob(s) against the source path.\n\nGlobs are deliberately small: `*` within a segment, `**` across segments, a trailing `/**` also\nmatches the bare prefix. Everything else \u2014 including `(`, `)`, `[`, `]` \u2014 is literal. Later entries win.\n\n## Precedence\n\nPer field: **CLI flag > config file > built-in default**. One exception \u2014 `--rules` and `--ignore`\nare selection, not configuration: `--rules` narrows the run to the ids it names and overrides a\nconfig-file `off` for them, but keeps their declared severity and options; `--ignore` adds `off`\nentries for the ids it names, layered on top of whatever `rules` resolved to, and beats `--rules`\nwhen both name the same rule.\n\n`overrides` has no CLI flag; route policy belongs in a committed file.\n\n## Validation\n\nAn unknown rule id or category, a negative weight, a malformed `overrides` entry, or an invalid\nrule setting is a **hard error (exit `2`)** \u2014 a typo must not silently un-gate CI. An unrecognized\n`treatDynamicAs`/`failOn` value, or an unknown top-level key, only warns.\n\n## Related\n\n- `svelte-vitals explain --list` \u2014 every rule id\n- `svelte-vitals docs show scoping` \u2014 accepting an existing backlog instead of disabling rules"
34
+ body: "# The config file\n\n## Where it lives\n\nIn the **analyzed directory only** \u2014 no upward search. First match wins:\n\n1. `svelte-vitals.config.mjs`\n2. `svelte-vitals.config.js`\n3. `svelte-vitals.config.ts`\n\nNo file means built-in defaults. `svelte-vitals install --client config-file` scaffolds one with\nevery option commented out.\n\n```js\n// svelte-vitals.config.mjs\nexport default {\n treatDynamicAs: 'warn',\n metaComponents: ['Seo'],\n rules: { 'seo/json-ld': 'off' },\n failOn: 'warning',\n weights: { seo: 2 }\n};\n```\n\nA `.ts` config can `import { defineConfig } from 'svelte-vitals'` for type-checking, but that is a\n**runtime** import: it needs svelte-vitals as a declared dependency and Node 22.18+ (or 23.6+).\nA plain `export default {}` in `.mjs` behaves identically and always works.\n\n## Options\n\n| Option | Type | Default |\n| ---------------- | -------------------------------------------------------------- | ------------------ |\n| `treatDynamicAs` | `'pass' \\| 'warn' \\| 'fail'` | `'pass'` |\n| `metaComponents` | `string[]` | `[]` |\n| `rules` | `Record<ruleId, 'off' \\| Severity \\| { severity?, options? }>` | `{}` |\n| `failOn` | `'critical' \\| 'warning' \\| 'info'` | `'critical'` |\n| `weights` | `Partial<Record<Category, number>>` | every category `1` |\n| `overrides` | `RuleOverride[]` | (none) |\n\n`Severity` is `'critical' | 'warning' | 'info'`. `Category` is `'seo' | 'performance' |\n'correctness' | 'security' | 'architecture' | 'a11y'`. A weight of `0` drops a category from the Health\naverage; setting every category to `0` is an error (exit `2`).\n\n## Turning a rule off or down\n\n```js\nexport default {\n rules: {\n 'seo/json-ld': 'off', // remove its findings entirely\n 'architecture/prop-count': 'info' // keep it, stop it failing the build\n }\n};\n```\n\nMany rules take options, so check whether the finding is a **threshold disagreement** rather than\na defect first. `svelte-vitals explain <rule-id>` prints each option's name, default, bounds, and\nmerge semantics (`integer` replaces, `string-list` appends, `string-map` is spread over).\n\n```js\nexport default {\n rules: {\n 'architecture/prop-count': { options: { max: 12 } }\n }\n};\n```\n\n## Scoping to routes or files (`overrides`)\n\n`rules` applies everywhere; `overrides` applies only where it matches \u2014 typically routes that\nare deliberately not public.\n\n```js\nexport default {\n overrides: [\n { files: 'src/routes/(app)/**', rules: { seo: 'off' } },\n { route: '/admin/**', rules: { 'seo/title-presence': 'info' } }\n ]\n};\n```\n\nEach entry needs `rules` (keys are rule ids **or** category names) plus at least one of:\n\n- **`route`** \u2014 glob(s) against the route id as reported (`/blog/[slug]`). SvelteKit `(group)`\n segments are **not** in the route id, so use `files` to target a group.\n- **`files`** \u2014 glob(s) against the source path.\n\nGlobs are deliberately small: `*` within a segment, `**` across segments, a trailing `/**` also\nmatches the bare prefix. Everything else \u2014 including `(`, `)`, `[`, `]` \u2014 is literal. Later entries win.\n\n## Precedence\n\nPer field: **CLI flag > config file > built-in default**. One exception \u2014 `--rules` and `--ignore`\nare selection, not configuration: `--rules` narrows the run to the ids it names and overrides a\nconfig-file `off` for them, but keeps their declared severity and options; `--ignore` adds `off`\nentries for the ids it names, layered on top of whatever `rules` resolved to, and beats `--rules`\nwhen both name the same rule.\n\n`overrides` has no CLI flag; route policy belongs in a committed file.\n\n## Validation\n\nAn unknown rule id or category, a negative weight, a malformed `overrides` entry, or an invalid\nrule setting is a **hard error (exit `2`)** \u2014 a typo must not silently un-gate CI. An unrecognized\n`treatDynamicAs`/`failOn` value, or an unknown top-level key, only warns.\n\n## Related\n\n- `svelte-vitals explain --list` \u2014 every rule id\n- `svelte-vitals docs show scoping` \u2014 accepting an existing backlog instead of disabling rules"
35
35
  },
36
36
  {
37
37
  name: "monorepo",
@@ -96,7 +96,7 @@ var DOCS_SHOW_ARGS = {
96
96
  ...HELP_ARG
97
97
  };
98
98
  async function buildDocsHelpText(rootCommand, locale) {
99
- const ja = locale === "ja" ? await import("./ja-BCE5T3H2.js") : void 0;
99
+ const ja = locale === "ja" ? await import("./ja-GAQSBSXF.js") : void 0;
100
100
  const optionsSection = stripAutoVersionLine(
101
101
  await localizedOptionsSection(rootCommand, "svelte-vitals docs", locale, ja?.JA_ARG_DESCRIPTIONS.docs ?? {})
102
102
  );
@@ -2,7 +2,7 @@ import {
2
2
  CI_ARGS,
3
3
  CI_UPGRADE_ARGS,
4
4
  runCiCliGunshi
5
- } from "./chunk-6BUOZU6F.js";
5
+ } from "./chunk-ICEJJDDA.js";
6
6
  import "./chunk-CTMKTFST.js";
7
7
  import "./chunk-GE7TKVTX.js";
8
8
  import "./chunk-NMVBVKLX.js";
@@ -1,23 +1,23 @@
1
1
  import {
2
2
  ROOT_ARGS
3
- } from "./chunk-Z2DBOC5X.js";
4
- import "./chunk-FVN7R2YK.js";
3
+ } from "./chunk-MZO3HVTN.js";
4
+ import "./chunk-5UQ6P2M2.js";
5
5
  import {
6
6
  DOCS_LIST_ARGS,
7
7
  DOCS_ROOT_ARGS,
8
8
  DOCS_SHOW_ARGS
9
- } from "./chunk-X7BU4DLG.js";
9
+ } from "./chunk-SLE22PPF.js";
10
10
  import {
11
11
  EXPLAIN_ARGS
12
- } from "./chunk-4ZE7NBVE.js";
12
+ } from "./chunk-JFLNGYPX.js";
13
13
  import {
14
14
  INSTALL_ARGS
15
- } from "./chunk-2RQRPUCR.js";
15
+ } from "./chunk-77MMJCFK.js";
16
16
  import {
17
17
  CATEGORIES,
18
18
  FAIL_ON_VALUES,
19
19
  TREAT_DYNAMIC_AS_VALUES
20
- } from "./chunk-MUIOPL5F.js";
20
+ } from "./chunk-43FGGQQZ.js";
21
21
  import {
22
22
  REPORTER_NAMES
23
23
  } from "./chunk-M5KM5SV7.js";
@@ -28,7 +28,7 @@ import "./chunk-TFBLQUAC.js";
28
28
  import {
29
29
  CI_ARGS,
30
30
  CI_UPGRADE_ARGS
31
- } from "./chunk-6BUOZU6F.js";
31
+ } from "./chunk-ICEJJDDA.js";
32
32
  import "./chunk-CTMKTFST.js";
33
33
  import "./chunk-GE7TKVTX.js";
34
34
  import "./chunk-NMVBVKLX.js";
@@ -3,7 +3,7 @@ import {
3
3
  DOCS_ROOT_ARGS,
4
4
  DOCS_SHOW_ARGS,
5
5
  runDocsCliGunshi
6
- } from "./chunk-X7BU4DLG.js";
6
+ } from "./chunk-SLE22PPF.js";
7
7
  import "./chunk-SLUMRYUD.js";
8
8
  import "./chunk-TFBLQUAC.js";
9
9
  import "./chunk-NMVBVKLX.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EXPLAIN_ARGS,
3
3
  runExplainCliGunshi
4
- } from "./chunk-4ZE7NBVE.js";
4
+ } from "./chunk-JFLNGYPX.js";
5
5
  import "./chunk-SLUMRYUD.js";
6
6
  import "./chunk-TFBLQUAC.js";
7
7
  import "./chunk-NMVBVKLX.js";
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  ROOT_ARGS
3
- } from "./chunk-Z2DBOC5X.js";
4
- import "./chunk-FVN7R2YK.js";
3
+ } from "./chunk-MZO3HVTN.js";
4
+ import "./chunk-5UQ6P2M2.js";
5
5
  import {
6
6
  JA_ARG_DESCRIPTIONS
7
- } from "./chunk-MHRO4GU7.js";
7
+ } from "./chunk-A5DVJX5O.js";
8
8
  import {
9
9
  INSTALL_ARGS
10
- } from "./chunk-2RQRPUCR.js";
11
- import "./chunk-MUIOPL5F.js";
10
+ } from "./chunk-77MMJCFK.js";
11
+ import "./chunk-43FGGQQZ.js";
12
12
  import "./chunk-M5KM5SV7.js";
13
13
  import "./chunk-SLUMRYUD.js";
14
14
  import "./chunk-TFBLQUAC.js";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HeadTag, Config, RuleOptionsSpec, Severity, RuleSetting, Category, Result } from '@svelte-vitals/core';
1
+ import { HeadTag, BranchStep, Config, RuleOptionsSpec, Severity, RuleSetting, Category, Result } from '@svelte-vitals/core';
2
2
  export { defineConfig } from '@svelte-vitals/core';
3
3
  import { AST } from 'svelte/compiler';
4
4
 
@@ -34,12 +34,37 @@ interface ParsedHeading {
34
34
  /** 1-based source line, or 0 if unknown. */
35
35
  line: number;
36
36
  }
37
+
38
+ interface A11yNode {
39
+ kind: 'landmark' | 'id' | 'idref' | 'component';
40
+ /** landmark → 'main'|'banner'|'contentinfo'|'complementary'; id/idref → the literal id; component → component name */
41
+ key: string;
42
+ line: number;
43
+ /** inside {#each} body or {#snippet} definition at any depth (excluded from duplication counting) */
44
+ repeatable: boolean;
45
+ /** branch address from template root (empty = unconditional) */
46
+ path: BranchStep[];
47
+ /** for kind 'idref': the referencing attribute ('for', 'aria-labelledby', …, 'href') */
48
+ attr?: string;
49
+ /** the landmark ancestor element within this file, if any */
50
+ inLandmark?: string;
51
+ /** for kind 'landmark' from <header>/<footer>: at template top level in this file (which also implies "not inside sectioning content" — depth 0 has no ancestors at all) */
52
+ topLevel?: boolean;
53
+ }
54
+ interface ParsedA11y {
55
+ nodes: A11yNode[];
56
+ /** landmark ancestor of this file's <slot>/{@render children()} position, if any */
57
+ slotInLandmark?: string;
58
+ /** file contains {@html} or a spread attribute — poisons the closed world for no-missing-id-ref */
59
+ unknowableContent: boolean;
60
+ }
37
61
  interface ParsedFile {
38
62
  headTags: ParsedTag[];
39
63
  components: ComponentUse[];
40
64
  imports: ImportMap;
41
65
  images: ParsedImage[];
42
66
  headings: ParsedHeading[];
67
+ a11y: ParsedA11y;
43
68
  }
44
69
 
45
70
  /**
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  routeMatcher,
7
7
  run,
8
8
  spinnerEnabled
9
- } from "./chunk-FVN7R2YK.js";
9
+ } from "./chunk-5UQ6P2M2.js";
10
10
  import {
11
11
  loadConfigFile
12
12
  } from "./chunk-M5KM5SV7.js";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  INSTALL_ARGS,
3
3
  runInstallCliGunshi
4
- } from "./chunk-2RQRPUCR.js";
5
- import "./chunk-MUIOPL5F.js";
4
+ } from "./chunk-77MMJCFK.js";
5
+ import "./chunk-43FGGQQZ.js";
6
6
  import "./chunk-M5KM5SV7.js";
7
7
  import "./chunk-SLUMRYUD.js";
8
8
  import "./chunk-TFBLQUAC.js";
@@ -5,7 +5,7 @@ import {
5
5
  explainHelpJa,
6
6
  installHelpJa,
7
7
  rootHelpJa
8
- } from "./chunk-MHRO4GU7.js";
8
+ } from "./chunk-A5DVJX5O.js";
9
9
  export {
10
10
  JA_ARG_DESCRIPTIONS,
11
11
  ciHelpJa,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "svelte-vitals",
3
- "version": "0.46.0",
4
- "description": "A deterministic SvelteKit code-health scanner (SEO, performance, correctness, security, architecture) — not a runtime Web Vitals reporter.",
3
+ "version": "0.47.0",
4
+ "description": "A deterministic SvelteKit code-health scanner (SEO, performance, correctness, security, architecture, accessibility) — not a runtime Web Vitals reporter.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Kazuma Oe (https://github.com/oekazuma)",
@@ -52,7 +52,7 @@
52
52
  "magicast": "^0.5.4",
53
53
  "svelte": "^5.56.8",
54
54
  "tinyglobby": "^0.2.17",
55
- "@svelte-vitals/core": "0.42.0"
55
+ "@svelte-vitals/core": "0.43.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@gunshi/docs": "0.37.1",