textlintをWeb Workerとして動かしてアプリに組み込む

Web Workers APIを使うのは初めてなので、この使い方で問題ないのかはよくわかっていない。これから学んでいきます。

動機

会社でtextlintを便利に使っており、ユーザーさんからのお問い合わせ窓口を自社開発しているので、返信内容を送信前にlintにかけたいというのも自然な流れ。などと思っていたら、azuさんがこういうのを用意してくれていた。

内部での利用だからtextlintをサーバーで動かして結果を受け取るのでもいいけど、これのためにWebサーバーにNode.js入れて動かすのもなんかやりすぎ。かといってJobサーバーで動かす構成にするのはもっとやりすぎ。 ブラウザ拡張も用意してくれているけど、構造はシンプルなのでアプリに組み込めればその方が使う側の差異が出なくてサポートがラク。というわけで組み込みを試してみようとなった。

要望

  • 送信前のメッセージをtextlintにかけられる
  • テキストエリアへのオーバーレイでのlint違反表示は不要
  • 特定のエリアに違反内容が表示されればいい

環境

  • typescript: 3.9.10
  • react: 17.0.2
  • @textlint/script-compiler: 0.12.1

tl;dr

  • ブラウザ拡張で使うための @textlint/script-compiler を用意してくれているので、これでworker用のコードを生成
  • 生成したWorkerのコードをロードし、あとは普通にpostMessageで送信し、onmessageで結果の受けとりを書けばいい

残る課題

コンパイルする仕組み上、設定をリソースファイルに切り出しているルールなどはコンパイルできない。prhは https://github.com/textlint/editor/tree/master/packages/%40textlint/config-inliner で対応してくれているけど、それ以外は今のままでは取り込めない。

https://github.com/lostandfound/textlint-rule-ja-hiragana-hojodoushi とかも使いたいので、config-inlinerで対応できるように改造したい。

やったこと

見返してみると別に何もしていない。worker用のコードは生成してくれたものをそのまま使っているだけだし。

textlintのスクリプトコンパイラーと使いたいルールをインストール

yarn add -D @textlint/script-compiler textlint-rule-no-dropping-the-ra

.textlintrcを作成

{
  "rules": {
    "no-dropping-the-ra": true
  }
}

textlint-worker.jsを生成

textlint-script-compiler --output-dir ./public --metadataName 'foo_bar' --metadataNamespace 'http://localhost:3000/' --metadataHomepage 'http://localhost:3000/'

そういえば、metadataNamespace, metadataHomepageはlocalhostのままだけど特に問題ないな。このままでいいのだろうか。

textlintルールを追加するたびに生成し直すので、package.jsonのscriptsにも入れておく。

  "scripts": {
    "compile-textlint-worker": "textlint-script-compiler --output-dir ./public --metadataName 'foo_bar' --metadataNamespace 'http://localhost:3000/' --metadataHomepage 'http://localhost:3000/'"
  }

これでpublic配下に出力されるので、あとは読み込み側。こんなモジュールを書いた。 意識するコマンドはただ使うだけなら lint, lint:result だけ。

const textlintWorker = new Worker("/textlint-worker.js");

export function postToTextlint(body: string) {
  textlintWorker.postMessage({
    command: "lint",
    text: body,
    ext: ".md",
  });
}

interface LintResult {
  type: string;
  ruleId: string;
  message: string;
  column: number;
  index: number;
  line: number;
}

export function registerReceiver(
  setLintResults: React.Dispatch<React.SetStateAction<string[]>>
) {
  textlintWorker.onmessage = function (event) {
    switch (event.data.command) {
      case "init":
        break;
      case "lint:result":
        const messages: LintResult[] = event.data?.result?.messages;
        const messageStrings = messages.map(
          (m) => `[${m.line} 行目:${m.column} 文字目] ${m.message}`
        );
        setLintResults(messageStrings);
        break;
      default:
        console.log("unknown command, ignore");
    }
  };
}

Reactコンポーネントの方で読み込み。こんな感じ。これでlintの結果がstateに入ってくれるようになった。

  const [lintResults, setLintResults] = React.useState([] as string[]);
  React.useEffect(() => {
    registerReceiver(setLintResults);
    postToTextlint(body);
  }, [body, setLintResults]);

あとはよしなにstateの内容を表示するとこんな感じになった。 これはng-wordsも入れている。便利。

f:id:shrkw:20210702164811p:plain
textlintの適用結果

ng-word ruleが行数とかが出ていないのはPRを出してある。マージしてくれるだろうか。

github.com