概要
タイトルにあるように、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 の設定
- Figma の macOS アプリケーションを開く
- メニューの
Plugins > Development > Create Plugin...
を選択する Click to choose a manifest.json file
をクリックする- figma-textlint のディレクトリ内の manifest.json を選択する
使い方
- 以下のコマンドを実行して localhost:3000 でローカルサーバーを立てる
$ npm run dev
- Figma アプリケーションでドキュメントを開く
- TextNode が存在しなかったら、適当に作成する
- メニューから
Plugins > Development > figma-textlint
を選択する - ファイルを選択ボタンをクリックする
- Personal Access Token が記載されたファイルを選択する
- Personal Access Token については こちら を参照
- Run ボタンをクリックする
仕様・実装
textlint API
Node.js + Express でローカルサーバーを用意して、そこに渡されたテキストに対して textlint を実行する API を生やしています。
またFigma Plugin から API を叩くと CORS の制限にひっかかるので、 cors
モジュールを導入して、制限を回避しています。
Express にした理由は特になくて、Figma Plugin と同じ言語、かつ早く実装できるものを選びました。
textlint のルールは textlint-rule-prefer-tari-tari
、
textlint-rule-preset-ja-technical-writing
、textlint-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.ts
の figma.ui.onmessage
を呼びます。
document.getElementById('execute').onclick = () => { parent.postMessage({pluginMessage: {type: 'execute'}}, '*'); }
そして次に code.ts
から figma.ui.postMessage
を介して、ui.html
の onmessage
が呼ばれます。
本プロトタイプでは 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 作ってみよう