Enforcing Alt Text for Markdown Images at Build Time

Markdown makes it easy to add an image and easy to skip the alt text: ![](./screenshot.png) 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, ![](divider.svg), 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:

RuleExampleFound
Missing or empty alt on a non-decorative image![](./deploy-settings.png)141
Alt is a file name![deploy-settings.png](...)41
Alt is a generic word![screenshot](...), ![image](...)47
Alt starts with "image of" or "picture of"![Image of the dashboard](...)8
Alt duplicates the adjacent captionalt and caption identical19
Alt longer than 250 charactersa whole table read aloud6

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

Count of each alt text problem found by the first build check Horizontal bars. Missing or empty alt on informative images 141. Generic words such as screenshot or image 47. File names 41. Alt duplicating the caption 19. Alt starting with image of 8. Alt over 250 characters 6. Alt text problems found on the first run Missing or empty 141 Generic word 47 File name 41 Duplicates caption 19 Starts with image of 8 Over 250 characters 6
262 problems across 1,140 pages; axe-core had reported only the first category.

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.

The alt text check's hook point in each generator family The root asks which Markdown engine the generator uses. remark, used by Astro, Starlight, Docusaurus and Next.js MDX, takes a remark plugin with fatal messages. markdown-it, used by Eleventy and VitePress, takes a renderer rule that throws. Goldmark in Hugo takes an image render hook with errorf. Kramdown in Jekyll takes a Ruby hook or an HTML check after the build. Where the check hooks in, per generator Which Markdown engine does the generator use? remark (Astro, Docusaurus) remark plugin fatal file message markdown-it (11ty, VitePress) renderer rule throw an Error Goldmark (Hugo) render hook errorf stops the build Kramdown (Jekyll) post-build HTML check scan img in _site
The rules are the same everywhere; only the hook differs.

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:

  1. Report only for two weeks, with the problem list posted as a CI annotation on every pull request.
  2. Fix the backlog. Writers fixed images in pages they owned; 262 problems took about 9 hours across four people, roughly two minutes each.
  3. Fail on new problems. The check became fatal, with a documented escape hatch: decorative images go in /images/decorative/, and a page can set altLintIgnore: true in frontmatter with a reason, which a reviewer must approve.
Timeline of the alt text check rollout over four weeks Four events. Week 0, the check is added in report-only mode with 262 problems. Weeks 1 and 2, writers fix the backlog, taking about nine hours in total. Week 3, the check becomes fatal for new problems. In the eight weeks after, it blocked 21 pull requests, each fixed in the same pull request. Rolling the check out without blocking everyone Report only Backlog fixed Fatal for new Steady state Week 0 262 problems listed Weeks 1–2 about 9 hours total Week 3 escape hatch documented +8 weeks 21 PRs blocked and fixed
The escape hatch was used twice in eight weeks, both for decorative illustrations.

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

MeasureBeforeAfterTool
Images with missing alt1410remark plugin
Images with useless alt1210remark plugin + HTML check
axe image-alt violations1410axe-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 PRCI 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 fail option 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.

Filed underHow-tos