html-validate transformer for Ember Glimmer templates — .gts, .gjs, and classic .hbs.
Lint your templates against html-validate's HTML5 spec checks, accessibility rules, content-model rules, and form-correctness rules — with diagnostics pointing at exact source positions.
| Format | What it is | Glint integration |
|---|---|---|
.gts |
Template-imports + TypeScript (Ember's modern default) | ✅ full (component → element resolution, attribute type narrowing, splatted-root literal extraction) |
.gjs |
Template-imports + JavaScript | ✅ same machinery as .gts (Glint understands both) |
.hbs |
Classic separate template file | <Input>/<Textarea>/<LinkTo>) substitute to their rendered native tag (<input>/<textarea>/<a>) so content-model rules apply; other components blank transparently (open/close tags removed; children float into the parent's content model). Static-text resolution applies ({{t 'Key'}}, {{if cond 'a' 'b'}}). |
The plugin works at every level — opt in to more accuracy as your project's typing investment grows:
- Bare (no project config, no
--glint): bundled:gts-recommendedpreset, components blank transparently (children float into the parent's content model), static-text resolution via t-helper / if-helper / top-level consts. - + project
.htmlvalidate.json: your rules / extends / transform overrides apply. Bundled CLI loads + merges them. - +
--glint(requires@glint/ember-tscinstalled):Signature['Element']resolves component invocations to native tags. Type-narrowed@argvalues flow into attribute enum checks. Splatted-root literal attributes propagate (e.g.<MySlider />substitutes to<input type='range' min='0' max='100' />with the actual literal values from the imported component's template).
Glint silently no-ops when @glint/ember-tsc isn't installed, so you can flip --glint on and off without breaking anything.
These are real bugs found in a 300-file Ember codebase that ember-template-lint and eslint-plugin-ember don't catch. They're HTML5 spec / a11y issues, not Ember/Glimmer-specific patterns.
templates/page.gts:42: error [no-implicit-close] Element <p> is implicitly closed by parent </div>
templates/page.gts:74: error [close-order] Stray end tag '</p>'
The browser silently rewrites the DOM; your screen-reader tree doesn't match what you wrote.
templates/pricing-table.gts:128: error [no-dup-id] Duplicate ID "price"
templates/pricing-table.gts:128: error [form-dup-name] Duplicate form control name "price"
…16 more
The author copy-pasted a pricing-tier column without parameterizing the id. aria-describedby="price-currency" resolves to whichever DOM node the browser sees first.
templates/sidebar.gts:18: error [element-permitted-content] <div> not permitted under <menu>
templates/sidebar.gts:23: error [element-permitted-content] <input> not permitted under <menu>
<menu> only accepts <li> per HTML5. Catches a common pattern of using <menu role='menu'> for popovers/sidebars (~50 sites in the audited app).
templates/page.gts:88: error [element-permitted-content] <p> not permitted under <span>
components/tile.gts:41: error [element-permitted-content] <div> not permitted under <button>
templates/page.gts:14: error [element-permitted-content] <div> not permitted under <h1>
components/dialog.gts:32: error [element-permitted-content] <div> not permitted under <label>
Replace inner <div>/<p> with <span class='block'> — keeps Tailwind classes, fixes the spec violation.
components/details-row.gts:22: error [element-required-ancestor] <dt> requires "dl > dt" ancestor
templates/item-detail.gts:104: error [element-permitted-content] <dl> not permitted under <dl>
templates/item-detail.gts:188: error [text-content] <button> must have accessible text
Add aria-label='Close' (or use the <button title='...'> "subjective" form — html-validate flags title as discouraged but accepts it).
components/chat-form.gts:24: error [wcag/h32] <form> element must have a submit button
HTML5 attribute names are case-insensitive and lowercase canonical. Camel-case in source compiles down to lowercase in the DOM, so attribute selectors silently fail.
templates/page.gts:42: error [attr-case] Attribute "data-test-userMenuList" should be lowercase
templates/dashboard.gts:96: error [no-raw-characters] Raw ">" must be encoded as ">"
components/menu-button.gts:18: error [no-dup-class] Class "flex" duplicated
components/footer.gts:12: error [attribute-allowed-values] Attribute "width" has invalid value "100px"
templates/admin.gts:18: error [input-attributes] Attribute "readonly" not allowed on <input type="checkbox">
html-validate is a peer dependency, so install both:
# npm
npm install --save-dev html-validate html-validate-ember
# pnpm
pnpm add --save-dev html-validate html-validate-ember
# yarn
yarn add --dev html-validate html-validate-emberWhy a peer dep? So you can pin html-validate's version and the plugin doesn't drag in a duplicate copy (which would break the
DynamicValueinstanceof checks).
If you forget to install html-validate you'll see a Cannot find package 'html-validate' error on first run — the fix is pnpm add --save-dev html-validate (or your package manager's equivalent).
Create .htmlvalidate.json at your project root:
{
"extends": ["html-validate:recommended", "html-validate-ember:gts-recommended"],
"plugins": ["html-validate-ember"],
"transform": {
"^.*\\.(gts|gjs|hbs)$": "html-validate-ember"
}
}That's it. html-validate **/*.{gts,gjs,hbs} will lint every template.
Pick whichever fits your project:
html-validate-ember:gts-recommended(recommended for most projects) — everything in:recommendedplus Ember/Glimmer style conventions baked in (void-style: selfclosingto matchember-template-lint'sself-closing-void-elements, etc.). Use this if you want the plugin to "just work" the way an Ember dev expects.html-validate-ember:recommended(minimal) — only the rule disables that are required for the transformer to behave correctly:no-trailing-whitespace(mustache lines blank to whitespace),no-self-closing(some emit paths preserve a self-closing/>),attr-quotes(rewritten attributes use double quotes). No stylistic opinions. Pick this if you'd rather keep all html-validate defaults and only opt into the transformer essentials.
The bundled validate-gts CLI accepts any mix of .gts files and directories. Directories are walked recursively for .gts. Exits non-zero when any file has errors.
# single file
npx validate-gts app/components/foo.gts
# directory (walked recursively)
npx validate-gts app/templates
# multiple targets
npx validate-gts app/templates app/components
# enable Glint type extraction
npx validate-gts --glint app/templates
# only show summary, skip per-file diagnostics
npx validate-gts --quiet appYou can also invoke html-validate's own CLI with the plugin wired up via .htmlvalidate.json:
npx html-validate 'app/**/*.gts'After installing this plugin (pnpm/npm/yarn), add these scripts:
"scripts": {
"lint:html:templates": "validate-gts --glint app/templates",
"lint:html:components": "validate-gts --glint app/components",
"lint:html": "validate-gts --glint app/templates app/components"
}Then pnpm lint:html lints both directories and exits non-zero on any error — wire it into CI alongside your existing lint scripts.
Install the official extension: html-validate.vscode-html-validate.
The extension only validates files whose VS Code language ID is in its html-validate.validate allow-list. The default list is ["html", "javascript", "markdown", "vue", "vue-html"] — none of which match .gts / .gjs / .hbs. Add the Ember/Glimmer language IDs to your project's .vscode/settings.json:
{
"html-validate.validate": [
"html",
"javascript",
"markdown",
"vue",
"vue-html",
"glimmer-ts",
"glimmer-js",
"handlebars"
]
}The Glimmer language IDs (glimmer-ts for .gts, glimmer-js for .gjs) are registered by the Glimmer / Glint extensions:
lifeart.vscode-glimmer-syntax(syntax highlighting + grammars)typed-ember.glint2-vscode(Glint language service)
Either one is enough. (gts / gjs shown in the language picker are display aliases — the actual ID is glimmer-ts / glimmer-js. Same trick Vue uses with vue / vue-html.) handlebars is a built-in language ID.
After adding the setting, reload the VS Code window (Cmd+Shift+P → "Developer: Reload Window"). The html-validate extension activates lazily — if you don't see "HTML-Validate" in the Output dropdown, run "Developer: Show Running Extensions" to confirm it loaded, and check "Workspaces: Manage Workspace Trust" (the extension refuses to run in untrusted workspaces).
If you installed via
pnpm install file:/path/to/html-validate-emberand you've updated the local source: pnpm'sfile:-dep cache doesn't always invalidate on source-content changes. Bump the version inhtml-validate-ember/package.json(or runpnpm install --forcein the consuming project), then reload VS Code. Stale plugin code looks like phantom diagnostics that don't reproduce when runningvalidate-gtsfrom the terminal.
When @glint/ember-tsc is installed in your project, the transformer can extract TypeScript type information for two patterns:
interface PopoverSig {
Args: { mode: 'auto' | 'manual' | 'hint' }
}
class Popover extends Component<PopoverSig> {
<template>
<div popover={{@mode}}>...</div>
</template>
}Without Glint: <div popover=""> (DynamicValue, no enum check).
With Glint: html-validate sees popover="auto" (or whichever union member is not in html-validate's enum, surfacing a typing bug if you've declared an invalid value).
When a component declares Signature['Element'], the transformer substitutes the invocation with the corresponding native tag, so content-model rules apply correctly:
class MyButton extends Component<{
Element: HTMLButtonElement
Args: { onClick: () => void }
}> { /* ... */ }→ html-validate sees a <button>, can apply no-implicit-button-type / text-content rules accordingly. (The transformer adds DynamicValue placeholders for type and label content so those rules don't FP-fire on substituted self-closing components — the actual button has its type and label set internally.)
For components with Element: unknown (typically yield-only components) the transformer treats the invocation as transparent — children float into the parent's content model.
Pass --glint to the bundled CLI or set HVE_GLINT=1 in the environment:
npx validate-gts --glint app
# or, when invoking html-validate directly:
HVE_GLINT=1 npx html-validate 'app/**/*.gts'Glint integration adds significant per-file overhead (TS program rebuild + module rewrite + TypeChecker calls). The static-resolution path is the default for that reason; turn Glint on for design-system-style codebases with strict typing discipline.
Glint results are content-addressed and cached on disk under node_modules/.cache/html-validate-ember/glint/. The cache key includes file SHA + tsconfig SHA + plugin version, so:
- Repeat runs (CI, pre-commit, IDE re-validation) skip the entire Glint pipeline for unchanged files. On the audited 207-file codebase: ~464s cold → ~2.5s warm.
- Plugin upgrades invalidate all entries naturally (version is in the key).
- Tsconfig changes invalidate per-project entries.
Set HVE_NO_CACHE=1 to bypass the cache (e.g. when debugging the Glint pipeline). Set HVE_DEBUG=1 to print per-file skip reasons during preload (non-gts/gjs, read error, rewrite returned empty/error) — useful when you see a non-zero "skipped" count and want to know what fell out.
Three layers, broadest to narrowest:
-
Project config — disable any rule in
.htmlvalidate.json:{ "rules": { "aria-label-misuse": ["error", { "allowAnyNamable": true }], "no-inline-style": "off" } } -
File-level disable — html-validate directives at the top of a file work normally:
-
Per-element directive — Glimmer comment containing the directive:
Use the long form
{{!-- ... --}}, not{{! ... }}. The transformer rewrites the long form to a<!-- -->HTML comment in place; the short form is too short to fit<!-- -->while preserving byte length.Inline reason / link — append
-- textinside the brackets (html-validate's directive parser splits on--after the rule name):Trailing text after the closing
]does not parse — html-validate raisesparser-error: Missing end bracket "]" on directive. The reason has to live inside the brackets.Variants (per html-validate's inline-config docs):
html-validate-disable rule— disablesrulefor the rest of the file (or until re-enabled withhtml-validate-enable).html-validate-disable-next rule— disables for the next element only.html-validate-disable-block rule— disables for all siblings and descendants of the directive's parent that follow the directive. Useful for scoping to e.g. a whole<dialog>subtree:
content-tag gives byte offsets for each <template> block's content. We compute line:column for the block's start, attach as the Source base, and emit length-equivalent HTML so byte positions inside the template match positions inside the original .gts. html-validate adds reported positions to the base.
No SourceMap machinery — same approach html-validate-vue and html-validate-angular use.
{{#if}}/{{else}} (and {{else if}} chains) are validated per branch by default. The transformer enumerates branch combinations (capped at 2³ = 8 to bound work), yields one html-validate Source per combination, and html-validate validates each independently. Errors from every branch surface — including the un-selected branch under single-pass.
The bundled validate-gts CLI dedupes identical messages by (line, column, ruleId, message) before printing, so an error stable across branches (e.g., a misnested element outside the if/else) is reported once even though it lives in every pass. The dedupe util is also exported as dedupeMultipassReport from lib/multipass-dedupe.js for custom consumers.
Direct html-validate consumers (the vscode-html-validate extension, the html-validate CLI used standalone) don't dedupe and may show the same outside-of-branch error N times — set HVE_MULTIPASS=0 to fall back to single-branch emission with the form-submit-aware heuristic if duplicates are noisy.
- More than 3 branch points per template. Multipass caps at 8 combinations to bound validation work. Surplus branch points fall back to the single-branch heuristic; branches you might want validated may be skipped. In practice, deeply branching templates are rare; refactoring into smaller components is usually cleaner anyway.
- Static-string scope.
{{NAME}}resolves against same-fileconst NAME = '...'declarations and one-level-deepimport { NAME } from './sibling'(relative paths only — package and path-aliased imports are skipped).{{this.field}}resolves against same-file class-field initializers (field = '...'orfield: T = '...'). What's not resolved: transitive re-exports (export { X } from './...'chains), default imports, namespace imports, and getters returning literals — Glint narrows some of these to string-literal types when--glintis on, which the blanker picks up through a separate code path. - TS-flavored block-param types are stripped, not parsed.
@glimmer/syntax's parser doesn't understand{{#each items as |item: T|}}-style annotations (or the comma separators that come with multi-param lists). The transformer pre-strips them to whitespace before Glimmer parses, with balanced-bracket scanning so unions (A | B), object types ({ a: number }), parenthesized types ((A | B)[]), generics (Map<string, number>), arrays (T[]), and qualified names (NS.Type) all work. Length-preserving — AST offsets after the strip match original source. The strip only operates insideas |…|ranges of mustache openers; type literals appearing elsewhere in the template aren't touched (and Glimmer wouldn't accept them there anyway).
- Custom rules — html-validate plugins can ship their own rules. Candidates:
ember-prefer-glimmer-comment-directive(flag<!-- [html-validate-disable …] -->and suggest{{!-- … --}}),ember-component-naming(enforce PascalCase / dotted invocations). - Piecewise string-builder in
blank.ts— currentsplit('')/join('')is O(n) per<template>block; not a bottleneck on real codebases (sub-second for 1000-line files).
When debugging a false positive:
node node_modules/html-validate-ember/dump-blanked.js path/to/file.gtsPrints the original <template> body and the length-equivalent HTML the transformer hands to html-validate. False positives are usually traceable to the blanker losing or mis-emitting structure.
PRs welcome. Run npm test for the unit + integration suite.