HTML-validate for your Ember templates (.gts, .gjs, .hbs): Run HTML5 spec checks, accessibility rules, content-model rules, and form-correctness rules — with diagnostics pointing at exact source positions.
html-validate is a peer dependency, so install both:
pnpm add --save-dev html-validate html-validate-emberCreate .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"
}
}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.).html-validate-ember:recommended(minimal) — only the rule disables that are required for the transformer to behave correctly:no-trailing-whitespace,no-self-closing,attr-quotes,no-raw-characters. No stylistic opinions.
The recommended pattern is to wire the bundled validate-gts into package.json scripts:
"scripts": {
"lint:html": "validate-gts app/templates app/components"
}Then:
pnpm lint:htmlWire it into CI alongside your existing lint scripts; non-zero exit fails the build.
For ad-hoc one-off runs, use pnpm exec:
pnpm exec validate-gts app/components/foo.gts # single file
pnpm exec validate-gts --no-glint app/components/foo.gts # single file no glint
pnpm exec validate-gts app/templates # directory (walked recursively)- Bare (no project config): bundled
:gts-recommendedpreset, built-in components (<Input>/<Textarea>/<LinkTo>) substitute to native tags, other components blank transparently as a fallback, 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. - + install
@glint/ember-tsc(auto-detected, default on; pass--no-glintto opt out):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).
html-validate's recommended preset enables 80 rules covering HTML5 content models, ARIA / WCAG, form correctness, and attribute validity.
A few examples — real bugs that ember-template-lint and eslint-plugin-ember don't catch:
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"
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">
| Tool | Question it answers |
|---|---|
eslint-plugin-ember / ember-template-lint |
Is this idiomatic Ember? Invocation style, reactivity, modifier API, built-in components, plus JS/TS-side rules from eslint-plugin-ember and some overlap of HTML spec and ARIA rules |
html-validate-ember (this plugin) |
Is the rendered HTML spec-correct and accessible? — content model, ARIA / WCAG, form correctness, attribute validity, duplicate IDs, unique landmarks. |
Install the official extension: html-validate.vscode-html-validate.
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"
]
}Install @glint/ember-tsc in your project and the transformer extracts TypeScript type information for two patterns. Default on when installed; pass --no-glint (or HVE_GLINT=0) to opt out.
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.
Glint adds per-file overhead (TS program build, module rewrite, TypeChecker calls). The cache makes repeat runs over unchanged files cheap, but the file you're actively editing pays the cost each iteration. Pass --no-glint (or HVE_GLINT=0) to skip — you keep built-in component substitution but lose Signature['Element'] resolution and @arg enum narrowing.
Glint results are content-addressed and cached on disk under node_modules/.cache/html-validate-ember/glint/. Set HVE_NO_CACHE=1 to bypass the cache.
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:
Inline reason / link — append
-- textinside the brackets (html-validate's directive parser splits on--after the rule name):Variants (per html-validate's inline-config docs):
How positions work
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.
Multipass branch validation
{{#if}}/{{else}} (and {{else if}} chains) are validated per branch by default. The transformer enumerates branch combinations, 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.
Enumeration is capped at the first 10 conditional branches per template to bound work; "conditional branch" here means any block helper with both a program and an {{else}} clause. The common forms are {{#if/else}}, {{#unless/else}}, and {{#each/else}} (empty fallback), but custom block helpers ({{#my-helper x}}A{{else}}B{{/my-helper}}) count too. Surplus conditional branches fall back to the single-branch heuristic, which can hide errors in their unselected arms. Override with --max-conditional-branches=N on validate-gts, or HVE_MAX_CONDITIONAL_BRANCHES=N when invoking html-validate directly. Set N=0 to disable multipass and use the single-branch heuristic everywhere.
Enumeration is tree-aware: branches are organized by nesting, and choosing one arm of a branch only enumerates that arm's nested branches. This matches the runtime DOM — choices inside a blanked arm can't affect what html-validate sees — and turns 2^N worst case into far fewer calls on nested templates. A 6-deep {{#if}}/{{else}} chain produces 7 distinct passes rather than 64. Pure-sibling branches still scale 2^N, so the cap matters most there.
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.
Known limitations
- 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@glint/ember-tscis installed, which the blanker picks up through a separate code path. no-implicit-button-typefires on every untyped<button>regardless of<form>ancestry — that's html-validate's strict design (defaulttype=submitis non-obvious). The plugin doesn't try to soften it; if you'd rather only flag buttons that actually live inside a<form>at runtime (where the default-submit matters), disable the rule project-wide and rely on review / a custom lint:{ "rules": { "no-implicit-button-type": "off" } }
Inspecting what gets emitted
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. CI runs pnpm install --frozen-lockfile, pnpm run build, pnpm run typecheck:tests, and pnpm test — mirror those locally.
