# HTML checks

> Warnings for obsolete and inaccessible HTML, before you render.

[`check`](https://kilnlang.com/docs/api/errors/#checking-templates) warns about HTML that is invalid, obsolete or hard to use with assistive technology, such as an image with no `alt`, a `<div>` inside a `<p>` or a skipped heading level. The language server reports the same warnings as you type. Nothing is checked while rendering, so pages render just as fast.

components/card.kiln:

```kiln
<center>
	<img src="{{ image.src }}">
	<button>Read more</button>
</center>
```

Warnings:

```text
html-obsolete-element  1:1  <center> is obsolete. Use CSS instead
html-img-alt           2:2  <img> has no alt attribute. Use alt="" if the image is decorative
html-button-type       3:2  <button> has no type attribute, so it submits any form it is in. Add type="button" or type="submit"
```

Each warning is a normal `check` diagnostic, with a stable `code`, a `message`, a `severity` and a position.

## Rules

The rules follow the HTML standard and WAI-ARIA, as the [MDN HTML reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements) describes them. **Spec** rules report HTML that the standards say is wrong. **Advice** rules report HTML that is valid but works badly for people, following MDN's guidance and WCAG.

| Code | Reports | Kind |
| --- | --- | --- |
| `html-obsolete-element` | An element the HTML standard has made obsolete. See [Obsolete elements](#obsolete-elements) | Spec |
| `html-obsolete-attribute` | An attribute the HTML standard has made obsolete, such as `align` or `bgcolor`. See [Obsolete attributes](#obsolete-attributes) | Spec |
| `html-attribute-value` | A value an attribute doesn't accept, such as `type="emial"`, or a boolean attribute set to `"false"`. See [Attribute values](#attribute-values) | Spec |
| `html-img-alt` | An `<img>` with no `alt` attribute | Spec |
| `html-button-type` | A `<button>` with no `type` attribute | Advice |
| `html-lang` | An `<html>` element with no `lang` attribute, or a `lang` that isn't a valid language tag | Advice |
| `html-iframe-title` | An `<iframe>` with no `title` attribute | Advice |
| `html-viewport-zoom` | A viewport `<meta>` that stops people zooming | Advice |
| `html-tabindex` | A `tabindex` above 0 | Advice |
| `html-aria-attribute` | An `aria-` attribute that WAI-ARIA doesn't define, such as `aria-labeledby` | Spec |
| `html-role` | A `role` that WAI-ARIA doesn't define, or an abstract role such as `widget` that pages must not use | Spec |
| `html-redundant-role` | A `role` an element already has, such as `<nav role="navigation">` | Advice |
| `html-aria-hidden-focusable` | `aria-hidden="true"` on an element people can tab to | Spec |
| `html-heading-order` | A heading that skips a level, such as an `<h4>` straight after an `<h2>` | Advice |
| `html-nested-interactive` | A link, button or other interactive element inside an `<a>` or a `<button>`, or a `<label>` inside a `<label>` | Spec |
| `html-permitted-content` | An element its parent can't hold, such as a `<div>` in a `<p>` or an `<li>` outside a list. See [Nesting](#nesting) | Spec |

Element and attribute names match in any case, as they do in HTML, so `<CENTER>` is reported too.

### Images

Screen readers read an image's `alt` text in its place. Without `alt`, many read out the file name instead. Describe what the image shows, or use an empty `alt` for an image that is only decoration:

```kiln
<img src="{{ post.image }}" alt="{{ post.imageAlt }}">
<img src="/images/divider.svg" alt="">
```

### Buttons

A `<button>` inside a form submits it unless it says otherwise. A button that opens a menu or a dialog then submits the form as well. Give every button a `type`, so moving it into a form later can't change what it does.

### Language

`lang` tells screen readers which voice and pronunciation to use, and tells browsers which language to translate from. Put it on `<html>`, such as `<html lang="en-GB">`.

On any element, a written-out `lang` must be a valid language tag. `lang="en_GB"` is reported, since language tags join their parts with `-`. An empty `lang` is allowed: it means the language is unknown.

### Frames

Screen readers announce an `<iframe>` by its `title`, so people can decide whether to go into it. Describe what it holds, such as `title="Map of our office"`.

### Zooming

People with low vision zoom in to read. A viewport `<meta>` whose `content` has `user-scalable=no`, or a `maximum-scale` below 2, stops them. WCAG asks that text can be zoomed to 200%.

### Tab order

A `tabindex` above 0 moves an element ahead of everything else when someone tabs through the page, so the order no longer follows what they see. Use `0` to make an element focusable in its normal place, or `-1` to focus it only from script. Only written-out numbers are checked.

### ARIA

Browsers ignore an `aria-` attribute or a `role` they don't know, so a typo quietly does nothing. The rules follow WAI-ARIA 1.2, plus the attributes and roles the 1.3 draft adds, such as `aria-description`. Roles from DPUB-ARIA and Graphics-ARIA, which start with `doc-` and `graphics-`, are accepted.

A `role` may list several roles as fallbacks, such as `role="switch checkbox"`, and each one is checked. Roles match in any case. Only written-out values are checked.

`html-redundant-role` reports a `role` that repeats the role an element always has, such as `<button role="button">`, `<main role="main">` or `<a href="/" role="link">`. Lists are left alone: `<ul role="list">` brings back list semantics that Safari drops from lists styled with `list-style: none`.

`aria-hidden="true"` hides an element from screen readers. On a link, button, form field or an element with `tabindex="0"`, people can still tab to it, and hear nothing. Remove it, or make the element unfocusable too. It is checked on the element itself only, and only when Kiln can see all of its attributes.

## Obsolete elements

These elements are obsolete in the HTML standard. Browsers may still show them, but they can't be relied on, and most have a better replacement.

| Element | Use instead |
| --- | --- |
| `acronym` | `abbr` |
| `applet` | `object` or `embed` |
| `basefont`, `big`, `blink`, `center`, `font`, `marquee`, `multicol`, `nobr`, `spacer` | CSS |
| `bgsound` | `audio` |
| `dir` | `ul` |
| `frame`, `frameset`, `noframes` | `iframe`, or a layout with CSS |
| `isindex` | A form with a text field |
| `keygen` | The Web Crypto API |
| `listing`, `plaintext`, `xmp` | `pre` and `code` |
| `menuitem` | A `button` inside `menu` |
| `nextid` | Nothing. Remove it |
| `noembed` | Fallback content inside `object` |
| `param` | Attributes on `object` |
| `rb`, `rtc` | `ruby` with `rt` |
| `strike` | `s`, or `del` for removed text |
| `tt` | `code`, `kbd` or `samp` |

## Obsolete attributes

These attributes are obsolete in the HTML standard. Most of them set how things look, which CSS now does. The warning says what to use instead when it isn't CSS, such as `id` in place of `<a name>`.

| Element | Obsolete attributes |
| --- | --- |
| `a` | `charset`, `coords`, `methods`, `name`, `rev`, `shape`, `urn` |
| `area` | `nohref`, `type` |
| `body` | `alink`, `background`, `bgcolor`, `link`, `text`, `vlink`, and the margin attributes, such as `marginwidth` and `topmargin` |
| `br` | `clear` |
| `caption`, `div`, `h1` to `h6`, `legend`, `p` | `align` |
| `col`, `tbody`, `thead`, `tfoot`, `tr` | `align`, `char`, `charoff`, `valign`, plus `width` on `col` and `bgcolor` on `tr` |
| `td`, `th` | `abbr` (on `td` only), `align`, `axis`, `bgcolor`, `char`, `charoff`, `height`, `nowrap`, `scope` (on `td` only), `valign`, `width` |
| `dl`, `menu`, `ol` | `compact` |
| `ul` | `compact`, `type` |
| `li` | `type` |
| `embed` | `align`, `hspace`, `name`, `vspace` |
| `head` | `profile` |
| `hr` | `align`, `color`, `noshade`, `size`, `width` |
| `html` | `manifest`, `version` |
| `iframe` | `align`, `allowtransparency`, `frameborder`, `framespacing`, `hspace`, `longdesc`, `marginheight`, `marginwidth`, `scrolling`, `vspace` |
| `img` | `align`, `border`, `hspace`, `longdesc`, `lowsrc`, `name`, `vspace` |
| `input` | `align`, `hspace`, `ismap`, `usemap`, `vspace` |
| `link` | `charset`, `rev`, `target` |
| `meta` | `scheme` |
| `object` | `align`, `archive`, `border`, `classid`, `code`, `codebase`, `codetype`, `declare`, `hspace`, `standby`, `vspace` |
| `option` | `name` |
| `pre` | `width` |
| `script` | `charset`, `event`, `for`, `language` |
| `table` | `align`, `bgcolor`, `border`, `cellpadding`, `cellspacing`, `frame`, `rules`, `summary`, `width` |

## Attribute values

Many attributes only accept certain keywords. A browser treats any other value as the default, so a typo such as `type="emial"` quietly gives a plain text field. Kiln reports a written-out value that isn't one of these, and suggests the closest. Keywords match in any case, apart from `<ol type>`, where `a` and `A` differ.

| Attribute | On | Values |
| --- | --- | --- |
| `type` | `input` | `button`, `checkbox`, `color`, `date`, `datetime-local`, `email`, `file`, `hidden`, `image`, `month`, `number`, `password`, `radio`, `range`, `reset`, `search`, `submit`, `tel`, `text`, `time`, `url`, `week` |
| `type` | `button` | `button`, `reset`, `submit` |
| `type` | `ol` | `1`, `a`, `A`, `i`, `I` |
| `method`, `formmethod` | `form`, and `button` and `input` | `get`, `post`, `dialog` |
| `enctype`, `formenctype` | `form`, and `button` and `input` | `application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain` |
| `autocomplete` | `form` | `on`, `off` |
| `loading` | `img`, `iframe` | `eager`, `lazy` |
| `decoding` | `img` | `async`, `auto`, `sync` |
| `fetchpriority` | `img`, `iframe`, `link`, `script` | `auto`, `high`, `low` |
| `crossorigin` | `audio`, `img`, `link`, `script`, `video` | Empty, `anonymous`, `use-credentials` |
| `referrerpolicy` | `a`, `area`, `iframe`, `img`, `link`, `script` | Empty, `no-referrer`, `no-referrer-when-downgrade`, `origin`, `origin-when-cross-origin`, `same-origin`, `strict-origin`, `strict-origin-when-cross-origin`, `unsafe-url` |
| `target`, `formtarget` | `a`, `area`, `base`, `form`, and `button` and `input` | Any name, or a keyword: `_blank`, `_parent`, `_self`, `_top`, `_unfencedTop`. Names can't start with `_`, so `target="_new"` is reported |
| `preload` | `audio`, `video` | Empty, `auto`, `metadata`, `none` |
| `scope` | `th` | `col`, `colgroup`, `row`, `rowgroup` |
| `kind` | `track` | `captions`, `chapters`, `descriptions`, `metadata`, `subtitles` |
| `wrap` | `textarea` | `hard`, `soft` |
| `shape` | `area` | `circle`, `default`, `poly`, `rect` |
| `capture` | `input` | `environment`, `user` |
| `sandbox` | `iframe` | Any of `allow-downloads`, `allow-forms`, `allow-modals`, `allow-orientation-lock`, `allow-pointer-lock`, `allow-popups`, `allow-popups-to-escape-sandbox`, `allow-presentation`, `allow-same-origin`, `allow-scripts`, `allow-storage-access-by-user-activation`, `allow-top-navigation`, `allow-top-navigation-by-user-activation`, `allow-top-navigation-to-custom-protocols`, separated by spaces |
| `blocking` | `link`, `script`, `style` | `render` |
| `popovertargetaction` | `button`, `input` | `hide`, `show`, `toggle` |
| `command` | `button` | `close`, `hide-popover`, `request-close`, `show-modal`, `show-popover`, `toggle-popover`, or a custom command starting with `--` |
| `shadowrootmode` | `template` | `closed`, `open` |
| `dir` | Any element | `auto`, `ltr`, `rtl` |
| `hidden` | Any element | Empty, `hidden`, `until-found` |
| `contenteditable` | Any element | Empty, `false`, `plaintext-only`, `true` |
| `draggable` | Any element | `false`, `true` |
| `spellcheck` | Any element | Empty, `false`, `true` |
| `translate` | Any element | Empty, `no`, `yes` |
| `autocapitalize` | Any element | `characters`, `none`, `off`, `on`, `sentences`, `words` |
| `inputmode` | Any element | `decimal`, `email`, `none`, `numeric`, `search`, `tel`, `text`, `url` |
| `enterkeyhint` | Any element | `done`, `enter`, `go`, `next`, `previous`, `search`, `send` |
| `popover` | Any element | Empty, `auto`, `hint`, `manual` |

Custom elements, whose names contain a `-`, define their own attributes, so only the attributes for any element are checked on them.

### Boolean attributes

A boolean attribute such as `disabled` is on whenever it is there, whatever its value. `disabled="false"` still disables the button, and `autoplay="false"` still plays the video. Kiln reports a boolean attribute written with `"false"`, `"0"`, `"no"` or `"off"`. Leave the attribute out to turn it off, or use `attrs`, which leaves out `false`:

```kiln
<button{{ attrs({ type: "submit", disabled: saving }) }}>Save</button>
```

The boolean attributes are `allowfullscreen`, `async`, `autofocus`, `autoplay`, `checked`, `controls`, `default`, `defer`, `disabled`, `formnovalidate`, `inert`, `ismap`, `itemscope`, `loop`, `multiple`, `muted`, `nomodule`, `novalidate`, `open`, `playsinline`, `readonly`, `required`, `reversed`, `selected`, `shadowrootclonable`, `shadowrootdelegatesfocus` and `shadowrootserializable`, on the elements that have them.

## Nesting

Some rules look at which elements are open around each one. Kiln follows the browser's rules for end tags HTML lets you leave out, such as `</li>`, `</p>` and `</td>`.

### Heading order

Screen reader users move around a page by its headings, and the levels tell them how sections fit together. A heading may go back up any number of levels, but should only go down one at a time, so an `<h2>` is followed by an `<h3>`, not an `<h4>`.

The first heading in a template can be any level, since a component may start at `<h3>`. After that, each heading is compared with the one before it in the same template.

### Interactive elements

A link or button can't contain another link, button, form field, `<details>`, `<iframe>` or an element with `tabindex`. Browsers handle it differently, and keyboard and screen reader users often can't reach the inner one. A `<label>` can't contain another `<label>`.

### Permitted content

`html-permitted-content` follows the "Permitted content" and "Permitted parents" of each element on MDN. It reports:

- An element that only holds text and inline elements, such as `<p>`, `<span>`, `<button>` or a heading, holding a block, such as a `<div>`, `<ul>` or `<h2>`. A `<p>` doesn't hold it at all: the browser ends the paragraph before it.
- Children a parent can't hold: only `<li>` in `<ul>`, `<ol>` and `<menu>`; `<dt>`, `<dd>` and `<div>` in `<dl>`; table parts in `<table>`, rows in `<thead>`, `<tbody>` and `<tfoot>`, and cells in `<tr>`; `<source>` and `<img>` in `<picture>`; headings and `<p>` in `<hgroup>`; and `<col>` in `<colgroup>`. `<script>` and `<template>` are allowed in all of these.
- An element outside the parent it needs: `<li>` outside a list, `<dt>` and `<dd>` outside `<dl>`, table parts outside a table, `<summary>` outside `<details>`, `<legend>` outside `<fieldset>`, `<figcaption>` outside `<figure>`, `<source>` outside `<picture>` or a media element, `<track>` outside a media element, and `<rt>` and `<rp>` outside `<ruby>`.
- A `<summary>` or `<legend>` that isn't the first child of its `<details>` or `<fieldset>`.
- A `<form>` inside a `<form>`, which the browser ignores, and a `<header>` or `<footer>` inside a `<header>` or `<footer>`.
- A `<main>` inside anything other than `<html>`, `<body>`, `<div>`, `<form>` or a custom element.

What `<select>`, `<optgroup>` and `<option>` may hold has grown with customisable selects, so they aren't checked.

## What Kiln can see

The rules read the HTML written in the template, not the page it renders. Kiln only warns when it is sure, so it may miss a problem but shouldn't report one that isn't there.

- An attribute whose value comes from `{{ }}` counts as present. `alt="{{ image.alt }}"` passes even if `image.alt` turns out empty.
- When a tag or an output such as `attrs(...)` sits among an element's attributes, Kiln can't tell which attributes the element ends up with. It doesn't report a missing attribute on that element, but still checks the attributes it can see. A comment there makes no difference.
- An attribute whose name is partly built at render time, such as `aria-{{ name }}`, isn't checked.
- An element whose name is built at render time, such as `<{{ tag }}>`, isn't checked.
- Content inside `<svg>` and `<math>` isn't checked. SVG and MathML have elements of their own, such as SVG's `<font>`.
- Content inside elements that hold text rather than HTML, such as `<script>`, `<style>`, `<textarea>` and `<title>`, isn't checked. Nor are HTML comments, Kiln comments or [`verbatim`](https://kilnlang.com/docs/tags/verbatim/).
- Each template is checked on its own. A template it includes is checked when you check that template.
- A template that starts with an XML declaration, such as a feed or a sitemap, isn't checked.

For nesting and heading order:

- Only elements written in the same template count. A template's top-level content could end up anywhere, so its parent is unknown, and so is the parent of the content of a `macro`, `fill`, `set`, `apply`, `add`, `island` or `component`, and of a page's blocks. Rules that need the parent skip those elements.
- If the branches of an `if` leave different elements open, or a `for` loop opens an element it doesn't close, Kiln no longer knows which elements are open at that point. Elements opened after it are checked again.
- An `include`, `component`, `island`, `slot` or `block` may hold headings, so the heading after it is compared with nothing. So is the heading after an output that may hold headings, such as `{{ post.body|raw }}`. An output inside an element that can't hold headings, such as a `<p>`, doesn't count.
- The same goes for the heading after an `if` whose branches end on different headings, or a `for` loop with headings in it.

```kiln
{# Not reported: the if may add alt #}
<img src="{{ src }}" {% if alt %}alt="{{ alt }}"{% endif %}>

{# Not reported: attrs may add type #}
<button{{ attrs(attributes) }}>{{ label }}</button>

{# Reported: aria-labeledby is a typo for aria-labelledby #}
<nav aria-labeledby="site-nav-title"{{ attrs(attributes) }}>
```

Some problems need the whole rendered page, such as heading order across a layout, a page and its components, repeated `id` values, a form field without a label, or more than one `<main>`. Kiln doesn't check these. Run an HTML validator or an accessibility checker over your rendered pages for them.

## Turning rules off

The rules are on by default, and each reports a warning. Use the `htmlChecks` option to change that. Give a rule `"off"`, `"warning"` or `"error"`. Rules you leave out stay as warnings.

```ts
const kiln = createKiln({
	loader,
	htmlChecks: {
		"html-button-type": "off",
		"html-img-alt": "error",
	},
});
```

Set `htmlChecks: false` to turn every rule off, such as for an environment that only renders plain-text emails. These rules don't depend on [strict mode](https://kilnlang.com/docs/language/strict-mode/).

To give your editor the same settings, add `htmlChecks` to [`kiln.json`](https://kilnlang.com/docs/tooling/editors/#telling-the-editor-about-your-project). `generateManifest` copies it from your environment.
