Amazonで商品を見るセール会場へ

Figmaプラグイン│外部スタイル参照を一括検出してCSV出力する

Figmaファイル内で外部ライブラリのスタイルを参照しているレイヤーを、一括で検出してCSV形式で書き出すプラグインの実装手順を解説します。

  • デザインシステムの運用中に、意図しない外部スタイルの混入を検出したい
  • リンク切れや不要な外部依存の原因となっている箇所を特定したい
  • デザインの整合性維持や保守運用のために監査ログを残したい

手動の目視確認には多くの工数がかかるため、プラグインを用いて全レイヤーを自動で走査します。

全レイヤーを自動走査して得られた外部スタイル参照の出力結果の例。

出力結果の例です。

取得できない場合に不明と表示されることがある外部スタイル参照の出力結果の例。

Figma APIの制約によりLibrary Nameの直接取得が難しいため、取得できないケースでは「不明」と表示されます。

目次

外部スタイル参照を一括検出してCSV出力するプラグインの仕組み

ローカルに定義されていない外部スタイルが適用されているレイヤーを、ファイル内から走査して検出します。

ローカルに定義されていない外部スタイルが適用されたレイヤーを走査・検出する仕組みのイメージ。

プラグインの処理手順は次のとおりです。

  • ローカルに存在するすべてのスタイルIDを取得してキャッシュする
  • 各レイヤーに設定されたスタイルIDがローカル一覧に含まれるかを判定する
  • ドキュメント内のノードツリーを深さ優先で走査する
  • 検出したスタイル情報をCSV形式に整形してUI側へ送信する

プラグインを構成する各ファイルのコードです。

ローカルスタイルの一覧をCSV形式で出力する方法は、以下の記事で解説しています。

manifest.json

{
  "name": "外部スタイル参照を調査",
  "id": "check-external-styles",
  "api": "1.0.0",
  "main": "code.js",
  "ui": "ui.html",
  "editorType": ["figma"],
  "permissions": ["currentuser"]
}

code.js

// ローカルスタイルIDをキャッシュ(Paint / Effect / Text / Grid)
const localStyleIds = new Set(
  [
    ...figma.getLocalPaintStyles(),
    ...figma.getLocalEffectStyles(),
    ...figma.getLocalTextStyles(),
    ...figma.getLocalGridStyles(),
  ].map((s) => s.id)
);

function isExternalStyle(style) {
  return !!style && style.remote === true && !localStyleIds.has(style.id);
}

function checkExternalStyleUsage(node) {
  const result = [];

  function add(styleId, type) {
    if (!styleId || typeof styleId !== "string") return; // figma.mixed は弾く
    const style = figma.getStyleById(styleId);
    if (isExternalStyle(style)) {
      result.push({
        name: node.name,
        id: "'" + node.id, // Excel誤変換防止
        type,
        styleName: style.name,
        styleId: style.id,
        libraryName: (style && style.libraryName) || "不明",
      });
    }
  }

  try {
    add(node.fillStyleId, "Fill Style");
  } catch (e) {}
  try {
    add(node.strokeStyleId, "Stroke Style");
  } catch (e) {}
  try {
    add(node.effectStyleId, "Effect Style");
  } catch (e) {}
  if ("textStyleId" in node) {
    try {
      add(node.textStyleId, "Text Style");
    } catch (e) {}
  }

  return result;
}

function walk(node, acc) {
  // SceneNodeのみチェック
  if (node && node.type && typeof node.type === "string") {
    try {
      acc.push(...checkExternalStyleUsage(node));
    } catch (e) {}
  }
  if ("children" in node && Array.isArray(node.children)) {
    for (const child of node.children) {
      walk(child, acc);
    }
  }
}

function getTimestamp() {
  const now = new Date();
  const yyyy = now.getFullYear();
  const mm = String(now.getMonth() + 1).padStart(2, "0");
  const dd = String(now.getDate()).padStart(2, "0");
  const hh = String(now.getHours()).padStart(2, "0");
  const min = String(now.getMinutes()).padStart(2, "0");
  return `${yyyy}-${mm}-${dd}_${hh}${min}`;
}

function toCSV(rows) {
  const headers = [
    "Layer Name",
    "Layer ID",
    "Style Type",
    "Style Name",
    "Style ID",
    "Library Name",
  ];
  const records = rows.map((r) => [
    r.name,
    r.id,
    r.type,
    r.styleName,
    r.styleId,
    r.libraryName,
  ]);
  return [headers, ...records]
    .map((row) =>
      row.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")
    )
    .join("\n");
}

// ---- UIを表示(小さめのモーダル) ----
figma.showUI(__html__, { width: 320, height: 160 });

figma.ui.onmessage = (msg) => {
  if (!msg || typeof msg !== "object") return;

  if (msg.type === "export") {
    const allResults = [];

    // ファイル全体:全ページを起点に再帰走査
    for (const page of figma.root.children) {
      if (page.type === "PAGE") {
        walk(page, allResults);
      }
    }

    if (allResults.length === 0) {
      figma.notify(
        "外部スタイルを参照しているレイヤーは見つかりませんでした。"
      );
      figma.ui.postMessage({ type: "error", message: "外部スタイルなし。" });
      return;
    }

    const csv = toCSV(allResults);
    const filename = `external_styles_${getTimestamp()}.csv`;
    figma.ui.postMessage({ type: "download", csv, filename });
  }

  if (msg.type === "close") {
    figma.closePlugin();
  }
};

ui.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>外部スタイル参照を調査</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <style>
      html,
      body {
        margin: 0;
        padding: 0;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          "Hiragino Kaku Gothic ProN", Meiryo, "Noto Sans JP", Arial, sans-serif;
        color: #111;
        background: #fff;
      }
      .container {
        padding: 16px;
      }
      h1 {
        font-size: 14px;
        margin: 0 0 6px;
        font-weight: 700;
      }
      p {
        font-size: 12px;
        margin: 0 0 12px;
        color: #555;
      }
      .actions {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      button {
        appearance: none;
        border: 1px solid #ccc;
        background: #0b62ff;
        color: #fff;
        font-size: 13px;
        padding: 8px 12px;
        border-radius: 8px;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.6;
        cursor: not-allowed;
      }
      .status {
        font-size: 11px;
        color: #666;
      }
    </style>
  </head>
  <body>
    <div class="container" role="main" aria-labelledby="title">
      <h1 id="title">外部スタイル調査</h1>
      <p>全ページ・全レイヤーを走査し、外部スタイル参照をCSVに書き出します。</p>
      <div class="actions">
        <button id="run" type="button">全体をスキャンしてCSV出力</button>
        <span
          id="status"
          class="status"
          role="status"
          aria-live="polite"
        ></span>
      </div>
    </div>

    <script>
      let csv = "",
        filename = "";
      const runBtn = document.getElementById("run");
      const statusEl = document.getElementById("status");

      function setLoading(on, text) {
        runBtn.disabled = !!on;
        statusEl.textContent = text || "";
      }

      function requestExport() {
        if (runBtn.disabled) return;
        setLoading(true, "ファイル全体を走査中…");
        parent.postMessage({ pluginMessage: { type: "export" } }, "*");
      }

      runBtn.addEventListener("click", requestExport);

      // Enter キーでも実行
      window.addEventListener("keydown", (e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          requestExport();
        }
      });

      onmessage = (event) => {
        const msg = event.data.pluginMessage;
        if (!msg) return;

        if (msg.type === "download") {
          csv = msg.csv;
          filename = msg.filename;

          try {
            const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
            const url = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(url);
            setLoading(false, "ダウンロード完了");
          } catch (err) {
            alert("ダウンロードに失敗しました。");
            setLoading(false, "");
          } finally {
            parent.postMessage({ pluginMessage: { type: "close" } }, "*");
          }
        }

        if (msg.type === "error") {
          setLoading(false, "");
          alert(msg.message || "エラーが発生しました。");
        }
      };
    </script>
  </body>
</html>

実装コードは以上です。

プラグインを実行し、外部スタイルの検出結果とCSV出力の内容を確認します。

プラグイン実行による外部スタイルの検出結果と出力されたCSVの内容。

Figmaプラグインの開発に関する記事は、ほかにもあります。

サービス

Service

デザイン制作に関心がありましたら、ぜひ詳細をご覧ください。

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

Figmaのおすすめ書籍

Figma用PCの選び方

周辺機器の優先度

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次