Pure ESMを使ったtextlintプラグインを作ったら地獄を見たので攻略方法を共有したい
要約(またはTL;DR)
- Pure ESMをバックエンドに使ったtextlintプラグインをTypeScriptで作ってみたら初見殺しに遭いました。
TypeScriptはクソゲーCommonJSとPure ESMをTypeScript内でうまく共存させる方法を書いていきます。 - PoCツールとしてDenoは最高でした。むしろ最初からES Modulesのみのパッケージを作るなら第一選択肢としてDenoを使うべきと思いました。小賢しいハックをしなくても普通に動くビルトインツールは最高です。
- 目的が目的とはいえTypeScript歴10日のド素人が手を出すもんではないなと反省しましたが、今後になって同等の問題にぶつかった際に解決する手法なども学べたとして結果的に良かったと感じています。あと、アルフォートミニチョコレートは変わりなく美味です。
序章: 自分がtextlintプラグインを作ることになった理由
自分はKeePassというFLOSSなパスワードマネージャーを愛用しており、その派生(?)実装であるKeePassXCがプラグインレスでSSHエージェントになったり、同じくプラグインレスでブラウザ統合できたりと上位互換的な機能に惹かれて乗り換えを検討していました。実際に動かしてみるまでは。
とにかく日本語翻訳が酷いです。 一部が未訳な部分はまだ許せるとして、マージなど明らかに当該ソフトウェアを使っていないと思われる機械翻訳っぷりが嫌でも目についてしまい試用段階で「ああ、これダメですね」と諦めてしまいました。
「いやいやそんなことないでしょう」と思う方は 「MSDNのドキュメントを資料として使う時は日本語版縛りで」 という制約を課されて悲鳴を上げない者でしょうので相当訓練されているか、もしくはSES戦士なのでしょう。自分なら「堪えきれぬ狂い火」を出すかもしれません。
それなら新しい言語ファイルを作って上書きするようプルリクを出せば?と思う者もいるかもしれません。しかし、Transifex経由ではないと翻訳を受け付けてくれないので無理なのです。
このTransifexというサービスがやっかいでして、ドキュメントを読むところバルクインサート(一括インポート)には対応しているけれど、未訳部分しか反映されない(つまり、日本語として違和感の残る文は1つずつ手作業で直してね)というゴミなのです。
幸いなことに(KeePassXCも)FLOSSな訳で 「だったら自分で納得がいく言語ファイルを作ってやらぁ!」 となり、個人プロジェクトとして新しい日本語言語ファイルの作成を始めることにしました。
誰が翻訳品質を担保するのか
とはいっても個人でやるものですので、誤字脱字の修正や半角全角の統一などを一人でこなすのは無理です。Markdownであればtextlintを使って(ある程度の)品質は担保できるのですが、今回はXMLベースの専用ファイル。もちろんそのままでは使えません。
だったらファイル形式を変換してやればよいのでは?
KeePassXCはQtフレームワークを使ったソフトウェアなのですが、外部ツールとしてLinguistという言語エディタ兼コンバーターをQtが用意してくれています。
これを使ってpo(gettext)あたりに変換すればtextlintと連携しやすくなるのでは?と思ったのですが、そうは問屋が卸しません。そもそも連携できるソフトウェアがないのです。
これは困りました。XMLベースですので、ぶっちゃけ文章を抽出してから純粋なテキストファイルにして後から差し戻すということも考えましたが、汎用性に欠けます。
そこでQt Linguistの進化という記事を見ていると見たことのないファイル形式を発見。
XLIFFという、第三の選択肢
別にGNU信仰者とかプロプライエタリ死すべし慈悲はないとかそういうものではないのです。ただ、単一のフレームワークに依存している言語ファイル形式に対応しただけで果たして意味があるのだろうかと自問してしまうのです。
それならpo(gettext)に対応したtextlintプラグインを書いてやればよいかと技術スタックを探していたのですが、今度はプラグイン作成に必要な情報が取れるパーサがないという問題に直面。
「諦めて専用形式に特化したプラグインにするか」と思ったのですが、上記にある「XLIFF」というまったく知名度がないファイル形式を発見。
どうやらMicrosoft公式の多言語化ツールキットにも採用されているファイル形式な模様。
これならXMLベースであっても汎用性が確保できるとして、Denoを使ってPoC(概念実証)を作ってみることにしました。
TxtASTに変換できるコンバーターは一日半で完成。でも…
使用するライブラリなどはすでに選定済みだったので、面倒くさい環境構築をしなくてもよいDenoを使ってサクッとPoCコードを作りました。
それで、dntを使って出力したモジュールをtextlintプラグインとして配布する予定でした。Pure ESMが組み込まれているとCommonJSとして出力できないと知るまでは。
本編: Pure ESMとの戦い。そして共存の道へ
本音を言うと。CommonJSのみか、Dual Packageのモジュールに依存しておくと何かこうガーってやってザーッとしてパパッとすればtextlintプラグインなんてあっという間にできます。
そんな人がこんな駄文を見るとは思えないので無視するとして、問題は上にある通り依存するモジュールがESMのみで提供されている場合です。
textlintのコア部分はCommonJSで書かれています。プラグインも例外ではなく、嫌でもCommonJSで書かされることになります。[1]
CommonJSからESMを読むには Dynamic imports
と呼ばれる方法しか使えません。
const { lipsum } = await import("@lorem/ipsum")
みたいな感じで書いてモジュールを呼び出す感じですね。
実はこれ、素のJavaScriptで書いた時は特に問題にならないのです。TypeScriptを経由すると地獄になります。
要求されるのがCommonJSということですから、普通は "module": "commonjs"
とtsconfigに設定するでしょう。
これでドツボにはまった人がいるのかいないのか自分で調べたところ見つからなかったのですが、上記の設定をしていると await import
を勝手に require
と変換してしまい、理不尽にも拡張子をmjsにするか "type": "module"
を設定しろ!と怒られます。
それだったらと "module": "es2022"
あたりにして変換されないようにすると、今度はNode.js側から「これCommonJSじゃねーじゃん(意訳)」と返されてしまい、やはり動きません。
大半の人はおそらくここで諦めてバベることを選択したからだと思われますが、タイトルにもある通り攻略方法はあります。
攻略方法
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16"
}
}
日本語版だとこんな風に書かれているのにと思った方も多そうです。オリジナルを確認すると、きちんと書いてあるのですが。
この設定によりフロント部分をCommonJSに保ちつつ、変換処理にPure ESMを組み込めるようになりました。
余談ですが、フロント部分にある preProcess
というメソッドに async
を追加するとPromiseを返してもいいという隠れ仕様があります。
付録1: ts-jestで拡張子つきインポートがうまくいかない場合
A. ts-jest-resolverをインストールしましょう。
変換部分にESMを使っているので拡張子を追加したインポートが必要となってしまいますが、そのままではJestが動きません。
これを使うと拡張子部分を勝手に読み替えてくれるのでJestが問題なく動くようになります。
cross-env
が開発停止しているんだけど
付録2: クロスプラットフォームで環境変数を受け渡す A. env-cmdをインストールしましょう。
設定ファイルがjson形式ですので、複数の起動オプションを渡す際にダブルクオートにシングルクオートを挟むという気持ち悪い記法をしなくて済むようになります。
Denoだったらどうすれば?
付録3:A. 最初からビルトインされています。
なんと rimraf
や cross-env
相当の機能が最初から付属しています。mkdirp
も当然あります。
詳しくはdeno taskを参照してください。
終わりに
ここまでの長文を真面目に読み進めた人はまずいないと思いますが、最後に何でここまでの長文を書くことになったかを少しだけ書こうと思います。
成果物は textlint-plugin-xliff なのですが、これ自分が最初から書いたコードが(空行を除いて)わずか64行なんですね。
一ヵ月ほどかかっているにも関わず、たった64行の為に「こんなの作りました」とドヤ顔で発表なんて恥ずかしいなんてもんじゃないんです。
そういう訳でめちゃくちゃ長文で誤魔化す感じになっています。
-
Pure ESMにする予定はあるそうですが、かなり先になりそうです。
v12.3.0でESMに対応(環境変数、または追加オプションが必要)となりました。 ↩︎
Discussion