Skip to content

johanrd/html-validate-ember

Repository files navigation

html-validate-ember

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.

Supported formats

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 ⚠️ no Glint integration. Built-in Ember components (<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'}}).

Progressive enhancement

The plugin works at every level — opt in to more accuracy as your project's typing investment grows:

  1. Bare (no project config, no --glint): bundled :gts-recommended preset, components blank transparently (children float into the parent's content model), static-text resolution via t-helper / if-helper / top-level consts.
  2. + project .htmlvalidate.json: your rules / extends / transform overrides apply. Bundled CLI loads + merges them.
  3. + --glint (requires @glint/ember-tsc installed): Signature['Element'] resolves component invocations to native tags. Type-narrowed @arg values 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.

What it catches

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.

Block element inside a <p> silently closes the paragraph

<p class='text-sm text-gray-600'>
  Some explanation
  <button>?</button>
  <div popover>...</div>   {{!-- ← parser auto-closes <p> here --}}
</p>                        {{!-- ← stray </p>, doesn't match anything --}}
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.

Hardcoded duplicate IDs across copy-pasted form rows

<td>
  <input type='number' name='price' id='price' value='1000' />  {{!-- 1× --}}
</td>
<td>
  <input type='number' name='price' id='price' value='2000' />  {{!-- 2× --}}
</td>
{{!-- 6 more identical rows --}}
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.

<menu> used as a styled sidebar

<menu class='shadow-md flex flex-col px-4 ...'>
  <div>...</div>
  <input type='search' />
  <DatePicker />
</menu>
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).

Block element inside phrasing parents

<span class='flex gap-2'>
  <p class='rounded-full ...'>Automatisert</p>   {{!-- ← <p> is flow content --}}
</span>

<button>
  <div class='truncate'>Daily total</div>  {{!-- ← <div> is flow content --}}
  <div class='font-semibold'>...</div>
</button>

<h1>
  <div class='skeleton'></div>                   {{!-- ← <div> is flow content --}}
</h1>

<label for='X'>
  <div>Label text</div>                          {{!-- ← <div> is flow content --}}
</label>
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.

<dt> / <dd> outside <dl>, or <dl> nested in <dl>

{{!-- Used as a key/value row inside <details><summary>: --}}
<details>
  <summary>
    <div class='flex justify-between'>
      <dt>Total</dt>             {{!-- ← needs <dl> ancestor --}}
      <dd>1,234</dd>             {{!-- ← needs <dl> ancestor --}}
    </div>
  </summary>
</details>

{{!-- Or nested incorrectly: --}}
<dl>
  <dt>{{row.key}}</dt>
  <dl>{{row.value}}</dl>         {{!-- ← author meant <dd> --}}
</dl>
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>

Icon-only <button> without an accessible name

<button type='button' commandfor='Modal' command='close'>
  <svg>...</svg>   {{!-- ← screen readers announce "button" --}}
</button>
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).

<form> missing a submit button

<form {{on 'submit' this.handleSubmit}}>
  <textarea name='message'></textarea>
  <button type='button'>Cancel</button>   {{!-- only button is type='button' --}}
</form>
components/chat-form.gts:24: error [wcag/h32] <form> element must have a submit button

Camel-case attribute names

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.

<div data-test-userMenuList></div>   {{!-- ← lowercased to data-test-usermenulist --}}
templates/page.gts:42: error [attr-case] Attribute "data-test-userMenuList" should be lowercase

Raw > and < in text content

<span>Memory > 1000 MB</span>   {{!-- ← should be &gt; --}}
templates/dashboard.gts:96: error [no-raw-characters] Raw ">" must be encoded as "&gt;"

Duplicate CSS class

<div class='flex w-full focus:bg-blue-500 focus:text-white rounded-md flex grow gap-2'>
                                                                  ↑↑↑↑
                                                                  duplicate "flex"
components/menu-button.gts:18: error [no-dup-class] Class "flex" duplicated

Invalid attribute values

<img alt='Logo' width='100px' />   {{!-- ← width must be unitless integer --}}
<input type='checkbox' readonly />  {{!-- ← readonly only valid on text-like inputs --}}
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">

Install

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

Why 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 DynamicValue instanceof 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).

Configure

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.

Two presets

Pick whichever fits your project:

  • html-validate-ember:gts-recommended (recommended for most projects) — everything in :recommended plus Ember/Glimmer style conventions baked in (void-style: selfclosing to match ember-template-lint's self-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.

Run

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 app

You can also invoke html-validate's own CLI with the plugin wired up via .htmlvalidate.json:

npx html-validate 'app/**/*.gts'

Wiring into package.json scripts

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.

Inline errors in VS Code

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:

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-ember and you've updated the local source: pnpm's file:-dep cache doesn't always invalidate on source-content changes. Bump the version in html-validate-ember/package.json (or run pnpm install --force in the consuming project), then reload VS Code. Stale plugin code looks like phantom diagnostics that don't reproduce when running validate-gts from the terminal.

Glint integration (opt-in)

When @glint/ember-tsc is installed in your project, the transformer can extract TypeScript type information for two patterns:

1. String-literal-union narrowing in attribute positions

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

2. Component → element substitution

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 }
}> { /* ... */ }
<MyButton @onClick={{this.foo}} />

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

Enabling Glint

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.

Caching

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.

Silencing rules

Three layers, broadest to narrowest:

  1. Project config — disable any rule in .htmlvalidate.json:

    {
      "rules": {
        "aria-label-misuse": ["error", { "allowAnyNamable": true }],
        "no-inline-style": "off"
      }
    }
  2. File-level disable — html-validate directives at the top of a file work normally:

    {{!-- [html-validate-disable no-dup-id] --}}
  3. Per-element directive — Glimmer comment containing the directive:

    {{!-- [html-validate-disable-next no-dup-id] --}}
    <div id={{this.id}}>x</div>

    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 -- text inside the brackets (html-validate's directive parser splits on -- after the rule name):

    {{!-- [html-validate-disable-next unique-landmark -- pending https://github.com/w3c/html-aria/issues/579] --}}
    <header>...</header>

    Trailing text after the closing ] does not parse — html-validate raises parser-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 — disables rule for the rest of the file (or until re-enabled with html-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:
      <dialog>
        {{!-- [html-validate-disable-block unique-landmark -- pending https://github.com/w3c/html-aria/issues/579] --}}
        <header>...</header>
        <section>...</section>
      </dialog>

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

Known limitations

  • 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-file const NAME = '...' declarations and one-level-deep import { NAME } from './sibling' (relative paths only — package and path-aliased imports are skipped). {{this.field}} resolves against same-file class-field initializers (field = '...' or field: 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 is 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 inside as |…| ranges of mustache openers; type literals appearing elsewhere in the template aren't touched (and Glimmer wouldn't accept them there anyway).

Future work

  • 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 — current split('') / join('') is O(n) per <template> block; not a bottleneck on real codebases (sub-second for 1000-line files).

Inspecting what gets emitted

When debugging a false positive:

node node_modules/html-validate-ember/dump-blanked.js path/to/file.gts

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

Contributing

PRs welcome. Run npm test for the unit + integration suite.

About

Plugin to run `html-validate` on ember templates

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors