Enforcing Alt Text for Markdown Images at Build Time
Markdown makes it easy to add an image and easy to skip the alt text:  is valid, renders, and leaves a screen reader user with nothing, or with the file name read aloud. On the reference site from Accessibility for Static Sites, the first audit found 141 images with empty or missing alt text and another 96 whose alt text was a file name, "image" or "screenshot". axe-core reported the 141. It could not see the 96, because an alt attribute containing "screenshot-2024-03-11.png" is present and therefore passes.
Images arrive through content, not templates, so they are added by writers who may never have heard of alt text. The way to keep the problem fixed is to check it where the content is written — in the Markdown, before the build — and fail with a message that explains what to do. This guide adds that check as a remark plugin for Astro and other unified-based generators, a markdown-it plugin for Eleventy and VitePress, and a render hook for Hugo.
Prerequisites
- A generator whose Markdown pipeline accepts plugins (Astro, Eleventy, VitePress, Docusaurus, Starlight) or render hooks (Hugo).
- CI that runs the build on pull requests, so a failing check blocks the merge.
- Agreement on a marker for decorative images. This guide uses an empty alt,
, plus an allow-list path for decorative assets.
What the Check Should Reject
The rule set came from the audit. Each of these was found on the reference site:
| Rule | Example | Found |
|---|---|---|
| Missing or empty alt on a non-decorative image |  | 141 |
| Alt is a file name |  | 41 |
| Alt is a generic word | ,  | 47 |
| Alt starts with "image of" or "picture of" |  | 8 |
| Alt duplicates the adjacent caption | alt and caption identical | 19 |
| Alt longer than 250 characters | a whole table read aloud | 6 |
"Image of" is redundant because screen readers already announce "image" or "graphic". Duplicated captions are read twice. Very long alt text cannot be navigated — a screen reader user cannot move through it word by word as easily as through a paragraph — so long descriptions belong in the page as text.
Step 1: A remark Plugin for Astro and Other unified Pipelines
Astro, Starlight, Docusaurus and Next.js MDX all use remark. The plugin visits every image node and records problems with the file and line:
// plugins/remark-alt-lint.mjs
import { visit } from 'unist-util-visit';
const GENERIC = /^(image|img|picture|photo|screenshot|graphic|diagram|figure)\s*\d*$/i;
const FILE = /\.(png|jpe?g|gif|webp|avif|svg)$/i;
const DECORATIVE = /\/decorative\//;
export default function remarkAltLint({ fail = true } = {}) {
return (tree, file) => {
visit(tree, 'image', (node) => {
const alt = (node.alt || '').trim();
const where = `${file.path}:${node.position?.start.line}`;
let problem = null;
if (!alt && !DECORATIVE.test(node.url)) problem = 'missing alt text';
else if (FILE.test(alt)) problem = 'alt text is a file name';
else if (GENERIC.test(alt)) problem = `alt text "${alt}" says nothing about the image`;
else if (/^(an? )?(image|picture|photo) of /i.test(alt)) problem = 'drop "image of"; screen readers announce images already';
else if (alt.length > 250) problem = 'alt text over 250 characters; move the detail into the page';
if (problem) {
const msg = file.message(`${problem} (${node.url})`, node);
msg.fatal = fail;
}
});
};
}
Register it in astro.config.mjs under markdown.remarkPlugins. Setting msg.fatal makes the build stop with the file and line in the error, which is the message a writer sees in CI. The caption-duplicate rule needs the surrounding figure, so it runs as a separate check on the HTML (Step 4).
Step 2: A markdown-it Plugin for Eleventy and VitePress
Eleventy and VitePress use markdown-it. The same rules, applied to the image renderer:
// .eleventy.js or .vitepress/config.mjs
function altLint(md) {
const original = md.renderer.rules.image;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const alt = token.content.trim();
const src = token.attrGet('src');
const problem = checkAlt(alt, src); // the same rules as the remark plugin
if (problem) throw new Error(`${env.page?.inputPath || env.relativePath}: ${problem} (${src})`);
return original(tokens, idx, options, env, self);
};
}
Throwing stops the build in both generators. Sharing checkAlt as a small module between projects keeps the rules identical across a company's sites; the reference organisation uses one module for an Eleventy blog and the Starlight docs.
Step 3: A Render Hook for Hugo
Hugo has no plugin system, but an image render hook sees every Markdown image and can stop the build with errorf:
{{/* layouts/_default/_markup/render-image.html */}}
{{- $alt := trim .Text " " -}}
{{- if and (eq $alt "") (not (strings.Contains .Destination "/decorative/")) -}}
{{- errorf "%s: missing alt text for %s" .Page.File.Path .Destination -}}
{{- else if findRE `(?i)^(image|screenshot|picture|photo)\s*\d*$` $alt -}}
{{- errorf "%s: alt text %q says nothing about %s" .Page.File.Path $alt .Destination -}}
{{- end -}}
<img src="{{ .Destination | safeURL }}" alt="{{ $alt }}" loading="lazy" decoding="async">
errorf fails hugo with a non-zero exit code, which fails CI. Use warnf while cleaning up existing content, then switch to errorf.
Step 4: An HTML Check for Everything Else
Some images do not come from Markdown: images in components, in layouts, in MDX JSX. A post-build check over the HTML catches them, plus the caption-duplicate rule, which needs <figure> context:
import { parse } from 'node-html-parser';
for (const file of htmlFiles) {
const root = parse(await fs.readFile(file, 'utf8'));
for (const fig of root.querySelectorAll('figure')) {
const img = fig.querySelector('img'); const cap = fig.querySelector('figcaption');
if (img && cap && img.getAttribute('alt')?.trim() === cap.text.trim())
problems.push(`${file}: alt duplicates caption "${cap.text.trim().slice(0, 50)}"`);
}
}
It runs in 3 seconds over 1,140 pages. For Jekyll, which has no convenient Markdown hook, this HTML check is the whole solution.
Step 5: Clean Up, Then Switch to Failing
Turning on a failing check against 262 existing problems would block every pull request. The rollout ran in three stages:
- Report only for two weeks, with the problem list posted as a CI annotation on every pull request.
- Fix the backlog. Writers fixed images in pages they owned; 262 problems took about 9 hours across four people, roughly two minutes each.
- Fail on new problems. The check became fatal, with a documented escape hatch: decorative images go in
/images/decorative/, and a page can setaltLintIgnore: truein frontmatter with a reason, which a reviewer must approve.
Writing Guidance That Goes With the Check
A check that only says "missing alt text" teaches writers to type something that passes. The error message links to a one-page guide with three questions: What would a reader miss if the image did not load? Is that already in the text or caption? Is the image decorative? And two examples from the site's own content: a screenshot whose alt is "Deploy settings with Build command set to npm run build and output directory dist", and a chart whose alt is "Build time by generator; full results in the table below". The quality of new alt text improved noticeably once the guide was linked from the error, judged by a sample review of 50 images before and after.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Images with missing alt | 141 | 0 | remark plugin |
| Images with useless alt | 121 | 0 | remark plugin + HTML check |
axe image-alt violations | 141 | 0 | axe-core 4.10 |
| Build time added | — | 0.4 s (lint) + 3 s (HTML) | CI logs |
| Pull requests blocked in 8 weeks | — | 21, all fixed in the PR | CI history |
Pitfalls & Rollback
- Failing immediately on a backlog. Report first, fix, then fail.
- No way to mark decorative images. Writers will add "decoration" as alt text, which is worse than empty.
- Checking only Markdown. Components and layouts add images too; the HTML check covers them.
- Treating length as quality. A long description is not a good one; keep alt short and put detail in the page.
- Rollback: set the plugin's
failoption to false to return to report-only mode without removing it.
Conclusion
Alt text problems come from content, and content changes every day, so the check belongs in the Markdown pipeline where writers will see it. A remark plugin, a markdown-it rule or a Hugo render hook — plus a short HTML check for everything else — found 262 problems on the reference site, of which axe could see only 141, and has kept new ones out since.
FAQ
Should every image have alt text?
Every image needs an alt attribute, but purely decorative images should have an empty one, alt equals empty string, so screen readers skip them. Informative images need text that conveys what the image tells a sighted reader. The build check should therefore require the attribute and reject useless values, while allowing an explicit marker for decorative images.
What makes alt text useless?
Text that does not carry the image's information. Common examples are the file name, words like image, screenshot or picture on their own, the same text as the caption next to the image, and very long descriptions that read a whole table aloud. A check can catch the first three mechanically; length and quality still need review.
Can AI-generated alt text replace writing it?
It can describe what an image looks like, but not why it is on the page. A screenshot of a settings screen might be described as a form with toggles, when the reader needs to know that the Enable caching toggle is switched on. Generated text is a reasonable draft for a writer to edit, not a substitute, and the build check should treat it like any other text.
How should alt text work for images with captions?
If a figure caption already says what the image shows, the alt text should not repeat it word for word, because screen readers will read both. Use alt text for what the caption does not say, or keep it brief. For charts, put the key finding in the caption and the data in a table, and keep the alt short.
Related
- Parent: Accessibility for Static Sites — content as the largest source of issues.
- Fixing Heading Order Problems in Markdown Content — the companion lint for headings.
- Accessible SVG Diagrams on Static Sites — text alternatives for inline diagrams.
- Markdown Pipelines: remark vs markdown-it — the two plugin systems used here.
- Fixing CLS From Images Without Dimensions in Markdown — the other Markdown image problem.
Filed underHow-tos