Skip to content

Commit 211ed97

Browse files
committed
fix(template-anchor-has-content): unwrap mustache literals via shared helper (Copilot review)
Extract a new `getStaticAttrValue` util that resolves literal-valued mustaches (`{{"foo"}}`, `{{true}}`, `{{-1}}`) and single-part concat statements (`"{{true}}"`) to their static string value. `isAriaHiddenTrue` now delegates to the helper, so quoted-mustache forms of aria-hidden (e.g. `<a aria-hidden="{{true}}">link</a>`) are recognised the same as their text-node counterparts when walking descendants for accessible content. Byte-identical carrier of lib/utils/static-attr-value.js across all PRs that land it.
1 parent df85eff commit 211ed97

3 files changed

Lines changed: 206 additions & 17 deletions

File tree

lib/rules/template-anchor-has-content.js

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,28 @@
11
const { isNativeElement } = require('../utils/is-native-element');
2+
const { getStaticAttrValue } = require('../utils/static-attr-value');
23

34
function isDynamicValue(value) {
45
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
56
}
67

78
// Returns true if the `aria-hidden` attribute is explicitly set to "true"
8-
// (case-insensitive) or mustache-literal `{{true}}`. Per WAI-ARIA 1.2 §6.6
9-
// + aria-hidden value table, valueless / empty-string `aria-hidden` resolves
10-
// to the default `undefined` — NOT `true` — so those forms do NOT hide the
9+
// (case-insensitive) or mustache-literal `{{true}}` / `{{"true"}}` / the
10+
// quoted-mustache concat equivalents. Per WAI-ARIA 1.2 §6.6 + aria-hidden
11+
// value table, valueless / empty-string `aria-hidden` resolves to the
12+
// default `undefined` — NOT `true` — so those forms do NOT hide the
1113
// element per spec. This aligns with the spec-first decisions in #2717 /
12-
// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention.
14+
// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention. All
15+
// shape-unwrapping is delegated to the shared `getStaticAttrValue` helper.
1316
function isAriaHiddenTrue(attr) {
14-
if (!attr?.value) {
17+
if (!attr) {
1518
return false;
1619
}
17-
if (attr.value.type === 'GlimmerTextNode') {
18-
return attr.value.chars.trim().toLowerCase() === 'true';
19-
}
20-
if (attr.value.type === 'GlimmerMustacheStatement') {
21-
const path = attr.value.path;
22-
if (path?.type === 'GlimmerBooleanLiteral') {
23-
return path.value === true;
24-
}
25-
if (path?.type === 'GlimmerStringLiteral') {
26-
return path.value.trim().toLowerCase() === 'true';
27-
}
20+
const resolved = getStaticAttrValue(attr.value);
21+
if (resolved === undefined) {
22+
// Dynamic — can't prove truthy.
23+
return false;
2824
}
29-
return false;
25+
return resolved.trim().toLowerCase() === 'true';
3026
}
3127

3228
// True if the anchor itself declares an accessible name via a statically

lib/utils/static-attr-value.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
/**
4+
* Return the statically-known string value of a Glimmer attribute value node,
5+
* or `undefined` when the value is dynamic (cannot be resolved at lint time).
6+
*
7+
* Unwraps:
8+
* - GlimmerTextNode → chars
9+
* - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value
10+
* - GlimmerConcatStatement whose parts are all statically resolvable → joined string
11+
*
12+
* A missing/undefined value (valueless attribute, e.g. `<input disabled>`)
13+
* returns the empty string. Pass `attr.value` — not the attribute itself.
14+
*/
15+
function getStaticAttrValue(value) {
16+
if (value === null || value === undefined) {
17+
return '';
18+
}
19+
if (value.type === 'GlimmerTextNode') {
20+
return value.chars;
21+
}
22+
if (value.type === 'GlimmerMustacheStatement') {
23+
return extractLiteral(value.path);
24+
}
25+
if (value.type === 'GlimmerConcatStatement') {
26+
const parts = value.parts || [];
27+
let out = '';
28+
for (const part of parts) {
29+
if (part.type === 'GlimmerTextNode') {
30+
out += part.chars;
31+
continue;
32+
}
33+
if (part.type === 'GlimmerMustacheStatement') {
34+
const literal = extractLiteral(part.path);
35+
if (literal === undefined) {
36+
return undefined;
37+
}
38+
out += literal;
39+
continue;
40+
}
41+
return undefined;
42+
}
43+
return out;
44+
}
45+
return undefined;
46+
}
47+
48+
function extractLiteral(path) {
49+
if (!path) {
50+
return undefined;
51+
}
52+
if (path.type === 'GlimmerBooleanLiteral') {
53+
return path.value ? 'true' : 'false';
54+
}
55+
if (path.type === 'GlimmerStringLiteral') {
56+
return path.value;
57+
}
58+
if (path.type === 'GlimmerNumberLiteral') {
59+
return String(path.value);
60+
}
61+
return undefined;
62+
}
63+
64+
module.exports = { getStaticAttrValue };
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use strict';
2+
3+
const { getStaticAttrValue } = require('../../../lib/utils/static-attr-value');
4+
5+
describe('getStaticAttrValue', () => {
6+
it('returns empty string for null/undefined (valueless attribute)', () => {
7+
expect(getStaticAttrValue(null)).toBe('');
8+
expect(getStaticAttrValue(undefined)).toBe('');
9+
});
10+
11+
it('returns chars for GlimmerTextNode', () => {
12+
expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: 'hello' })).toBe('hello');
13+
expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: '' })).toBe('');
14+
});
15+
16+
it('unwraps GlimmerMustacheStatement with BooleanLiteral', () => {
17+
expect(
18+
getStaticAttrValue({
19+
type: 'GlimmerMustacheStatement',
20+
path: { type: 'GlimmerBooleanLiteral', value: true },
21+
})
22+
).toBe('true');
23+
expect(
24+
getStaticAttrValue({
25+
type: 'GlimmerMustacheStatement',
26+
path: { type: 'GlimmerBooleanLiteral', value: false },
27+
})
28+
).toBe('false');
29+
});
30+
31+
it('unwraps GlimmerMustacheStatement with StringLiteral', () => {
32+
expect(
33+
getStaticAttrValue({
34+
type: 'GlimmerMustacheStatement',
35+
path: { type: 'GlimmerStringLiteral', value: 'foo' },
36+
})
37+
).toBe('foo');
38+
expect(
39+
getStaticAttrValue({
40+
type: 'GlimmerMustacheStatement',
41+
path: { type: 'GlimmerStringLiteral', value: '' },
42+
})
43+
).toBe('');
44+
});
45+
46+
it('unwraps GlimmerMustacheStatement with NumberLiteral', () => {
47+
expect(
48+
getStaticAttrValue({
49+
type: 'GlimmerMustacheStatement',
50+
path: { type: 'GlimmerNumberLiteral', value: -1 },
51+
})
52+
).toBe('-1');
53+
expect(
54+
getStaticAttrValue({
55+
type: 'GlimmerMustacheStatement',
56+
path: { type: 'GlimmerNumberLiteral', value: 0 },
57+
})
58+
).toBe('0');
59+
});
60+
61+
it('returns undefined for GlimmerMustacheStatement with a dynamic PathExpression', () => {
62+
expect(
63+
getStaticAttrValue({
64+
type: 'GlimmerMustacheStatement',
65+
path: { type: 'GlimmerPathExpression', original: 'this.foo' },
66+
})
67+
).toBeUndefined();
68+
});
69+
70+
it('joins GlimmerConcatStatement with only static parts', () => {
71+
expect(
72+
getStaticAttrValue({
73+
type: 'GlimmerConcatStatement',
74+
parts: [
75+
{ type: 'GlimmerTextNode', chars: 'prefix-' },
76+
{
77+
type: 'GlimmerMustacheStatement',
78+
path: { type: 'GlimmerStringLiteral', value: 'mid' },
79+
},
80+
{ type: 'GlimmerTextNode', chars: '-suffix' },
81+
],
82+
})
83+
).toBe('prefix-mid-suffix');
84+
});
85+
86+
it('joins concat with boolean and number literal parts', () => {
87+
expect(
88+
getStaticAttrValue({
89+
type: 'GlimmerConcatStatement',
90+
parts: [
91+
{
92+
type: 'GlimmerMustacheStatement',
93+
path: { type: 'GlimmerBooleanLiteral', value: true },
94+
},
95+
],
96+
})
97+
).toBe('true');
98+
expect(
99+
getStaticAttrValue({
100+
type: 'GlimmerConcatStatement',
101+
parts: [
102+
{
103+
type: 'GlimmerMustacheStatement',
104+
path: { type: 'GlimmerNumberLiteral', value: -1 },
105+
},
106+
],
107+
})
108+
).toBe('-1');
109+
});
110+
111+
it('returns undefined for GlimmerConcatStatement with a dynamic part', () => {
112+
expect(
113+
getStaticAttrValue({
114+
type: 'GlimmerConcatStatement',
115+
parts: [
116+
{ type: 'GlimmerTextNode', chars: 'x-' },
117+
{
118+
type: 'GlimmerMustacheStatement',
119+
path: { type: 'GlimmerPathExpression', original: 'this.foo' },
120+
},
121+
],
122+
})
123+
).toBeUndefined();
124+
});
125+
126+
it('returns undefined for an unknown node type', () => {
127+
expect(getStaticAttrValue({ type: 'GlimmerSubExpression' })).toBeUndefined();
128+
});
129+
});

0 commit comments

Comments
 (0)