# Accessibility Auditor

URL: /docs/experiments/accessibility-auditor
> Run axe-core WCAG 2.1 AA accessibility audits with Browser Rendering and suggest alt text for unlabeled images via Workers AI vision
Tags: browser, ai, accessibility, wcag, axe-core, a11y, workers-ai
Bindings: BROWSER, AI
Markdown: /llms.mdx/experiments/accessibility-auditor/content.md



Audit any page for WCAG 2.0/2.1 **A + AA** issues by injecting [axe-core](https://github.com/dequelabs/axe-core) into a **Browser Rendering** session, then optionally describe images that are missing alt text with a **Workers AI** vision model.

## Features [#features]

* **GET /audit** - axe-core run on the fully rendered page, summarized into a compact JSON report
* **Impact counts and score** - violated rules grouped by `critical` / `serious` / `moderate` / `minor`, plus a 0–100 weighted score
* **Alt text suggestions** - `?altText=true` describes up to 5 `<img>` elements with missing or empty `alt`
* **POST /alt-text** - standalone alt text generation for an uploaded image or image URL

## API Reference [#api-reference]

### GET /audit [#get-audit]

Loads the page, injects axe-core, and runs rules tagged `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`.

<TypeTable
  type="{
  url: {
    description: &#x22;Page URL to audit (`http://` or `https://`)&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
  altText: {
    description:
      &#x22;`true` to fetch up to 5 images missing alt text (max 2 MB, `image/*` only) and generate suggestions&#x22;,
    type: &#x22;boolean&#x22;,
  },
}"
/>

#### Example Request [#example-request]

```bash
curl "https://your-worker.workers.dev/audit?url=https://dequeuniversity.com/demo/mars/&altText=true"
```

#### Success Response [#success-response]

```json
{
  "url": "https://dequeuniversity.com/demo/mars/",
  "score": 35,
  "counts": { "critical": 3, "serious": 5, "moderate": 0, "minor": 0 },
  "violations": [
    {
      "id": "button-name",
      "impact": "critical",
      "help": "Buttons must have discernible text",
      "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/button-name?application=axeAPI",
      "totalNodes": 1,
      "nodes": [
        {
          "target": [".departure-date > .ui-datepicker-trigger:nth-child(4)"],
          "html": "<button class=\"ui-datepicker-trigger\" type=\"button\">…"
        }
      ]
    }
  ],
  "passes": 29,
  "incomplete": 3,
  "altSuggestions": [
    {
      "src": "https://dequeuniversity.com/assets/demo-sites/mars/images/mars-spaceman.jpg",
      "suggestion": "A space suit with a helmet on a rocky surface."
    }
  ]
}
```

**`counts`** `object`

Number of violated **rules** per impact level.

**`violations[].totalNodes`** `number`

Failing elements for that rule. At most 10 are returned in `nodes`, each with its CSS `target` (shadow DOM hops joined with `>>>`) and `html` truncated to 300 characters.

**`score`** `number`

`100 × passes / (passes + penalty)`, where each violated rule adds 10 (critical), 5 (serious), 3 (moderate), or 1 (minor). `100` means no violations.

**`altSuggestions`** `array` (only with `altText=true`)

`{ src, suggestion }`. When an image can't be fetched or described, `suggestion` is `null` and `error` explains why.

### POST /alt-text [#post-alt-text]

Generate alt text for a single image. Send raw image bytes (`Content-Type: image/*`, max 2 MB) **or** pass an image URL.

<TypeTable
  type="{
  image: {
    description: &#x22;Image URL to describe instead of sending a body (`http://` or `https://`)&#x22;,
    type: &#x22;string&#x22;,
  },
}"
/>

#### Example Request [#example-request-1]

```bash
curl -X POST "https://your-worker.workers.dev/alt-text" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

#### Success Response [#success-response-1]

```json
{
  "altText": "A red bicycle leaning against a brick wall.",
  "model": "@cf/llava-hf/llava-1.5-7b-hf"
}
```

#### Error Codes [#error-codes]

* `400` - Missing or invalid `url` / `image` (`INVALID_URL`), `altText` not `true`/`false` (`INVALID_QUERY`), empty body (`MISSING_IMAGE`)
* `413` - Image larger than 2 MB (`PAYLOAD_TOO_LARGE`)
* `415` - Body or remote image is not `image/*` (`UNSUPPORTED_MEDIA_TYPE`)
* `502` - Navigation or axe-core injection failed (`BROWSER_ERROR`), remote image fetch failed (`FETCH_ERROR`), Workers AI call failed (`AI_ERROR`)

## Implementation Details [#implementation-details]

* **Injection** - `page.addScriptTag({ content: axe.source })` after `page.setBypassCSP(true)`, so strict Content-Security-Policy headers don't block the inline script. If the page reloads itself (e.g. `<meta http-equiv="refresh">`) and `window.axe` is gone, axe is injected once more.
* **`keep_names: false`** - `axe.source` is axe-core's function stringified from the Worker bundle. With Wrangler's default `keep_names`, esbuild injects `__name()` calls into that function that don't exist in the page, so `wrangler.json` turns it off.
* **Navigation** - 1280×800 viewport, `networkidle2`, 20 s timeout.
* **Vision model** - `@cf/llava-hf/llava-1.5-7b-hf` receives `{ image: number[], prompt, max_tokens: 80 }`; prefixes like "Image of" are stripped from the output.

## Use Cases [#use-cases]

* Add an accessibility gate to CI or preview deployments
* Track WCAG regressions across a list of pages over time
* Bootstrap alt text for legacy image-heavy content
* Learn how to inject third-party scripts into Browser Rendering sessions

## Limitations [#limitations]

* Automated checks catch only part of WCAG; manual review is still required
* Single page per request; no crawling or authenticated pages
* Alt text suggestions are a starting point. Review them for context, and use `alt=""` for decorative images
* Images over 2 MB, non-`image/*` responses, and `data:` URIs are skipped
* Workers AI vision models are billed per request and can be slow on cold starts

## Use in your project [#use-in-your-project]

<UseInYourProject
  experiment="accessibility-auditor"
  dependencies="[&#x22;@cloudflare/puppeteer&#x22;, &#x22;axe-core&#x22;]"
  bindings="[&#x22;Browser Rendering&#x22;]"
  platform="[&#x22;Workers runtime&#x22;]"
  files="[
  &#x22;src/lib/browser.ts&#x22;,
  &#x22;src/lib/report.ts&#x22;,
  &#x22;src/lib/url.ts&#x22;,
  &#x22;src/types/audit.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/browser.ts&#x22;,&#x22;title&#x22;:&#x22;browser.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import puppeteer from \&#x22;@cloudflare/puppeteer\&#x22;;\nimport type { Page } from \&#x22;@cloudflare/puppeteer\&#x22;;\nimport axe from \&#x22;axe-core\&#x22;;\nimport {\n  AXE_TAGS,\n  DEFAULT_VIEWPORT,\n  MAX_ALT_IMAGES,\n  MAX_AXE_ATTEMPTS,\n  NAVIGATION_TIMEOUT_MS,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { PageAuditResult } from \&#x22;../types/audit\&#x22;;\n\ntype AxeGlobal = {\n  axe?: {\n    run: (\n      context: Document,\n      options: { runOnly: { type: \&#x22;tag\&#x22;; values: string[] }; resultTypes: string[] }\n    ) => Promise<{\n      violations: {\n        id: string;\n        impact?: string | null;\n        help: string;\n        helpUrl: string;\n        nodes: { target: (string | string[])[]; html: string }[];\n      }[];\n      passes: unknown[];\n      incomplete: unknown[];\n    }>;\n  };\n};\n\n/** Returns null when `window.axe` is missing (e.g. a meta refresh reloaded the page after injection). */\nasync function evaluateAxe(page: Page): Promise<PageAuditResult | null> {\n  // Runs inside the page: must not reference module scope (pass values as args).\n  return (await page.evaluate(\n    async (tags: string[], maxImages: number) => {\n      const axeGlobal = (window as unknown as AxeGlobal).axe;\n      if (!axeGlobal) return null;\n      const results = await axeGlobal.run(document, {\n        runOnly: { type: \&#x22;tag\&#x22;, values: tags },\n        resultTypes: [\&#x22;violations\&#x22;],\n      });\n      const imagesMissingAlt = Array.from(document.querySelectorAll(\&#x22;img\&#x22;))\n        .filter(\n          (img) => !img.getAttribute(\&#x22;alt\&#x22;)?.trim() && img.getAttribute(\&#x22;role\&#x22;) !== \&#x22;presentation\&#x22;\n        )\n        .map((img) => img.currentSrc || img.src)\n        .filter((src) => src.startsWith(\&#x22;http://\&#x22;) || src.startsWith(\&#x22;https://\&#x22;))\n        .filter((src, index, all) => all.indexOf(src) === index)\n        .slice(0, maxImages);\n      return {\n        axe: {\n          violations: results.violations.map((v) => ({\n            id: v.id,\n            impact: v.impact ?? null,\n            help: v.help,\n            helpUrl: v.helpUrl,\n            nodes: v.nodes.map((n) => ({ target: n.target, html: n.html })),\n          })),\n          passes: results.passes.length,\n          incomplete: results.incomplete.length,\n        },\n        imagesMissingAlt,\n      };\n    },\n    AXE_TAGS,\n    MAX_ALT_IMAGES\n  )) as PageAuditResult | null;\n}\n\n/** Navigates to `url`, injects axe-core, and returns slim results plus `<img>` srcs lacking alt text. */\nexport async function runAxeAudit(browserBinding: Fetcher, url: string): Promise<PageAuditResult> {\n  let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null;\n  try {\n    browser = await puppeteer.launch(browserBinding);\n    const page = await browser.newPage();\n    await page.setViewport(DEFAULT_VIEWPORT);\n    // Lets the inline axe <script> run on sites whose CSP forbids inline scripts.\n    await page.setBypassCSP(true);\n    await page.goto(url, { waitUntil: \&#x22;networkidle2\&#x22;, timeout: NAVIGATION_TIMEOUT_MS });\n\n    for (let attempt = 0; attempt < MAX_AXE_ATTEMPTS; attempt++) {\n      // axe.source is axe's bundled function stringified; wrangler.json sets keep_names: false\n      // so esbuild doesn't inject __name() calls into it that are undefined in the page.\n      await page.addScriptTag({ content: axe.source });\n      const result = await evaluateAxe(page);\n      if (result) return result;\n    }\n    throw new Error(\&#x22;axe-core could not be loaded into the page\&#x22;);\n  } finally {\n    if (browser) {\n      try {\n        await browser.close();\n      } catch {\n        /* ignore close errors */\n      }\n    }\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/report.ts&#x22;,&#x22;title&#x22;:&#x22;report.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { IMPACT_WEIGHTS, MAX_HTML_LENGTH, MAX_NODES_PER_VIOLATION } from \&#x22;../constants/defaults\&#x22;;\nimport type { AuditReport, ImpactCounts, RawAxeNode, RawAxeResults } from \&#x22;../types/audit\&#x22;;\n\nexport function truncate(text: string, max: number = MAX_HTML_LENGTH): string {\n  return text.length > max ? `${text.slice(0, max - 1)}…` : text;\n}\n\nfunction flattenTarget(target: RawAxeNode[\&#x22;target\&#x22;]): string[] {\n  return target.map((part) => (Array.isArray(part) ? part.join(\&#x22; >>> \&#x22;) : part));\n}\n\n/**\n * Weighted pass ratio: each violated rule costs IMPACT_WEIGHTS[impact] \&#x22;passes\&#x22;.\n * 100 means no violations; rules without an impact count as minor.\n */\nexport function computeScore(passes: number, counts: ImpactCounts): number {\n  const penalty =\n    counts.critical * IMPACT_WEIGHTS.critical +\n    counts.serious * IMPACT_WEIGHTS.serious +\n    counts.moderate * IMPACT_WEIGHTS.moderate +\n    counts.minor * IMPACT_WEIGHTS.minor;\n  if (penalty === 0) return 100;\n  return Math.round((100 * passes) / (passes + penalty));\n}\n\nexport function summarizeAxeResults(url: string, raw: RawAxeResults): AuditReport {\n  const counts: ImpactCounts = { critical: 0, serious: 0, moderate: 0, minor: 0 };\n  for (const violation of raw.violations) {\n    counts[violation.impact ?? \&#x22;minor\&#x22;] += 1;\n  }\n\n  const violations = raw.violations.map((violation) => ({\n    id: violation.id,\n    impact: violation.impact,\n    help: violation.help,\n    helpUrl: violation.helpUrl,\n    totalNodes: violation.nodes.length,\n    nodes: violation.nodes.slice(0, MAX_NODES_PER_VIOLATION).map((node) => ({\n      target: flattenTarget(node.target),\n      html: truncate(node.html),\n    })),\n  }));\n\n  return {\n    url,\n    score: computeScore(raw.passes, counts),\n    counts,\n    violations,\n    passes: raw.passes,\n    incomplete: raw.incomplete,\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/url.ts&#x22;,&#x22;title&#x22;:&#x22;url.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { ALLOWED_SCHEMES } from \&#x22;../constants/defaults\&#x22;;\n\nexport function validateUrl(input: string | undefined): string | null {\n  if (!input || typeof input !== \&#x22;string\&#x22;) return null;\n  const trimmed = input.trim();\n  if (!trimmed) return null;\n  try {\n    const url = new URL(trimmed);\n    if (!ALLOWED_SCHEMES.includes(url.protocol as (typeof ALLOWED_SCHEMES)[number])) {\n      return null;\n    }\n    return url.href;\n  } catch {\n    return null;\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/types/audit.ts&#x22;,&#x22;title&#x22;:&#x22;audit.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export type Impact = \&#x22;critical\&#x22; | \&#x22;serious\&#x22; | \&#x22;moderate\&#x22; | \&#x22;minor\&#x22;;\n\n/** Slim subset of axe-core results returned from the page. */\nexport type RawAxeNode = {\n  target: (string | string[])[];\n  html: string;\n};\n\nexport type RawAxeViolation = {\n  id: string;\n  impact: Impact | null;\n  help: string;\n  helpUrl: string;\n  nodes: RawAxeNode[];\n};\n\nexport type RawAxeResults = {\n  violations: RawAxeViolation[];\n  passes: number;\n  incomplete: number;\n};\n\nexport type PageAuditResult = {\n  axe: RawAxeResults;\n  imagesMissingAlt: string[];\n};\n\nexport type ViolationNode = {\n  target: string[];\n  html: string;\n};\n\nexport type Violation = {\n  id: string;\n  impact: Impact | null;\n  help: string;\n  helpUrl: string;\n  totalNodes: number;\n  nodes: ViolationNode[];\n};\n\nexport type ImpactCounts = Record<Impact, number>;\n\nexport type AltSuggestion = {\n  src: string;\n  suggestion: string | null;\n  error?: string;\n};\n\nexport type AuditReport = {\n  url: string;\n  score: number;\n  counts: ImpactCounts;\n  violations: Violation[];\n  passes: number;\n  incomplete: number;\n  altSuggestions?: AltSuggestion[];\n};\n\nexport type AltTextResponse = {\n  altText: string;\n  model: string;\n};&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const ALLOWED_SCHEMES = [\&#x22;http:\&#x22;, \&#x22;https:\&#x22;] as const;\nexport const DEFAULT_VIEWPORT = { width: 1280, height: 800 };\nexport const NAVIGATION_TIMEOUT_MS = 20_000;\n\nexport const AXE_TAGS = [\&#x22;wcag2a\&#x22;, \&#x22;wcag2aa\&#x22;, \&#x22;wcag21a\&#x22;, \&#x22;wcag21aa\&#x22;];\nexport const MAX_NODES_PER_VIOLATION = 10;\nexport const MAX_HTML_LENGTH = 300;\nexport const MAX_AXE_ATTEMPTS = 2;\n\nexport const IMPACT_WEIGHTS = { critical: 10, serious: 5, moderate: 3, minor: 1 } as const;\n\nexport const MAX_ALT_IMAGES = 5;\nexport const MAX_IMAGE_BYTES = 2 * 1024 * 1024;\nexport const IMAGE_FETCH_TIMEOUT_MS = 10_000;\n\nexport const VISION_MODEL = \&#x22;@cf/llava-hf/llava-1.5-7b-hf\&#x22;;\nexport const ALT_TEXT_PROMPT =\n  \&#x22;Write concise alt text (one sentence, under 125 characters) describing this image for a screen reader user. Do not start with 'Image of' or 'Picture of'.\&#x22;;\nexport const ALT_TEXT_MAX_TOKENS = 80;&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Typical API: GET /audit?url=https://example.com\n * Requires wrangler.json: \&#x22;browser\&#x22;: { \&#x22;binding\&#x22;: \&#x22;BROWSER\&#x22; }, \&#x22;keep_names\&#x22;: false\n */\nimport { runAxeAudit } from \&#x22;../src/lib/browser\&#x22;;\nimport { summarizeAxeResults } from \&#x22;../src/lib/report\&#x22;;\nimport { validateUrl } from \&#x22;../src/lib/url\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: { BROWSER: Fetcher }): Promise<Response> {\n    const url = validateUrl(new URL(request.url).searchParams.get(\&#x22;url\&#x22;) ?? undefined);\n    if (!url) {\n      return Response.json(\n        { error: \&#x22;Missing or invalid url\&#x22;, code: \&#x22;INVALID_URL\&#x22; },\n        { status: 400 }\n      );\n    }\n    const { axe } = await runAxeAudit(env.BROWSER, url);\n    return Response.json(summarizeAxeResults(url, axe));\n  },\n};&#x22;}]"
/>

<Callout type="warning">
  Set `"keep_names": false` in your `wrangler.json`, or the injected axe-core source will throw `__name is not defined` in the page.
</Callout>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/accessibility-auditor)
  </Step>

  <Step>
    ### Enable Browser Rendering and Workers AI [#enable-browser-rendering-and-workers-ai]

    The Worker uses the `BROWSER` and `AI` bindings declared in `wrangler.json`. Both must be available on your account. See the [Browser Rendering docs](https://developers.cloudflare.com/browser-rendering/) and [Workers AI docs](https://developers.cloudflare.com/workers-ai/).
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    curl "https://your-worker.workers.dev/audit?url=https://example.com"
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/accessibility-auditor
npm install
npm run dev
```

<Callout>
  Workers AI always runs remotely, even in `wrangler dev`, so run `wrangler login` first. Use `npx wrangler dev --remote` if a local browser isn't available.
</Callout>

```bash
curl "http://localhost:8787/audit?url=https://example.com&altText=true"
```

## Configuration [#configuration]

`wrangler.json` declares:

* **`browser` binding** `BROWSER`
* **`ai` binding** `AI`
* **`keep_names: false`** - required for `axe.source` injection

Dependencies: `@cloudflare/puppeteer`, `axe-core`, `hono`.

## Cloudflare Features Used [#cloudflare-features-used]

* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime
* **[Browser Rendering](https://developers.cloudflare.com/browser-rendering/)** - Headless Chromium via `@cloudflare/puppeteer`
* **[Workers AI](https://developers.cloudflare.com/workers-ai/models/llava-1.5-7b-hf/)** - `@cf/llava-hf/llava-1.5-7b-hf` image-to-text for alt text

## Next Steps [#next-steps]

<Cards>
  <Card title="Screenshot API" href="/experiments/screenshot-api">
    Capture PNG screenshots with Browser Rendering
  </Card>

  <Card title="Privacy Tracker Scanner" href="/experiments/privacy-tracker-scanner">
    Detect trackers and cookies set before consent
  </Card>
</Cards>
