Audit a website, a folder, or your codebase for image accessibility — find missing and low‑quality alt text before a screen‑reader user does.
altpilot is a zero‑dependency CLI + library that scans .html, .jsx, .tsx, .vue, .svelte, and .astro files (or a live URL) for <img>, Next.js <Image>, and JSX *Image components, then reports:
- Missing
alt— the single most common WCAG failure. - Empty
altthat isn't decorative —alt=""is correct only for decorative images. - Low‑quality
alt— filename‑as‑alt (DSC_0042.jpg), redundant prefixes ("image of…"), placeholder words ("image","logo"), and over‑long alt.
It exits non‑zero when it finds errors, so it drops straight into CI as a gate. No build step, no config file, no API key required.
$ altpilot ./public
✖:13 <img> No alt attribute. Add alt text describing the image… [missing-alt]
src: /img/hero.png
✖:22 <img> Alt text looks like a filename or slug… [filename-as-alt]
src: /img/DSC_0042.JPG
▲:25 <img> Alt starts with "image of" / "photo of"… drop the prefix. [redundant-prefix]
4 errors · 4 warnings · 1 info · 2/10 clean
$ echo $?
1
About 1 in 4 U.S. adults lives with a disability, and screen‑reader users depend on alt text to know what an image conveys. When alt is missing, most screen readers fall back to announcing the filename — so DSC_0042.jpg gets read out, character by character. When alt is bad ("image", "photo of a cat", the filename echoed back), it's noise that wastes the user's time without conveying meaning.
This is codified in WCAG 2.2 Success Criterion 1.1.1 — Non‑text Content (Level A): all non‑text content must have a text alternative that serves the equivalent purpose. The hard part isn't the rule — it's that the alt attribute can be present and still be useless, and a type‑checker or HTML validator won't catch that. altpilot grades quality, not just presence, following the W3C WAI Alt‑Text Decision Tree and WebAIM's Alternative Text guidance.
Note: automated tooling can verify mechanics (is alt present? is it a filename? is it decorative‑but‑described?). It cannot verify that the alt accurately describes the image — that's human judgment about the image's purpose in context.
altpilotis honest about this line and never pretends to cross it.
# one-off, no install
npx @velkina/altpilot ./public
# project dev dependency (recommended for CI)
npm install --save-dev @velkina/altpilot
# global
npm install -g @velkina/altpilotRequires Node 18+ (uses native fetch for URL scanning). Zero runtime dependencies.
altpilot <path-or-url> [options]altpilot ./public # scan a directory
altpilot index.html # scan a single file
altpilot src/components --json # JSON output for tooling/CI
altpilot https://example.com # scan a live URL's HTML
altpilot ./src --strict # treat warnings as failures too
altpilot ./public --suggest # draft alt-text suggestions (see below)| Option | Description |
|---|---|
--json |
Emit a structured JSON report instead of text. |
--strict |
Treat warnings as failures (exit 1 on warnings too). |
--suggest |
Draft alt suggestions for missing/weak images (offline by default). |
--no-color |
Disable ANSI colors. |
--ext <list> |
Comma‑separated extensions to scan (e.g. --ext .html,.tsx). |
--quiet |
Print only the one‑line summary. |
--help, -h |
Show help. |
--version, -v |
Show version. |
| Code | Meaning |
|---|---|
0 |
Clean — no errors (warnings allowed unless --strict). |
1 |
Accessibility errors found (or warnings under --strict). |
2 |
Usage / runtime error (bad path, network failure). |
Each rule maps to WCAG 1.1.1 and carries a stable rule id (so output is greppable and you can suppress per‑rule in your own tooling).
| Rule | Severity | Triggers when… | Why |
|---|---|---|---|
missing-alt |
error | No alt attribute at all. |
Screen readers fall back to reading the filename. Hard WCAG 1.1.1 failure. |
empty-alt-non-decorative |
warning | alt="" but the image isn't marked role="presentation"/aria-hidden. |
Empty alt is correct only for decorative images; otherwise it hides meaning. |
filename-as-alt |
error | Alt equals the src basename, ends in an image extension, or matches a device pattern (DSC_0042, IMG_2031, 20231101-120000), or is a slug (hero-banner-final-v2). |
A filename is not a description. This is the most common "present but useless" failure. |
placeholder-alt |
error / warning | Alt is a single low‑info word. "image", "photo", "thumbnail" → error. "logo", "icon", "banner", "avatar" → warning (they often just need brand/context). |
"image" conveys nothing; "logo" needs which brand. |
redundant-prefix |
warning | Alt starts with "image of", "photo of", "picture of", "graphic of", etc. |
Screen readers already announce it as an image — the prefix is repeated noise. (WebAIM) |
alt-too-long |
warning | Alt is longer than 125 characters. | Long descriptions belong in body text, a <figcaption>, or aria-describedby. 125 is the widely‑cited soft ceiling (historic JAWS truncation point). |
decorative-ok |
info | alt="" on an image correctly marked decorative. |
Confirmation, not a problem — surfaced so you can see it was intentional. |
dynamic-alt |
info | JSX alt={expr} — value can't be known statically. |
Reported, never failed: ensure the runtime value is descriptive. |
spread-alt |
info | JSX <img {...props} /> with no literal alt. |
Alt may be supplied via the spread; verify it always is. |
logo/iconare warnings, not errors.alt="Acme Corporation logo"is fine; barealt="logo"usually isn't, but it's not a hard failure — so it warns rather than blocking CI.125chars is a soft limit (warning). There is no hard WCAG number; concision is a recommendation, not a rule, so over‑long alt never fails the build by itself.- Comments and
<img>text inside them are ignored. The scanner blanks//,/* */, JSX{/* */}, and<!-- -->comments before matching, so example markup in comments doesn't produce false positives. - Dynamic and spread alts never fail CI.
alt={caption}and{...props}are surfaced asinfo, because their value isn't statically knowable —altpilotwon't block you on something it can't actually see.
import { scan } from '@velkina/altpilot';
const report = await scan('./public');
console.log(report.summary);
// { images: 42, clean: 30, errors: 5, warnings: 7, infos: 0, byRule: {...} }
if (report.summary.errors > 0) {
process.exit(1);
}Lower‑level building blocks are exported too:
import { analyzeImage, extractImages } from '@velkina/altpilot';
extractImages('<img src="/x.png">', 'html');
// [{ tag: 'img', src: '/x.png', alt: null, altPresent: false, line: 1, ... }]
analyzeImage({ alt: 'DSC_0042.jpg', altPresent: true, src: '/img/DSC_0042.jpg' });
// [{ rule: 'filename-as-alt', severity: 'error', message: '…', wcag: '1.1.1' }]# .github/workflows/a11y.yml
name: a11y
on: [push, pull_request]
jobs:
alt-text:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npx @velkina/altpilot ./src --strictThe step fails the build when errors are found (or warnings, under --strict). Use --json if you want to upload a machine‑readable artifact:
npx @velkina/altpilot ./src --json > altpilot-report.json# .husky/pre-commit
npx @velkina/altpilot ./public || {
echo "Fix the alt-text issues above before committing." >&2
exit 1
}--suggest drafts candidate alt text for missing/weak images. The core tool is fully useful without it.
By default it runs offline: it derives a scaffold from the filename (golden-retriever-puppy.jpg → "Golden retriever puppy"), clearly marked low‑confidence and "NOT from image content — verify before using." When a filename carries no meaning (DSC_0042.jpg), it says so honestly rather than inventing detail.
✖:13 <img> No alt attribute… [missing-alt]
src: /img/golden-retriever-puppy.jpg
suggest: alt="Golden retriever puppy" (scaffold, confidence: low)
note: Draft derived from the filename only — NOT from image content. Verify before using.
To get real, content‑aware descriptions, register a vision provider. altpilot ships no SDK and no hardcoded key — you wire your own and it's read from the environment, degrading to the offline scaffold if the key is absent or the call fails:
import { registerProvider, createSuggester } from '@velkina/altpilot';
// Contract: async (input, env) => { text, confidence, source? }
// input = { src, tag, file, context }
registerProvider('openai', async (input, env) => {
// your call to a vision model using env.OPENAI_API_KEY …
return { text: 'A golden retriever puppy chewing a tennis ball on grass', confidence: 'high' };
});
const suggest = createSuggester(); // auto-detects provider from env
const out = await suggest({ src: '/img/puppy.jpg' });Provider selection: set ALTPILOT_SUGGEST_PROVIDER=openai (or rely on auto‑detection from a present OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY among registered providers). If nothing is configured, you get the offline scaffold. altpilot never auto‑writes suggestions into your files — alt text is human‑approved by design.
- It does not judge whether your alt text is accurate. It can tell
alt="DSC_0042.jpg"is a filename; it cannot tell whetheralt="A cat"is true of the image. That requires seeing the image (use--suggestwith a vision provider) and, ultimately, human judgment. - It is a static scanner, not a browser. For a live URL it reads the server‑rendered HTML; images injected later by client‑side JavaScript won't be seen. For SPAs, scan the source files instead.
- It checks
<img>,<Image>,<image>, and*Imagecomponents. It does not yet check CSSbackground-image,<svg><title>/role,<input type="image">,<area>,aria-labelon icon buttons, or<picture>/<source>nuances. These are tracked as future work. - String literals containing
<img>are not stripped (only comments are). A<img>inside a runtime string template is a rare and benign false‑positive source.
These are deliberate scope lines, not hidden gaps. PRs that extend coverage are welcome.
git clone https://github.com/VelkinaStudio/altpilot.git
cd altpilot
node --test # run the test suite (no install needed — zero deps)
node bin/altpilot.js test/fixtures/sample.html # try it on the fixturesMIT © Velkina Studio