Repository navigation
Expand file tree
/
Copy pathblank.ts
More file actions
2956 lines (2839 loc) · 128 KB
/
Copy pathblank.ts
File metadata and controls
2956 lines (2839 loc) · 128 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { traverse } from '@glimmer/syntax';
import { parseTemplate } from './lib/parse-template.js';
import type { AST } from '@glimmer/syntax';
// At runtime, AST node `loc` fields are `SourceSpan` instances that
// expose `getStart()` / `getEnd()` returning a `SourceOffset` with a
// `.offset` byte-position. The public `SourceLocation` type only
// declares `start`/`end` as `Position { line, column }` (no offset),
// and `SourceSpan` itself isn't re-exported at the package's top level.
// We need byte offsets for length-preserved rewrites — declare the
// minimum shape we rely on and cast through it at access points.
interface SpanLike {
getStart(): { offset: number };
getEnd(): { offset: number };
}
function startOffset(node: { loc: AST.SourceLocation }): number {
return (node.loc as unknown as SpanLike).getStart().offset;
}
function endOffset(node: { loc: AST.SourceLocation }): number {
return (node.loc as unknown as SpanLike).getEnd().offset;
}
// Register an attribute injection at the given source-text offset.
// First-write-wins on (offset, attr) — matches the prior `*SplatOffsets`
// channels' behavior (which were Sets by construction or used .has()
// checks before .set()).
function addAttrInjection(
registry: Map<number, AttrInjection[]>,
offset: number,
attr: string,
value: string | null,
): void {
let arr = registry.get(offset);
if (!arr) {
arr = [];
registry.set(offset, arr);
}
if (!arr.some((a) => a.attr === attr)) {
arr.push({ attr, value });
}
}
function hasAttrInjection(
registry: Map<number, AttrInjection[]>,
offset: number,
attr: string,
): boolean {
return registry.get(offset)?.some((a) => a.attr === attr) ?? false;
}
import htmlTags from 'html-tags';
import svgTags from 'svg-tags';
import { mathmlTagNames } from 'mathml-tag-names';
import html5Schema from 'html-validate/elements/html5';
import type { MetaDataTable } from 'html-validate';
import { lookupBuiltinComponent } from './lib/builtin-components.js';
import type { ComponentAttrs } from './lib/builtin-components.js';
import { STRUCTURAL_CHILD_TAGS as CONTENT_RESTRICTED_STRUCTURAL_CHILDREN } from './lib/element-sets.js';
import type { AttrTypeInfo } from './lib/cache.js';
import { DYNAMIC_VALUE_PLACEHOLDER, isDynamicValuePlaceholder } from './lib/dynamic-value.js';
// ---------------------------------------------------------------------------
// Schema-derived metadata (computed once at module load).
// ---------------------------------------------------------------------------
// Native boolean attributes per HTML5. Sourced from html-validate's
// elements/html5 schema (the same data we use for attribute-allowed-values).
// Used to decide how a bare-mustache value gets emitted: a boolean attr
// resolves at runtime to either presence-only (when truthy) or omit (when
// falsy), per docs/glimmer-attribute-behavior.md. We emit presence-only —
// the canonical "rendered" form — letting attribute-boolean-style stay
// enabled. Non-boolean bare-mustache attrs use the empty-quoted +
// processAttribute DynamicValue path.
const html5: MetaDataTable = html5Schema;
const BOOLEAN_ATTR_KEYS: ReadonlySet<string> = (() => {
const set = new Set<string>();
for (const [tag, schema] of Object.entries(html5)) {
const attrs = schema?.attributes ?? {};
for (const [name, meta] of Object.entries(attrs)) {
if (meta && (meta as { boolean?: boolean }).boolean === true) {
set.add(`${tag}/${name}`);
}
}
}
return set;
})();
// Same authoritative element-name list as eslint-plugin-ember's
// `isNativeElement` util (lib/utils/is-native-element.js).
const NATIVE_TAGS: ReadonlySet<string> = new Set([
...htmlTags,
...svgTags,
...mathmlTagNames,
]);
// HTML5 void elements (no closing tag, no children). Used to choose
// between in-place tag rename (void: keeps parent attrs visible) and
// open+close-pair emission (non-void: needs explicit content).
// Derived from html-validate's HTML5 metadata so we follow its
// `void: true` flag rather than maintaining a parallel list.
const VOID_ELEMENTS: ReadonlySet<string> = new Set(
Object.entries(html5Schema as MetaDataTable)
.filter(([, meta]) => (meta as { void?: boolean })?.void === true)
.map(([tag]) => tag),
);
function isBooleanAttr(tagName: string, attrName: string): boolean {
return BOOLEAN_ATTR_KEYS.has(`${tagName}/${attrName}`) || BOOLEAN_ATTR_KEYS.has(`*/${attrName}`);
}
// Returns the html-validate enum constraint for an attribute on a given
// element, or null when the attribute has no enum or isn't in the schema.
// Filters out regex-string entries (which html-validate uses for numerics
// like maxlength) — we only handle pure-string enums for branch validation.
function attrEnumFor(tagName: string, attrName: string): string[] | null {
const schemas = [html5[tagName], html5['*']];
for (const schema of schemas) {
const meta = schema?.attributes?.[attrName] as { enum?: unknown[] } | undefined;
if (meta && Array.isArray(meta.enum)) {
const strings = meta.enum.filter(
(v): v is string => typeof v === 'string' && !v.startsWith('/'),
);
if (strings.length > 0) {
return strings;
}
}
}
return null;
}
// For a string-literal-union from Glint (e.g. ['text','select','number','date']),
// against an html-validate enum (e.g. valid <input type> values), return the
// first union member NOT in the enum — that's the bug we surface. If all
// values are valid, return the first (status quo).
//
// Example: `<input type={{@x}}>` where @x: 'text'|'select' → enum has
// 'text' but not 'select' → emit 'select' so attribute-allowed-values fires.
function pickRevealingValue(unionValues: string[], tagName: string, attrName: string): string {
const fallback = unionValues[0] ?? '';
const enumValues = attrEnumFor(tagName, attrName);
if (!enumValues) {
return fallback;
}
const enumSet = new Set(enumValues.map((v) => v.toLowerCase()));
for (const value of unionValues) {
if (!enumSet.has(value.toLowerCase())) {
return value;
}
}
return fallback;
}
function isNativeTag(tag: string): boolean {
return NATIVE_TAGS.has(tag);
}
// Predicate for "this ElementNode is a Glimmer component invocation
// the resolver should attempt to handle" — covers PascalCase
// (`<HdsButton>`), dotted (`<X.Y>`, `<o.Section>`), `<this.X>`, and
// let-element bindings. Excludes:
// - Known HTML5 tags (`<div>`, `<span>`, …) — pure native elements.
// - Named-block slots (`<:body>`, `<:header>`) — Glimmer slot syntax
// that's a placeholder for yielded content, not a component.
// - `@`-prefixed tags (`<@foo>`) — arg-bound invocations; we can't
// name-resolve them and Glimmer normalizes them via consumerArgs
// flow, not the resolver.
// Mirrors Glimmer's internal `isComponent` predicate at
// @glimmer/syntax/lib/v2/normalize.ts:818 (not exported); the
// ember-eslint-parser carries its own local copy at src/parser/
// transforms.js:76 for the same reason.
function isComponentTag(tag: string): boolean {
if (!tag) return false;
if (tag.startsWith(':') || tag.startsWith('@')) return false;
return !isNativeTag(tag);
}
// ---------------------------------------------------------------------------
// AST predicates and small utilities.
// ---------------------------------------------------------------------------
function isGlimmerOnlyAttr(name: string | undefined | null): boolean {
if (!name) return false;
return name.startsWith('@') || name === '...attributes' || name === 'as' || name.startsWith('|');
}
type Range = [number, number];
interface NodeWithLoc {
loc: AST.SourceLocation;
}
function rangeOf(node: NodeWithLoc): Range {
return [startOffset(node), endOffset(node)];
}
// True when any of the element's children is a Glimmer dynamic construct
// OR a non-native component invocation (PascalCase / dotted-path).
// Used to flag the element for `processElement`'s DynamicValue text content
// — even mixed-content cases (e.g. `<button>{{t 'Save'}} <kbd>⌘S</kbd></button>`)
// where a real label is dynamic but other static markup is also present;
// without the marker, rules like `text-content` may treat the static portion
// as the accessible name and miss issues with the dynamic portion.
//
// Component-child case: `<a><FormattedText @value={{x}} /></a>` — the
// component renders text at runtime, but our transparent substitution
// blanks the component's tags entirely, leaving the anchor empty in the
// output we hand html-validate. Without flagging the parent as dynamic,
// `wcag/h30` / `text-content` / `empty-heading` FP-fire. Trade-off: we
// also silence those rules when the component genuinely renders no text
// (icon-only via component, e.g. `<a><MyIcon /></a>`) — but icon-only
// patterns are usually native (`<svg>`, `<i>`) and still get caught.
function elementHasDynamicContent(node: AST.ElementNode): boolean {
const children = node.children ?? [];
for (const child of children) {
if (
child.type === 'MustacheStatement' ||
child.type === 'BlockStatement' ||
child.type === 'MustacheCommentStatement'
) {
return true;
}
if (child.type === 'ElementNode' && !isNativeTag(child.tag)) {
return true;
}
}
return false;
}
// Recursively walk a Glimmer subtree (Block / Element children) looking for
// a submit-style form control: `<button type='submit'>` or `<input
// type='submit'|'image'>`. Used by the BlockStatement handler to decide
// which branch of `{{#if}}/{{else}}` to emit when one branch is the
// "submitting" branch — single-branch emission would otherwise hide the
// submit button and FP-fire `wcag/h32`.
function blockHasSubmitButton(programOrInverse: AST.Block | null | undefined): boolean {
if (!programOrInverse?.body) return false;
for (const child of programOrInverse.body) {
if (nodeHasSubmitButton(child)) return true;
}
return false;
}
function nodeHasSubmitButton(node: AST.Statement | AST.TopLevelStatement): boolean {
if (node.type === 'ElementNode') {
if (node.tag === 'button' || node.tag === 'input') {
for (const attr of node.attributes ?? []) {
if (attr.name === 'type' && attr.value.type === 'TextNode') {
const v = attr.value.chars;
if (v === 'submit' || v === 'image') return true;
}
}
}
for (const child of node.children ?? []) {
if (nodeHasSubmitButton(child)) return true;
}
} else if (node.type === 'BlockStatement') {
if (blockHasSubmitButton(node.program)) return true;
if (node.inverse && blockHasSubmitButton(node.inverse)) return true;
}
return false;
}
// Returns the literal value if `node` is a string literal whose contents are
// safe to embed into our blanked HTML output (no chars that would alter HTML
// structure or shift columns).
function safeLiteralString(node: AST.Expression | undefined): string | null {
if (!node || node.type !== 'StringLiteral' || typeof node.value !== 'string') {
return null;
}
if (/[<>&"'\\\n\r]/u.test(node.value)) {
return null;
}
return node.value;
}
interface AttrCtx {
attrTagName?: string;
attrName?: string;
}
// Returns the static string a MustacheStatement would render to, or null
// when we can't resolve. Handles:
// `{{t 'Key'}}` → 'Key' (ember-intl translation key as placeholder)
// `{{if cond 'a' 'b'}}` → 'a' (pick the truthy branch when both literals)
// `{{NAME}}` → lookup NAME in `scope` (top-level string consts
// extracted from the .gts JS portion).
// Glint type lookup → string-literal or string-literal-union from
// component Signature. For unions, picks a value
// that exposes the bug — see pickRevealingValue.
function tryStaticText(
node: AST.MustacheStatement,
scope: ReadonlyMap<string, string> | undefined,
glintTypeMap: ReadonlyMap<string, AttrTypeInfo> | undefined | null,
attrCtx?: AttrCtx,
): string | null {
// Glint type lookup: when @glint/ember-tsc resolves the mustache to a
// string-literal type (or string-literal union), use the literal as the
// static-text substitute. For unions in attribute positions where the
// attribute has an html-validate enum, pick a union member NOT in the
// enum (if any) so the user's union-type definition surfaces as a real
// attribute-allowed-values violation. Otherwise pick the first branch.
if (glintTypeMap && node.loc.start) {
const key = `${node.loc.start.line}:${node.loc.start.column}`;
const typeInfo = glintTypeMap.get(key);
if (
typeInfo &&
(typeInfo.kind === 'string-literal' || typeInfo.kind === 'string-literal-union')
) {
let value: string | undefined = typeInfo.values[0];
if (
typeInfo.kind === 'string-literal-union' &&
attrCtx?.attrTagName &&
attrCtx.attrName
) {
value = pickRevealingValue(typeInfo.values, attrCtx.attrTagName, attrCtx.attrName);
}
if (typeof value === 'string' && !/[<>&"'\\\n\r]/u.test(value)) {
return value;
}
}
}
const path = node.path;
if (
!path ||
path.type !== 'PathExpression' ||
(path.head.type !== 'VarHead' && path.head.type !== 'ThisHead')
) {
return null;
}
const helper = path.original;
// Bare reference — no params, no hash. Resolve against `scope`.
// The map is keyed by the path's `original` text:
// - `{{NAME}}` → scope.get('NAME') (top-level const)
// - `{{this.field}}` → scope.get('this.field') (class field)
if (
scope &&
(!node.params || node.params.length === 0) &&
(!node.hash || !node.hash.pairs || node.hash.pairs.length === 0)
) {
const value = scope.get(helper);
if (value !== undefined && !/[<>&"'\\\n\r]/u.test(value)) {
return value;
}
}
// Helper-form lookups (`{{t 'Key'}}` / `{{if cond 'a' 'b'}}`) are only
// meaningful for VarHead; `{{this.t 'Key'}}` would be a method call,
// not the t-helper.
if (path.head.type === 'VarHead') {
if (helper === 't') {
return safeLiteralString(node.params[0]);
}
if (helper === 'if') {
const truthy = safeLiteralString(node.params[1]);
if (truthy !== null) return truthy;
return safeLiteralString(node.params[2]);
}
}
return null;
}
// Position-finding helpers. We re-scan the source string rather than rely
// solely on AST locs because the AST doesn't directly expose the "open tag
// end" / "close tag start" offsets we need for byte-accurate substitution.
//
// Skip past:
// - Quoted strings (`"..."` and `'...'`)
// - Mustache pairs (`{{...}}`, including `{{!-- --}}` and `{{! }}`)
// so a `>` character inside any of those doesn't false-trigger.
function findOpenTagEnd(content: string, openStart: number): number {
for (let i = openStart + 1; i < content.length; i++) {
const ch = content[i];
if (ch === '>') {
return i;
}
if (ch === '"' || ch === "'") {
const close = content.indexOf(ch, i + 1);
if (close < 0) return -1;
i = close;
continue;
}
if (ch === '{' && content[i + 1] === '{') {
// Skip past matching `}}`. Glimmer mustaches don't nest at the
// top level here (we're inside an HTML element open tag, not
// inside another mustache), so a flat `indexOf` is fine. Long-
// form comments `{{!-- ... --}}` end with `--}}` which still
// ends in `}}`, so the same scan works.
const close = content.indexOf('}}', i + 2);
if (close < 0) return -1;
i = close + 1;
continue;
}
}
return -1;
}
function findCloseTagStart(content: string, elementEnd: number): number {
for (let i = elementEnd - 1; i >= 0; i--) {
if (content[i] === '<') {
return i;
}
}
return -1;
}
// ---------------------------------------------------------------------------
// Per-element handlers. Each takes the AST node + a mutable `ctx` object
// that accumulates the rewrite plan (blank ranges, renames, etc.).
// ---------------------------------------------------------------------------
type BranchChoice = 'program' | 'inverse';
interface Context {
content: string;
scope: ReadonlyMap<string, string> | undefined;
glintTypeMap: ReadonlyMap<string, AttrTypeInfo> | null | undefined;
glintComponentTagMap: ReadonlyMap<string, string> | null | undefined;
glintComponentAttrMap: ReadonlyMap<string, ComponentAttrs> | null | undefined;
blankRanges: Range[];
renames: Array<[number, number, string]>;
fullyBlankedRanges: Range[];
dynamicContentOffsets: number[];
// Per-offset attribute injections, applied at parse-time by
// transform.ts's `processElement` hook via `setAttribute`. Each
// entry pairs an attribute name with either a literal value or
// null (inject the DynamicValue placeholder).
//
// Used when the component-substituted source can't carry an
// attribute through a narrow Glimmer-attr slot — e.g.:
// - `<img>` rooted in a component template: `src`/`alt` need to
// be present so `wcag/h37` and required-attr rules don't FP-fire.
// - `<a>` rooted in a chain: `href` must be present so
// `attribute-misuse` (target/rel) doesn't FP-fire on an
// anchor-without-href.
// - `<input>`/`<button>`: `type` must carry a value (literal when
// the chain pinned one, else DynamicValue) so
// `no-implicit-input-type` and `attribute-allowed-values`
// don't fire on a substituted void element.
//
// Replaces four separate `*SplatOffsets` channels.
attrInjections: Map<number, AttrInjection[]>;
effectiveComponentAttrMap?: Map<string, ComponentAttrs>;
// When set, `handleBlockStatement` uses the selection from this map
// (keyed by the BlockStatement's source-start offset) instead of the
// default form-submit-aware heuristic. Drives `blankTemplateContentMultipass`,
// which enumerates branch combinations to validate each independently.
branchSelections?: ReadonlyMap<number, BranchChoice>;
inFullyBlankedRange(offset: number): boolean;
}
// Default fallback for component invocations without Glint resolution
// (or as a fallback when source is too short for native substitution).
//
// Blank the open and close tags ENTIRELY — children float to the actual
// parent for content-model checks. This is the same treatment Glint uses
// when `Signature['Element']` resolves to `unknown`/`any`.
//
// Example: `<table><TheadComponent><tr>...</tr></TheadComponent></table>`
// becomes `<table> <tr>...</tr> </table>`
// — html-validate sees `<tr>` as a direct child of `<table>` (which IS
// allowed), so `element-permitted-content` doesn't fire.
//
// Trade-off: we lose content-model verification ON the component itself
// (we can't say "this component renders block content; it shouldn't be
// inside a phrasing-only parent" without Glint type info). The previous
// `<x-c>` placeholder approach also couldn't do that — and additionally
// FP-fired in strict-content parents like `<table>` / `<ul>` / `<dl>` /
// `<select>` / `<menu>` because `<x-c>` itself isn't an allowed child
// there. Transparent treatment avoids those FPs at the cost of some
// detection power for clearly-misnested components.
function neutralizeComponent(node: AST.ElementNode, ctx: Context): void {
blankComponentTagsTransparent(node, ctx);
}
// Attempt Glint-driven component substitution. Returns the resolved native
// tag name when handled (caller should treat the element as a native tag
// for attribute processing) or null when the caller should fall back to
// neutralizeComponent. Three cases based on what Glint resolves for the
// component's `Signature['Element']`:
//
// 1. Known DOM tag (e.g. HTMLButtonElement → 'button') — rename in place
// (block form) or emit `<RESOLVED type=' '>...</RESOLVED>` (self-
// closing form, FP-fix for empty-element rules).
// 2. 'transparent' (Element === unknown / any) — yields-only / no
// Element declared. Blank open/close tags, keep children. They float
// into the parent's content model.
// 3. Anything else — caller falls back to neutralizeComponent (transparent).
function handleGlintSubstitution(node: AST.ElementNode, ctx: Context): string | null {
if (isNativeTag(node.tag) || !node.loc.start) {
return null;
}
const key = `${node.loc.start.line}:${node.loc.start.column}`;
// Glint-driven resolution takes precedence (per-invocation accurate).
// Fall back to the built-in Ember component map for canonical
// components Glint either can't see (`.hbs` runs) or didn't surface
// (running without `--glint`). The built-ins also seed `attrCtx` so
// splatted-root attribute injection works for them without a real
// file walk (we ship the metadata). Glint can return a tag-only
// entry for canonical components (e.g. `LinkTo` resolved via
// `@ember/routing` types — no project `.gts` to read for the
// splatted root); in that case we still need the builtin's
// attrs as long as its canonical tag matches what Glint resolved.
//
// Trade-off: a project that shadows a built-in name (a user-defined
// component literally called `LinkTo` resolving to `<a>` but not
// necessarily rendering `href`) gets the canonical builtin's
// attrs applied here, masking any anchor/aria errors on the
// shadow. This is the same FN risk the `.hbs` path has always
// carried (no Glint there, so the built-in wins by name); we
// accept it for consistency rather than leave canonical `<LinkTo>`
// in `.gts` permanently FP-flagged.
let resolved: string | undefined = ctx.glintComponentTagMap?.get(key);
let attrCtx: ComponentAttrs | undefined = ctx.glintComponentAttrMap?.get(key);
if (!resolved || !attrCtx) {
const builtin = lookupBuiltinComponent(node.tag);
if (builtin) {
if (!resolved) resolved = builtin.tag;
if (!attrCtx && resolved === builtin.tag) attrCtx = builtin;
}
}
if (resolved === 'transparent') {
blankComponentTagsTransparent(node, ctx);
return 'transparent';
}
if (typeof resolved !== 'string' || resolved.length > node.tag.length) {
return null;
}
// Stash the resolved attrCtx in ctx for later lookup helpers
// (lookupComponentAttr) — they currently key off
// ctx.glintComponentAttrMap, but we want builtin-derived attrCtx to
// be visible too. Rather than threading a parameter through several
// call sites, write the entry into a per-call effective map.
if (attrCtx && !ctx.glintComponentAttrMap?.has(key)) {
if (!ctx.effectiveComponentAttrMap) {
ctx.effectiveComponentAttrMap = new Map(ctx.glintComponentAttrMap ?? []);
}
ctx.effectiveComponentAttrMap.set(key, attrCtx);
}
if (node.selfClosing) {
// Two paths depending on whether the resolved native is void:
// - Void natives (input, img, br, hr, …): in-place tag rename keeps
// the parent's non-Glimmer attrs (id, class, name, data-*) visible
// for validation. Glimmer-only attrs (`@arg`, modifiers,
// `...attributes`) are blanked. For <input>, we additionally
// inject `type=' '` into a Glimmer-attr blank area so
// `no-implicit-input-type` doesn't FP-fire (the actual `type`
// comes from the component's internal template, which we can't
// see).
// - Non-void natives (button, span, …): open+close-pair emission.
// Loses parent attrs but supplies content via DynamicValue text
// (registered in dynamicContentOffsets) and `type=' '` for button.
if (VOID_ELEMENTS.has(resolved)) {
return substituteSelfClosingVoidComponent(node, ctx, resolved, attrCtx) ? resolved : null;
}
return substituteSelfClosingComponent(node, ctx, resolved, attrCtx) ? resolved : null;
}
// Block-form: rename open and close tags in place. Children stay visible
// to html-validate.
//
// Guard against block-form invocations whose resolved tag is VOID
// (e.g. `<G.CheckboxField>...</G.CheckboxField>` where the curried
// sub-component renders `<input ...attributes>`). Substituting open
// and close tags would produce `<input>...</input>` — invalid HTML
// (void elements can't have content). Bail to transparent-blanking
// instead so the children float to the actual parent's content
// model, same as an unresolved component. Inner-content semantics
// (e.g. validation against the input's content model) are
// unavailable for these substitutions; that's the same trade-off
// as the non-substituted path. The void's required attributes
// (e.g. `<input type=...>`) get checked via the addon's own
// template lint, not the consumer's substitution.
if (VOID_ELEMENTS.has(resolved)) {
blankComponentTagsTransparent(node, ctx);
return 'transparent';
}
const elementStart = startOffset(node);
const elementEnd = endOffset(node);
const tagStart = elementStart + 1;
const padding = ' '.repeat(node.tag.length - resolved.length);
ctx.renames.push([tagStart, tagStart + node.tag.length, resolved + padding]);
const closeTagStart = elementEnd - node.tag.length - 1;
ctx.renames.push([closeTagStart, closeTagStart + node.tag.length, resolved + padding]);
// Erase any `as |…|` block-param clause from the renamed open tag.
// Block params are a Glimmer-side binding for yielded content — they
// never appear as attributes in the rendered DOM. Without this blank,
// the in-place rename leaves `as |item|` in the open tag and html-
// validate's parser treats `|item|` as an attribute, firing `attr-case`
// (and downstream rules cascade).
//
// Take the LAST regex match: per Glimmer syntax the block-params clause
// is always the rightmost thing before `>`, so the last match is
// unambiguously it — defends against the (rare) case where an attribute
// value happens to contain a literal `as |x|`.
if (node.blockParams.length > 0) {
const openTagEnd = findOpenTagEnd(ctx.content, elementStart);
if (openTagEnd >= 0) {
const openTagText = ctx.content.slice(elementStart, openTagEnd + 1);
const re = /\bas\s+\|[^|]*\|/g;
let last: RegExpExecArray | null = null;
let m: RegExpExecArray | null;
while ((m = re.exec(openTagText)) !== null) last = m;
if (last) {
ctx.blankRanges.push([elementStart + last.index, elementStart + last.index + last[0].length]);
}
}
}
// Yield-ancestor substitution: the chosen tag is an inner element of
// the wrapper's own template (e.g. `<nav><ol>{{yield}}</ol></nav>`
// resolved as `<ol>` to put yielded `<li>` children under the correct
// structural parent). At runtime, the consumer's attributes splat onto
// the outer wrapper via `...attributes`, NOT onto the inner — so the
// in-place tag rename produces `<ol aria-label='X'>` even though
// `aria-label='X'` lands on `<nav>` at runtime. Strip `aria-*` from
// the substituted open tag so html-validate doesn't fire
// aria-label-misuse / aria-labelledby-misuse against the wrong tag.
// Scoped to `aria-*` because that's where the observed FP class lives
// (ARIA-in-HTML rules differ per host element); other consumer attrs
// (id, class, data-*, role) generally validate the same against
// either tag.
if (attrCtx?.fromYieldAncestor) {
for (const attr of node.attributes ?? []) {
if (!attr.name.startsWith('aria-')) continue;
const r: [number, number] = [startOffset(attr), endOffset(attr)];
ctx.blankRanges.push(r);
// Mark fully blanked so the later `emitAttribute` loop skips this
// attribute. Without this, ConcatStatement/MustacheStatement value
// rewrites would inject `"` quotes back into the blanked region.
ctx.fullyBlankedRanges.push(r);
}
}
// Inject the resolved component's static attrs into Glimmer-attr blank
// regions in the open tag (mirrors the self-closing input-type
// injection). Without this, e.g. <LinkTo>...</LinkTo> resolves to a
// bare <a> with no href — html-validate then treats it as
// non-interactive and FP-fires `aria-label-misuse` and other
// role-dependent rules. The same mechanism gives Glint-resolved
// components a way to surface canonical attrs (e.g. a custom
// <SubmitButton> that always renders <button type='submit'>).
if (attrCtx && Object.keys(attrCtx.attrs).length > 0) {
tryInjectComponentAttrs(node, ctx, resolved, attrCtx.attrs);
}
// Block-form `<button>` substitutions (typically curried hash-yielded
// components like `<RT.Toggle>...</RT.Toggle>` resolved to
// `<button>`) reach here without a chain `attrCtx` populated. The
// in-place tag rename produces `<button ...>` with no `type`,
// FP-firing `no-implicit-button-type` even though the addon's
// template literally writes `<button type="button" ...attributes>`.
// Register the offset so the `processElement` hook calls
// setAttribute('type', DynamicValue) at parse time. Skipped when
// the consumer wrote `type=` themselves.
if (resolved === 'button') {
const present = new Set((node.attributes ?? []).map((a) => a.name));
if (!present.has('type') && !hasAttrInjection(ctx.attrInjections, startOffset(node), 'type')) {
addAttrInjection(ctx.attrInjections, startOffset(node), 'type', null);
}
}
return resolved;
}
// Self-closing component substituted to a void native (input, img, br,
// …). In-place tag-name rename — the existing attribute-processing loop
// in handleElementNode then preserves non-Glimmer attrs (id, class,
// name, …) and blanks Glimmer-only attrs. For <input>, additionally
// inject `type=' '` into a Glimmer-attr blank area so
// `no-implicit-input-type` doesn't FP-fire.
//
// Returns true on successful substitution; false when source is too
// short to fit the resolved tag name (caller falls back to transparent
// neutralization).
function substituteSelfClosingVoidComponent(
node: AST.ElementNode,
ctx: Context,
resolved: string,
attrCtx?: ComponentAttrs | null,
): boolean {
const elementStart = startOffset(node);
// Minimum required: room for `<RESOLVED` (tag-name rename only).
if (node.tag.length < resolved.length) return false;
const tagStart = elementStart + 1;
const padding = ' '.repeat(node.tag.length - resolved.length);
ctx.renames.push([tagStart, tagStart + node.tag.length, resolved + padding]);
// For <input>, find a Glimmer-attr blank area to inject `type=' '`.
if (resolved === 'input') {
tryInjectInputType(node, ctx);
}
// For <img> substitutions where the addon's splatted-root binds `src`
// / `alt` (literal OR mustache-driven), record the offset so the
// `processElement` hook synthesizes those attrs at parse time. The
// source-side `tryInjectComponentAttrs` path can't always fit the
// placeholders in the consumer's narrow Glimmer-attr slots
// (`@src="…"` is typically narrower than `src=' '` plus other
// injected attrs combined). Skipped when the consumer wrote `src=` /
// `alt=` explicitly.
if (resolved === 'img' && attrCtx) {
tryInjectImgRequiredAttrsViaHook(node, ctx, attrCtx);
}
return true;
}
// Variant of tryInjectImgRequiredAttrs scoped to component-substituted
// <img>: the addon's splatted-root template is recorded as
// `attrCtx.attrs`. If `src`/`alt` are projected there (via literal OR
// mustache) and the consumer didn't override, push the consumer's
// element offset so the processElement hook runs setAttribute.
function tryInjectImgRequiredAttrsViaHook(
node: AST.ElementNode,
ctx: Context,
attrCtx: ComponentAttrs,
): void {
const present = new Set((node.attributes ?? []).map((a) => a.name));
// `src` / `alt` ATTRS on the consumer (not @src / @alt args) take
// precedence — the consumer chose to write the literal, html-validate
// should validate it. Skip per-attr in that case.
//
// Per-attr precision matters here: a component whose template
// binds `src={{this.src}}` but not `alt` registers only `src` in
// `attrInjections` — `wcag/h37` (missing alt) still fires correctly
// when the consumer forgets to pass an alt.
const offset = startOffset(node);
if (!present.has('src') && attrCtx.attrs['src'] !== undefined) {
addAttrInjection(ctx.attrInjections, offset, 'src', null);
}
if (!present.has('alt') && attrCtx.attrs['alt'] !== undefined) {
addAttrInjection(ctx.attrInjections, offset, 'alt', null);
}
}
// Find a Glimmer-only attribute (`@arg`, modifier, `...attributes`)
// whose source span has room for `type='X'` (no newlines), and rewrite
// the first chars of that span. The remainder of the attribute's range
// stays in `blankRanges` (added by emitAttribute), so the area outside
// our injection becomes spaces.
//
// Two strategies for the value:
// 1. If `glintComponentAttrMap` has a literal `type` for this
// component (the splatted-root has a literal `type='range'`-style
// attribute), inject the actual value: `<input type='range' ...`.
// Lets html-validate validate the value against the enum.
// 2. Otherwise, inject `type=' '` (3-space placeholder). The
// `processAttribute` hook converts the whitespace value to
// DynamicValue (its >=3-char threshold), so `attribute-allowed-
// values` doesn't fire on the placeholder.
//
// No-op when no suitable attr area is found — `no-implicit-input-type`
// will still fire in that case, but the user can silence per-site.
// Symmetric to tryInjectInputType but for void natives whose required attrs
// can be supplied by the consumer via `...attributes`. Without injection,
// `<img ...attributes>` blanks to an attribute-less `<img>` and html-validate
// FP-fires `element-required-attributes` (src) and `wcag/h37` (alt) even
// though both come from the splat at runtime.
//
// We don't rewrite the source — the minimal `...attributes` slot is 13
// chars and html-validate accepts no two-attr form that fits (bare
// `src alt` triggers `attribute-allowed-values`-missing-value, empty
// quoted `src=''` triggers `attribute-allowed-values`-invalid-value,
// and the wide whitespace form `src=' ' alt=' '` is 19+ chars).
//
// Instead, record the element's start offset; the transformer's
// `processElement` hook reads this list and calls `setAttribute` with
// a DynamicValue at parse time, sidestepping source-side slot width
// entirely. Skipped when the consumer already wrote `src=` / `alt=`
// explicitly (no FP to suppress).
//
// Currently scoped to <img>; the same shape applies to
// <source>/<track>/<area>/<iframe> if real-world FPs surface there.
function tryInjectImgRequiredAttrs(node: AST.ElementNode, ctx: Context): void {
const hasSplat = (node.attributes ?? []).some((a) => a.name === '...attributes');
if (!hasSplat) return;
const present = new Set((node.attributes ?? []).map((a) => a.name));
// Native `<img ...attributes>`: the splat is the contract — whatever
// the parent passes flows through. We don't know statically which
// attrs come through, so be permissive: register both `src` and `alt`
// for hook-injection so downstream rules see "attribute present,
// value unknowable" for whichever the consumer didn't write
// explicitly. Real-bug detection happens at the consumer of THIS
// component (where they may forget to pass src/alt).
const offset = startOffset(node);
if (!present.has('src')) addAttrInjection(ctx.attrInjections, offset, 'src', null);
if (!present.has('alt')) addAttrInjection(ctx.attrInjections, offset, 'alt', null);
}
function tryInjectInputType(node: AST.ElementNode, ctx: Context): void {
const literalType = lookupComponentAttr(node, ctx, 'type');
// Build the injected text. Prefer a literal value when known and
// safe (no embedded quotes / HTML-altering chars).
const valueLiteral = isLiteralSafeForAttr(literalType) ? literalType : null;
const TYPE_TEXT =
valueLiteral !== null ? `type='${valueLiteral}'` : `type='${DYNAMIC_VALUE_PLACEHOLDER}'`;
const candidates: Range[] = [];
for (const attr of node.attributes ?? []) {
if (isGlimmerOnlyAttr(attr.name)) {
candidates.push([startOffset(attr), endOffset(attr)]);
}
}
for (const m of node.modifiers ?? []) {
candidates.push([startOffset(m), endOffset(m)]);
}
for (const [s, e] of candidates) {
if (e - s < TYPE_TEXT.length) continue;
const slice = ctx.content.slice(s, s + TYPE_TEXT.length);
if (/[\n\r]/.test(slice)) continue;
ctx.renames.push([s, s + TYPE_TEXT.length, TYPE_TEXT]);
return;
}
// No suitable Glimmer-attr slot — register the consumer's element
// offset so the `processElement` hook synthesizes `type` at parse
// time. Mirrors the imgSplat / aSplatHref hook-time fallback.
// Registering the offset is unconditional once source-side fitting
// failed: even when `lookupComponentAttr` returned null, injecting
// a DynamicValue clears `no-implicit-input-type` and leaves
// `attribute-allowed-values` to the parent invocation that owns
// the value (the consumer can still pass an explicit `type=` to
// override the projection).
addAttrInjection(ctx.attrInjections, startOffset(node), 'type', valueLiteral);
}
// Generalized attr injection for block-form component substitution.
// For each `(name, value)` in `attrs`, find a Glimmer-only attribute
// or modifier blank region in the open tag with enough room for
// `name='value'` (no newlines), and rewrite it. Each region is used
// by at most one injected attr.
//
// Used today for `<LinkTo>...</LinkTo>` to surface its computed
// `href` so html-validate sees an interactive `<a>`. Generalized
// because the same mechanism applies to any block-form component
// substitution where a Glint-resolved or builtin entry has static
// attrs (e.g. `<SubmitButton>` that always renders
// `<button type='submit'>`).
//
// Value sourcing per attr: prefer a literal looked up via
// `lookupComponentAttr` (matches the self-closing input-type path),
// fall back to a 3-space placeholder so the `processAttribute` hook
// converts the value to `DynamicValue`. The placeholder length must
// be >= 3 to clear that hook's threshold.
function tryInjectComponentAttrs(
node: AST.ElementNode,
ctx: Context,
resolvedTag: string,
attrs: Readonly<Record<string, string>>,
): void {
const candidates: Range[] = [];
// Names already present as non-Glimmer attrs on the invocation; skip
// injecting these so the substituted tag doesn't carry duplicates
// (e.g. `<SubmitButton type='button'>` against a splatted root with
// `type='submit'` would otherwise emit two `type` attrs and trip
// html-validate's `no-dup-attr`).
//
// Limitation: `componentAttrMap` records the literal but not the
// position of `...attributes` in the splatted root. Two layouts
// are common in Glimmer:
// 1. `<button class='primary' type='submit' ...attributes>`
// — caller-wins. Conventional, dominant in real code.
// 2. `<button ...attributes type='submit'>`
// — component-wins. Forces a literal regardless of caller.
// We default to layout (1): drop the canonical attr when the
// caller supplies the same name. Layout (2) would render the
// component's value at runtime, which we then misrepresent as
// the caller's value — but emitting both attrs doesn't actually
// help: HTML5 parsing also takes the first one, so the validator's
// DOM view is identical to the caller-wins path. We just avoid
// the spurious `no-dup-attr` noise for the dominant layout.
const existingNonGlimmer = new Set<string>();
for (const attr of node.attributes ?? []) {
if (isGlimmerOnlyAttr(attr.name)) {
candidates.push([startOffset(attr), endOffset(attr)]);
} else {
existingNonGlimmer.add(attr.name);
}
}
for (const m of node.modifiers ?? []) {
candidates.push([startOffset(m), endOffset(m)]);
}
// Build the injection plan first, then place longer texts before
// shorter ones. Otherwise a short attr visited first can claim the
// only candidate slot wide enough for a longer attr, silently
// dropping it. Per-attr emission rules (boolean → presence-only,
// safe literal → embedded, otherwise → DynamicValue placeholder)
// are documented in the .map below.
const plan = Object.keys(attrs)
.filter((name) => !existingNonGlimmer.has(name))
.map((attrName) => {
// Boolean attrs (`disabled`, `required`, `selected`, …) emit
// presence-only regardless of the recorded value. Per HTML5 any
// value (including `''`, `'disabled'`, the DynamicValue
// placeholder, etc.) is equivalent to "true"; emitting
// `name='value'` would unnecessarily fire `attribute-boolean-style`.
if (isBooleanAttr(resolvedTag, attrName)) return { name: attrName, text: attrName };
const literal = lookupComponentAttr(node, ctx, attrName);
if (isLiteralSafeForAttr(literal))
return { name: attrName, text: `${attrName}='${literal}'` };
return { name: attrName, text: `${attrName}='${DYNAMIC_VALUE_PLACEHOLDER}'` };
});
// Two-phase placement:
// Phase 1: any attr in `ANCHOR_ATTRS` (currently just `role`).
// These authorize aria-* attrs on otherwise-incompatible
// elements (e.g. `<div role="dialog" aria-labelledby="…">`
// is valid; `<div aria-labelledby="…">` alone trips
// `aria-label-misuse`). Place anchors first so dependents
// have authorization context; if an anchor doesn't fit,
// drop its dependents from phase 2 to avoid landing the
// dependent without its anchor.
// Phase 2: everything else, longest-first (the original
// behavior — longer attrs need wider slots and would
// otherwise be starved by shorter ones grabbing slots).
//
// Rationale: aria-labelledby on `<HdsAppSideNav>`-style
// wrappers (whose splatted root is `<div role={{…}}
// aria-labelledby={{…}}>`) was getting injected without role
// because role was shorter and lost the slot-fitting race —
// FP-firing `aria-label-misuse` ("aria-labelledby cannot be
// used on this element").
// Anchor → dependents: when an aria-* injection lands on a
// substituted element WITHOUT its anchoring `role`, html-validate
// rules (`aria-label-misuse`, the metadata-driven role-property
// permission checks, etc.) fire because the element-without-role
// doesn't accept the aria-* attr.
//
// Drift-proof: rather than enumerate which aria-* are role-
// dependent (the list lives inside individual html-validate rules
// and may grow over versions), gate the entire `aria-*` prefix on
// the presence of `role` in the same injection. When the addon's
// splatted-root literally writes `<div role="dialog"
// aria-labelledby="…">`, both attrs are recorded; we inject them
// together or not at all.
const ANCHOR_ATTRS = new Set(['role']);
function isAriaDependent(name: string): boolean {
return name.startsWith('aria-');
}
const used = new Set<number>();
const unfitted = new Set<string>();
function placeOne(text: string, name: string): boolean {
for (let i = 0; i < candidates.length; i++) {
if (used.has(i)) continue;
const [s, e] = candidates[i]!;
if (e - s < text.length) continue;
const slice = ctx.content.slice(s, s + text.length);
if (/[\n\r]/.test(slice)) continue;
ctx.renames.push([s, s + text.length, text]);
used.add(i);
return true;
}
unfitted.add(name);
return false;
}
// Phase 1: anchors.
const anchorPlan = plan.filter((p) => ANCHOR_ATTRS.has(p.name));
anchorPlan.sort((a, b) => b.text.length - a.text.length);
for (const { text, name } of anchorPlan) placeOne(text, name);
// If `role` didn't fit, drop all aria-dependents from the rest of
// the plan: injecting them on a plain element would fire
// `aria-label-misuse`. Mark them unfitted so downstream channels
// see they were skipped.
const roleFitted = ANCHOR_ATTRS.has('role') && !unfitted.has('role') &&
plan.some((p) => p.name === 'role');
const restPlan = plan.filter((p) => {
if (ANCHOR_ATTRS.has(p.name)) return false;
if (!roleFitted && isAriaDependent(p.name)) {
unfitted.add(p.name);
return false;
}
return true;
});
restPlan.sort((a, b) => b.text.length - a.text.length);
// (legacy single-pass placement for the rest)
for (const { text, name } of restPlan) {
let placed = false;
for (let i = 0; i < candidates.length; i++) {
if (used.has(i)) continue;
const [s, e] = candidates[i]!;
if (e - s < text.length) continue;
const slice = ctx.content.slice(s, s + text.length);
if (/[\n\r]/.test(slice)) continue;
ctx.renames.push([s, s + text.length, text]);
used.add(i);
placed = true;
break;
}
if (!placed) unfitted.add(name);
}
// Hook-time fallback for `<a>`: when the chain records `href` but the
// consumer's narrow Glimmer-attr slots couldn't fit it, the substituted
// `<a target='_blank' >` lacks `href` — html-validate then fires
// `attribute-misuse` ("target requires href"). Register via the
// unified `attrInjections` registry so the `processElement` hook
// calls setAttribute('href', DynamicValue) at parse time (the same
// mechanism the `<img>` src/alt and `<button>` type fallbacks use).
// Scoped to `<a>` because that's where the observed FP class lives;
// extend if other element/attr pairs surface.
if (resolvedTag === 'a' && unfitted.has('href') && !existingNonGlimmer.has('href')) {
addAttrInjection(ctx.attrInjections, startOffset(node), 'href', null);