こんにちは。アイレット株式会社デザイン事業部の鶴若です。
リリース前にアクセシビリティ検証を自動で回せると安心です。ここでは、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.jsonscripts に以下を追加すると、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) には generatedAtrecords が含まれ、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 からの一括修正フロー

違反件数が多い場合は、最新レポートを定型手順で処理すると抜け漏れが防げます。推奨ステップは次の通りです。

  1. axe-result/result-*.json の中でタイムスタンプが最大のファイル名を特定し、Violation Type ごとに整理します(各グループに URL / HTML Element / DOM Element / Messages を含める)。
  2. 整理結果をもとに、該当しそうな src/public/ 配下の HTML・JS ファイルを特定し、修正案を diff コードフェンス (```diff) 形式でまとめて差分ごとに 1 行コメントで変更理由を添えます。
  3. 提案したパッチを適用したあと、npm run buildnpm run axe-test:dist の順に実行し、結果(成功 / 失敗と要旨)をレポートします。
  4. 自動化したい場合は、上記情報を 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 実行時の例外を防ぎます。
  • parseCliArgsloadTargets で CLI オプションを解析し、URL もしくは dist 配下の HTML を検証対象にします。
  • analyzeHtml が JSDOM 上で axe-core を実行し、mapResultsToRows が違反ノードを行データに変換します。
  • writeJsonReportgeneratedAtrecords を持つ 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;
});