ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

Figma ドキュメント上のテキストを textlint でチェックする Figma Plugin のプロトタイプを作った

概要

タイトルにあるように、Figma ドキュメント上の TextNode に対して、ローカルサーバーに用意した API を通して textlint を実行し、その結果を Figma ドキュメントにコメントとして投稿する Figma Plugin を作ってみました。

経緯

所属している会社では Figma を使ってデザインデータを作成しています。
そのデザインデータに対してレビューを行うのですが、デザインや仕様に関する指摘よりも単純な文言の指摘に時間を割かれがちという現状があり、あんまり本質的ではないなと思っていました。
また、仕様書や一部プロダクトでは CI + Danger + textlint を組み合わせて、GitHub の Pull Request が作成されたときに自動的に文言チェックを行っているので、Figma でもなんとかしたい気持ちが強くなっていきました。

そんな折、技術書典 9 で『Figma Developers Book - Figma Web REST API + Plugins 開発入門』を購入していたことを思い出し、この本を片手にプロトタイプを作ってみることにしました。

できたもの

デモ動画

macOS アプリケーションの Figma で開いたドキュメント上の TextNode (デモでは 綺麗既に)に対して、ローカルサーバーの API を介して textlint を実行し、その結果をコメントして投稿しています。

ソースコード

GitHub で公開しています。
どんな感じで動いているかの雰囲気を掴んでいただけるかと思います。
https://github.com/macneko-ayu/figma-textlint-with-server

インストール方法

$ git clone git@github.com:macneko-ayu/figma-textlint-with-server.git
$ cd figma-textlint-with-server

textlint server

$ cd textlint-server
$ npm install

Figma Plugin

開発環境の設定

$ cd figma-textlint
$ npm install

Figma Plugin の設定

  1. Figma の macOS アプリケーションを開く
  2. メニューの Plugins > Development > Create Plugin... を選択する
  3. Click to choose a manifest.json file をクリックする
  4. figma-textlint のディレクトリ内の manifest.json を選択する

使い方

  1. 以下のコマンドを実行して localhost:3000 でローカルサーバーを立てる $ npm run dev
  2. Figma アプリケーションでドキュメントを開く
  3. TextNode が存在しなかったら、適当に作成する
  4. メニューから Plugins > Development > figma-textlint を選択する
  5. ファイルを選択ボタンをクリックする
  6. Personal Access Token が記載されたファイルを選択する
    • Personal Access Token については こちら を参照
  7. Run ボタンをクリックする

仕様・実装

textlint API

Node.js + Express でローカルサーバーを用意して、そこに渡されたテキストに対して textlint を実行する API を生やしています。
またFigma Plugin から API を叩くと CORS の制限にひっかかるので、 cors モジュールを導入して、制限を回避しています。
Express にした理由は特になくて、Figma Plugin と同じ言語、かつ早く実装できるものを選びました。

textlint のルールは textlint-rule-prefer-tari-taritextlint-rule-preset-ja-technical-writingtextlint-rule-prh を導入していますが、お好きなものを利用できます。
本プロトタイプでは特にカスタマイズしていませんが、 textlint-rule-prh を導入すれば独自のルールを作成することができ、小回りが効くので気に入っています。

API のコアの部分( textlint.ts )は以下のような実装になっています。

import express from 'express';
import cors from 'cors';
import { TextLintEngine } from 'textlint';

const router = express.Router();

router.post('/', cors(), async (req: express.Request, res: express.Response) => {
    try {
        const body = req.body.text;
        const engine = new TextLintEngine();
        const results = await engine.executeOnText(body);
        const messages = results[0].messages.map(item => item.message);
        res.status(200).json({ messages: messages });
    } catch (error) {
        res.status(400).json({ message: error.message });
    }
});

export default router;

Figma Plugin

ui.html に UI と自前の API 及び Figma API との通信処理、code.ts に Figma ドキュメント側の処理を実装しています。

code.ts では、ドキュメント上の TextNode をすべて取得して TextNode ごとに Object に詰め直して ui.html に渡しています。
Object は Figma API のリクエスト時に必要な fileKey 、コメント投稿時にコメントを TextNode と関連づけるために id 、TextNode のコンテンツの characters で構成されています。
fileKey とはブラウザで Figma ドキュメントを開いたときの URL の https://www.figma.com/file/(ここ)/ の文字列です。

コードの全体像は以下のようになっています。

figma.showUI(__html__);

figma.ui.onmessage = msg => {
  if (msg.type === 'execute') {
    const textNodes = figma.currentPage.findAll(node => node.type === 'TEXT');
    const textInfos = textNodes.map(node => {
      return {
        fileKey: figma.fileKey,
        characters: (node as TextNode).characters,
        nodeId: node.id
      };
    });
    figma.ui.postMessage({ type: 'send-text', textInfos })
  }

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

ui.html では、ダイアログの UI と、各ボタンをタップしたときの処理、各種リクエスト処理を行っています。
Figma Plugin では通信に XMLHttpRequest を使う必要があり、初めて使ったのでちょっと戸惑いました。

Token が記載されたテキストファイルの読込処理は以下のようになっています。
FileReader でファイルの内容を読み込んで、変数に代入してリクエスト時に使うようにしました。

const reader = new FileReader();
const input = document.getElementById('file');
input.addEventListener('change', () => {
  reader.readAsText(input.files[0], 'UTF-8');
  reader.onload = () => {
    token = reader.result;
  };
});

ダイアログの Run ボタンをクリックすると、parent.postMessage~ を介して、code.tsfigma.ui.onmessage を呼びます。

document.getElementById('execute').onclick = () => {
  parent.postMessage({pluginMessage: {type: 'execute'}}, '*');
}

そして次に code.ts から figma.ui.postMessage を介して、ui.htmlonmessage が呼ばれます。

本プロトタイプでは onmessage で受け取った値をもとに textlint API にリクエストしたり、その結果をコメントとして投稿したりしています。

onmessage = async (event) => {
  if (event.data.pluginMessage.type !== 'send-text') return;

  const textInfos = event.data.pluginMessage.textInfos;
  await Promise.all(textInfos.map(async textInfo => {
    const fileKey = textInfo.fileKey;
    const nodeId = textInfo.nodeId;
    const characters = textInfo.characters;

    const messages = await getTextlintMessages(fileKey, nodeId, characters)
      .catch((e) => {
        alert(e);
      });
    if (messages !== undefined) {
      await Promise.all(messages.map(async message => {
        await postComment(fileKey, nodeId, message)
          .catch((e) => {
            alert(e);
          });
      }));
    }
  }));
  alert('Done.');
  sendCloseMessage();
}

textlint API へのリクエスト処理は以下のようになっています。
エラー処理は適当です…。

async function getTextlintMessages(fileKey, nodeId, characters) {
  return new Promise((resolve, reject) => {
    const request = new XMLHttpRequest();
    request.open('POST', 'http://localhost:3000/v1/textlint');
    request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    request.responseType = 'json';
    request.onload = () => {
      if (request.status !== 200) {
        reject(new Error(`Error: Textlint post request is ${request.status}. ${request.statusText}`));
      } else {
        resolve(request.response.messages);
      }
    };
    request.onerror = () => {
      reject(new Error('Error: Textlint post request is network error'));
    };
    request.send(`text=${characters}`);
  });
}

続いて、textlint API の結果を Figma API を介してコメントに投稿します。
コメントの投稿に必要なパラメータは data Object にまとめました。
message はコメント本文、client_meta.node_id はコメントを紐付ける Node の id、client_meta.node_offset はコメントのバッジをつける位置です。
Figma API へのリクエストには X-FIGMA-TOKEN というヘッダーに token を渡す必要があります。
request.status は Figma API の rate limit に達したときに 429 が返されるので、そこだけ分岐をわけています。
https://www.figma.com/developers/api#errors
こちらもエラー処理は適当です。

コメント投稿処理は以下のようになっています。

async function postComment(fileKey, nodeId, comment) {
  return new Promise((resolve, reject) => {
    const data = {
      "message": comment,
      "client_meta": {
        "node_id": nodeId,
        "node_offset": {
          "x": 10,
          "y": 10
        }
      }
    }

    const request = new XMLHttpRequest();
    request.open('POST', `https://api.figma.com/v1/files/${fileKey}/comments`);
    request.setRequestHeader('X-FIGMA-TOKEN', token);
    request.setRequestHeader('Content-Type', 'application/json');
    request.onload = () => {
      if (request.status === 429) {
        reject(new Error(`Error: Figma api is rate limit. ${request.statusText}`));
      }
      if (request.status !== 200) {
        reject(new Error(`Error: Comment post request is ${request.status}. ${request.statusText}`));
      }
      resolve();
    }
    request.onerror = () => {
      reject(new Error('Error: Comment post request is network error'));
    };
    request.send(JSON.stringify(data));
  });
}

実用するにあたって解決しないといけなさそうなこと

コードを見ていただくとおわかりかと思うのですが、プロトタイプという名目で可能な限り手を抜いたので、実用にあたって解決しないといけない部分が多々あるかと思います。
私がなんとなく書き出したものだけでも以下の量なので、実用するにはまだ手がかかりそうです。

  • textlint-serverのデプロイ先を決める
  • textlint-serverのセキュリティ対策(いまノーガード)
  • textlint-serverのリクエスト数
    • TextNodeの数分だけリクエストするので、現実的じゃない
    • 選択したものだけ処理するにしても限界を決めないとだめ
  • Figma APIのリクエスト数
    • TextNode数:lintの数が 1 : n なので、こちらも現実的じゃなさそう
  • リクエストのエラーハンドリング
  • ダイアログのUIがダサい
  • アカウントのパーソナルトークンをファイルで読み込んでいる
    • OAuth2 でやるといいのかも

まとめ

作り始めるまでは面倒そうとか、私に実装できるかなとか、いろいろ考えてましたが、いざ手を動かしてみると意外とコード量も少なく実装できました。
API もほとんど書いたことがなくてお作法がわからず、まぁ動けばいいよねって感じで書いたけど、ちゃんと?動いててよかった。
また機会があったら、他の Figma Plugin 作ってみよう