👁️‍🗨️

アクセシビリティチェックをするdevツールを作成してみた。

に公開

はじめに

どうやらdevツールを自分で作れるらしい。。

そんな情報を聞きつけた私は、devツールを何かしら作りたい&社内にa11y(アクセシビリティ)の浸透をさせたいという両方を満たすツールを作ろうと思い至りました。

ページ内でサイト評価をするツールはいくつかあります。
「lighthouse」に...
「axe」に...
既存であるツールを一から作るよりかは、既存のツールにプラスで自分の欲しいものを追加し、社内で使用できるものを作れば良いんじゃね?というIQが一時的に200を越えた時のお話です。

devツールとは

Developer Toolsの略称で、ソフトウェア開発者がアプリケーションやウェブサイトの開発、デバッグ、最適化を行うために使用するツールを総称しています。

今回作成するdevツールはブラウザの開発者ツールで、普段からフロントエンドエンジニアはブラウザの開発者ツールを開きながら画面を作成していると言っても過言ではないくらい頻繁に利用します。

その中にそっとa11yを添えられたらと思い、今回devツールにてa11yチェックをdevツールで作成しようと思い立ったわけです。

ツール詳細

以下リポジトリになります。
https://github.com/shibata56651/a11y-devtools-extension/tree/main
修正点はまだちらほらありますが、a11y関係で1つの成果物を作成できたことに満足しています。

内容に関しましては、a11yチェックツールのaxeを使用し、問題箇所があった場合にdevツールに該当箇所と問題内容を表示しています。
devツールにて表示されている問題箇所をクリックすると現在表示しているページの問題箇所までアンカーリンクのように自動スクロールします。

基本的な構造は以下の通りで

devtools-extension/
├── manifest.json # 拡張機能の定義ファイル
├── devtools.html # DevToolsパネルのHTML
├── content.js # DevTools パネルのボタンが押された際のJavaScript
├── devtools.js # DevToolsパネルを作成するJavaScript
└── axe.min.js # axe-coreライブラリ(ダウンロードまたは CDN 利用)

devツールを構成するファイル

manifest.json

このファイルでdevツールにて表示する内容や読み込むファイルを記載していきます。
name:devツールのタブの名前
description:機能の説明
devtools_page:ページとして表示するhtmlファイルを指定します。
permissions:どんな権限を付与するか宣言しています。権限一覧
host_permissions:ホストで実行する場合に関しては全ての権限を付与しています。
web_accessible_resources:使用するa11yのリソースについてです。
今回はaxeの圧縮ファイルをローカルで読み込みそのファイルにルールに則りa11yチェックを行います。
content_security_policy:コンテンツセキュリティポリシーについての設定です。

{
  "name": "JIS X 8341-3:2016 Checker",
  "version": "1.0",
  "manifest_version": 3,
  "description": "DevToolsパネルでJIS X 8341-3:2016の違反をチェックし、該当箇所をハイライトします。",
  "devtools_page": "devtools.html",
  "permissions": ["scripting", "activeTab"],
  "host_permissions": ["<all_urls>"],
  "content_scripts": [
    {
      "js": ["content.js"],
      "matches": ["<all_urls>"]
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["axe.min.js"],
      "matches": ["<all_urls>"]
    }
  ],
  "icons": {
    "16": "icon16.png",
    "48": "icon48.png",
    "128": "icon128.png"
  },
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'"
  }
}

devtools.html

devツールで表示するhtmlファイルです。
axe.min.jsの読み込みやjsの起動をこの画面で行ってます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Accessibility Checker</title>
    <script src="axe.min.js"></script>
    <!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.4.1/axe.min.js"></script> -->
    <script src="./devtools.js" defer></script>
    <script src="./content.js" defer></script>
  </head>
  <body>
    <h1>アクセシビリティ検査</h1>
    <button id="runCheck">検閲する</button>
    <pre id="results"></pre>
  </body>
</html>

devtools.js

devパネルの作成とdevパネル内にdevtools.htmlを表示させる記載をここで行っています。

// DevToolsパネルを作成
try {
  chrome.devtools.panels.create(
    "JIS X 8341-3 Checker",
    "icon48.png",
    "devtools.html"
  );
} catch (error) {
  console.error("DevTools拡張: devtools.js 内でエラーが発生:", error);
}

a11yを構成するファイル

axe.min.js

axe-core公式の圧縮版ファイルです。
axe-core公式github

content.js

大まかな処理としては、
ボタンがクリックされたら「axe.min.js」を読み込む。
→ devツールのhtmlにスクリプトとして注入する。
→ axe.min.jsを実行。
→ 実行自体に問題がなければ、現在開いているページの違反箇所をハイライトさせる。
→ 検査結果をパネルに表示し、違反内容や参考文献も一緒に表示。

document.addEventListener("DOMContentLoaded", () => {
  // パネル内のボタンを取得
  const runCheckButton = document.getElementById("runCheck");
  const resultsElement = document.getElementById("results");

  if (!runCheckButton || !resultsElement) {
    console.error("必要な要素が見つかりません: runCheck または results");
    return;
  }

  // ボタンのクリックイベントを設定
  runCheckButton.addEventListener("click", async () => {
    try {
      console.log("アクセシビリティ検査を開始します...");

      // axe.run を対象ページで実行
      const scriptSrc = chrome.runtime.getURL("axe.min.js");

      const result = await new Promise((resolve, reject) => {
        chrome.scripting.executeScript(
          {
            target: { tabId: chrome.devtools.inspectedWindow.tabId },
            world: "MAIN",
            func: (src) => {
              return new Promise((innerResolve, innerReject) => {
                console.log("スクリプトが注入されました:", src);

                const script = document.createElement("script");
                script.src = src;

                script.onload = () => {
                  if (!window.axe || typeof window.axe.run !== "function") {
                    console.error("axe-core の run メソッドが利用できません");
                    innerReject("axe-core の run メソッドが利用できません");
                    return;
                  }

                  window.axe
                    .run()
                    .then((results) => {
                      console.log("検査結果:", results);
                      innerResolve({ success: true, results });
                    })
                    .catch((err) => {
                      console.error("axe-core 実行中にエラーが発生:", err);
                      innerReject({ success: false, error: err.message });
                    });
                };

                script.onerror = () => {
                  console.error(
                    "axe-core の読み込みに失敗しました:",
                    script.src
                  );
                  innerReject("axe-core の読み込みに失敗しました");
                };

                document.head.appendChild(script);
              });
            },
            args: [scriptSrc],
          },
          (injectionResults) => {
            if (chrome.runtime.lastError) {
              console.error(
                "スクリプト実行エラー:",
                chrome.runtime.lastError.message
              );
              reject(chrome.runtime.lastError.message);
            } else if (
              injectionResults?.length > 0 &&
              injectionResults[0].result
            ) {
              resolve(injectionResults[0].result);
            } else {
              reject("スクリプトの実行結果がありません");
            }
          }
        );
      });

      console.log("検査結果:", result);

      if (result.success) {
        const violations = result.results.violations;

        // 違反箇所をハイライト
        violations.forEach((violation, index) => {
          chrome.scripting.executeScript({
            target: { tabId: chrome.devtools.inspectedWindow.tabId },
            func: (
              selector,
              failureSummaries,
              translationCategoryMap,
              index
            ) => {
              document
                .querySelectorAll(selector)
                .forEach((element, nodeIndex) => {
                  // アウトラインを追加してエラー箇所を強調
                  element.style.outline = "3px solid red";
                  element.style.position
                    ? element.style.position
                    : (element.style.position = "relative"); // 親要素に相対位置を指定
                  element.classList.add(
                    "scroll-target-" + (index + 1) + "-" + (nodeIndex + 1)
                  );
                });
            },
            args: [
              violation.nodes.map((node) => node.target.join(", ")).join(", "),
              violation.nodes.map((node) => node.failureSummary), // failureSummary のリスト
              translationCategoryMap, // 翻訳データ
              index,
            ],
          });
        });

        // 検査結果をパネルに表示
        if (violations.length === 0) {
          resultsElement.innerText = "検査結果: 問題は検出されませんでした。";
        } else {
          const explanationList = violations.map((violation, index) => {
            // 違反の概要
            const summary = `違反: ${index + 1}: ${violation.id}`;
            const details = `詳細: ${violation.description}`;
            const help = `説明: ${violation.help}`;
            const helpUrl = `参考文献: ${violation.helpUrl}`;

            // 違反箇所のリスト
            const nodeDetails = violation.nodes
              .map((node, nodeIndex) => {
                const selectors = node.target.join(", ");
                const failureSummary = node.failureSummary
                  ? translationCategoryMap[node.failureSummary]?.description ||
                    node.failureSummary
                  : "理由の説明はありません";

                // クリック可能なリンクを作成
                return `
                  <span style="margin: 18px 0 0; padding: 0; text-align: left; display: inline-block; color: blue; text-decoration: none; word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere; white-space: normal;"><a href="#"
                    class="scroll-to-element"
                    data-selector="scroll-target-${index + 1}-${
                  nodeIndex + 1
                }">対象 ${nodeIndex + 1}: ${selectors}</a>
                </span>
                <span style="display: inline-block; margin: 6px 0 0; padding: 0; word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere; white-space: normal;">${failureSummary}</span>
                `;
              })
              .join("\n");

            // 各違反の説明を HTML として整形
            return `<div style="padding: 8px; border: 1px solid #ccc; margin-bottom: 8px; overflow: hidden; display: flex; flex-direction: column; gap: 4px;">
              <span style="word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere; white-space: normal;">
                <strong>${details}</strong>
              </span>
              <span style="word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere; white-space: normal;">
                <strong>${summary}</strong>
              </span>
              <span style="word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere; white-space: normal;">
                ${help}
              </span>
              <span style="word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere; white-space: normal;">
                ${helpUrl}
              </span>
              <div style="display: flex; flex-direction: column; gap: 4px;">
                ${nodeDetails}
              </div>
            </div>`;
          });

          // 日本語説明を結果に表示
          // resultsElement.innerText = `検査結果:\n${explanationList.join(
          //   "\n\n"
          // )}`;
          resultsElement.innerHTML = explanationList.join("\n\n");
        }

        document.querySelectorAll(".scroll-to-element").forEach((link) => {
          link.addEventListener("click", (event) => {
            event.preventDefault();

            const selector = event.target.dataset.selector;

            // ページ内にスクロールするリクエストを送信
            chrome.scripting.executeScript({
              target: { tabId: chrome.devtools.inspectedWindow.tabId },
              func: (selector) => {
                const element = document.querySelector(`.${selector}`);
                if (element) {
                  element.scrollIntoView({
                    behavior: "smooth",
                    block: "center",
                  });
                  element.style.outline = "3px solid blue"; // 強調表示
                  setTimeout(() => {
                    element.style.outline = "3px solid red"; // 強調解除
                  }, 2000);
                } else {
                  console.error("指定された要素が見つかりません:", selector);
                }
              },
              args: [selector],
            });
          });
        });
      } else {
        console.error("検査エラー:", result.error);
      }
    } catch (error) {
      console.log("キャッチしたエラー:", error);

      let errorDetails = "不明なエラーが発生しました";

      if (error instanceof Error) {
        errorDetails = `${error.name}: ${error.message}`;
        console.error("エラースタック:", error.stack);
      } else if (typeof error === "object" && error !== null) {
        errorDetails = JSON.stringify(error, null, 2);
      } else if (typeof error === "string") {
        errorDetails = error;
      }

      // エラーをパネルに表示
      resultsElement.innerText = `エラー詳細: ${errorDetails}`;
    }
  });
});

最後に

a11yがwebページ上で重要なファクターであることはわかるけれども、実際にどこから意識して内容を合わせていけば良いかわからない人の為に、今回このツールを作成し社内に広めa11y対応の足掛かりになればと思い作成しました。

振り返ってみれば、devツール作成の良い機会になりましたし、実際にツールを使用してもらい「現状表示している違反箇所が英語なので分かりにくいよね。」といったようなFBも貰え、個人的にも成長できた良い機会だと思いました。

今後はこの作成したツールをブラッシュアップし、よりa11yを身近に感じてもらえるよう鋭意創意工夫をしていこうと思います。

ご拝読くださりありがとうございました!

BLT SDC Tech Blog

Discussion