Skip to content

Commit bb1ebb3

Browse files
committed
feat(utils): add glimmer-attr-presence — encodes the verified rendering model
Adds lib/utils/glimmer-attr-presence.js exporting: - classifyAttribute(attrNode, options?) → { presence, value } Maps every AST shape (valueless / GlimmerTextNode / GlimmerMustacheStatement with each path type / GlimmerConcatStatement) to a (presence, value) pair per the verified model in docs/glimmer-attribute-behavior.md. Each branch cites the relevant doc row IDs (m1–m19, h1–h15, d1–d10, t1–t7, i1–i5). - inferAttrKind(name) → 'boolean' | 'aria' | 'numeric' | 'plain-string' Used when classifyAttribute callers don't pass options.kind explicitly. - BOOLEAN_HTML_ATTRS, NUMERIC_ATTRS — exported sets, useful for callers that want to extend the kind model. Key empirical asymmetries this util encodes correctly (and that audit findings show several rules currently misclassify): - Bare {{false}} / {{null}} / {{undefined}} on falsy-coerced kinds (boolean / aria / numeric) → presence='absent' (Glimmer omits attribute). Same forms on plain-string → presence='present', value='false' / etc. - Bare {{"false"}} (StringLiteral) is JS-truthy, never coerced — renders the literal value across all attribute kinds. - aria-hidden={{true}} renders aria-hidden="" (h5, contested), not aria-hidden="true" — the util surfaces value='' here so callers comparing value === 'true' don't false-match. - Concat is never falsy: any concat form is presence='present'; the resolved value comes from the existing getStaticAttrValue helper. Tests: 35 unit tests covering every doc row + the kind-override option. Updates docs/glimmer-attribute-behavior.md to reference the actual file and replaces the "(forthcoming)" sketch with a working example.
1 parent 1b384a7 commit bb1ebb3

3 files changed

Lines changed: 609 additions & 8 deletions

File tree

docs/glimmer-attribute-behavior.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,20 +146,25 @@ Rule authors who classify attribute values must consume the reference table abov
146146

147147
### Recommended pattern
148148

149-
A shared utility `lib/utils/glimmer-attr-presence.js` (forthcoming) — encodes this table once. Rule authors should consume it rather than re-implementing the AST walk:
149+
The shared utility [`lib/utils/glimmer-attr-presence.js`](../lib/utils/glimmer-attr-presence.js) encodes the verdict table once. Rule authors should consume it rather than re-implementing the AST walk:
150150

151151
```js
152-
// Sketch of the API surface — actual implementation tracked separately.
153152
const { classifyAttribute } = require('../utils/glimmer-attr-presence');
154153

155-
const result = classifyAttribute(attr, {
156-
kind: 'boolean-coerced' /* or 'plain-string' / 'numeric' */,
157-
});
158-
// result.kind: 'absent' | 'omitted-bare-falsy' | 'static' | 'present-unknown'
159-
// result.value: string | null (only present when kind === 'static')
154+
const attr = node.attributes?.find((a) => a.name === 'aria-hidden');
155+
const { presence, value } = classifyAttribute(attr);
156+
// presence: 'absent' | 'present' | 'unknown'
157+
// value: string | null
158+
//
159+
// kind is inferred from attr.name (boolean / aria / numeric / plain-string).
160+
// Override with options.kind when needed: classifyAttribute(attr, { kind: 'aria' }).
161+
162+
if (presence === 'present' && value === 'true') {
163+
// hidden — covers h3, h7, h12, h14 in one branch.
164+
}
160165
```
161166
162-
Until the utility lands, follow the AST-shape table above directly and cite the specific row IDs in code comments where the classification logic lives.
167+
The utility maps every row in the reference table above to a single `(presence, value)` pair, including the surprising cases (`{{"false"}}` is JS-truthy, `aria-hidden={{true}}` renders empty per h5, concat is never falsy, etc.). Cite the doc row IDs from code comments where you call it so reviewers can confirm the lint truth without re-reading the AST.
163168
164169
## To reproduce the reference table
165170

lib/utils/glimmer-attr-presence.js

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
'use strict';
2+
3+
const { getStaticAttrValue } = require('./static-attr-value');
4+
5+
// HTML boolean attributes per WHATWG HTML Living Standard
6+
// (https://html.spec.whatwg.org/multipage/indices.html#attributes-3, "Boolean
7+
// attribute" column). Glimmer's bare-mustache falsy-coercion applies to these.
8+
const BOOLEAN_HTML_ATTRS = new Set([
9+
'allowfullscreen',
10+
'async',
11+
'autofocus',
12+
'autoplay',
13+
'checked',
14+
'controls',
15+
'default',
16+
'defer',
17+
'disabled',
18+
'formnovalidate',
19+
'hidden',
20+
'inert',
21+
'ismap',
22+
'itemscope',
23+
'loop',
24+
'multiple',
25+
'muted',
26+
'nomodule',
27+
'novalidate',
28+
'open',
29+
'playsinline',
30+
'readonly',
31+
'required',
32+
'reversed',
33+
'selected',
34+
]);
35+
36+
// Numeric attributes whose bare-falsy coercion is verified or expected.
37+
// Verified for `tabindex` (rows t6, t7). `colspan` / `rowspan` follow the same
38+
// numeric-attribute pattern but are not directly verified.
39+
const NUMERIC_ATTRS = new Set(['tabindex', 'colspan', 'rowspan']);
40+
41+
/**
42+
* Infer the attribute kind from its name. Used when the caller doesn't pass
43+
* `options.kind` explicitly.
44+
*
45+
* Returns one of: 'boolean' | 'aria' | 'numeric' | 'plain-string'.
46+
*
47+
* NOTE: `role` is intentionally classified as `'plain-string'` (not `'aria'`)
48+
* because empirically it does NOT participate in the bare-mustache
49+
* falsy-coercion list (per cross-attribute observations in the doc — `role`
50+
* is a plain DOM string attribute despite living conceptually with ARIA).
51+
*/
52+
function inferAttrKind(name) {
53+
if (BOOLEAN_HTML_ATTRS.has(name)) {
54+
return 'boolean';
55+
}
56+
if (NUMERIC_ATTRS.has(name)) {
57+
return 'numeric';
58+
}
59+
if (name.startsWith('aria-')) {
60+
return 'aria';
61+
}
62+
return 'plain-string';
63+
}
64+
65+
/**
66+
* Classify a Glimmer attribute against the verified rendering model in
67+
* docs/glimmer-attribute-behavior.md.
68+
*
69+
* Result shape: { presence, value }
70+
*
71+
* presence: 'absent' | 'present' | 'unknown'
72+
* - 'absent' — attribute will not be on the rendered element.
73+
* Either attrNode is null/undefined, OR the source is
74+
* bare {{false}}/{{null}}/{{undefined}} (or {{0}} for
75+
* `boolean` kind) on a falsy-coerced attribute kind
76+
* (boolean / aria / numeric). Doc rows: m6, m9, m10, m12,
77+
* d3, d6, h6, h9, h10, t6, t7.
78+
* - 'present' — attribute will be present at runtime. `value` is the
79+
* resolved static string when known, or null when the
80+
* value is dynamic (e.g., bare {{this.x}} on a plain-string
81+
* attribute).
82+
* - 'unknown' — cannot determine statically (dynamic mustache / dynamic
83+
* concat part on a falsy-coerced kind, since the runtime
84+
* value could be falsy and thus omit the attribute).
85+
*
86+
* value: string | null
87+
* The resolved HTML attribute value when statically known. null when:
88+
* - presence is 'absent' or 'unknown'
89+
* - presence is 'present' but the value is dynamic
90+
*
91+
* @param {object|null|undefined} attrNode - The AttrNode, or null/undefined when not found.
92+
* @param {object} [options]
93+
* @param {'boolean'|'aria'|'numeric'|'plain-string'} [options.kind] - Override inferred kind.
94+
* @returns {{presence: 'absent'|'present'|'unknown', value: string|null}}
95+
*/
96+
function classifyAttribute(attrNode, options = {}) {
97+
if (!attrNode) {
98+
return { presence: 'absent', value: null };
99+
}
100+
101+
const kind = options.kind || inferAttrKind(attrNode.name);
102+
const isFalsyCoerced = kind === 'boolean' || kind === 'aria' || kind === 'numeric';
103+
const value = attrNode.value;
104+
105+
// Valueless attribute: <input disabled />, <div aria-hidden></div>
106+
// Renders as `attr=""`. Doc rows: d1, h1.
107+
if (value === null || value === undefined) {
108+
return { presence: 'present', value: '' };
109+
}
110+
111+
// Static text: attr="anything". Renders the literal chars.
112+
// Doc rows: m1-m4, h2-h4, d1, t-static, i1.
113+
if (value.type === 'GlimmerTextNode') {
114+
return { presence: 'present', value: value.chars };
115+
}
116+
117+
// Bare-mustache: attr={{X}}
118+
if (value.type === 'GlimmerMustacheStatement') {
119+
return classifyBareMustache(value, kind, isFalsyCoerced);
120+
}
121+
122+
// Concat-mustache: attr="...{{X}}..." — never falsy.
123+
// Doc cross-attribute observation: "Concat is never falsy."
124+
// For plain-string/aria/numeric, the rendered value is the stringified
125+
// concatenation of parts; if any part is dynamic, value is unknown.
126+
// For boolean attrs, the IDL property is set true regardless of inner literal
127+
// (rows m13-m19, d7-d10), so the conceptual "value" is irrelevant for
128+
// boolean callers — but we still report the resolved string when known so
129+
// string-comparing callers (e.g., aria-hidden === "true") work for h12-h15.
130+
if (value.type === 'GlimmerConcatStatement') {
131+
const resolved = getStaticAttrValue(value);
132+
return { presence: 'present', value: resolved === undefined ? null : resolved };
133+
}
134+
135+
// Unknown AST shape (e.g., a future Glimmer node type) — be conservative.
136+
return { presence: 'unknown', value: null };
137+
}
138+
139+
function classifyBareMustache(value, kind, isFalsyCoerced) {
140+
const path = value.path;
141+
if (!path) {
142+
return { presence: 'unknown', value: null };
143+
}
144+
145+
// {{true}} / {{false}}
146+
if (path.type === 'GlimmerBooleanLiteral') {
147+
if (path.value === false) {
148+
// {{false}} on falsy-coerced kind → omitted (m6, d3, h6, t6 verified).
149+
// {{false}} on plain-string → renders "false" (i4 verified for autocomplete).
150+
if (isFalsyCoerced) {
151+
return { presence: 'absent', value: null };
152+
}
153+
return { presence: 'present', value: 'false' };
154+
}
155+
// {{true}}: present on all kinds.
156+
// For aria-coerced specifically, h5 shows the rendered HTML value is "" —
157+
// not "true". Callers comparing aria-hidden values to "true" should NOT
158+
// match this case. Reflect that asymmetry in the resolved value:
159+
if (kind === 'aria') {
160+
return { presence: 'present', value: '' };
161+
}
162+
// Boolean reflecting (d2: disabled=""), boolean non-reflecting (m5: HTML
163+
// omitted but IDL true), numeric (untested for {{true}}), plain-string
164+
// (untested for {{true}}) — for the rule's purposes, the attribute is
165+
// present and conceptually "true". We surface the literal source value
166+
// "true" so string-comparing callers behave like for {{"true"}} (m7, h7).
167+
return { presence: 'present', value: 'true' };
168+
}
169+
170+
// {{null}} / {{undefined}}
171+
if (path.type === 'GlimmerNullLiteral' || path.type === 'GlimmerUndefinedLiteral') {
172+
// Verified for falsy-coerced kinds via cross-attribute observation
173+
// (rows m9, m10, h9, h10, d6, t7).
174+
// For plain-string, behavior is not yet verified — return 'unknown' to
175+
// avoid claiming behavior the doc doesn't guarantee.
176+
if (isFalsyCoerced) {
177+
return { presence: 'absent', value: null };
178+
}
179+
return { presence: 'unknown', value: null };
180+
}
181+
182+
// {{"string"}}
183+
// Bare-mustache string literals never coerce — render literal value.
184+
// Doc rows: m7, m8, h7, h8, d4, d5, i2.
185+
if (path.type === 'GlimmerStringLiteral') {
186+
return { presence: 'present', value: path.value };
187+
}
188+
189+
// {{0}}, {{1}}, {{-1}}, etc.
190+
if (path.type === 'GlimmerNumberLiteral') {
191+
// {{0}} for boolean kind → omitted (m12 verified for muted).
192+
// For numeric kind, t1 verifies {{0}} renders "0" (focusable).
193+
// For plain-string, untested.
194+
if (path.value === 0 && kind === 'boolean') {
195+
return { presence: 'absent', value: null };
196+
}
197+
return { presence: 'present', value: String(path.value) };
198+
}
199+
200+
// Dynamic path: {{this.x}}, {{x}}, {{(some-helper)}}, etc.
201+
// For falsy-coerced kinds, runtime value could be falsy → attribute omitted.
202+
// For plain-string, the attribute renders something (even null/undefined coerce
203+
// via stringification), but the value isn't statically known.
204+
if (isFalsyCoerced) {
205+
return { presence: 'unknown', value: null };
206+
}
207+
return { presence: 'present', value: null };
208+
}
209+
210+
module.exports = {
211+
classifyAttribute,
212+
inferAttrKind,
213+
BOOLEAN_HTML_ATTRS,
214+
NUMERIC_ATTRS,
215+
};

0 commit comments

Comments
 (0)