Skip to content

feat: add build-time a11y report generation#210

Open
onmax wants to merge 7 commits intonuxt:mainfrom
onmax:feat/report-generation
Open

feat: add build-time a11y report generation#210
onmax wants to merge 7 commits intonuxt:mainfrom
onmax:feat/report-generation

Conversation

@onmax
Copy link

@onmax onmax commented Jan 8, 2026

Closes #208
Closes #209

Summary

Adds build-time accessibility report generation during nuxt generate/prerender. Runs axe-core server-side via jsdom on each route and generates a markdown report.

Features

  • Auto-enabled during prerender - report.enabled defaults to !nuxt.options.dev
  • Markdown report - Violations grouped by impact (critical/serious/moderate/minor)
  • CI integration - Exit non-zero when violations found (failOnViolation: true)
  • Configurable output - Default: .nuxt/a11y-report.md

Configuration

export default defineNuxtConfig({
  a11y: {
    report: {
      enabled: true,              // default: !dev
      output: '.nuxt/a11y-report.md',
      failOnViolation: true,      // exit 1 on violations
    }
  }
})

StackBlitz

Link Expected
a11y-209-fixed Report at .nuxt/a11y-report.md

Or locally

git sparse-checkout add a11y-209-fixed
cd ../a11y-209-fixed && pnpm i && pnpm generate
# Report at .nuxt/a11y-report.md, exits 1

Example Output

# Accessibility Report

Generated: 2026-01-08
Routes scanned: 5
Total violations: 23

## Critical (6)

### image-alt
**Images must have alternative text** | Routes: /, /about

Elements:
- `.hero-image` - Fix any of the following:
...

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt/a11y/@nuxt/a11y@210

commit: d91bcca

@onmax onmax force-pushed the feat/report-generation branch from 6a7e4e7 to 42d157b Compare January 8, 2026 15:43
@timdamen
Copy link
Collaborator

timdamen commented Jan 9, 2026

Hey @onmax, great work

I noticed that your PR also addresses the requirements from two other open issues:

#208 - Check accessibility when prerendering pages

This issue requests exactly what your PR implements:

  • ✅ Check for violations during prerender (render:html hook)
  • ✅ Fail build if violations present (failOnViolation: true)
  • ✅ Log violations to console with summary

#207 - Integrate with nuxt/test-utils for testing

Your runAxeOnHtml() utility in src/runtime/server/utils/axe-server.ts could serve as the foundation for test utilities. Once exported, it could be used like:

import { runAxeOnHtml } from '@nuxt/a11y/server'

const violations = await runAxeOnHtml(html, '/test-route', {})
expect(violations).toHaveLength(0)

Could you link #208 in your PR description? Something like:

Closes #209
Closes #208

This way, both issues get closed automatically when the PR merges.

For #207, it's not fully addressed, but your PR provides the core utilities needed - that can remain open as a follow-up for adding Vitest matchers and test-utils integration. Do you have time to read #207 and align your code so that it could serve foundation for 207?

Thanks!

@onmax
Copy link
Author

onmax commented Jan 9, 2026

done!

Also happy to help with #207 😃

@timdamen
Copy link
Collaborator

PR Review: Testing Results

Thanks for the PR! I tested this locally and found a couple of issues.

1. Critical: ESM Import Error with entities package

When running pnpm generate in the playground, the prerendering fails with:

The requested module '.../entities/dist/esm/decode.js' does not provide an export named 'default'

This appears to be caused by jsdom's dependency on entities. When Nitro bundles the server plugin for prerendering, there's an ESM/CJS compatibility issue.

Result: The main routes fail to render, only fallback pages (/200.html, /404.html) were scanned.

Attempted fixes (none worked):

  • nitro.rollupConfig.external with jsdom and all its dependencies
  • nitro.externals.external with jsdom
  • nitro:config hook to manipulate externals/inline settings

The issue seems to be that Rollup/Nitro is generating incorrect import statements for the entities package (using ESM import syntax for what expects CJS).

Alternative approaches to consider:

  • Use linkedom instead of jsdom (lighter, more ESM-friendly)
  • Use happy-dom as an alternative
  • Investigate Vite/Rollup config to force CJS resolution for entities

2. Minor: Double-nested output path

The report was written to .nuxt/.nuxt/a11y-report.md instead of .nuxt/a11y-report.md.

In a11y-report.ts:57-60:

const baseDir = '.nuxt'
const requestedOutput = reportConfig?.output || 'a11y-report.md'
const resolved = resolve(baseDir, requestedOutput)

When output is already .nuxt/a11y-report.md (the default), it resolves to .nuxt/.nuxt/a11y-report.md.

Suggested fix: Strip the .nuxt/ prefix from the default, or handle absolute vs relative paths differently.

What worked

  • Report file was created ✅
  • Violations are grouped by impact level ✅
  • Exit code is 1 when violations exist ✅
  • Report format matches documentation ✅

Let me know if you need help debugging the ESM issue!

@onmax
Copy link
Author

onmax commented Jan 14, 2026

Thanks for the review! I could reproduce the double-nested path issue and will fix it.

However, I could not reproduce the ESM error with entities. Tested on macOS ARM64:

  • Node 20, 22, 24 + pnpm ✓
  • Node 24 + npm ✓

All combinations work without ESM errors.

Repro: GitHub · StackBlitz

⚠️ Note: StackBlitz shows JSDOM errors (Object.defineProperty called on non-object) - this is a known WebContainers limitation, not related to the entities ESM issue you reported.

Could you share:

  • OS + architecture (Linux x64? Windows?)
  • Node version
  • Package manager + version
  • Any custom nitro config?
Output running in the playground


> nuxt-a11y-playground@ generate /Users/maxi/nuxt/a11y/playground
> nuxt generate

│
▲  Changing NODE_ENV from development to production, to avoid unintended behavior.
┌  Building Nuxt for production...
│
●  Nuxt 4.2.2 (with Nitro 2.12.9, Vite 7.3.0 and Vue 3.5.26)
│
●  Nitro preset: static
ℹ Building client...
ℹ vite v7.3.0 building client environment for production...
ℹ transforming...
ℹ ✓ 134 modules transformed.
ℹ rendering chunks...
ℹ computing gzip size...
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/manifest.json                   4.91 kB │ gzip:  0.66 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/default.BxQ-eq5D.css      1.55 kB │ gzip:  0.65 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/error-500.D5Kt4MWO.css    1.91 kB │ gzip:  0.73 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/error-404.DyQKRGU8.css    2.43 kB │ gzip:  0.86 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/index.DE9dVmKx.css        2.86 kB │ gzip:  0.84 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/about-us.B_Oltg1m.css     2.96 kB │ gzip:  0.80 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/contact.y5SIdETt.css      3.20 kB │ gzip:  0.92 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/vbfELOx8.js               0.33 kB │ gzip:  0.24 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/Cy99982I.js               1.91 kB │ gzip:  0.84 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/DLDu4tFn.js               3.10 kB │ gzip:  1.11 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/BZZGZfa7.js               3.50 kB │ gzip:  1.56 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/DOG_3Sfc.js               3.81 kB │ gzip:  1.71 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/BEhwj1fY.js               5.05 kB │ gzip:  1.73 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/B4ceoDh_.js               5.11 kB │ gzip:  1.99 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/CGU_VDQo.js               5.35 kB │ gzip:  1.25 kB
ℹ ../node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/BhwfSNmC.js             166.34 kB │ gzip: 63.22 kB
ℹ ✓ built in 593ms
✔ Client built in 599ms
ℹ Building server...
ℹ vite v7.3.0 building ssr environment for production...
ℹ transforming...
ℹ ✓ 165 modules transformed.
ℹ rendering chunks...
ℹ ✓ built in 962ms
✔ Server built in 966ms
[nitro] ℹ Initializing prerenderer
[nitro] ℹ Prerendering 3 initial routes with crawler
[nitro]   ├─ /200.html (133ms)
[nitro]   ├─ /404.html (268ms)
[nitro]   ├─ / (280ms)
[nitro]   ├─ /_payload.json?447e3c05-205e-4f1c-9660-328da830443b (5ms) (skipped)
[nitro]   ├─ /_payload.json (5ms)
[nitro]   ├─ /contact (682ms)
[nitro]   ├─ /contact/_payload.json?447e3c05-205e-4f1c-9660-328da830443b (1ms) (skipped)
[nitro]   ├─ /about-us (697ms)
[nitro]   ├─ /contact/_payload.json (1ms)
[nitro]   ├─ /about-us/_payload.json?447e3c05-205e-4f1c-9660-328da830443b (1ms) (skipped)
[nitro]   ├─ /about-us/_payload.json (0ms)
[a11y] ✔ Report written to /Users/maxi/nuxt/a11y/playground/.nuxt/a11y-report.md

[a11y]  WARN  Found 23 violations (6 critical, 14 serious)

[nitro] ℹ Prerendered 8 routes in 3.712 seconds
[nitro] ✔ Generated public .output/public
[nitro] ✔ You can preview this build using npx serve .output/public
│
└  ✨ You can now deploy .output/public to any static hosting!
 ELIFECYCLE  Command failed with exit code 1.

My output running generate in the onmax/repros/a11y-210

●  Nitro preset: static
ℹ Building client...                                                                                                                                                           7:45:34 AM
ℹ vite v7.3.1 building client environment for production...                                                                                                                    7:45:34 AM
ℹ ✓ 109 modules transformed.                                                                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/manifest.json                   2.73 kB │ gzip:  0.42 kB                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/_nuxt/error-500.Uir4SZ6b.css    1.88 kB │ gzip:  0.72 kB                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/_nuxt/error-404.DdmRM2zk.css    3.53 kB │ gzip:  1.09 kB                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/_nuxt/BOkUOWE9.js               0.25 kB │ gzip:  0.20 kB                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/_nuxt/CQ9yqJBo.js               3.41 kB │ gzip:  1.54 kB                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/_nuxt/S_rfWAX8.js               9.28 kB │ gzip:  3.75 kB                                                                                                   7:45:35 AM
ℹ .nuxt/dist/client/_nuxt/CzV3_qdi.js             132.67 kB │ gzip: 49.10 kB                                                                                                   7:45:35 AM
ℹ ✓ built in 618ms                                                                                                                                                             7:45:35 AM
✔ Client built in 624ms                                                                                                                                                        7:45:35 AM
ℹ Building server...                                                                                                                                                           7:45:35 AM
ℹ vite v7.3.1 building ssr environment for production...                                                                                                                       7:45:35 AM
ℹ ✓ 48 modules transformed.                                                                                                                                                    7:45:35 AM
ℹ ✓ built in 117ms                                                                                                                                                             7:45:35 AM
✔ Server built in 119ms                                                                                                                                                        7:45:35 AM
ℹ Initializing prerenderer                                                                                                                                               nitro 7:45:35 AM
ℹ Prerendering 3 initial routes with crawler                                                                                                                             nitro 7:45:37 AM
  ├─ /200.html (111ms)                                                                                                                                                    nitro 7:45:37 AM
  ├─ /404.html (125ms)                                                                                                                                                    nitro 7:45:37 AM
  ├─ / (128ms)                                                                                                                                                            nitro 7:45:37 AM
  ├─ /_payload.json?a20a45d2-97a7-41b2-9020-6a3246cad41e (0ms) (skipped)                                                                                                  nitro 7:45:37 AM
  ├─ /_payload.json (1ms)                                                                                                                                                 nitro 7:45:37 AM
✔ Report written to /Users/maxi/repros/a11y-210/.nuxt/.nuxt/a11y-report.md                                                                                                a11y 7:45:37 AM

 WARN  Found 7 violations (0 critical, 6 serious)                                                                                                                          a11y 7:45:37 AM

ℹ Prerendered 4 routes in 1.848 seconds                                                                                                                                  nitro 7:45:37 AM
✔ Generated public .output/public                                                                                                                                        nitro 7:45:37 AM
✔ You can preview this build using npx serve .output/public                                                                                                              nitro 7:45:37 AM
│                                                                                                                                                                               7:45:37 AM
└  ✨ You can now deploy .output/public to any static hosting!

@timdamen
Copy link
Collaborator

After the clean install, all routes prerendered successfully without the entities ESM error. (mb!)

Only the double-nested path issue needs fixing.

@onmax onmax force-pushed the feat/report-generation branch 2 times, most recently from 059355a to 3e32ec6 Compare January 20, 2026 07:52
@onmax onmax force-pushed the feat/report-generation branch from 3e32ec6 to 7a6df04 Compare January 20, 2026 07:54
@onmax
Copy link
Author

onmax commented Jan 20, 2026

i fixed the double nested path issue and make sure pr is ready to be merged

please let me know if we need something else :)

Copy link
Member

@danielroe danielroe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be great! a few suggested changes. I think we can improve the DX here.

also, I got the same entities issue, which also went away when purging node_modules, dist, etc.

if (collectedViolations.length > 0) {
const stats = getStats(collectedViolations)
logger.warn(`Found ${stats.totalViolations} violations (${stats.byImpact.critical || 0} critical, ${stats.byImpact.serious || 0} serious)`)
if (reportConfig?.failOnViolation) process.exitCode = 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could do this from module.ts - and maybe even print out a nice little summary and point people to the full details in the markdown file. (we could pass information back and forth with useStorage)


try {
if (options.axeOptions)
axe.configure(options.axeOptions)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it'll be configured lots of times - should we do this only once?

reportWritten = true

// Ensure output path is within .nuxt/ to prevent path traversal
const baseDir = resolve(process.cwd(), '.nuxt')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should not hard code this - it's possible to set a custom base dir


try {
await mkdir(dirname(output), { recursive: true })
await writeFile(output, report, 'utf-8')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally we should use nitro storage rather than using node fs directly here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danielroe feedback applied

@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

Adds build-time accessibility report generation to the Nuxt a11y module. Introduces a new a11y.report config block (enabled, output, failOnViolation) and exposes it via runtimeConfig. Adds server-side logic: a Nitro plugin that runs axe on prerendered HTML using a new runAxeOnHtml utility (jsdom-based, serialized via a mutex), aggregates violations, and writes a Markdown report via a new report formatter. Updates package.json to add jsdom. Documentation (README) is updated with configuration and example report output. No client runtime behavior changes beyond conditional wiring.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: adding build-time accessibility report generation during prerender.
Description check ✅ Passed The description thoroughly covers the feature, configuration, and includes examples matching the changeset additions.
Linked Issues check ✅ Passed Changes fully implement #208 (prerender checks, fail on violations, console summary) and #209 (file-based report generation with configurable output).
Out of Scope Changes check ✅ Passed All changes directly support the build-time report feature: module config, server plugin, axe utilities, report formatter, and documentation updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/module.ts`:
- Around line 88-99: The code resolves the report path with
resolve(nuxt.options.buildDir, options.report.output) but does not validate path
traversal, contradicting the README; update the logic that builds and writes the
report (around formatMarkdownReport, resolve(...), mkdir, writeFile) to verify
the resolved output path is inside nuxt.options.buildDir (e.g., compare path
prefixes or use path.relative and ensure it doesn't start with '..' and is not
absolute outside the buildDir); if validation fails, log a clear error via
logger.error and abort writing instead of proceeding to mkdir/writeFile. Ensure
you reference options.report.output and perform the check before calling
mkdir(dirname(output)) or writeFile.
🧹 Nitpick comments (2)
src/runtime/server/utils/axe-server.ts (1)

79-82: Configuration is only applied when axeOptions is provided on the first call.

The current logic skips configuration if axeOptions is falsy. If the first call to runAxeOnHtml has no axeOptions but subsequent calls do, the configuration will never be applied. This is fine for the current use case where options are consistently passed, but worth noting for future extensibility.

♻️ Alternative: Configure once on first non-empty options
-      if (options.axeOptions && !configured) {
-        axe.configure(options.axeOptions)
-        configured = true
-      }
+      if (!configured && options.axeOptions) {
+        axe.configure(options.axeOptions)
+        configured = true
+      }

The current implementation is functionally equivalent; this is just a readability suggestion to emphasize the guard first.

src/runtime/server/plugins/a11y-report.ts (1)

12-16: HTML reconstruction from render context.

The constructHtml helper correctly assembles the full HTML document from Nuxt's render context parts. One minor note: if htmlAttrs or bodyAttrs arrays are empty, this produces <html > with a trailing space, which is harmless but slightly untidy.

♻️ Optional: Trim empty attribute strings
 function constructHtml(ctx: NuxtRenderHTMLContext): string {
-  const htmlAttrs = ctx.htmlAttrs.join(' ')
-  const bodyAttrs = ctx.bodyAttrs.join(' ')
-  return `<!DOCTYPE html><html ${htmlAttrs}><head>${ctx.head.join('')}</head><body ${bodyAttrs}>${ctx.bodyPrepend.join('')}${ctx.body.join('')}${ctx.bodyAppend.join('')}</body></html>`
+  const htmlAttrs = ctx.htmlAttrs.length ? ` ${ctx.htmlAttrs.join(' ')}` : ''
+  const bodyAttrs = ctx.bodyAttrs.length ? ` ${ctx.bodyAttrs.join(' ')}` : ''
+  return `<!DOCTYPE html><html${htmlAttrs}><head>${ctx.head.join('')}</head><body${bodyAttrs}>${ctx.bodyPrepend.join('')}${ctx.body.join('')}${ctx.bodyAppend.join('')}</body></html>`
 }

Comment on lines +88 to +99

const report = formatMarkdownReport(violations)
const output = resolve(nuxt.options.buildDir, options.report.output)

try {
await mkdir(dirname(output), { recursive: true })
await writeFile(output, report, 'utf-8')
}
catch (err) {
logger.error('Failed to write report:', err)
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Documentation states path escaping will be ignored, but no validation is implemented.

The README states "Paths that attempt to escape the .nuxt/ directory will be ignored", but the current implementation uses resolve(nuxt.options.buildDir, options.report.output) without path traversal validation. A user could potentially write to locations outside buildDir with ../ in the output path.

🔒 Proposed fix: Validate output path stays within buildDir
           const report = formatMarkdownReport(violations)
           const output = resolve(nuxt.options.buildDir, options.report.output)
 
+          // Ensure output stays within buildDir
+          if (!output.startsWith(nuxt.options.buildDir)) {
+            logger.warn(`Report output path escapes build directory, using default location`)
+            output = resolve(nuxt.options.buildDir, 'a11y-report.md')
+          }
+
           try {
             await mkdir(dirname(output), { recursive: true })

Note: Alternatively, update the README to remove the claim about path escaping being ignored if this validation is intentionally omitted.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const report = formatMarkdownReport(violations)
const output = resolve(nuxt.options.buildDir, options.report.output)
try {
await mkdir(dirname(output), { recursive: true })
await writeFile(output, report, 'utf-8')
}
catch (err) {
logger.error('Failed to write report:', err)
return
}
const report = formatMarkdownReport(violations)
const output = resolve(nuxt.options.buildDir, options.report.output)
// Ensure output stays within buildDir
if (!output.startsWith(nuxt.options.buildDir)) {
logger.warn(`Report output path escapes build directory, using default location`)
output = resolve(nuxt.options.buildDir, 'a11y-report.md')
}
try {
await mkdir(dirname(output), { recursive: true })
await writeFile(output, report, 'utf-8')
}
catch (err) {
logger.error('Failed to write report:', err)
return
}
🤖 Prompt for AI Agents
In `@src/module.ts` around lines 88 - 99, The code resolves the report path with
resolve(nuxt.options.buildDir, options.report.output) but does not validate path
traversal, contradicting the README; update the logic that builds and writes the
report (around formatMarkdownReport, resolve(...), mkdir, writeFile) to verify
the resolved output path is inside nuxt.options.buildDir (e.g., compare path
prefixes or use path.relative and ensure it doesn't start with '..' and is not
absolute outside the buildDir); if validation fails, log a clear error via
logger.error and abort writing instead of proceeding to mkdir/writeFile. Ensure
you reference options.report.output and perform the check before calling
mkdir(dirname(output)) or writeFile.

@timdamen timdamen force-pushed the feat/report-generation branch from 109e316 to d91bcca Compare January 25, 2026 20:17
@timdamen
Copy link
Collaborator

timdamen commented Jan 25, 2026

Looks good to me now but I'm no Nitro expert. What do you think @danielroe ?

@timdamen timdamen requested a review from danielroe February 1, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: CLI command for generating reports check accessibility when prerendering pages

3 participants