Open8

TS/Jest/モノリポ、ESM化チャレンジ

markuplint ESM化計画

  • 無事v2.0.0をリリースできたのでESM化(もしくはCJSハイブリッド)を進めたい
  • 既に依存モジュールの一部がESM化が始まっていて最新のバージョンを利用できていない
  • csstreeaccname あたりは、それの機能に依存しているのでわりと深刻な問題

パッケージ毎にひとつずつ始めていく

モノリポなので、パッケージ毎に package.jsontsconfig.json を分けているため(TSは extends で継承しながら)ESM化もパッケージ毎にできるのでは?

ということで、モノリポの中で依存ツリーの最上位になる markuplint からESM化することにする。なぜ最上位からというと、下層から初めてしまうとそれに依存するパッケージもESM化しないといけなくなる(気がする)から。正直ここもわかってない。何も知らない。

設定変更

まずは対象のパッケージの設定を変える。

package.json
{
-   "main": "lib/index.js",
-   "types": "lib/index.d.ts",
+   "type": "module",
+   "main": "lib/index.mjs",
+   "types": "lib/index.d.mts"
}
tsconfig.json
{
  "compilerOptions": {
+    "module": "NodeNext",
+    "target": "ES2020",
    "composite": true,
    "outDir": "./lib",
    "rootDir": "./src"
  }
}

VS Code が「"module": "NodeNext" は安定してないから TypeScript@next をインストールしろ」と警告してくるのでそこもインストールしなおす。

$ yarn add -DW typescript@next

※モノリポのため -W オプションが必要

確認時点で typescript@4.6.0-dev.20220211 が入った。

しかし、警告は消えない。一旦ここは無視する。

とりま .ts ファイルを .mts に置換

VS Code のエクスプローラー(通常左にあるファイルツリーペイン)から、愚直に拡張子を書き換えていく。そうすると VS Code が気を利かせて、相対的に import しているパスを勝手に .mjs に書き換えてくれる(なんて便利な!!)。

ESM では、ESM のファイルは .mjs を、CommonJS のファイルは .cjs として、それを import するときに拡張子を読ませる作法か仕様らしく一旦それに従う。

index.ts
export { MLEngine } from './api';
export * from './i18n';
export * from './testing-tool';
export * from './types';

index.mts
export { MLEngine } from './api/index.mjs';
export * from './i18n.mjs';
export * from './testing-tool/index.mjs';
export * from './types.mjs';

う〜ん、index ってファイル辞めようかなぁって感じてくるなァ。

ここで拡張子が変わったパスに対して ESLint が警告を出してくる。標準ではなく eslint-plugin-import というのを入れているため import/no-unresolved というルールが実体ファイルを見つけられずに警告を出してしまったようだ。出力先のフォルダが異なるので当然。しかし TS コンパイラは .mjs.mts と解釈して型チェックを行うため TS 側でのエラーにはならない。慣れるまで気持ち悪いけど我慢が必要っぽい。 ESLint の警告はひとまず無効化することで対応。

.eslintrc
{
  "rules": {
+    "import/no-unresolved": [
+      "error",
+      {
+        "ignore": ["\\.mjs$"]
+      }
    ]
  }
}

依存パッケージをESM対応の最新にする

いくつかESM化されていたせいで最新にできなかったパッケージを今回は最新にした!(気持ちがいいね!)

そして、ビルドをしたところコンパイル成功🎉

なかなかスムーズにできている気がする。

コマンド実行ファイルの修正

ESMでは拡張子が必須のようで、次のように修正。
require も使えなくなる(工夫すれば使えるらしい)のでDynamic Importで対応。Top Level Awaitが使えるのも嬉しい(ここではあんまり意味ないけど)。

bin/markuplint
#!/usr/bin/env node

require('../lib/cli');

bin/markuplint.mjs
#!/usr/bin/env node

await import('../lib/cli/index.mjs');
package.json
{
  "bin": {
-     "markuplint": "bin/markuplint"
+     "markuplint": "bin/markuplint.mjs"
  }
}

Named Export 問題

さて、ためしに実行してみる

import { prompt } from 'enquirer';
         ^^^^^^
SyntaxError: Named export 'prompt' not found. The requested module 'enquirer' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from 'enquirer';
const { prompt } = pkg;

といった問題が発生していたので修正。

cli/prompt.mts
- import { prompt } from 'enquirer';
+ import enquirer from 'enquirer';
+
+ const { prompt } = enquirer;

しかし、、、

import { createRuleHelper } from '@markuplint/create-rule-helper';
         ^^^^^^^^^^^^^^^^
SyntaxError: Named export 'createRuleHelper' not found. The requested module '@markuplint/create-rule-helper' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from '@markuplint/create-rule-helper';
const { createRuleHelper } = pkg;

これって、モノリポの中の依存パッケージも全部これに修正しなきゃいけないってことじゃね??

全部 ESM 化する予定なのに、

import pkg from '@markuplint/create-rule-helper';
const { createRuleHelper } = pkg;

この書き方に一時的に直すの辛いんですけど…🥺

…と思ったら、これが必要なのは @markuplint/create-rule-helper だけで、他の @markuplint/file-resolver などは普通に Named Export できてる。…????…意味がわからない…。

しかも、以下のように * as で対応しないとダメだった。

import * as createRuleHelper from '@markuplint/create-rule-helper';

// 略

createRuleHelper.createRuleHelper(/* ...略... */);

パッケージ側の設定の問題かと思ったので、比較してみる。

比較

packages/@markuplint/file-resolver/package.json
{
  "name": "@markuplint/file-resolver",
  "version": "2.0.0",
  "main": "lib/index.js",
  "types": "lib/index.d.ts"
}
packages/@markuplint/create-rule-helper/package.json
{
  "name": "@markuplint/create-rule-helper",
  "version": "2.0.0",
  "main": "lib/index.js",
  "types": "lib/index.d.ts"
}
packages/@markuplint/file-resolver/lib/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
tslib_1.__exportStar(require("./config-provider"), exports);
tslib_1.__exportStar(require("./resolve-files"), exports);
tslib_1.__exportStar(require("./resolve-parser"), exports);
tslib_1.__exportStar(require("./resolve-rules"), exports);
tslib_1.__exportStar(require("./resolve-specs"), exports);
tslib_1.__exportStar(require("./types"), exports);
packages/@markuplint/create-rule-helper/lib/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
(0, tslib_1.__exportStar)(require("./create-rule-helper"), exports);
(0, tslib_1.__exportStar)(require("./types"), exports);

なにが問題になってるんだろ…。

未解決

ただ、実行できるようにはなったので、すべてESM化した後に何もなければ忘れることにする。

Jestが *.mts を一切受け付けない

テストができない…。

(つづく)

ログインするとコメントできます