Skip to content

Commit 14c44f0

Browse files
Add additional edge cases and refactor code to handle them as needed.
1 parent 1082df4 commit 14c44f0

File tree

2 files changed

+324
-16
lines changed

2 files changed

+324
-16
lines changed

lib/util/labelUtils.ts

Lines changed: 150 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { TSESLint } from "@typescript-eslint/utils";
88
import { JSXOpeningElement } from "estree-jsx";
99
import { TSESTree } from "@typescript-eslint/utils";
1010

11+
const validIdentifierRe = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
12+
1113
/**
1214
* Checks if the element is nested within a Label tag.
1315
*/
@@ -27,7 +29,8 @@ const isInsideLabelTag = (context: TSESLint.RuleContext<string, unknown[]>): boo
2729
* Capture groups (when the alternation matches) are in positions 2..6
2830
* (group 1 is the element/tag capture used in some surrounding regexes).
2931
*/
30-
const idOrExprRegex = /(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\}|\{\s*([A-Za-z_$][A-ZaLign$0-9_$]*)\s*\})/i;
32+
// FIXED: typo in identifier character class (A-ZaLign -> A-Za-z)
33+
const idOrExprRegex = /(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\}|\{\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\})/i;
3134

3235
const escapeForRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3336

@@ -42,11 +45,54 @@ const extractCapturedId = (match: RegExpExecArray): string | undefined => {
4245
};
4346

4447
/**
45-
* New small helper: normalize attribute value (string list vs identifier vs empty/none)
46-
* Keeps getProp/getPropValue usage isolated and provides a single place to trim/split.
47-
* Return shape (for consumers):
48+
* Evaluate simple constant BinaryExpression concatenations (left/right are Literals or nested BinaryExpressions).
49+
* Returns string when evaluation succeeds, otherwise undefined.
50+
*/
51+
const evalConstantString = (node: any): string | undefined => {
52+
if (!node || typeof node !== "object") return undefined;
53+
if (node.type === "Literal") {
54+
return String(node.value);
55+
}
56+
if (node.type === "BinaryExpression" && node.operator === "+") {
57+
const left = evalConstantString(node.left);
58+
if (left === undefined) return undefined;
59+
const right = evalConstantString(node.right);
60+
if (right === undefined) return undefined;
61+
return left + right;
62+
}
63+
return undefined;
64+
};
65+
66+
/**
67+
* Small renderer to reconstruct simple expression source text for BinaryExpressions and Literals.
68+
* This provides a normalized textual form we can use to search the raw source for an exact expression match.
69+
* For strings, we preserve quotes by using JSON.stringify; numbers use String(value).
70+
*/
71+
const renderSimpleExprSource = (node: any): string | undefined => {
72+
if (!node || typeof node !== "object") return undefined;
73+
if (node.type === "Literal") {
74+
const val = (node as any).value;
75+
if (typeof val === "string") return JSON.stringify(val); // keep the quotes "..."
76+
return String(val);
77+
}
78+
if (node.type === "BinaryExpression" && node.operator === "+") {
79+
const left = renderSimpleExprSource(node.left);
80+
if (left === undefined) return undefined;
81+
const right = renderSimpleExprSource(node.right);
82+
if (right === undefined) return undefined;
83+
return `${left} + ${right}`;
84+
}
85+
// Not attempting to render arbitrary expressions (Identifiers, MemberExpression, etc.)
86+
return undefined;
87+
};
88+
89+
/**
90+
* New small helper: normalize attribute value (string list vs identifier vs empty/none vs template)
91+
*
92+
* Return shapes:
4893
* { kind: "string", raw: string, tokens: string[] }
4994
* { kind: "identifier", name: string }
95+
* { kind: "template", template: string } // template uses backticks and ${exprName} placeholders
5096
* { kind: "empty" }
5197
* { kind: "none" }
5298
*/
@@ -57,18 +103,71 @@ const getAttributeValueInfo = (
57103
): any => {
58104
const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], attrName);
59105

106+
// Prefer inspecting the AST expression container directly when present
60107
if (prop && prop.value && (prop.value as any).type === "JSXExpressionContainer") {
61108
const expr = (prop.value as any).expression;
109+
110+
// Identifier: only accept valid JS identifiers
62111
if (expr && expr.type === "Identifier") {
63-
return { kind: "identifier", name: expr.name as string };
112+
if (typeof expr.name === "string" && validIdentifierRe.test(expr.name)) {
113+
return { kind: "identifier", name: expr.name as string };
114+
}
115+
return { kind: "none" };
64116
}
117+
118+
// Literal inside expression container: {"x"} or {'x'}
65119
if (expr && expr.type === "Literal" && typeof (expr as any).value === "string") {
66120
const trimmed = ((expr as any).value as string).trim();
67121
if (trimmed === "") return { kind: "empty" };
68-
return { kind: "string", raw: trimmed, tokens: trimmed.split(/\s+/) };
122+
return { kind: "string", raw: trimmed, tokens: trimmed.split(/\s+/), exprText: JSON.stringify((expr as any).value) };
123+
}
124+
125+
// BinaryExpression evaluation for constant concatenations: {"my-" + "label"} or {"my-" + 1}
126+
if (expr && expr.type === "BinaryExpression") {
127+
const v = evalConstantString(expr);
128+
if (typeof v === "string") {
129+
const trimmed = v.trim();
130+
if (trimmed === "") return { kind: "empty" };
131+
// Reconstruct simple source for the binary expression so we can search for an exact occurrence in raw source
132+
const exprText = renderSimpleExprSource(expr);
133+
if (exprText) {
134+
return { kind: "string", raw: trimmed, tokens: trimmed.split(/\s+/), exprText };
135+
}
136+
return { kind: "string", raw: trimmed, tokens: trimmed.split(/\s+/) };
137+
}
138+
}
139+
140+
// TemplateLiteral: reconstruct a canonical template string (preserve placeholders as ${name})
141+
if (expr && expr.type === "TemplateLiteral") {
142+
try {
143+
const quasis = (expr as any).quasis || [];
144+
const expressions = (expr as any).expressions || [];
145+
let templateRaw = "`";
146+
for (let i = 0; i < quasis.length; i++) {
147+
const q = quasis[i];
148+
const rawPart = (q && q.value && (q.value.raw ?? q.value.cooked)) || "";
149+
templateRaw += rawPart;
150+
if (i < expressions.length) {
151+
const e = expressions[i];
152+
if (e && e.type === "Identifier" && typeof e.name === "string") {
153+
templateRaw += "${" + e.name + "}";
154+
} else if (e && e.type === "Literal") {
155+
templateRaw += "${" + String((e as any).value) + "}";
156+
} else {
157+
// unknown expression placeholder — include empty placeholder
158+
templateRaw += "${}";
159+
}
160+
}
161+
}
162+
templateRaw += "`";
163+
return { kind: "template", template: templateRaw };
164+
} catch {
165+
// if anything goes wrong, fall through
166+
}
69167
}
70168
}
71169

170+
// Fallback: try to resolve via getPropValue (covers literal attrs and expression-literals and other resolvable forms)
72171
const resolved = prop ? getPropValue(prop) : undefined;
73172
if (typeof resolved === "string") {
74173
const trimmed = resolved.trim();
@@ -93,8 +192,6 @@ const hasBracedAttrId = (
93192

94193
/**
95194
* Checks if a Label exists with htmlFor that matches idValue.
96-
* Handles:
97-
* - htmlFor="id", htmlFor={'id'}, htmlFor={"id"}, htmlFor={idVar}
98195
*/
99196
const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
100197
if (!idValue) return false;
@@ -112,7 +209,6 @@ const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext<st
112209

113210
/**
114211
* Checks if a Label exists with id that matches idValue.
115-
* Handles: id="x", id={'x'}, id={"x"}, id={x}
116212
*/
117213
const hasLabelWithHtmlId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
118214
if (!idValue) return false;
@@ -146,12 +242,7 @@ const hasOtherElementWithHtmlId = (idValue: string, context: TSESLint.RuleContex
146242
};
147243

148244
/**
149-
* Generic helper for aria-* attributes:
150-
* - if prop resolves to a string (literal or expression-literal) then we check labels/ids
151-
* - if prop is an identifier expression (aria-*= {someId}) we fall back to a narrow regex that checks
152-
* other elements/labels with id={someId}
153-
*
154-
* This keeps the implementation compact and robust for the project's tests and common source patterns.
245+
* Generic helper for aria-* attributes.
155246
*/
156247
const hasAssociatedAriaText = (
157248
openingElement: TSESTree.JSXOpeningElement,
@@ -165,6 +256,14 @@ const hasAssociatedAriaText = (
165256
if (hasLabelWithHtmlId(id, context) || hasOtherElementWithHtmlId(id, context)) {
166257
return true;
167258
}
259+
// Fallback: if this string was produced by evaluating a BinaryExpression in the source,
260+
// attempt to match the exact binary-expression source in other element id attributes.
261+
if (info.exprText) {
262+
const labelRe = new RegExp(`<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${escapeForRegExp(info.exprText)}\\s*\\}`, "i");
263+
const otherRe = new RegExp(`<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${escapeForRegExp(info.exprText)}\\s*\\}`, "i");
264+
const src = getSourceText(context);
265+
if (labelRe.test(src) || otherRe.test(src)) return true;
266+
}
168267
}
169268
return false;
170269
}
@@ -174,6 +273,27 @@ const hasAssociatedAriaText = (
174273
return hasBracedAttrId("Label|label", "id", varName, context) || hasBracedAttrId("div|span|p|h[1-6]", "id", varName, context);
175274
}
176275

276+
if (info.kind === "template") {
277+
const templ = info.template as string;
278+
const src = getSourceText(context);
279+
// Build a pattern which matches the template's literal parts but allows any expression
280+
// inside `${...}` placeholders. This lets templates with non-Identifier expressions
281+
// (e.g. `${a.b}`) match the canonicalized template produced from the AST.
282+
const placeholderRe = /\$\{[^}]*\}/g;
283+
let pattern = "";
284+
let idx = 0;
285+
let m: RegExpExecArray | null;
286+
while ((m = placeholderRe.exec(templ)) !== null) {
287+
pattern += escapeForRegExp(templ.slice(idx, m.index));
288+
pattern += "\\$\\{[^}]*\\}";
289+
idx = m.index + m[0].length;
290+
}
291+
pattern += escapeForRegExp(templ.slice(idx));
292+
const labelRe = new RegExp(`<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${pattern}\\s*\\}`, "i");
293+
const otherRe = new RegExp(`<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${pattern}\\s*\\}`, "i");
294+
return labelRe.test(src) || otherRe.test(src);
295+
}
296+
177297
return false;
178298
};
179299

@@ -191,14 +311,28 @@ const hasAssociatedLabelViaHtmlFor = (openingElement: TSESTree.JSXOpeningElement
191311
const info = getAttributeValueInfo(openingElement, context, "id");
192312

193313
if (info.kind === "string") {
194-
return hasLabelWithHtmlForId(info.raw, context);
314+
// primary: match literal/htmlFor forms
315+
if (hasLabelWithHtmlForId(info.raw, context)) return true;
316+
// fallback: match htmlFor written as a BinaryExpression / other expression that matches the same source text
317+
if (info.exprText) {
318+
const src = getSourceText(context);
319+
const htmlForRe = new RegExp(`<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${escapeForRegExp(info.exprText)}\\s*\\}`, "i");
320+
if (htmlForRe.test(src)) return true;
321+
}
322+
return false;
195323
}
196324

197325
if (info.kind === "identifier") {
198326
const varName = info.name;
199327
return hasBracedAttrId("Label|label", "htmlFor", varName, context);
200328
}
201329

330+
if (info.kind === "template") {
331+
const templ = info.template as string;
332+
const src = getSourceText(context);
333+
return new RegExp(`<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${escapeForRegExp(templ)}\\s*\\}`, "i").test(src);
334+
}
335+
202336
return false;
203337
};
204338

0 commit comments

Comments
 (0)