🖊

textlintをVue.js上で動かしてみる

2024/03/13に公開

始めに

皆さんは textlint というツールをご存知でしょうか?
textlint はテキストファイルや Markdown ファイルの文章を校正してくれるツールです。
エンジニアの皆さんには ESLint のテキストファイル版と説明するとわかりやすいかもしれません。
弊社テックブログの文章の校正にも使用されています。

具体的な使い方の説明は textlint の公式サイト に任せるのですが、公式サイトを見る限りだと基本的には CLI から使うことを想定しているツールのようです。
社内の一部のエンジニアから「Liny のマニュアルサイトに使えるのでは」といった声やマニュアルサイトの記事を執筆している社員からも「textlint 便利そう。気になる」といった声が出てきました。

textlint が web サイトでも使えないかと思いググって見たところ、素の JavaScript で実装している記事React で実装している記事 を見つけたので、Vue3 で実装しました。
実際の Liny のマニュアルで使用している Vue.js は Vue2 なので、実際は Vue2 で書き、動いているコードを自分の Vue3 勉強用に書き直した内容となります。

実装

以降、実装していく環境は Vue3 + TypeScript となります。

動作イメージは下の画像のようになります。

  1. 必要なライブラリのインストール
    まず始めに、textlint と textlint 用のルールなどをインストールします。

    shell
    npm install textlint textlint-rule-no-nfd textlint-rule-preset-japanese textlint-rule-prh @textlint/script-compiler
    
  2. ライブラリに手を加える
    このまま実装していっても色々エラーが出て Vue3 で動かないので、textlint-rule-prh と@textlint/script-compiler のコードに手を加えていきます。(Vue2 の環境(laravel+vue2)では手を加える必要がありませんでした。)
    まず始めに textlint-rule-prh の方から。
    node_modules/textlint-rule-prh/lib/textlint-rule-prh.jsの 15 行目にて定義されている変数 homeDirectory を適当なパスに書き換えます。(存在しないパスでも動きます。)

    textlint-rule-prh.js
    @@ -15,7 +15,7 @@
    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
    // LICENSE : MIT
    
    -const homeDirectory = os.homedir();
    +const homeDirectory = "/hoge/fuga/piyo";
    
    const untildify = (filePath) => {
     return homeDirectory ? filePath.replace(/^~(?=$|\/|\\)/, homeDirectory) : filePath;
    

    続いて@textlint/script-compiler の方。
    node_modules/@textlint/script-compiler/lib/compiler.jsの 83 行目にて return されているオブジェクトに以下を追記します。

     externals: {
         "node:path": "node",
         "node:os": "node"
     }
    
    追記後イメージ
    return {
      mode: mode,
      devtool: false,
      entry: {
        "textlint-worker": inputFilePath,
      },
      output: {
        library: "textlint",
        libraryTarget: "self",
        path: outputDir,
        hashFunction: "xxhash64",
      },
      plugins: [
        // https://github.com/azu/kuromojin injection
        // 1.x 2.x supports
        new webpack_1.default.DefinePlugin({
          "process.env.KUROMOJIN_DIC_PATH": JSON.stringify(
            "https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict"
          ),
          "process.env.TEXTLINT_SCRIPT_METADATA": JSON.stringify(metadata),
        }),
        // kuromoji patch
        new webpack_1.default.NormalModuleReplacementPlugin(
          /kuromoji\/src\/loader\/BrowserDictionaryLoader\.js/,
          path_1.default.join(__dirname, "../patch/kuromoji.js")
        ),
        new webpack_1.default.BannerPlugin({
          banner: `textlinteditor:@@@ ${JSON.stringify(metadata)} @@@`,
        }),
        // Node.js polyfill
        new node_polyfill_webpack_plugin_1.default({}),
      ],
      module: {
        rules: experimentalInlining ? [fsInliningRule] : [],
      },
      optimization: {
        minimize: true,
        minimizer: [
          // Preserve licence and banner comment
          new terser_webpack_plugin_1.default({
            terserOptions: {
              format: {
                comments: /^\**!|@preserve|@license|@cc_on/i,
              },
            },
            extractComments: false,
          }),
        ],
      },
      resolve: {
        fallback: {
          fs: false,
        },
      },
      performance: {
        hints: false,
      },
      externals: {
        "node:path": "node",
        "node:os": "node",
      },
    };
    
  3. Textlint で適用するルール等の設定
    Textlint で適用するルール等を設定するために.textlintrc を作成します。具体的な.textlintrc の中身は以下のような感じになります。

    .textlintrc
    {
       "rules": {
         "preset-japanese": true,
         "no-nfd": true,
         "prh": {
           "rulePaths": [
             "./textlint/prh/demo-rule.yml"
           ]
         }
       }
    }
    

    上記の内容では以下のルールを適用するものになります。

    • textlint-rule-no-nfd: UTF-8 NFD[1]を無効にするルール
    • textlint-rule-preset-japanese: 日本語向けのプリセットルール
    • textlint-rule-prh: 表記ゆれチェックのためのルール

    続いて表記ゆれチェックの辞書ファイルを作成します。(.textlintrc 内の rulePaths に書いてある./textlint/prh/demo-rule.ymlを作成)

    demo-rule.yml
     version: 1
     rules:
       - expected: 友だち
         pattern:
           - 友達
           - お友だち
           - お友達
    

    上記の例だと「友達」、「お友だち」、「お友達」を「友だち」に修正するように指摘するという内容になります。
    詳しい書き方は textlint-rulr-prh 内のドキュメント を見てください。

  4. Web Worker 用に Textlint をコンパイルする
    今回 Textlint を動かすにあたって JavaScript の Web Worker を使用します。Web Worker はスクリプトをバックグラウンドの別スレッドで動かす手段です。
    そのままでは、CLI 環境前提の Textlint を動かすことができないため、ブラウザでも使えるようにコンパイルしていきます。
    package.json の scripts に以下を追記します

    "compile-textlint-worker": "textlint-script-compiler --mode production --output-dir ./src/assets --metadataName 'textlint-worker' --metadataNamespace 'http://localhost:3000/' --metadataHomepage 'http://localhost:3000/'"
    

    この 1 行を追加したことによってnpm run compile-textlint-workerを実行することによってコンパイルが行えます。
    npx extlint-script-compiler --mode production --output-dir ./src/assets --metadataName 'textlint-worker' --metadataNamespace 'http://localhost:3000/' --metadataHomepage 'http://localhost:3000/'を実行すればコンパイルは可能です。
    しかし、.textlintrc や prh のルールの変更を適用するためにコンパイルが必要になるため追加しておく方が効率が良いと思います。

  5. vue のコードを書いていく
    実際にコードをゴリゴリ書いていくターンです。

    Textlint を動作させるための部分を抜粋したコードが以下になります。

    import { onBeforeUnmount, ref, watch } from "vue";
    import type { TextlintMessage } from "@textlint/types";
    
    const text = ref<string>("");
    const lintResults = ref<string[]>([]);
    
    // ポイント①
    const worker = new Worker("src/assets/textlint-worker.js");
    // ポイント②
    worker.addEventListener("message", (event) => {
      const { data } = event;
      if (data.command === "lint:result" && data.result.messages.length > 0) {
        lintResults.value = data.result.messages.map(
          (message: TextlintMessage) =>
            `${message.loc.start.line}行目 ${message.loc.start.column}${message.loc.end.column}文字目: ${message.message}`
        );
      }
    });
    const runTextlint = (text: string) => {
      // ポイント③
      worker.postMessage({
        command: "lint",
        text: text,
        ext: ".txt",
      });
    };
    
    // ポイント④
    onBeforeUnmount(() => worker.terminate());
    watch(text, () => runTextlint(text.value));
    
    • ポイント ① では 4 でコンパイルした Textlint のコードを動かす worker を生成しています。
    • ポイント ② ではポイント ① で生成した worker からのメッセージを受け取っています。受け取った際に第二引数の関数を実行されます。
      今回の例だと、worker から Textlint のコードを動かした結果がメッセージで来るため、lint を実行した結果かつ指摘事項が 1 つでもあった場合に変数 lintResults へ指摘事項を整形して代入するという形になっています。
    • ポイント ③ では ポイント ① で生成した worker に対して、lint を実行しろと言うコマンドと入力された文字列を含んだメッセージを送信しています。
    • ポイント ④ では vue インスタンスが破壊される際にポイント ① で生成された worker を終了させています。worker を終了させる必要はないですが、不要になったリソースは削除したほうが精神的に好ましいので終了させています。

最後に

自分の Github にサンプルコードをおいているので、実際に書いたコードを見てみたい方は気軽に覗いて見てください。
https://github.com/hsrmy/demo/tree/main/textlint-vue

脚注
  1. 詳しくは こちら を参照。 ↩︎

ソーシャルデータバンク テックブログ

Discussion