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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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
|
+
}
|
package/src/parse/jsx.ts
ADDED
|
@@ -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
|
+
}
|