textlint v12.3.0をリリースして、 ECMAScript Modules(ESM)で書かれたルールやプラグインを扱えるように対応しました。

新しい仕組みには細かな互換性のないところがあるので、v12.3.0の段階ではフラグ付きで利用できるようにしています。 次のメジャーバージョンであるv13で、デフォルトに切り替える予定です。

コマンドラインユーザー向け

textlintをコマンドラインから利用している場合は、次のように環境変数を指定すると新しい実装に切り替わります。

TEXTLINT_USE_NEW_CLI=1 textlint README.md

または、textlint-esmというコマンドが増えているので、次のコマンドでも同じ結果になります。

textlint-esm README.md

コマンドラインレベルでは、基本的に新しい実装でも結果は同じになるはずです。 同じ結果にならなかったり、新しい実装にするとエラーが出たら報告してください。

エディタユーザー向け

vscode-textlintなどエディタプラグインは、おそらく古いAPIを使ってる場合があるので、プラグイン開発者の対応が必要です。 もし、プラグイン開発をしている際にtextlintパッケージを使っていて、新しいAPIだと実装できないケースがあったらおしらせください。 (.textlintrcを読み込まない場合は、@textlint/kernelパッケージが一番低レベルなAPIなので最適です。)

エディタプラグインでもtextlintコマンドを使って実装してるケースは、TEXTLINT_USE_NEW_CLI=1を使って切り替えておかしくないかを確認してください。 基本的にはコマンドレベルでは互換性があるので、多くは問題ないはずです。

新しい実装のtextlintコマンドでは、--stdinフラグを使っているときは--stdin-filename=仮想的なファイル名の指定も必須になっています。 ファイル名を指定しないとファイルの種類(textやMarkdownなど)が判定できなくておかしいので、ちゃんとチェックするように修正しています。

ESMに対応した新しい仕組みを導入した背景

今までのtextlintは、CommonJSで書かれた(CommonJSに変換された)ルールやプラグインしか読み込めませんでした。 CommonJSで書かれたルールやプラグインは、CommonJSのrequire関数で読み込めます。 一方で、ESMで書かれたルールやプラグインはimport()(Dynamic Import)で読み込む必要があります。

require関数は同期処理なのに対して、import()は非同期処理です。 そのため、ルールを読み込むところを非同期処理にする必要があり、これはtextlintをモジュールとして使う場合のAPIに影響がありました。

具体的には、TextLintCore/TextFixEngine/TextLintEngineというAPIの中で、.textlintrcファイルを読み込んでルールなどをrequireで読み込んでいました。 今までは同期処理が前提だったので、これらのクラスの初期化処理も同期的なAPIになっていました。

v12.3.0では、これらのAPIの代わりとなるloadTextlintrc/createLinter/loadLinterFormatter/loadFixerFormatterという新しいAPIを導入しました。 今までのTextLintCore/TextFixEngine/TextLintEngineは、.textlintrcの読み込みとLintという処理が一緒になっていたクラスでした。 新しいAPIでは、loadTextlintrc.textlintrcを読み込み、その読み込んだ情報を使ってcreateLinterでLintをするというように、.textlintrcの読み込みとLintの処理を分離しています。

古いAPIだと次のように書いていました。

import { TextLintEngine } from "textlint";
const engine = new TextLintEngine({
  formatterName: "stylish"
});
const results = await engine.executeOnFiles(["*.md"]);
const output = engine.formatResults(results);
console.log(output);

新しいAPIだと次のようにかけます。

import { createLinter, loadTextlintrc, loadLinterFormatter } from "textlint";
const descriptor = await loadTextlintrc();
const linter = createLinter({
    descriptor
});
const results = await linter.lintFiles(["*.md"]);
const formatter = await loadLinterFormatter({ formatterName: "stylish" })
const output = formatter.format(results);
console.log(output);

それぞれ、.textlintrcのロードやformatterのロードなどがLint処理自体を別れるようになっています。 無理やり対応しようと思えば、TextLintEngineのまま対応もできたのですが、一部のAPIに破綻するものがあったので新しいAPIとして用意することにしました。

具体的には、textlintにはプラグインで対応するファイルの種類を増やせるという仕組みが関係しています。 今までは、engine.availableExtensionsでLintに対応したファイル拡張子の一覧が取得できていました。

import { TextLintEngine } from "textlint";
const engine = new TextLintEngine({
  formatterName: "stylish"
});
console.log(engine.availableExtensions); // => [".md", ".txt"]

しかし、この拡張子の一覧は設定ファイルやプラグインなどを読み込まないとわかりません。 require関数で読み込んでいた場合は同期処理なので成立しましたが、ESMの場合は非同期処理となるためこの一覧は読み込み完了前に決定できません。

対応しているファイルは、.textlintrcファイルを読み込んだ時点(プラグインを読み込んだ時点)で決定するため、新しいAPIでは次のようにloadTextlintrcの結果から対応する拡張子の一覧が取得できます。 Linterは関係なく、設定ファイルを読み込むとその結果からわかるようになっています。

import { createLinter, loadTextlintrc } from "textlint";
const textlintrcDescriptor = await loadTextlintrc();
const availableExtensions = textlintrcDescriptor.availableExtensions;
console.log(availableExtensions); // => [".md", ".txt"]

他にも新しいAPIのサンプルコードは次のページにあるので、textlintパッケージを使う人は参照してください。

ロードマップ

まだフラグ付きで、新しいAPIを入れた段階です。 そのため、詳細は決まっていませんが、次のような段階を踏んで古いAPIを削除する予定です。

  • textlint v12.3.0: 古いAPIをDeprecatedに変更、新しいAPIをUnstableで追加
    • 古いAPIがデフォルトです
    • CLIも古いAPIを利用します
  • textlint v13.0.0: 古いAPIをDeprecatedだけど引き続きサポート、新しいAPIはStableに変更
  • textlint v14?: 古いAPIを削除

まとめ

textlint v12.3.0では、ESM対応として新しいAPIを追加しました。 まだフラグ付きですが、ESMで書いたルールやプラグインが動くようになりました。 互換性のためv12では古いAPIをデフォルトにし、v13から新しいAPIがデフォルトとなります。

APIを分けて実装することで、既存の実装と共存して段階的に進んでいけるようになったと思います。 また、古いAPIにはConfigという変更するのが難しいオブジェクトがありましたが、新しいAPIではConfigを使わずに済むようになりました。 これは、以前にコア部分をモジュールとして分離していたことで、textlintの責務を明確にできていたことが大きいかったと思います。

textlint自体のESM化はまだやっていませんが、ブラウザでネイティブに実行できると面白そうなので、今後の課題として考えています。 ただ、ブラウザではtextlintパッケージではなく@textlint/kernelを扱うようになる気がしています(textlintはfsを扱うので結局ほとんどが不要) どちらかというとImport Mapsの対応を考えていくと、ブラウザやDenoで動かすのに関係していきそうな気がします。

構想では.textlintrcの文字列からesm.shを利用したImport Mapsを動的に生成して、ブラウザでそのままtextlintをimportして動かせるようになるんじゃないかなと思っています。 (.textlintrcが文字列なのは、文字列としてパースしてfsを扱うのを避けるため、Import Mapsを使うのはパッケージのバージョンがtextlintrcには入ってないため)

textlintへのContributeや新しいAPIへのフィードバックを歓迎しています!

Dynamic Importでプラグインの仕組みを実装すると、Windowsで動かないという罠を毎回踏むため、メモを置いておきます。

Windowではファイルパスは C:\\.. のようなパスですが、このファイルパスは import() ではロードできません。 そのため、import(url.pathToFileURL(filePath).href) のように File URL(file:///)に変換してからロードする必要があります。