|
| 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