-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtransform.ts
More file actions
474 lines (456 loc) · 20.6 KB
/
Copy pathtransform.ts
File metadata and controls
474 lines (456 loc) · 20.6 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
import { Preprocessor } from 'content-tag';
import { DynamicValue as DynamicValueESM } from 'html-validate';
import type {
AttributeData,
ProcessAttributeCallback,
ProcessElementCallback,
Source,
SourceHooks,
Transformer,
} from 'html-validate';
import { createRequire } from 'node:module';
import { preprocess } from '@glimmer/syntax';
import {
blankTemplateContent,
blankTemplateContentMultipass,
} from './blank.js';
import { buildResolutionMaps } from './lib/resolver/build-maps.js';
import { isDynamicValuePlaceholder } from './lib/dynamic-value.js';
import { extractAttrTypeMap } from './lib/glint.js';
import { extractStringScope } from './lib/scope.js';
// Cross-realm `DynamicValue` shim.
//
// html-validate is published as a dual package (ESM + CJS) — `import`
// resolves to `dist/esm/index.js` and `require()` to `dist/cjs/index.js`.
// Each build defines its OWN `class DynamicValue { ... }`. When our
// plugin runs in a context that loaded html-validate through the
// opposite build (e.g. the html-validate VS Code extension loads
// html-validate as CJS via `require()`, but our plugin imports it as
// ESM), `text instanceof DynamicValue` checks on the host side return
// false against our ESM-class instance — so `TextNode.isDynamic` is
// false, `classifyNodeText` returns `EMPTY_TEXT` instead of
// `DYNAMIC_TEXT`, and rules like `empty-heading` / `text-content`
// FP-fire on dynamic content the user CAN'T see is empty.
//
// Fix: load BOTH DynamicValue classes (ESM + CJS), and define our own
// marker class. Patch `Symbol.hasInstance` on each html-validate
// class so it returns true for either:
// - The original prototype-based check (genuine DynamicValue
// instances from that realm), OR
// - Any object carrying our marker symbol.
//
// Then the host's `instanceof DynamicValue` check passes regardless of
// which realm loaded html-validate. Trade-off: a one-time mutation of
// the host's DynamicValue class (only adds tolerance, doesn't change
// existing behavior). No runtime cost beyond the patch.
const require = createRequire(import.meta.url);
const { DynamicValue: DynamicValueCJS } = require('html-validate') as {
DynamicValue: typeof DynamicValueESM;
};
const HVE_DYNAMIC = Symbol.for('html-validate-ember.DynamicValue');
class DynamicValue {
expr: string;
[HVE_DYNAMIC] = true as const;
constructor(expr: string) {
this.expr = expr;
}
toString(): string {
return this.expr;
}
}
for (const cls of new Set<typeof DynamicValueESM>([DynamicValueESM, DynamicValueCJS])) {
Object.defineProperty(cls, Symbol.hasInstance, {
configurable: true,
value(instance: unknown) {
if (
instance &&
typeof instance === 'object' &&
(instance as Record<symbol, unknown>)[HVE_DYNAMIC]
) {
return true;
}
return cls.prototype.isPrototypeOf(instance as object);
},
});
}
const preprocessor = new Preprocessor();
// Side-channel from the transformer to `dedupeMultipassReport`. For
// each .gts/.gjs/.hbs file, holds the file-line ranges of templates
// that multipass actually branched on (i.e., yielded >1 `Source`). The
// dedupe step uses these ranges to drop reports from rules that don't
// compose cleanly with branch-by-branch validation — see
// `MULTIPASS_INCOMPATIBLE_RULES` in `lib/multipass-dedupe.ts` —
// scoped to the branched template only, so multi-template files don't
// over-suppress.
//
// Key matches `Source.filename`, which html-validate reflects
// unchanged as `Result.filePath` in the report it builds. Not safe
// for re-entrant validation: the map is module-global and cleared
// imperatively by the transformer (on entry) and by the dedupe (on
// completion). A single-process / single-thread harness is fine; an
// embedder that runs concurrent `validateFile` calls would need to
// rework this.
export const __multipassBranchedRanges = new Map<string, Array<[number, number]>>();
// Build an inline `<!--html-validate-disable …-->` directive to prepend
// to a multipass branched Source. The only rule passed in today is
// `no-unused-disable` — directives load-bearing in one branch
// combination would otherwise be reported "unused" in another. The
// post-report dedupe in `lib/multipass-dedupe.ts` only runs for callers
// that route through `dedupeMultipassReport` (the bundled CLI and
// tests; NOT the html-validate VS Code extension, NOT the standalone
// `html-validate` CLI), so we mirror it inline. The rule name must
// stay in sync with `MULTIPASS_INCOMPATIBLE_RULES` in
// `lib/multipass-dedupe.ts`.
//
// Structural-rule suppressions (wcag/h32, wcag/h63, wcag/h67, wcag/h71,
// element-permitted-content, element-permitted-parent,
// element-required-content) are no longer routed through this directive
// — they land as per-element `el.disableRules(...)` calls via the
// `processElement` hook (see `BlankResult.disablePerElement`).
//
// Bracket-less form is the shortest valid spelling per html-validate's
// `MATCH_DIRECTIVE` regex; no newline so we can compensate with a
// single column-shift on the Source.
function buildDisableDirective(rules: ReadonlyArray<string>): string {
if (rules.length === 0) return '';
// html-validate's directive grammar requires COMMA-separated rule
// names. Space-separated silently disables only the first rule —
// masking suppression in branched templates where the directive
// carries both `no-unused-disable` and a structural-yield rule.
return `<!--html-validate-disable ${rules.join(', ')}-->`;
}
function offsetToLineCol(source: string, offset: number): { line: number; column: number } {
let line = 1;
let column = 1;
for (let i = 0; i < offset; i++) {
if (source.charCodeAt(i) === 10) {
line++;
column = 1;
} else {
column++;
}
}
return { line, column };
}
function makeHooks(
dynamicSet: ReadonlySet<number>,
attrInjections: ReadonlyMap<number, ReadonlyArray<{ attr: string; value: string | null }>>,
disablePerElement: ReadonlyMap<number, ReadonlySet<string>>,
startOffset: number,
): SourceHooks {
const processAttribute: ProcessAttributeCallback = (attr: AttributeData) => {
// Bare-mustache attribute values (`id={{x}}`) reach this hook as
// whitespace-only strings. Two upstream sources produce them:
// 1. `blank.ts` blanks each mustache span in place — the
// resulting whitespace is the same length as the original
// mustache (variable, can be much longer than the sentinel).
// 2. Explicit injections by `blank.ts` and `component-attrs.ts`
// use the literal `DYNAMIC_VALUE_PLACEHOLDER` from
// `lib/dynamic-value.ts` (a fixed-length 3-space string at
// the time of writing).
// `isDynamicValuePlaceholder` accepts both: any whitespace-only
// string of length >= the sentinel's length. This is intentional —
// we want the same DynamicValue conversion for either source so
// rules see "attribute present, value unknowable" regardless of
// how the placeholder was produced.
if (isDynamicValuePlaceholder(attr.value)) {
return [{ ...attr, value: new DynamicValue('') as unknown as DynamicValueESM }];
}
return [attr];
};
const processElement: ProcessElementCallback = function (el) {
const location = (el as unknown as { location?: { offset?: number } }).location;
if (!location || typeof location.offset !== 'number') {
return;
}
// html-validate's location.offset for an element points to the
// tag-name byte (one past `<`), while Glimmer's getStart() points
// to the `<` itself. Adjust by 1.
const templateRelativeOffset = location.offset - startOffset - 1;
if (dynamicSet.has(templateRelativeOffset)) {
(el as unknown as { appendText(value: unknown, location: unknown): void }).appendText(
new DynamicValue(''),
location,
);
}
// Apply attribute injections registered by blank.ts. Each entry
// names an attr and either a literal value or null (= DynamicValue
// placeholder). Attribute-already-present is a no-op so consumer-
// written values always win (the blanker's per-attr precision
// already gates which attrs get registered, but defensive double-
// check guards races where a substitution path didn't propagate).
const injections = attrInjections.get(templateRelativeOffset);
if (injections && injections.length > 0) {
const elWithAttrs = el as unknown as {
tagName?: string;
hasAttribute(name: string): boolean;
setAttribute(
name: string,
value: unknown,
keyLocation: unknown,
valueLocation: unknown,
): void;
};
for (const { attr, value } of injections) {
if (elWithAttrs.hasAttribute(attr)) continue;
const injected = value === null ? new DynamicValue('') : value;
elWithAttrs.setAttribute(attr, injected, location, location);
}
}
// Per-element rule disables. Registered by blank.ts when a
// specific element triggers a Glimmer-opacity FP class (e.g.,
// `<table>` with a cell-loop {{#each}}, `<form>` with a
// yield-bearing body, `<img>` with dynamic title, …). Rule
// listeners (`element:ready`, `tag:end`, `dom:ready`, …) check
// `ruleEnabled(...)` inside their `report()` paths, so this
// disable lands before emission regardless of when the rule
// fires for this element.
const disables = disablePerElement.get(templateRelativeOffset);
if (disables && disables.size > 0) {
(el as unknown as { disableRules(rules: ReadonlyArray<string>): void }).disableRules([
...disables,
]);
}
};
return { processAttribute, processElement };
}
function* transformGlimmer(source: Source): Generator<Source, void, unknown> {
const data = source.data;
const originalData = source.originalData ?? data;
const filename = source.filename ?? '';
// Clear any stale state left by a previous validation of this file
// whose dedupe step never ran (e.g., the file was clean and `run.ts`
// skipped dedupe, or an embedder consumes reports without dedupe).
// Long-running processes (editor LSP) shouldn't accumulate ranges.
__multipassBranchedRanges.delete(filename);
// Classic .hbs template: the file IS the template content. No JS
// portion, no `<template>` extraction, no Glint integration (Glint's
// .hbs flow uses Ember's container resolver — different machinery).
// Components blank transparently (open/close tags removed; children
// float to the actual parent), with built-in <Input>/<Textarea>/<LinkTo>
// mapping to native tags. Static-text resolution covers t-helper /
// if-helper. No top-level scope (no JS).
if (filename.endsWith('.hbs')) {
// Classic-Ember by-name component resolution: parse the template
// once to walk PascalCase tags, look each one up in node_modules
// against the canonical addon component-template paths, and feed
// the resulting tag/attr maps to the blanker. Lets `<EsCard>` /
// `<HdsCard>` / etc. substitute to their actual rendered tag
// (`<li>`, `<div>`, …) instead of transparent-blanking, which
// fixes a major class of `element-permitted-content` FPs in
// classic Ember apps. Glint isn't involved — `.hbs` doesn't go
// through TS.
let classicTagMap: Map<string, string> | null = null;
let classicAttrMap: Parameters<typeof blankTemplateContent>[4] | null = null;
try {
const ast = preprocess(data, { mode: 'codemod' });
const maps = buildResolutionMaps(filename, ast);
classicTagMap = maps.componentTagMap;
classicAttrMap = maps.componentAttrMap;
} catch {
// Parse failure here is non-fatal — `blankTemplateContent`
// re-parses and reports the error. Drop the maps and continue.
}
const result = blankTemplateContent(data, undefined, undefined, classicTagMap, classicAttrMap);
if (result.error) {
process.stderr.write(
`[html-validate-ember] glimmer parse failure on ${filename}: ${result.error.message}\n`,
);
}
if (result.content.length !== data.length) {
process.stderr.write(
`[html-validate-ember] BUG: blanked length ${result.content.length} != original ${data.length}\n`,
);
}
// .hbs files never go through the multipass branched path, so
// there's no `no-unused-disable` directive to inject and no
// structural-rule file-level disables (those are all per-element
// now). The Source's data starts at offset 0 / column 1.
yield {
data: result.content,
filename,
line: 1,
column: 1,
offset: 0,
originalData,
hooks: makeHooks(
new Set(result.dynamicContentOffsets ?? []),
result.attrInjections ?? new Map(),
result.disablePerElement ?? new Map(),
0,
),
};
return;
}
// .gts / .gjs: extract `<template>` blocks via content-tag, blank
// each one, optionally enrich with Glint type info.
const scope = extractStringScope(data, filename);
let glintTypeMap = null;
let glintComponentTagMap: Map<string, string> | null = null;
let glintComponentAttrMap: Parameters<typeof blankTemplateContent>[4] | null = null;
// Glint type extraction is opt-OUT: it's worthwhile in real-world
// use (it lifts the resolver from "what's imported" to "what TS
// says about each invocation", catching @arg literals, Signature
// ['Element'] declarations, polymorphic-tag union picks, etc.),
// and `extractAttrTypeMap` handles a missing `@glint/ember-tsc`
// install gracefully by returning null and letting the canonical-
// resolver fallback below take over. Set `HVE_GLINT=0` (or pass
// `--no-glint` to the bundled runners) to disable.
//
// When Glint extraction runs, `extractAttrTypeMap` returns the same
// component-tag + attr maps the canonical resolver alone would
// produce, PLUS attrTypeMap (string-literal-union narrowing for
// mustache attr values). When it's disabled (or unavailable), we
// still need the tag/attr maps so e.g. `<HdsCard>` substitutes to
// `<li>` and `<AddResource>` substitutes to `<dialog id="…" …>` —
// without them, custom elements stay as-is in the blanked output
// and rules like `no-dup-id`, `element-permitted-content`,
// `prefer-tbody` etc. can't see the rendered DOM. The per-template
// `buildResolutionMaps` call inside the loop below covers that
// fallback path.
if (process.env['HVE_GLINT'] !== '0') {
try {
const result = extractAttrTypeMap(filename, data);
if (result) {
glintTypeMap = result.attrTypeMap;
glintComponentTagMap = result.componentTagMap;
glintComponentAttrMap = result.componentAttrMap;
}
} catch (err) {
process.stderr.write(
`[html-validate-ember] glint type extraction failed for ${filename}: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}
let parsed: ReturnType<Preprocessor['parse']>;
try {
parsed = preprocessor.parse(data, { filename });
} catch (err) {
process.stderr.write(
`[html-validate-ember] parse failure on ${filename}: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
return;
}
// Multipass branch validation: enumerate {{#if}}/{{else}} branch
// combinations and yield one Source per combination so each is
// independently validated. Errors in unselected branches surface;
// identical blanked outputs are deduped before validation.
//
// Trade-off: an error stable across branches (e.g., a real misnesting
// OUTSIDE the if/else) gets reported once per pass. The bundled
// `validate-gts` CLI dedupes by (line, column, ruleId, message)
// before printing. Direct html-validate consumers (the VS Code
// extension, the `html-validate` CLI used standalone) don't dedupe;
// set `HVE_MAX_CONDITIONAL_BRANCHES=0` to fall back to the
// single-branch form-submit-aware heuristic if duplicates are
// annoying.
//
// For the `no-unused-disable` catch-22 (a directive load-bearing in
// one pass looks unused in another), we DON'T rely on
// `dedupeMultipassReport` alone — the dedupe lives outside
// html-validate and direct consumers don't run it. Instead, we
// prepend an inline `<!--html-validate-disable no-unused-disable-->`
// directive to each branched Source's data, and shift the Source's
// `offset`/`column` by the directive's length so the original
// content's positions still map back to the original file. That
// makes the rule effectively off inside any branched template
// regardless of who calls html-validate. Trade-off: a *genuinely*
// unused directive inside a branched template stops firing too. We
// accept that: it's bounded (only branched templates) and chosen
// over the FP catch-22 that has no escape hatch for users. Keep in
// sync with `MULTIPASS_INCOMPATIBLE_RULES` in `lib/multipass-dedupe.ts`.
for (const tpl of parsed) {
if (tpl.tagName !== 'template') {
continue;
}
const startOffset = tpl.contentRange.startChar;
const { line, column } = offsetToLineCol(data, startOffset);
// No Glint info available → run the canonical resolver alone for
// this template block. Mirrors the .hbs path: walk PascalCase
// invocations, resolve via imports + addon by-name, project the
// resolved component's root tag + static attrs into the blanker.
// Without this, editors that don't set `HVE_GLINT=1` (e.g. the
// VS Code html-validate extension) blank-out `<MyComponent>` as
// a custom element and downstream rules can't see the rendered
// tag — observably, no-dup-id / element-permitted-content /
// prefer-tbody all stop firing on shape that the CLI catches.
let tagMap = glintComponentTagMap;
let attrMap = glintComponentAttrMap;
if (!tagMap) {
try {
const ast = preprocess(tpl.contents, { mode: 'codemod' });
const maps = buildResolutionMaps(filename, ast);
tagMap = maps.componentTagMap;
attrMap = maps.componentAttrMap;
} catch {
// Parse failure here is non-fatal — `blankTemplateContent`
// re-parses and reports the error. Drop the maps and continue.
}
}
const results = blankTemplateContentMultipass(
tpl.contents,
scope,
glintTypeMap,
tagMap,
attrMap,
);
const branched = results.length > 1;
if (branched) {
// Record the file-line range covered by this template's content
// so the dedupe can scope its rule-suppression to just this
// template (not the whole file). Multi-template files keep
// non-branched templates' diagnostics intact.
const endLine = offsetToLineCol(data, tpl.contentRange.endChar).line;
const ranges = __multipassBranchedRanges.get(filename) ?? [];
ranges.push([line, endLine]);
__multipassBranchedRanges.set(filename, ranges);
}
for (const result of results) {
if (result.error) {
process.stderr.write(`[html-validate-ember] glimmer parse failure: ${result.error.message}\n`);
}
if (result.content.length !== tpl.contents.length) {
process.stderr.write(
`[html-validate-ember] BUG: blanked length ${result.content.length} != original ${tpl.contents.length}\n`,
);
}
// The only rule the prefix directive carries today is
// `no-unused-disable` for branched (multipass) sources;
// structural suppressions live in `result.disablePerElement`
// and land via `processElement → el.disableRules` instead.
const prefix = branched ? buildDisableDirective(['no-unused-disable']) : '';
const sourceData = prefix + result.content;
const sourceOffset = startOffset - prefix.length;
const sourceColumn = column - prefix.length;
// Elements whose only Glimmer source content was mustaches will look
// empty after blanking. Hook them and append a DynamicValue placeholder
// so html-validate's empty-heading / text-content rules see "has content,
// unknowable" rather than truly empty.
yield {
data: sourceData,
filename,
line,
column: sourceColumn,
offset: sourceOffset,
originalData,
hooks: makeHooks(
new Set(result.dynamicContentOffsets ?? []),
result.attrInjections ?? new Map(),
result.disablePerElement ?? new Map(),
startOffset,
),
};
}
}
}
// html-validate transformers carry an `api` version marker as a static
// property. The Transformer type is a callable interface and our
// generator function shape doesn't exactly match its signature, so cast
// through `unknown`.
const transformer = transformGlimmer as unknown as Transformer & { api: number };
transformer.api = 1;
export default transformer;