ultra11y 2.22.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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,247 @@
1
+ // Lower a real JSX/TSX Babel AST into the engine's existing Doc/El model so all
2
+ // static rules run unchanged on accurate trees. Casing routes the work for free:
3
+ // intrinsic elements (div, img, input) are lowercased — existing tag-literal
4
+ // rules apply; component elements (Button, IconButton) keep their original case —
5
+ // existing rules skip them, only cross-file rules (which resolve the component to
6
+ // its definition) look at them. Offsets index the ORIGINAL source, so the Doc is
7
+ // not lossy and `fix` can edit by range.
8
+ import type { AstNode, AstFile } from "./jsx-ast.js";
9
+ import type { Doc, El, HNode } from "./html.js";
10
+ import { lineStartsOf } from "./html.js";
11
+
12
+ // React → HTML attribute spellings the rules look for (matches parse/jsx.ts).
13
+ // Applied AFTER lowercasing, so e.g. tabIndex→tabindex falls out of lowercasing
14
+ // and only className/htmlFor need an explicit remap.
15
+ const RENAME: Record<string, string> = { classname: "class", htmlfor: "for" };
16
+
17
+ function asNode(v: unknown): AstNode | undefined {
18
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as AstNode) : undefined;
19
+ }
20
+ function asNodes(v: unknown): AstNode[] {
21
+ return Array.isArray(v) ? (v as AstNode[]) : [];
22
+ }
23
+
24
+ export function jsxName(name: AstNode | undefined): string {
25
+ if (!name) return "unknown";
26
+ if (name.type === "JSXIdentifier") return typeof name.name === "string" ? name.name : "unknown";
27
+ if (name.type === "JSXNamespacedName") {
28
+ const ns = asNode(name.namespace);
29
+ const nm = asNode(name.name);
30
+ return `${ns?.name ?? ""}:${nm?.name ?? ""}`;
31
+ }
32
+ if (name.type === "JSXMemberExpression") {
33
+ const parts: string[] = [];
34
+ let n: AstNode | undefined = name;
35
+ while (n && n.type === "JSXMemberExpression") {
36
+ const prop = asNode(n.property);
37
+ if (prop && typeof prop.name === "string") parts.unshift(prop.name);
38
+ n = asNode(n.object);
39
+ }
40
+ if (n && typeof n.name === "string") parts.unshift(n.name);
41
+ return parts.join(".");
42
+ }
43
+ return "unknown";
44
+ }
45
+
46
+ // Intrinsic (HTML) names start lowercase; components are Capitalized; for member
47
+ // expressions (motion.div, S.Button) the head decides.
48
+ export function isIntrinsic(tag: string): boolean {
49
+ const head = tag.split(".")[0] ?? tag;
50
+ const first = head[0] ?? "";
51
+ return first === first.toLowerCase();
52
+ }
53
+
54
+ export function normAttrName(raw: string): string {
55
+ const lower = raw.toLowerCase();
56
+ return RENAME[lower] ?? lower;
57
+ }
58
+
59
+ function attribsOf(opening: AstNode, source: string): Record<string, string> {
60
+ const attribs: Record<string, string> = {};
61
+ for (const a of asNodes(opening.attributes)) {
62
+ if (a.type === "JSXSpreadAttribute") continue; // {...props} — tracked by the graph, not an attr
63
+ if (a.type !== "JSXAttribute") continue;
64
+ const name = normAttrName(jsxName(asNode(a.name)));
65
+ if (name in attribs) continue; // first wins, like htmlparser2
66
+ const value = asNode(a.value);
67
+ if (!value) {
68
+ attribs[name] = ""; // boolean attribute (present)
69
+ } else if (value.type === "StringLiteral") {
70
+ attribs[name] = typeof value.value === "string" ? value.value : "";
71
+ } else if (value.type === "JSXExpressionContainer") {
72
+ // A container wrapping a LITERAL (tabIndex={5}, aria-label={"Save"}, {-1}) carries a
73
+ // known value — unwrap it (no braces) so value-based rules read "5"/"Save", not "{5}".
74
+ // Anything else (identifiers, calls, ternaries) stays the raw `{…}` slice = dynamic.
75
+ const expr = asNode(value.expression);
76
+ if (expr && (expr.type === "NumericLiteral" || (expr.type === "UnaryExpression" && asNode(expr.argument)?.type === "NumericLiteral"))) {
77
+ attribs[name] = source.slice(expr.start ?? 0, expr.end ?? 0); // "5" / "-1"
78
+ } else if (expr && expr.type === "StringLiteral") {
79
+ attribs[name] = typeof expr.value === "string" ? expr.value : "";
80
+ } else {
81
+ attribs[name] = source.slice(value.start ?? 0, value.end ?? 0); // dynamic expression — keep raw {…}
82
+ }
83
+ } else {
84
+ // <jsx/> value or other → keep the raw source slice ("present / non-empty name").
85
+ attribs[name] = source.slice(value.start ?? 0, value.end ?? 0);
86
+ }
87
+ }
88
+ return attribs;
89
+ }
90
+
91
+ // A JSX node collected from inside an expression, tagged with the conditional arm it
92
+ // belongs to (mutually-exclusive arms get distinct tags; "" = unconditional).
93
+ interface Collected {
94
+ node: AstNode;
95
+ arm: string;
96
+ }
97
+
98
+ // Collect JSXElement/JSXFragment nodes nested inside an arbitrary expression
99
+ // (e.g. {cond && <img/>} or {items.map(i => <li/>)}) without descending into the
100
+ // matched node (convert() handles its subtree). Ternary consequent/alternate and the
101
+ // right operand of `&&`/`||` are tagged as distinct arms so order-sensitive rules can
102
+ // tell two elements are mutually exclusive (never both rendered). `seq` mints unique
103
+ // conditional ids across the whole doc so arms from different conditionals never collide.
104
+ function collectJsx(node: unknown, out: Collected[], arm: string, seq: { n: number }): void {
105
+ if (Array.isArray(node)) {
106
+ for (const item of node) collectJsx(item, out, arm, seq);
107
+ return;
108
+ }
109
+ const n = asNode(node);
110
+ if (!n || typeof n.type !== "string") return;
111
+ if (n.type === "JSXElement" || n.type === "JSXFragment") {
112
+ out.push({ node: n, arm });
113
+ return;
114
+ }
115
+ if (n.type === "ConditionalExpression") {
116
+ const id = seq.n++;
117
+ collectJsx(n.test, out, arm, seq);
118
+ collectJsx(n.consequent, out, `${arm}/c${id}T`, seq);
119
+ collectJsx(n.alternate, out, `${arm}/c${id}F`, seq);
120
+ return;
121
+ }
122
+ if (n.type === "LogicalExpression") {
123
+ const id = seq.n++;
124
+ collectJsx(n.left, out, arm, seq);
125
+ collectJsx(n.right, out, `${arm}/c${id}R`, seq);
126
+ return;
127
+ }
128
+ for (const key in n) {
129
+ if (key === "loc" || key === "start" || key === "end" || key === "range" || key === "type") continue;
130
+ collectJsx(n[key], out, arm, seq);
131
+ }
132
+ }
133
+
134
+ export function jsxAstToDoc(ast: AstFile, source: string, file: string): Doc {
135
+ const lineStarts = lineStartsOf(source);
136
+ const elements: El[] = [];
137
+ const byId = new Map<string, El>();
138
+ const roots: HNode[] = [];
139
+ const seq = { n: 0 }; // doc-wide counter for unique conditional-arm ids
140
+
141
+ const convert = (node: AstNode, parent: El | null, arm = ""): El => {
142
+ let tag = "#fragment";
143
+ let attribs: Record<string, string> = {};
144
+ let spread = false;
145
+ if (node.type === "JSXElement") {
146
+ const opening = asNode(node.openingElement);
147
+ const raw = jsxName(opening ? asNode(opening.name) : undefined);
148
+ tag = isIntrinsic(raw) ? raw.toLowerCase() : raw;
149
+ if (opening) {
150
+ attribs = attribsOf(opening, source);
151
+ spread = asNodes(opening.attributes).some((a) => a.type === "JSXSpreadAttribute");
152
+ }
153
+ }
154
+ const el: El = {
155
+ type: "element",
156
+ tag,
157
+ attribs,
158
+ children: [],
159
+ parent,
160
+ line: node.loc?.start.line ?? 1,
161
+ col: (node.loc?.start.column ?? 0) + 1,
162
+ start: node.start ?? 0,
163
+ end: node.end ?? 0,
164
+ ...(spread ? { spread: true } : {}),
165
+ ...(arm ? { branchArm: arm } : {}),
166
+ };
167
+ elements.push(el);
168
+ const id = attribs.id;
169
+ if (id && !byId.has(id)) byId.set(id, el);
170
+
171
+ for (const c of asNodes(node.children)) {
172
+ if (c.type === "JSXText") {
173
+ const data = typeof c.value === "string" ? c.value : "";
174
+ if (data.trim() !== "") el.children.push({ type: "text", data, parent: el });
175
+ } else if (c.type === "JSXElement" || c.type === "JSXFragment") {
176
+ el.children.push(convert(c, el, arm));
177
+ } else if (c.type === "JSXExpressionContainer" || c.type === "JSXSpreadChild") {
178
+ // Keep the raw `{…}` as text (non-empty content for name/empty rules)…
179
+ el.children.push({ type: "text", data: source.slice(c.start ?? 0, c.end ?? 0), parent: el });
180
+ // …and surface any JSX rendered inside the expression as real children, tagged
181
+ // with the conditional arm they came from (so heading-order won't cross arms).
182
+ const nested: Collected[] = [];
183
+ collectJsx(c.expression ?? c, nested, arm, seq);
184
+ for (const j of nested) el.children.push(convert(j.node, el, j.arm));
185
+ }
186
+ }
187
+ return el;
188
+ };
189
+
190
+ // Walk the whole program. The first time a JSX node is reached it becomes a
191
+ // root and convert() consumes its whole subtree; visit() then returns without
192
+ // descending, so nested JSX is never re-rooted (the AST is a tree).
193
+ const visit = (node: unknown): void => {
194
+ if (Array.isArray(node)) {
195
+ for (const item of node) visit(item);
196
+ return;
197
+ }
198
+ const n = asNode(node);
199
+ if (!n || typeof n.type !== "string") return;
200
+ if (n.type === "JSXElement" || n.type === "JSXFragment") {
201
+ roots.push(convert(n, null));
202
+ return;
203
+ }
204
+ for (const key in n) {
205
+ if (key === "loc" || key === "start" || key === "end" || key === "range" || key === "type") continue;
206
+ visit(n[key]);
207
+ }
208
+ };
209
+ visit(ast.program);
210
+
211
+ const opaqueComponents = opaqueLibrarySpecifiers(ast, elements);
212
+ return { file, source, lossy: false, kind: "jsx-ast", roots, elements, byId, lineStarts, ...(opaqueComponents.length ? { opaqueComponents } : {}) };
213
+ }
214
+
215
+ // A specifier resolved from node_modules (a real package), not a relative path or
216
+ // a project path-alias (./x, ../x, /x, @/x, ~/x).
217
+ function isLibrarySpecifier(spec: string): boolean {
218
+ if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("@/") || spec.startsWith("~")) return false;
219
+ if (/^@[a-zA-Z][^/]*\/.+/.test(spec)) return true; // scoped package: @scope/name
220
+ return /^[a-zA-Z]/.test(spec); // bare package: react, lodash…
221
+ }
222
+
223
+ // The library specifiers whose components are actually rendered in this file. Maps
224
+ // each used component's head binding (Button, motion in motion.div…) to its import
225
+ // source and keeps the bare/library ones.
226
+ function opaqueLibrarySpecifiers(ast: AstFile, elements: El[]): string[] {
227
+ const importSrc = new Map<string, string>();
228
+ const body = asNodes((asNode(ast.program) ?? ast).body);
229
+ for (const stmt of body) {
230
+ if (stmt.type !== "ImportDeclaration") continue;
231
+ const source = asNode(stmt.source);
232
+ const spec = source && typeof source.value === "string" ? source.value : "";
233
+ if (!spec) continue;
234
+ for (const s of asNodes(stmt.specifiers)) {
235
+ const local = asNode(s.local);
236
+ if (local && typeof local.name === "string") importSrc.set(local.name, spec);
237
+ }
238
+ }
239
+ const opaque = new Set<string>();
240
+ for (const el of elements) {
241
+ if (isIntrinsic(el.tag) || el.tag === "#fragment") continue;
242
+ const head = el.tag.split(".")[0] ?? el.tag;
243
+ const spec = importSrc.get(head);
244
+ if (spec && isLibrarySpecifier(spec)) opaque.add(spec);
245
+ }
246
+ return [...opaque].sort();
247
+ }
@@ -0,0 +1,19 @@
1
+ // Best-effort JSX/TSX → HTML-ish normalisation so the HTML pipeline + rules can
2
+ // run on React components. This is deliberately lossy (flagged in the Doc): JSX
3
+ // expression children become text, surrounding TS code may leave noise tags, and
4
+ // PascalCase components are lowercased. It is enough to catch the structural
5
+ // a11y issues the static rules target (missing alt, clickable div, unlabeled
6
+ // field…), never a substitute for auditing rendered output.
7
+ export function jsxToHtml(source: string): string {
8
+ let s = source;
9
+ // {/* JSX comments */}
10
+ s = s.replace(/\{\/\*[\s\S]*?\*\/\}/g, " ");
11
+ // React attribute names → HTML names the rules look for
12
+ s = s.replace(/\bclassName=/g, "class=");
13
+ s = s.replace(/\bhtmlFor=/g, "for=");
14
+ s = s.replace(/\btabIndex=/g, "tabindex=");
15
+ // {expr} attribute values stay as a present, non-empty token so "attribute
16
+ // present" checks (alt={x}, aria-label={t}) are honoured. htmlparser2 reads
17
+ // `attr={expr}` as attr="{expr}" already; nothing to do here.
18
+ return s;
19
+ }
@@ -0,0 +1,75 @@
1
+ // Detect the input kind and normalise it into a position-aware Doc the engine
2
+ // walks. HTML is parsed directly. JSX/TSX is parsed into a real Babel AST and
3
+ // lowered to the same Doc model (accurate offsets, not lossy); only if that hard
4
+ // fails do we fall back to the best-effort lossy regex transform, so an audit
5
+ // always produces a result.
6
+ import { parseHtml, type Doc } from "./html.js";
7
+ import { jsxToHtml } from "./jsx.js";
8
+ import { parseJsxAst, type AstFile } from "./jsx-ast.js";
9
+ import { jsxAstToDoc } from "./jsx-bridge.js";
10
+ import { parseCaptureProvenance } from "../capture.js";
11
+
12
+ export type SourceKind = "html" | "jsx" | "sfc";
13
+
14
+ export function detectKind(file: string, forceJsx = false): SourceKind {
15
+ if (forceJsx) return "jsx";
16
+ if (/\.(jsx|tsx)$/i.test(file)) return "jsx";
17
+ // Single-file components: parsed via the HTML path but with component case
18
+ // preserved (so PascalCase components keep their identity and rules skip them).
19
+ if (/\.(vue|svelte|astro)$/i.test(file)) return "sfc";
20
+ return "html"; // .html/.htm/.xhtml, stdin, and unknown extensions default to HTML
21
+ }
22
+
23
+ const ASTRO_FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
24
+
25
+ /** Astro's `---\n…\n---` code fence at the top of the file (component script:
26
+ * imports, props, data prep) — NOT markup. Returns the frontmatter's own source
27
+ * (for the graph to parse as a script AST, see graph/build.ts) and a `blanked`
28
+ * variant of the full file with every frontmatter character except newlines
29
+ * replaced by a space: same length, same line count, same byte offsets as the
30
+ * original — so `parseHtml` never mistakes frontmatter TS syntax for markup
31
+ * (`Array<string>` reads as a `<string>` tag unstripped) while every finding's
32
+ * line number AND byte offset (`fix`'s range-based codemods) stay exact. Absent
33
+ * frontmatter (no leading `---` fence) returns the source unchanged. */
34
+ export function splitAstroFrontmatter(source: string): { frontmatter: string; blanked: string } {
35
+ const m = ASTRO_FRONTMATTER_RE.exec(source);
36
+ if (!m) return { frontmatter: "", blanked: source };
37
+ const fence = m[0];
38
+ const frontmatter = fence.replace(/^---\r?\n/, "").replace(/\r?\n---\r?\n?$/, "");
39
+ const blanked = fence.replace(/[^\n\r]/g, " ") + source.slice(fence.length);
40
+ return { frontmatter, blanked };
41
+ }
42
+
43
+ export function parseSource(source: string, file: string, opts: { forceJsx?: boolean } = {}): Doc {
44
+ return parseSourceWithAst(source, file, opts).doc;
45
+ }
46
+
47
+ /** `parseSource` plus the Babel AST it built on the way (null for HTML/SFC templates,
48
+ * and for a JSX file that only survived the lossy fallback). The dependency-graph pass
49
+ * needs BOTH — the Doc to extract markup facts and the AST to extract imports/exports —
50
+ * and going through this one function is what lets the graph hand its already-parsed
51
+ * Docs to the audit loop instead of every markup file being read and parsed twice. */
52
+ export function parseSourceWithAst(source: string, file: string, opts: { forceJsx?: boolean } = {}): { doc: Doc; ast: AstFile | null } {
53
+ const kind = detectKind(file, opts.forceJsx);
54
+ if (kind === "jsx") {
55
+ const ast = parseJsxAst(source);
56
+ if (ast) return { doc: jsxAstToDoc(ast, source, file), ast };
57
+ // Catastrophic parse failure → lossy fallback (flagged lossy in the Doc).
58
+ return { doc: parseHtml(jsxToHtml(source), file, true), ast: null };
59
+ }
60
+ // .astro's frontmatter fence is stripped (blanked) before HTML parsing — see
61
+ // splitAstroFrontmatter. .vue/.svelte have no such fence (their <script> block
62
+ // parses cleanly as an ordinary — if foreign-cased — element).
63
+ const isAstro = kind === "sfc" && /\.astro$/i.test(file);
64
+ const htmlSource = isAstro ? splitAstroFrontmatter(source).blanked : source;
65
+ // SFC templates parse as HTML but keep component case (preserveCase) and a
66
+ // distinct kind so the injected-content guards treat them like JSX, not HTML.
67
+ const doc = parseHtml(htmlSource, file, false, kind === "sfc");
68
+ // A rendered capture is real HTML (kind "html"): attach its provenance so findings
69
+ // are re-attributed to the source component. SFC/JSX are source, never captures.
70
+ if (kind === "html") {
71
+ const capture = parseCaptureProvenance(source);
72
+ if (capture) doc.capture = capture;
73
+ }
74
+ return { doc, ast: null };
75
+ }