Deadheading is cutting the spent growth off a plant so the rest keeps blooming.
This does that to your <head>.
It finds HTML that is harmful, deprecated or unnecessary, and for every finding it links to a written explanation of why, with sources.
$ npx deadhead dist
dist/index.html
5:5 unnecessary meta/revisit-after
A crawl-schedule hint that search engines ignore; recrawl timing comes
from sitemaps, not markup.
-> Delete the element and publish change timing in an XML sitemap.
https://deadhead.cevdet.ch/rules/meta/revisit-after
x 1 finding (1 unnecessary)
Severity is harmful, deprecated or unnecessary -- what the finding costs you,
not how loudly the tool wants to say it.
That last line is the whole point, and every rule is published at deadhead.cevdet.ch, generated from the same markdown the linter is built from. The finding and its explanation cannot drift, because they are the same file.
Most tools tell you that something is wrong. Head markup needs why: whether the advice you read in 2014 still holds. Every rule here is a markdown document with at least two independent sources, and the linter is generated from those documents. If a rule cannot be explained, it does not ship.
- Node
>=24.0.0(enforced at install time viaengine-strict=truein.npmrc) - pnpm (version pinned in the
packageManagerfield ofpackage.json)
pnpm installNot yet published. From a clone:
pnpm install && pnpm build && pnpm check <path>.
Build the rule set from the markdown source of truth, then run the CLI:
pnpm build
pnpm check -- path/to/your/htmldeadhead takes files, directories, or globs -- it expands globs itself, so they
behave the same in every shell:
deadhead dist # walk a directory for .html and .htm
deadhead "src/**/*.html" # quote it; the CLI does the expanding
deadhead --format=sarif dist # stylish (default), json, sarif
deadhead --fail-on=harmful dist # exit 1 only on harmful findings
deadhead --skip-templates dist # leave <template> contents alonedeadhead --fix dist # rewrite files, then report what is leftExit codes are the CI contract: 0 nothing at or above the --fail-on threshold,
1 threshold met, 2 usage, config or I/O error, no HTML files to lint, or an
internal error. A broken invocation never looks
like a clean run.
--fix splices byte ranges out of the original file. It never parses your document and
prints it back, because that rewrites quote style and attribute order across the whole
file. Whitespace and character references shift with them, turning a one-line fix into
a thousand-line
diff. The same property is what makes ESLint autofix free, and the conformance suite
asserts the two produce byte-identical output.
A fix is one of remove-element, remove-attribute, remove-token, remove-tokens or
none. They all subtract: nothing asserts a value the rule was never asked about.
remove-token drops a single keyword from a space-separated attribute:
rel="shortcut icon mask-icon" becomes rel="icon mask-icon". It edits inside the
quotes, so the delimiter you chose survives. remove-tokens deletes every keyword the
selector tests with [attr~=…]; it removes the element only when none survive.
Two things are never fixed automatically: a rule that declares fix: { op: "none" }, and
any rule whose detectability is partial: if the rule is not certain, it does not get
to edit your file. The fixer skips overlapping fixes rather than merging them, and a
second pass
picks them up.
deadhead.config.ts, optional, read from the project directory. Every flag beats it.
export default {
include: ["dist/**/*.html"],
ignore: ["**/vendor/**"],
rules: {
"meta/http-equiv-x-ua-compatible": "off",
"script/type-javascript-mime": "harmful", // or re-severity it
},
failOn: "deprecated",
baseline: ".deadhead-baseline.json",
};Once the packages are published, wrapping the object in defineConfig from
@deadhead/cli gets you completion and type checking; a plain object works either way.
A typo in a rule id is an error rather than a setting that silently does nothing. The failure it prevents is a rule you thought you had disabled still being on.
Adopt deadhead on an existing site without fixing the backlog first:
deadhead --baseline .deadhead-baseline.json --update-baseline dist # accept today
deadhead --baseline .deadhead-baseline.json dist # fail only on newThe baseline records a count per file per rule, not line numbers or source hashes. Those rot: reindent a file, or add an element above, and every entry below it would go stale and light up CI with findings nobody introduced. An allowance of 3 absorbs a different third finding of the same rule in the same file. When the backlog shrinks, deadhead says so and suggests pruning.
Silence a finding in the markup itself:
<!-- deadhead-disable-next-line meta/http-equiv-x-ua-compatible -->
<meta http-equiv="X-UA-Compatible" content="IE=edge"><!-- deadhead-disable --> (optionally with rule ids) turns findings off until
<!-- deadhead-enable -->. The engine never lints contents of <pre>, <code>,
<textarea>, <samp>, <kbd>, <iframe>, <noembed>, <noframes>, <noscript>,
<plaintext>, <title> and <xmp> -- documenting bad markup is not writing it.
Other commands:
pnpm validate:rules # frontmatter and rule docs fail fast, before the suite
pnpm test # full test suite (node:test)
pnpm test:conformance # every fixture through every adapter
pnpm typecheck # strict typecheck (core, rules, cli, scripts, tests)
pnpm check:self # run the linter over its own clean fixtures
pnpm check:site # build the docs site, then lint its own <head> with the CLI
pnpm lint:deps # dependency hygiene checkpnpm site:dev # Astro dev server on the rule markdown
pnpm site:build # static build to site/dist
pnpm site:preview # preview static build of site/dist
pnpm site:check # typecheck the .astro filessite/ is an Astro project that reads content/rules/**/*.md in place -- no
copy, no second corpus -- and publishes each one at /rules/<ruleId>, the exact url
every finding prints. Its enums are imported from
packages/core/vocabulary.ts, so a site that could
render a severity the engine rejects will not typecheck.
It is also a fixture. pnpm check:site runs the CLI over the rendered output, which
means the head this project ships is held to the rules this project ships -- in a
document assembled by a framework, which is where head markup goes wrong.
- CLI for CI and pre-commit:
npx deadhead dist - Bookmarklet to inspect any page you are looking at, including pages you did not build. It reads the rendered DOM, which is the ground truth for what shipped.
- ESLint plugin for editor feedback while you type. A convenience, not the foundation: for framework users the head tags live in JSX, Vue or Svelte where an HTML parser never sees them.
All three run the same rule set and are tested against each other, so they cannot
disagree. pnpm test:conformance runs every fixture through every adapter and fails
if they diverge, including the built bookmarklet, executed as a bundle.
pnpm build && pnpm build:bookmarklet && pnpm build:csspackages/browser/bookmarklet.js is one self-contained IIFE with the rules inlined and
no network access at all, so a strict Content-Security-Policy cannot block it.
That matters, because a locked-down page is the one worth inspecting. The same file is
the devtools snippet: paste it into Sources → Snippets and run.
packages/browser/deadhead.css outlines offenders in place, in the
ct.css spirit, using head, head * { display: block } to
give head elements a box to draw. Only selector-backed rules can appear in it: a
kind: "document" rule asks something CSS cannot ask, and a rule refined by code is
included but drawn with a dashed outline and labelled (approximate), because the
stylesheet cannot run the refinement.
// eslint.config.js
import htmlParser from "@html-eslint/parser";
import deadhead from "eslint-plugin-deadhead";
export default [
{
files: ["**/*.html"],
languageOptions: { parser: htmlParser },
plugins: { deadhead },
rules: deadhead.configs.recommended.rules,
},
];Rule names are the ruleId verbatim (deadhead/meta/http-equiv-x-ua-compatible),
because ESLint splits an unscoped rule id on its first slash. The ESLint config, the CLI
output, your suppression comments and the markdown all say the same permanent string.
configs.recommended enables everything above unnecessary; configs.all enables every
rule. Severity stays yours to set.
A rendered document has no source text, so range() and loc() are null in the
browser. The browser adapter offers no fixes, and the engine does not read suppression
comments there. A rule whose
verdict depends on byte offsets does not fire there. head/charset-position asks whether
a declaration
lands inside the first 1024 bytes, and a live DOM has no offsets to compare. That asymmetry is
declared in the conformance suite and asserted in both directions, so it cannot quietly
become drift.
Early in development, but the foundations are in place: all three runtimes work and are checked against each other, autofix, the config file and the baseline file have landed, and the rule documentation is published. One markdown file per rule; see the site for the list. It grows one researched rule at a time, and that is the bottleneck by design.
One rule per issue, one rule per pull request. Open a New rule issue; the form is the research checklist. See CONTRIBUTING.md.