Product design skills

scripts/run-axe.mjs

A supporting file of the a11y-check-page skill, shown as source.

#!/usr/bin/env node
// 同梱の axe.min.js を使って、指定 URL のページで axe-core を実行し、結果を JSON で出力する。
//
// このスキルの基本は Playwright MCP でのブラウザ操作だが、axe.min.js (約 570KB) は
// browser_evaluate に一度に渡すには大きすぎることがある。その場合の確実な代替として使う
// (SKILL.md 手順4 / references/playwright-workflow.md「axe-core の実行」)。
//
// 対象タグ・出力形式は playwright-workflow.md のスニペットと揃えてある。CDN は使わず、
// 常に同梱の assets/axe.min.js を読み込む (サプライチェーン対策)。
//
// 使い方:
//   node run-axe.mjs <url> [オプション]
//
// オプション:
//   --width <px>       ビューポート幅 (既定: 1280)
//   --height <px>      ビューポート高さ (既定: 900)
//   --wait <state>     goto の待機条件 load|domcontentloaded|networkidle (既定: networkidle)
//   --nodes <n>        1 ルールあたり出力するノード数の上限 (既定: 10)
//   --out <file>       結果をファイルに書き出す (既定: 標準出力)
//   --help             このヘルプを表示
//
// 状態を変えた画面 (モーダルを開いた等) を対象にしたい場合は、Playwright MCP 側でその状態を
// 作ってから axe を注入する。このスクリプトは初期表示や、URL だけで再現できる状態に向く。

import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { launchChromium } from "./browser.mjs";

const AXE_PATH = fileURLToPath(new URL("../assets/axe.min.js", import.meta.url));

const USAGE = `使い方:
  node run-axe.mjs <url> [オプション]

オプション:
  --width <px>    ビューポート幅 (既定: 1280)
  --height <px>   ビューポート高さ (既定: 900)
  --wait <state>  load|domcontentloaded|networkidle (既定: networkidle)
  --nodes <n>     1 ルールあたりのノード数上限 (既定: 10)
  --out <file>    結果の出力先ファイル (既定: 標準出力)
  --help          このヘルプを表示

対象タグ: wcag2a wcag2aa wcag21a wcag21aa wcag22aa best-practice
`;

/** ページ内で実行する axe。playwright-workflow.md のスニペットと同一の出力形。 */
function axeRunner(maxNodes) {
  return async (limit) => {
    const r = await axe.run(document, {
      runOnly: {
        type: "tag",
        values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"],
      },
      resultTypes: ["violations", "incomplete"],
    });
    const node = (n) => ({ target: n.target, html: n.html.slice(0, 300), failureSummary: n.failureSummary });
    return {
      version: axe.version,
      url: location.href,
      violations: r.violations.map((v) => ({
        id: v.id,
        impact: v.impact,
        help: v.help,
        tags: v.tags,
        total: v.nodes.length,
        nodes: v.nodes.slice(0, limit).map(node),
      })),
      incomplete: r.incomplete.map((v) => ({
        id: v.id,
        help: v.help,
        total: v.nodes.length,
        nodes: v.nodes.slice(0, limit).map((n) => ({ target: n.target, html: n.html.slice(0, 200) })),
      })),
    };
  };
}

function parseArgs(argv) {
  const opts = { width: 1280, height: 900, wait: "networkidle", nodes: 10, out: null, url: null };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--help" || a === "-h") return { help: true };
    else if (a === "--width") opts.width = parseInt(argv[++i], 10);
    else if (a === "--height") opts.height = parseInt(argv[++i], 10);
    else if (a === "--wait") opts.wait = argv[++i];
    else if (a === "--nodes") opts.nodes = parseInt(argv[++i], 10);
    else if (a === "--out") opts.out = argv[++i];
    else if (a.startsWith("--")) throw new Error(`不明なオプション: ${a}`);
    else if (!opts.url) opts.url = a;
    else throw new Error(`余分な引数: ${a}`);
  }
  return opts;
}

async function main(argv) {
  let opts;
  try {
    opts = parseArgs(argv);
  } catch (e) {
    process.stderr.write(`${e.message}\n\n${USAGE}`);
    return 1;
  }
  if (opts.help || !opts.url) {
    process.stdout.write(USAGE);
    return opts.help ? 0 : 1;
  }
  if (!existsSync(AXE_PATH)) {
    process.stderr.write(
      `assets/axe.min.js が見つかりません: ${AXE_PATH}\n` +
        "ビルド (pnpm build) が済んでいないか、スキルが正しく配置されていない可能性があります。\n"
    );
    return 1;
  }

  const axeSource = await readFile(AXE_PATH, "utf8");
  const browser = await launchChromium();
  try {
    const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } });
    await page.goto(opts.url, { waitUntil: opts.wait });
    await page.evaluate(axeSource); // ページに axe を定義する
    const result = await page.evaluate(axeRunner(opts.nodes), opts.nodes);
    result.viewport = { width: opts.width, height: opts.height };

    const json = JSON.stringify(result, null, 2);
    if (opts.out) {
      await writeFile(opts.out, json + "\n", "utf8");
      process.stderr.write(
        `${opts.out} に書き出しました (violations: ${result.violations.length}, incomplete: ${result.incomplete.length})\n`
      );
    } else {
      process.stdout.write(json + "\n");
    }
    return 0;
  } finally {
    await browser.close();
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main(process.argv.slice(2)).then(
    (code) => {
      process.exitCode = code;
    },
    (e) => {
      process.stderr.write(`${e.message}\n`);
      process.exitCode = 1;
    }
  );
}