こんにちは。アイレット株式会社デザイン事業部の鶴若です。
リリース前にアクセシビリティ検証を自動で回せると安心です。ここでは、axe-coreとAIでのアクセシビリティ診断・修正のセットアップから運用までの流れをまとめます。
axe-test.jsの準備
長いので下記参照:
axe-test.js
必要なファイルとディレクトリの準備
- プロジェクト直下に axe-test.js を配置します。
- URL リストを使う場合は
urls.txtを作成し、1 行ずつ検証したい URL を書き込みます。 - 実行結果は
axe-result/に JSON で保存されます。ディレクトリがなくても初回実行時に自動生成されます。
依存パッケージのインストール
npm install axe-core jsdom glob
実行時に HTMLCanvasElement getContext の警告が表示されたら、以下を追加して解消できます(任意)。
npm install canvas
npm スクリプトの設定例
package.json の scripts に以下を追加すると、CLI での呼び出しがシンプルになります。
"scripts": {
"axe-test": "node axe-test.js",
"axe-test:dist": "node axe-test.js –dist",
"axe-test:urls": "node axe-test.js –urls urls.txt"
}
実行パターン
npm run axe-test: デフォルトでurls.txtを読み込んで検証します。npm run axe-test -- --urls other-list.txt: 任意の URL リストを指定して検証できます。npm run axe-test:dist:dist/**/*.htmlを自動検出し、ビルド済みのページを一括チェックします(事前にnpm run buildを実行)。
出力された JSON (axe-result/result-YYYY-MM-DDTHH-MM-SS-sssZ.json) には generatedAt と records が含まれ、records 配列に URL や Violation Type などの詳細がまとまります。
スクリプトのカスタマイズ
- 検証対象の WCAG レベルは axe-test.js の
TAGS配列で調整できます。 - メッセージの言語は axe-test.js のロケール設定を変更することで切り替えられます。
- JSON の保存先やファイル名パターンを変えたい場合は axe-test.js の
writeJsonReportを編集します。
CI への組み込み
ビルドジョブの後に node axe-test.js --dist を実行し、生成された JSON をアーティファクトとして保存すると、違反内容を後から確認できます。CI を落としたい場合は、違反検出時に process.exitCode = 1 を設定するロジックを axe-test.js に追加するとよいでしょう。
JSON からの一括修正フロー
違反件数が多い場合は、最新レポートを定型手順で処理すると抜け漏れが防げます。推奨ステップは次の通りです。
axe-result/result-*.jsonの中でタイムスタンプが最大のファイル名を特定し、Violation Typeごとに整理します(各グループに URL / HTML Element / DOM Element / Messages を含める)。- 整理結果をもとに、該当しそうな
src/やpublic/配下の HTML・JS ファイルを特定し、修正案を diff コードフェンス (```diff) 形式でまとめて差分ごとに 1 行コメントで変更理由を添えます。 - 提案したパッチを適用したあと、
npm run build→npm run axe-test:distの順に実行し、結果(成功 / 失敗と要旨)をレポートします。 - 自動化したい場合は、上記情報を AI へのプロンプト雛形に流し込み、まとめて修正案生成・適用・再検証まで回すフローを構築できます。
上記のフローは次のようなプロンプトを渡すと修正が実行されます。
1. axe-result/result-*.json から最新タイムスタンプのファイルを特定し、records 配列を解析して Violation Type ごとに要約してください(各グループへ URL、HTML Element、DOM Element、Messages を含めること)。 2. 要約を基に src または public/ 配下の関連するテンプレート/HTML/JS ファイルを洗い出し、修正案を統一 diff 形式(```diff)で提示し、同じ差分をファイルへ反映してください。差分ごとに 1 行で理由を記載してください。 3. 変更後に npm run build と npm run axe-test:dist を順に実行し、成功可否と要旨を報告してください。
まとめ
axe-test.js をプロジェクトに取り込むだけで、URL ベースでもビルド成果物ベースでも柔軟にアクセシビリティ診断を自動化できます。継続的にレポートを蓄積し、改善サイクルに組み込んでいきましょう。
axe-test.js
主なポイントは以下の通りです。
setupCanvasで JSDOM に不足する Canvas API をパッチし、axe 実行時の例外を防ぎます。parseCliArgsとloadTargetsで CLI オプションを解析し、URL もしくはdist配下の HTML を検証対象にします。analyzeHtmlが JSDOM 上でaxe-coreを実行し、mapResultsToRowsが違反ノードを行データに変換します。writeJsonReportがgeneratedAtとrecordsを持つ JSON をaxe-result/に保存します。runが上記フローをまとめて実行し、完了時に保存されたファイルパスを出力します。
import AXE_LOCALE_JA from 'axe-core/locales/ja.json' with { type: 'json' };
import axeCore from 'axe-core';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { globSync } from 'glob';
import { JSDOM } from 'jsdom';
let canvasBindings = null;
try {
canvasBindings = await import('canvas');
} catch (error) {
canvasBindings = null;
}
const CANVAS_PATCH_SYMBOL = Symbol('jsdomCanvasPatched');
// JSDOM で canvas API を再現し、axe 実行時のエラーを防ぐ
function setupCanvas(window) {
const { HTMLCanvasElement } = window;
if (!HTMLCanvasElement) {
return;
}
const prototype = HTMLCanvasElement.prototype;
if (prototype[CANVAS_PATCH_SYMBOL]) {
return;
}
prototype[CANVAS_PATCH_SYMBOL] = true;
const normalizeType = (type) => (typeof type === 'string' ? type.toLowerCase() : '');
const installNoopContext = () => {
Object.defineProperty(prototype, 'getContext', {
configurable: true,
enumerable: false,
value(type) {
const normalized = normalizeType(type);
if (normalized === '2d' || normalized === 'canvasrenderingcontext2d' || normalized === 'context-2d') {
return null;
}
return null;
}
});
};
if (!canvasBindings) {
if (!setupCanvas.warnedMissingCanvas) {
console.warn('[axe-test] canvas module not available; falling back to null canvas contexts.');
setupCanvas.warnedMissingCanvas = true;
}
installNoopContext();
return;
}
const moduleDefault = canvasBindings.default;
const resolveExport = (name) => {
if (canvasBindings && Object.prototype.hasOwnProperty.call(canvasBindings, name)) {
return canvasBindings[name];
}
if (moduleDefault && Object.prototype.hasOwnProperty.call(moduleDefault, name)) {
return moduleDefault[name];
}
return undefined;
};
const createCanvasExport = resolveExport('createCanvas');
const CanvasCtor = resolveExport('Canvas');
let createCanvasFn = null;
if (typeof createCanvasExport === 'function') {
createCanvasFn = createCanvasExport;
} else if (typeof moduleDefault === 'function') {
createCanvasFn = (width, height) => new moduleDefault(width, height);
} else if (typeof CanvasCtor === 'function') {
createCanvasFn = (width, height) => new CanvasCtor(width, height);
}
if (typeof createCanvasFn !== 'function') {
installNoopContext();
return;
}
const ImageCtor = resolveExport('Image');
const ImageDataCtor = resolveExport('ImageData');
const Path2DCtor = resolveExport('Path2D');
const CanvasGradientCtor = resolveExport('CanvasGradient');
const CanvasPatternCtor = resolveExport('CanvasPattern');
const CanvasRenderingContext2DCtor = resolveExport('CanvasRenderingContext2D');
if (ImageCtor && !window.Image) {
window.Image = ImageCtor;
}
if (ImageDataCtor && !window.ImageData) {
window.ImageData = ImageDataCtor;
}
if (Path2DCtor && !window.Path2D) {
window.Path2D = Path2DCtor;
}
if (CanvasGradientCtor && !window.CanvasGradient) {
window.CanvasGradient = CanvasGradientCtor;
}
if (CanvasPatternCtor && !window.CanvasPattern) {
window.CanvasPattern = CanvasPatternCtor;
}
if (CanvasRenderingContext2DCtor && !window.CanvasRenderingContext2D) {
window.CanvasRenderingContext2D = CanvasRenderingContext2DCtor;
}
const sampleCanvas = createCanvasFn(1, 1);
const sampleContext = sampleCanvas?.getContext?.('2d');
if (!window.CanvasRenderingContext2D && sampleContext?.constructor) {
window.CanvasRenderingContext2D = sampleContext.constructor;
}
const sampleGradient = sampleContext?.createLinearGradient?.(0, 0, 1, 1);
if (!window.CanvasGradient && sampleGradient?.constructor) {
window.CanvasGradient = sampleGradient.constructor;
}
if (!window.CanvasPattern && typeof sampleContext?.createPattern === 'function') {
const patternCandidate = sampleContext.createPattern(createCanvasFn(1, 1), 'repeat');
if (patternCandidate?.constructor) {
window.CanvasPattern = patternCandidate.constructor;
}
}
const backingStore = new WeakMap();
const contextStore = new WeakMap();
const readDimensions = (element) => {
const attrWidth = Number(element.getAttribute('width'));
const propWidth = Number(element.width);
const attrHeight = Number(element.getAttribute('height'));
const propHeight = Number(element.height);
const widthCandidate = Number.isFinite(attrWidth) && attrWidth > 0 ? attrWidth : propWidth;
const heightCandidate = Number.isFinite(attrHeight) && attrHeight > 0 ? attrHeight : propHeight;
const width = Number.isFinite(widthCandidate) && widthCandidate > 0 ? widthCandidate : 300;
const height = Number.isFinite(heightCandidate) && heightCandidate > 0 ? heightCandidate : 150;
return {
width,
height
};
};
const resetBacking = (element) => {
backingStore.delete(element);
contextStore.delete(element);
};
const ensureBacking = (element) => {
const { width, height } = readDimensions(element);
let backing = backingStore.get(element);
if (!backing || backing.width !== width || backing.height !== height) {
backing = createCanvasFn(width, height);
backingStore.set(element, backing);
contextStore.delete(element);
}
return backing;
};
Object.defineProperty(prototype, 'getContext', {
configurable: true,
enumerable: false,
value(type, options) {
const normalized = normalizeType(type);
if (normalized === '2d' || normalized === 'canvasrenderingcontext2d' || normalized === 'context-2d') {
const backing = ensureBacking(this);
let ctx = contextStore.get(this);
if (!ctx) {
ctx = backing.getContext('2d', options);
contextStore.set(this, ctx);
}
return ctx ?? null;
}
if (normalized === 'webgl' || normalized === 'webgl2' || normalized === 'experimental-webgl' || normalized === 'experimental-webgl2') {
return null;
}
return null;
}
});
const widthDescriptor = Object.getOwnPropertyDescriptor(prototype, 'width');
if (widthDescriptor?.set) {
Object.defineProperty(prototype, 'width', {
configurable: widthDescriptor.configurable,
enumerable: widthDescriptor.enumerable,
get: widthDescriptor.get,
set(value) {
widthDescriptor.set.call(this, value);
resetBacking(this);
}
});
}
const heightDescriptor = Object.getOwnPropertyDescriptor(prototype, 'height');
if (heightDescriptor?.set) {
Object.defineProperty(prototype, 'height', {
configurable: heightDescriptor.configurable,
enumerable: heightDescriptor.enumerable,
get: heightDescriptor.get,
set(value) {
heightDescriptor.set.call(this, value);
resetBacking(this);
}
});
}
const originalSetAttribute = prototype.setAttribute;
if (typeof originalSetAttribute === 'function') {
Object.defineProperty(prototype, 'setAttribute', {
configurable: true,
enumerable: false,
value(name, value) {
originalSetAttribute.call(this, name, value);
if (name === 'width' || name === 'height') {
resetBacking(this);
}
}
});
}
const originalToDataURL = prototype.toDataURL;
Object.defineProperty(prototype, 'toDataURL', {
configurable: true,
enumerable: false,
value(type, quality) {
const backing = ensureBacking(this);
if (typeof backing.toDataURL === 'function') {
return backing.toDataURL(type, quality);
}
if (typeof originalToDataURL === 'function') {
return originalToDataURL.call(this, type, quality);
}
throw new window.DOMException('toDataURL is not supported', 'NotSupportedError');
}
});
}
// axe-coreを日本語メッセージで実行するための設定
const config = {
locale: AXE_LOCALE_JA
};
// WCAG準拠の検証で使用するタグ群
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'];
// CLI オプションを解析して検証ソースとURLリストを決定する
function parseCliArgs() {
const args = process.argv.slice(2);
const useDist = args.includes('--dist');
const urlsOptionIndex = args.findIndex((arg) => arg === '--urls');
const urlsEquals = args.find((arg) => arg.startsWith('--urls='));
if (useDist && (urlsOptionIndex !== -1 || urlsEquals)) {
throw new Error('`--dist` と `--urls` は同時に指定できません。');
}
let urlFile = 'urls.txt';
if (urlsOptionIndex !== -1) {
const next = args[urlsOptionIndex + 1];
if (!next || next.startsWith('--')) {
throw new Error('`--urls` の後にはファイルパスを指定してください。');
}
urlFile = next;
}
if (urlsEquals) {
urlFile = urlsEquals.split('=')[1] ?? 'urls.txt';
}
return {
source: useDist ? 'dist' : 'urls',
urlFile: path.resolve(process.cwd(), urlFile)
};
}
// 検証対象の dist HTML または URL 一覧を読み込む
async function loadTargets({ source, urlFile }) {
if (source === 'dist') {
const files = globSync('dist/**/*.html', { nodir: true }).map((file) => path.resolve(file)).sort();
if (files.length === 0) {
throw new Error('dist 内に HTML ファイルが見つかりません。`npm run build` 済みか確認してください。');
}
return files.map((file) => ({ kind: 'file', filePath: file }));
}
if (!existsSync(urlFile)) {
throw new Error(`URL リストファイルが見つかりません: ${urlFile}`);
}
const raw = await fs.readFile(urlFile, 'utf8');
const targets = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (targets.length === 0) {
throw new Error(`URL リストファイル (${urlFile}) に有効な URL がありません。`);
}
return targets.map((url) => ({ kind: 'url', url }));
}
// 対象を取得して HTML と基準 URL を返す
async function loadHtml(target) {
if (target.kind === 'file') {
const html = await fs.readFile(target.filePath, 'utf8');
const url = pathToFileURL(target.filePath).href;
return { html, baseUrl: url, label: target.filePath };
}
if (typeof fetch !== 'function') {
throw new Error('この環境には fetch がありません。Node.js 18 以降で実行するか、WHATWG Fetch 実装を追加してください。');
}
const response = await fetch(target.url, { redirect: 'follow' });
if (!response.ok) {
throw new Error(`URL へのアクセスに失敗しました (${target.url}) - HTTP ${response.status}`);
}
const html = await response.text();
return { html, baseUrl: target.url, label: target.url };
}
// JSDOM 上で axe-core を走らせて診断結果を得る
async function analyzeHtml(html, baseUrl) {
const dom = new JSDOM(html, {
url: baseUrl,
pretendToBeVisual: true,
runScripts: 'outside-only',
beforeParse(window) {
setupCanvas(window);
}
});
try {
dom.window.eval(axeCore.source);
dom.window.axe.configure(config);
const results = await dom.window.axe.run(dom.window.document, {
runOnly: {
type: 'tag',
values: TAGS
}
});
results.url = baseUrl;
return results;
} finally {
dom.window.close();
}
}
// 結果出力用ディレクトリを確保する
function ensureOutputDir() {
const dir = path.resolve(process.cwd(), 'axe-result');
return fs.mkdir(dir, { recursive: true }).then(() => dir);
}
// axe 結果を行データへ展開する
function mapResultsToRows(results) {
const rows = [];
const { url, violations = [] } = results;
if (!violations.length) {
rows.push([url, 'no-violations', '', '', '', '', '']);
return rows;
}
for (const violation of violations) {
const nodes = violation.nodes ?? [];
if (!nodes.length) {
rows.push([
url,
violation.id ?? '',
violation.impact ?? '',
violation.helpUrl ?? '',
'',
'',
''
]);
continue;
}
for (const node of nodes) {
if (!node) continue;
const messages = (node.any ?? []).map((item) => item?.message ?? '').filter(Boolean).join(' -- ');
const targets = (node.target ?? []).filter(Boolean).join(' -- ');
rows.push([
url,
violation.id ?? '',
violation.impact ?? '',
violation.helpUrl ?? '',
node.html ?? '',
messages,
targets
]);
}
}
return rows;
}
// 診断結果を JSON レポートとしてタイムスタンプ付きファイルへ保存する
async function writeJsonReport(rows) {
const outputDir = await ensureOutputDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filePath = path.join(outputDir, `result-${timestamp}.json`);
const header = ['URL', 'Violation Type', 'Impact', 'Help', 'HTML Element', 'Messages', 'DOM Element'];
const records = rows.map((row) => {
const entry = {};
header.forEach((key, index) => {
entry[key] = row[index] ?? '';
});
return entry;
});
const payload = {
generatedAt: new Date().toISOString(),
records
};
await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return filePath;
}
// 全体のワークフローを実行し結果を出力する
async function run() {
const options = parseCliArgs();
const targets = await loadTargets(options);
console.log(`[axe-test] ${targets.length} 件のターゲットを検証します (${options.source === 'dist' ? 'dist 配下の HTML' : options.urlFile})`);
const allRows = [];
for (const target of targets) {
const { html, baseUrl, label } = await loadHtml(target);
console.log(`[axe-test] 検証中: ${label}`);
const results = await analyzeHtml(html, baseUrl);
allRows.push(...mapResultsToRows(results));
}
const output = await writeJsonReport(allRows);
console.log(`[axe-test] すべての検証が完了しました。結果: ${output}`);
}
run().catch((error) => {
console.error('[axe-test] エラーが発生しました:', error instanceof Error ? error.message : error);
if (error instanceof Error && error.stack) {
console.error(error.stack);
}
process.exitCode = 1;
});