株式会社はてなに入社しました
「Node.js は偶数バージョンが LTS」ではなくなる
長らく Node.js では偶数バージョン (v22, v24 など) が LTS としてメンテナンスされてました。しかし 2027/4 リリース予定の v27 以降はそうではなくなります。Node.js 公式ブログで、v27 以降に取り入れられる新しいリリーススケジュールについて説明されています。
かいつまんで説明すると:
- major リリースは年2回から年1回となる
- 奇数番号だけでなく全てのリリースが LTS となる
- 早期テスト目的で利用されてた奇数リリースは alpha チャンネルで代替される
- alpha 期間は約半年確保
- v27 から新スケジュールで運用開始
- 暦に合わせた番号のバージョンがリリースされる
- 2027 年なら v27、2028 年なら v28...
公式ブログの説明によると Node.js メンテナーのメンテナンスやリリースの負担軽減が主な目的のようですが、我々ユーザによっても分かりやすくなって良いですね。
サービスに利用する Node.js では奇数バージョンの利用を避ける運用をしていたケースが多かったと思いますが、今後はそういう運用も変えていくことになりそうです。
追記: そもそも「LTS」の定義について
「全バージョンが LTS なら、何と比較して Long Term なサポートなんやねん」と思った人もいるかもしれません。ちょっと混乱するのですが、Node.js における「LTS」は major バージョンに割り当てられるステータスのようなものです。新たな major バージョンがリリースされると、まず半年間 Current というステータスになって、その後 30ヶ月間 LTS になります。Current では新機能がどんどん実装されていきますが、LTS ではセキュリティ修正などを主に受け付ける期間になります *1。
これは余談ですが、LTS はセキュリティ修正以外も受け付けることがあります。最近だと LTS であった Node.js 20 に require(ESM) が backport されています。
- https://nodejs.org/en/blog/release/v20.19.0#requireesm-is-now-enabled-by-default
- https://github.com/nodejs/node/pull/55085
安全に backport できること、エコシステムが ESM 移行を進めるために重要であることなどを理由に、LTS バージョンに backport されたようです。実際このおかげで ESM 移行がより進んでいますから、ありがたいことですね。
*1:v27 からは Current の前に Alpha というステータスが半年間追加されます。
npm package 実装用のテンプレートを更新した
「npm-package-template」という、
id:mizdra が爆速で npm package を実装するための開発テンプレートがある。以前ブログで紹介した。
毎年ちょっとした変更を加えているのだけど、今年は色々な事情があって大きな変更を加えた。高速な formatter/linter が登場したり、npmjs.com へのサプライチェーン攻撃が流行ったり、Coding Agent が普及したり... 時代が変化してるので、それに合わせたテンプレートに変えてみた。
- Update 2026/02 (Part 1) by mizdra · Pull Request #53 · mizdra/npm-package-template
- Update 2026/02 (Part 2) by mizdra · Pull Request #54 · mizdra/npm-package-template
ざっと要点を書き出すと以下のような変更をした。
- Prettier から Oxfmt に移行
- ESLint から Oxlint に移行
- 型チェックは
oxlint --type-aware --type-checkでやる - npm-script に
lin-fixを追加 - AGENTS.md/CLAUDE.md を追加
- tsconfig.base.json を更新
- Trusted Publishing でリリースする workflow を追加
- GitHub Actions のバージョンを pinning
- GitHub リポジトリの基本的な設定をするためのシェルスクリプトを追加
Oxfmt/Oxlint
Oxfmt/Oxlint が普段使いしても良いレベルに成熟してきたので、Prettier/ESLint に乗り換えてみた。Oxlint 向けには @mizdra/oxlint-config という Shareable Config を作って、それを参照する形にしてる。Oxfmt も Oxlint も特に大きな不満なく動いていていい感じ。
Oxfmt は Shareable Config を実現するための仕組みがないので、設定をパッケージ化せずにベタ書きしてる。けど公式リポジトリを見ると oxfmt.config.ts という形式をサポートする issue が立ってたので、近い将来解決するかも。
多分 oxfmt.config.ts が実装されたら、以下のようなコードで Shareable Config を参照できるようになるはず。
// oxfmt.config.ts export * from '@mizdra/oxfmt-config';
型チェックは oxlint --type-aware --type-check でやる
TypeScript の型チェックは今まで tsc --noEmit でやってたけど、最近 Oxlint に --type-check というオプションが生えたのでこれを使ってみた。
このオプションを使うと、なんと lint 結果と一緒に TypeScript のエラーを報告してくれる。Oxlint は型情報を使った lint rule を実行するために内部で TypeScript の型チェックをしていて、その結果をそのまま吐くという仕組みになってる。ESLint 時代はこういうのできなかったので便利になったなあと思う。
ちなみに Oxlint は型チェックに tsgo を fork した tsgolint を使ってるので、型チェック自体も爆速で終わるようになってる。速くて嬉しい。
npm-script に lin-fix を追加
lint-fix": "eslint . --fix" みたいなやつのこと。今までの
id:mizdra はまあ npx eslint . --fix と打てば良いだけだし無くても良い派だったのだけど、Coding Agent 向けにはあったら便利そうなので追加してみた。
AGENTS.md/CLAUDE.md
どのリポジトリにもあったら良いものということで、npm-script の使い方や commit message の規則、PR に付けるタグのルールを書いたファイルを置いておいた。AGENTS.md が本体で、CLAUDE.md はそれへの symlink にしてる。symlink できるだけ使いたくない気持ちはあるけどこれ以外良い方法を思いつかなかった。
tsconfig.base.json を更新
"types": ["node"] や "noUncheckedSideEffectImports": true を追加したり。以前このブログで紹介した tsconfig.json の推奨オプションをベースに、TypeScript 5.9 で推奨となったオプション を追加しただけ。
近々リリースされる TypeScript 6.0 では "strict": true がデフォルトになったりと色々変わるそうなので、また推奨される tsconfig.json のオプションが変わりそうだなと思ってる。TypeScript 6.0 がリリースされたら見直したい。
Trusted Publishing でリリースする workflow を追加
npm の Trusted Publishing が 2025/7 に GA したので、それを使ったリリース workflow を追加した。
GitHub Actions のバージョンを pinning
サプライチェーン攻撃のリスクを軽減するため、pinact run で GitHub Actions のバージョンを固定した。
GitHub リポジトリの基本的な設定をするためのシェルスクリプトを追加
自分のOSSリポジトリにGitHubのセキュリティ設定を入れ、自分用の手順書を作った - $shibayu36->blog; で紹介されている設定を一発で行うシェルスクリプトを用意した。手でちまちま設定するの面倒だなと思ったのでカッとなって作った。
一部抜粋して紹介。
OWNER=$(gh repo view --json owner -q .owner.login) REPO=$(gh repo view --json name -q .name) # Setup common repository settings gh repo edit \ --delete-branch-on-merge \ --enable-auto-merge \ --enable-discussions=false \ --enable-projects=false \ --enable-secret-scanning \ --enable-secret-scanning-push-protection \ --enable-wiki=false # Enable Code scanning gh api -X PATCH /repos/$OWNER/$REPO/code-scanning/default-setup -f state=configured # Enable immutable releases gh api -X PUT /repos/$OWNER/$REPO/immutable-releases # Setup rulesets DEFAULT_BRANCH_PROTECTION=$(gh api /repos/mizdra/npm-package-template/rulesets/13184851) VERSION_TAG_PROTECTION=$(gh api /repos/mizdra/npm-package-template/rulesets/13184887) gh api -X POST /repos/$OWNER/$REPO/rulesets --input - <<< $DEFAULT_BRANCH_PROTECTION gh api -X POST /repos/$OWNER/$REPO/rulesets --input - <<< $VERSION_TAG_PROTECTION # Require actions to be pinned to a full-length commit SHA gh api -X PUT /repos/$OWNER/$REPO/actions/permissions -F enabled=true -F sha_pinning_required=true
いくつかの設定は gh repo edit でできるのでそれで設定して、残りは gh api で REST API 叩いて設定してる。欲しかった REST API 全部存在してて GitHub すごい。
ruleset の設定はちょっとハックっぽいことをしてて、mizdra/npm-package-template リポジトリの ruleset を export して、それを新しいリポジトリに import してる。これが一番短く書けるのでこうしてる。mizdra/npm-package-template は公開リポジトリで、公開リポジトリの ruleset は誰でも REST API で読み出せるので、一応このスクリプト自体は誰でも実行できると思う。
あと先ほどのコードには書いてなかったけど、
id:mizdra はライセンスファイルの更新をするスクリプトも追加してる。最近
id:anatofuz さんが gh コマンドで gitignore ファイルを生成できると呟いていて、それをみて gh コマンドのリファレンス読んだらライセンスファイルを生成するコマンドを発見したので、それを使ってみている。
# Change license gh repo license view mit | sed "s/\[year\]/$(date +%Y)/;s/\[fullname\]/mizdra/" > LICENSE npm pkg set license=MIT && npm i
GitHub のラベルの設定をするスクリプトも追加した。github-label-setup と自分向けの label preset を使ってる。
# Setup labels GITHUB_TOKEN=$(gh auth token) npx \ -p @azu/github-label-setup \ -p @mizdra/github-label-presets \ github-label-setup \ --labels @mizdra/github-label-presets
おわりに
npm-package-template は CC0-1.0 でライセンスしてるので真似したり参考にしてみてください。
@.css-modules-kit/ts-plugin を Emacs で動かすまでの覚書
@.css-modules-kit/ts-plugin を Neovim で動かすまでの覚書 - mizdra's blog の Emacs 版。
Emacs のインストール
macOS なら brew install --cask emacs でインストールできる *1。
一応 https://emacsformacosx.com/ から dmg も落とせるけど、dmg からインストールすると emacs コマンドがパスの通ったディレクトリに配置されなくて、ちょっと面倒だった。brew install --cask emacs であれば emacs コマンドもパス通ったディレクトリに配置してくれる。
設定ファイルの置き場所
~/.config/emacs があれば ~/.config/emacs/init.el に、なければ ~/.emacs/init.el に配置される。~/.config/emacs と ~/.emacs の両方がある時は後者が優先される。
ちなみに emacs --init-directory=~/.config/emacs-test . のようにして Emacs を起動すると、~/.config/emacs-test が設定ファイルの置き場所として使われる。デバッグ目的で初期設定の Emacs を起動したい時や、設定を切り替えたい時に便利。
Emacs Lisp の書き方
init.el は Emacs Lisp で書く。以下の公式ドキュメントを読んで書き方を学ぶと良い。
実際に実行しながら挙動を確かめたいなら、Emacs で M-: (print "Hello World!") などと打ち込むと良い。Emacs Lisp を実行できる。あとは *scratch* バッファに記入して C-j で実行するのも便利。以下のドキュメントに色々方法がまとまってる。
Mode という概念について
Emacs にはエディタの動作を切り替える仕組みがあり、これを「Mode」と呼ぶ。Mode には major mode と minor mode の二種類がある。major mode は特定のファイルタイプに特化した機能を提供するもので、1つのファイルにつき 1つだけ割り当てられる。一方 minor mode は1つのファイルに複数割り当て可能なもの。
例えば typescript-ts-mode という major mode がある。これは TypeScript 向けにシンタックスハイライトなどを提供したり、forward-sexp / backward-sexp などのコマンドによる文字移動を TypeScript に特化した挙動にする。
一方 Eglot (Emacs 組み込みの Language Client) は major mode ではなく、minor mode として動作する。Language Client が特定のファイルに紐づいて動作するわけではなく、エディタ横断で動作する類のもののため、minor mode として実装されている。
major mode は VS Code でいうところの Language Identifiers のような役割も兼ねている。この major mode の名前を使って、Language Server との紐付けを行うことになる。
major mode について
Emacs 29 以降では以下の major mode が組み込まれており、基本的にはこれらを使うのが良い。
js-ts-mode: JavaScript 向けの major modetypescript-ts-mode: TypeScript 向けの major modetsx-ts-mode: TSX 向けの major modecss-ts-mode: CSS 向けの major mode
ちなみに *-ts-mode の ts の部分は tree-sitter の略。tree-sitter を使ってパースをして AST-aware なシンタックスハイライトをしている実装になってて、このような名前になってる。
補足: major mode の名前に -ts- が付いている理由
何故わざわざ -ts- 付けているのか疑問に思うかもしれないが、どうやら歴史的経緯によるものらしい。元々 Emacs には組み込みや 3rd-party で tree-sitter 実装ではない major mode (js-mode や typescript-mode) が存在していた。ただそれらの major mode では正規表現を使ってコードを解析しており、ユーザの期待通りのシンタックスハイライトなどを実現できない問題があったようだ。そこで Emacs 29 になって tree-sitter をベースにした major mode が実装された。
- https://www.masteringemacs.org/article/tree-sitter-complications-of-parsing-languages
- https://www.masteringemacs.org/article/how-to-get-started-tree-sitter
しかし古い major mode と新しい major mode は本質的に挙動が異なるものである。突然 js-mode が tree-sitter ベースになると、古い major mode の挙動に依存した init.el が壊れてしまう。またそもそも Emacs のビルドには tree-sitter は必須ではなく、オプショナルである。tree-sitter がない場合も Emacs が機能するようになっていないといけない。後方互換性、そして tree-sitter なしで Emacs がビルドされているケースに対応するため、tree-sitter ベースの major mode はオプトイン (既存の major mode と名前を分けてデフォルトでは有効化しない) とされているようだった。
tree-sitter ベースの major mode を有効化する
tree-sitter ベースの major mode はオプトインで、デフォルトでは有効化されていない。そのためまずはそれらを有効化する設定を書く必要がある。init.el を以下のように編集すれば良い。
;; tree-sitter のセットアップと構文定義のインストール (use-package treesit :ensure nil :config (setq treesit-font-lock-level 4) (setq treesit-language-source-alist (append '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src") (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src") (tsx "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src") (css "https://github.com/tree-sitter/tree-sitter-css" "master" "src")) treesit-language-source-alist)) (add-hook 'emacs-startup-hook (lambda () (dolist (lang (mapcar #'car treesit-language-source-alist)) (unless (treesit-language-available-p lang) (treesit-install-language-grammar lang)))))) ;; major mode の設定 (add-to-list 'auto-mode-alist '("\\.js\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.ts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode)) (add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode))
実は Emacs には tree-sitter の構文定義の情報が組み込まれていない。そのため、ただ *.ts に typescript-ts-mode を紐づけてもシンタックスハイライトなどが効かない。そこで構文定義を外部からインストールしてくる必要がある。それが設定の前半部分。後半部分が major mode の紐付け。
これで JavaScript/TypeScript/CSS のシンタックスハイライトが機能するようになる。
補足: 構文定義が Emacs に組み込まれていない理由
tree-sitter の構文定義くらい Emacs に組み込んであったら良いのだが、色々理由があってできてないらしい。構文定義のビルドには Node.js と node-gyp が必要なため、Emacs のビルドにそれらが必須になるのは困るとか。構文定義のビルド方法の詳細に Emacs が依存したくないとか。
- https://lists.gnu.org/archive/html/emacs-devel/2024-12/msg01296.html
- https://lists.gnu.org/archive/html/emacs-devel/2024-12/msg01059.html
補足: jsx-ts-mode がない理由
*.tsx は tsx-ts-mode に紐づけるのに *.jsx は jsx-ts-mode に紐付けないのがびっくりするかも (
id:mizdra はびっくりした)。何故こうしているかというと、Emacs には jsx-ts-mode なる major mode が存在しないから。というかそもそも JSX 用の tree-sitter の構文定義が存在しない。JavaScript と JSX で統一されているらしい。
Emacs 側の議論はちゃんと追ってないけど、多分 tree-sitter の構文定義が統一されているので major mode も統一しているのだろう。
Language Server の設定
先ほど少し触れたが、Emacs には Eglot という Language Client が組み込まれている。major mode と Language Server の起動コマンドの組のリストからなる eglot-server-programs という変数があり、この定義に従って Language Server が起動される。ちなみにデフォルトでは JavaScript と TypeScript では typescript-language-server が Language Server の実装として使われる。
- https://github.com/emacs-mirror/emacs/blob/003962fa5f2b9cc6c5a78fc52b91fb3d930b9300/lisp/progmodes/eglot.el#L238
- https://github.com/emacs-mirror/emacs/blob/003962fa5f2b9cc6c5a78fc52b91fb3d930b9300/lisp/progmodes/eglot.el#L261-L268
- https://github.com/emacs-mirror/emacs/blob/003962fa5f2b9cc6c5a78fc52b91fb3d930b9300/lisp/progmodes/eglot.el#L328-L330
Eglot は minor mode であり、デフォルトでは起動されない。特定の major mode が ON になったら、Eglot を起動するような設定を書く必要がある。
まずは Language Server をインストールしておく。
$ npm i -g typescript-language-server typescript
そして use-package の :hook と eglot-ensure を使って以下のような設定を書く。
;; tree-sitter のセットアップと構文定義のインストール (use-package treesit :ensure nil :config (setq treesit-font-lock-level 4) (setq treesit-language-source-alist (append '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src") (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src") (tsx "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src") (css "https://github.com/tree-sitter/tree-sitter-css" "master" "src")) treesit-language-source-alist)) (add-hook 'emacs-startup-hook (lambda () (dolist (lang (mapcar #'car treesit-language-source-alist)) (unless (treesit-language-available-p lang) (treesit-install-language-grammar lang)))))) ;; major mode の設定 (add-to-list 'auto-mode-alist '("\\.js\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.ts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode)) (add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode)) ;; Language Server のセットアップ (use-package eglot :ensure nil :hook ((js-ts-mode . eglot-ensure) (typescript-ts-mode . eglot-ensure) (tsx-ts-mode . eglot-ensure)))
これで JavaScript/TypeScript で Language Server が起動するようになったはず。
@css-modules-kit/ts-plugin のセットアップ
@css-modules-kit/ts-plugin は TypeScript Language Service Plugin なので、TypeScript の Language Server に読み込ませる、というステップが必要になる。
まずは @css-modules-kit/ts-plugin をインストールしておく。
npm i -g @css-modules-kit/ts-plugin
そして init.el を以下のように書き換える。
;; tree-sitter のセットアップと構文定義のインストール (use-package treesit :ensure nil :config (setq treesit-font-lock-level 4) (setq treesit-language-source-alist (append '((javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src") (typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src") (tsx "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src") (css "https://github.com/tree-sitter/tree-sitter-css" "master" "src")) treesit-language-source-alist)) (add-hook 'emacs-startup-hook (lambda () (dolist (lang (mapcar #'car treesit-language-source-alist)) (unless (treesit-language-available-p lang) (treesit-install-language-grammar lang)))))) ;; major mode の設定 (add-to-list 'auto-mode-alist '("\\.js\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.mjs\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.cjs\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.jsx\\'" . js-ts-mode)) (add-to-list 'auto-mode-alist '("\\.ts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.mts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.cts\\'" . typescript-ts-mode)) (add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode)) (add-to-list 'auto-mode-alist '("\\.css\\'" . css-ts-mode)) ;; Language Server のセットアップ (use-package eglot :ensure nil :hook ((js-ts-mode . eglot-ensure) (typescript-ts-mode . eglot-ensure) (tsx-ts-mode . eglot-ensure) (css-ts-mode . eglot-ensure)) :config (require 'subr-x) ;; string-trim (setq npm-root (string-trim (shell-command-to-string "npm root -g"))) (add-to-list 'eglot-server-programs `(((js-ts-mode :language-id "javascript") (typescript-ts-mode :language-id "typescript") (tsx-ts-mode :language-id "typescriptreact") (css-ts-mode :language-id "css")) . ("typescript-language-server" "--stdio" :initializationOptions ((plugins . [((name . "@css-modules-kit/ts-plugin") (location . ,npm-root) (languages . ["css"]))]))))))
やってることは Neovim の時とほぼ同じ。CSS でも typescript-language-server が起動されるようにしつつ、@css-modules-kit/ts-plugin を plugin として読み込ませている。:hook に (css-ts-mode . eglot-ensure) を足しているのもポイント。これがないとそもそも CSS で Eglot が起動してくれない。
厳密には eglot-server-programs にデフォルトの JavaScript/TypeScript の Language Server を起動する設定が残っているから、それを新しいもので置換したほうが行儀は良いと思う。ただ Eglot は eglot-server-programs の先頭からリストを探索して major mode に一致するものがあったらそれを使う挙動になっている。そのため、add-to-list で先頭に設定を追加してしまえば、古いものがリスト内の残っていても特に問題がない。
という訳でこれで @css-modules-kit/ts-plugin が Emacs でも動くようになった。めでたしめでたし。
おまけ: CSS Language Server と共存できない問題について
勘の良い人は気づいたかもしれないが、Eglot は 1 つの major mode に複数の Language Server を起動することができない。eglot-server-programs の先頭に定義されている Language Server が優先して起動されることになる。
つまり今回
id:mizdra が紹介した init.el では、*.css に対して TypeScript/CSS 両方の Language Server を起動できない。TypeScript の Language Server だけが起動される。そのため @css-modules-kit/ts-plugin が提供する言語機能 (クラスセレクターの Go to Definition や Find All References など) は機能するが、CSS の Language Server が提供する言語機能 (プロパティの補完など) は機能しない。
この問題については長らく Eglot の discussion で議論されていたのだけど、つい最近になって Eglot ではサポートしないという方針になったようだった。
- https://github.com/joaotavora/eglot/discussions/1429
- https://github.com/joaotavora/eglot/discussions/1429#discussioncomment-15189380
代わりに https://github.com/joaotavora/rassumfrassum や https://github.com/thefrontside/lspx のような LSP マルチプレクサの使用を推奨している模様。まあ気持ちはわかる... わかるが Emacs 組み込みのパッケージなのだから組み込みで Multiple Server サポートしてほしいなー。
@css-modules-kit/ts-plugin を使いつつ、*.css に対して TypeScript/CSS 両方の Language Server の両方を起動するなら多分以下のような設定になるはず。
;; Language Server のセットアップ (use-package eglot :ensure nil :hook ((js-ts-mode . eglot-ensure) (typescript-ts-mode . eglot-ensure) (tsx-ts-mode . eglot-ensure) (css-ts-mode . eglot-ensure)) :config (require 'subr-x) ;; string-trim (setq npm-root (string-trim (shell-command-to-string "npm root -g"))) (add-to-list 'eglot-server-programs `(((js-ts-mode :language-id "javascript") (typescript-ts-mode :language-id "typescript") (tsx-ts-mode :language-id "typescriptreact") (css-ts-mode :language-id "css")) . ("lspx" "--lsp" "typescript-language-server --stdio" "--lsp" "vscode-css-language-server" :initializationOptions ((plugins . [((name . "@css-modules-kit/ts-plugin") (location . ,npm-root) (languages . ["css"]))]))))))
けどこれ、vscode-css-language-server にも initializationOptions が渡ってしまうし、そもそも vscode-css-language-server に *.ts の LSP Request が飛んでしまうし、全然ダメな気がする。lspx では上手くいかなさそう。rassumfrassum もドキュメント読む限りはこういうケースには対応してなさそう。打つ手なし...
まあ CSS の Language Server が提供する言語機能 (プロパティの補完など) は機能しないのは諦めてもらうということで...
おまけ: Language Server のデバッグテクニック
TypeScript Language Service Plugin や typescript-language-server が出力するログを見るには、tsserver.log を出力すると良い。TSS_LOG 環境変数を設定して Emacs を起動すれば良い。
TSS_LOG="-level verbose -file /tmp/tsserver.log" emacs -nw .
*1:Nonfree systems という見出しの下に macOS が書いてあって GNU らしさがある。
@.css-modules-kit/ts-plugin を Neovim で動かすまでの覚書
id:mizdra は CSS Modules Kit という CSS Modules のためのツールキットを作っている。そのツールキットの中に @css-modules-kit/ts-plugin というものがある。CSS Modules の言語機能を提供するための TypeScript Language Service Plugin である。
一応これは任意のエディタで動かせるように作ってる。実際、VS Code / Zed などで動くことを確認してる。しかし、Neovim で動くことはまだ確認できてなかった。いや、動くはずなんだけど Neovim の使い方がさっぱりわからなくて... 何度か挑戦したけど、LSP の設定の仕方とか、plugin のインストールとかが難しくて挫折した。あとキーバインドが全然わからん。completion のウインドウってどうやって出すの? そもそも TypeScript Language Service Plugin が読み込まれているか確認するために tsserver.log を見たいのだけど、どうやったら見れるの?
...という状態だったのだけど、この度 Claude に色々聞きながらようやく @css-modules-kit/ts-plugin を動かすことができた。忘れないように覚えたことを記事にまとめておく。
Neovim のインストール
macOS なら Homebrew でインストールできる。
インストールしたら nvim で起動できるはず。
設定ファイルの置き場所
OS によって設定ファイルの置き場所は異なるが、macOS なら ~/.config/nvim か $XDG_CONFIG_HOME/nvim になる。$XDG_CONFIG_HOME がセットされてない時は前者が、セットされてる時は後者が使われる。
ここに init.vim や init.lua といった vimrc と呼ばれる設定ファイルを置くと、それが起動時に実行されるらしい。世の中的には init.lua で書くのが主流っぽい。
ちなみに NVIM_APPNAME=nvim-test nvim のようにして Neovim を起動すると、~/.config/nvim-test が設定ファイルの置き場所として使われる。デバッグ目的で初期設定の Neovim を起動したい時や、設定を切り替えたい時に便利。
Lua の書き方
init.lua は Lua で書く。以下の公式ドキュメントを読んで書き方を学ぶと良い。
LLM に「<あなたがよく使う言語> を書く人向けに Lua の記法や作法、違いを簡潔に教えてもらえますか?」と聞くのも良い。
実際に実行しながら挙動を確かめたいなら、Neovim のノーマルモードで :lua print("Hello!") などと打ち込むと良い。Lua を実行できる。
Neovim の LSP サポートについて
Neovim は LSP のサポートを組み込んでいる。ただし、デフォルトではどの Language Server も起動されないようになっている。ユーザが Language Server を起動するための設定が必要になる。
まず Language Server をインストールする必要がある。TypeScript の Language Server は typescript-language-server と vtsls の二種類がある。今回は typescript-language-server を使ってみる。以下のコマンドでインストールできる。
npm i -g typescript-language-server typescript
そして init.lua に以下のような設定を書く。
-- 補完を有効にするための設定 vim.cmd[[set completeopt+=menuone,noselect,popup]] vim.api.nvim_create_autocmd('LspAttach', { callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client:supports_method('textDocument/completion') then vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) -- TypeScript の Language Server の設定 vim.lsp.config('ts_ls', { init_options = { hostInfo = 'neovim' }, cmd = { 'typescript-language-server', '--stdio' }, filetypes = { 'javascript', 'javascriptreact', 'javascript.jsx', 'typescript', 'typescriptreact', 'typescript.tsx', }, root_markers = { 'tsconfig.json', 'jsconfig.json', 'package.json', '.git' }, }) -- TypeScript の Language Server を有効に vim.lsp.enable('ts_ls')
何故か補完を有効にするには vim.api.nvim_create_autocmd が必要らしい。デフォルトで補完有効になってて良い気がするけど、なんでそうなってないんでしょうね。
VS Code ユーザなら以下のように CTRL-Space で補完ウインドウが出るようにしておくと便利だと思う。
vim.keymap.set('i', '<c-space>', function() vim.lsp.completion.get() end)
その他 LSP に関する設定は以下を参照。LSP 関連のキーバインドもここに書いてある。
- https://neovim.io/doc/user/lsp.html
- https://neovim.io/doc/user/lsp.html#_global-defaults
- https://neovim.io/doc/user/lsp.html#_buffer-local-defaults
nvim-lspconfig
世の中の人皆同じような設定を書くので、よく使われる Language Server の共通設定がまとめられた nvim-lspconfig Package が存在する。これを使うのがデファクトらしい。
早速 nvim-lspconfig の使い方を紹介したいところだが、その前に「Plugin」と「Package」という用語について説明する。
「Plugin」と「Package」
どちらも Vim に由来する用語である。「Plugin」は Vim スクリプトファイルをまとめたもので、「Package」は Plugin を含むディレクトリを指す。
Package は ~/.config/nvim/pack/ に以下のようなディレクトリ構成で配置される。
~/.config/nvim/pack/
├─ nvim/
│ ├─ start/
│ │ ├─ start-pkg-1/
│ │ │ └─ plugin/
│ │ │ └─ start-pkg-1.lua
│ │ └─ start-pkg-2/
│ │ └─ plugin/
│ │ └─ start-pkg-2.lua
│ └─ opt/
│ ├─ opt-pkg-1/
│ │ └─ plugin/
│ │ └─ opt-pkg-1.lua
│ └─ opt-pkg-2/
│ └─ plugin/
│ └─ opt-pkg-2.lua
└─ multiple-plugins-pkg/
└─ start/
├─ plugin-1/
│ └─ multiple-plugins-pkg-plugin-1.lua
└─ plugin-2/
└─ multiple-plugins-pkg-plugin-2.lua
pack/nvim/start/* にあるのが「"start" package」と呼ばれるもので、Neovim の起動時に自動で実行される *1。pack/nvim/opt/* にあるのが「"opt" package」で、:packadd した時に実行される。ちなみに Package は Plugin を複数持つこともできて、その例が pack/multiple-plugins-pkg である *2。なんだかややこしいが、pack/* にあるものも、pack/*/{start,opt}/* にあるものも Package と呼ぶらしい? とにかく Plugin を含むディレクトリなら Package と呼ぶ世界観に見える。
id:mizdra の理解が間違っていたら誰かツッコんでほしい!
nvim-lspconfig のインストールとセットアップ
話を戻すと、nvim-lspconfig は Package である。よってこれをインストールする必要がある。nvim-lspconfig の README にインストール方法がいくつか書かれているので、そのどれかの方法でインストールすれば良い。
ミニマムにやりたいなら git clone か vim.pack を使う方法が良い。後者は Neovim の組み込みの Plugin Manager *3 なのだが、まだ実験的な機能で stable では使えない。そこで今回は git clone を使ってインストールしてみる。
git clone https://github.com/neovim/nvim-lspconfig ~/.config/nvim/pack/nvim/start/nvim-lspconfig
これで LSP の設定を以下のように記述できる。
-- 補完を有効にするための設定 vim.cmd[[set completeopt+=menuone,noselect,popup]] vim.api.nvim_create_autocmd('LspAttach', { callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client:supports_method('textDocument/completion') then vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) -- TypeScript の Language Server を有効に vim.lsp.enable('ts_ls')
これだけみると、「nvim-lspconfig から提供される Language Server の設定はどこで読み込んでるんだ?」とびっくりするかも (
id:mizdra はびっくりした)。実は vim.lsp.enable('ts_ls') を実行した時に読み込まれている。Package 内の lsp/* から Language Server の設定が自動で探索される挙動となってて、結果として nvim-lspconfig/lsp/ts_ls.lua が読み込まれている。
@css-modules-kit/ts-plugin のセットアップ
@css-modules-kit/ts-plugin は TypeScript Language Service Plugin なので、TypeScript の Language Server に読み込ませる、というステップが必要になる。
まずは @css-modules-kit/ts-plugin をインストールしておく。
npm i -g @css-modules-kit/ts-plugin
そして以下のように init.lua を書く。
-- 補完を有効にするための設定 vim.cmd[[set completeopt+=menuone,noselect,popup]] vim.api.nvim_create_autocmd('LspAttach', { callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client and client:supports_method('textDocument/completion') then vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true }) end end, }) -- @css-modules-kit/ts-plugin を使うための設定 local npm_root = vim.trim(vim.fn.system('npm root -g')) local ts_ls_default = vim.lsp.config.ts_ls vim.lsp.config('ts_ls', { filetypes = vim.list_extend(ts_ls_default.filetypes, { 'css' }), init_options = { plugins = { { name = '@css-modules-kit/ts-plugin', location = npm_root, languages = { 'css' }, }, }, }, }) -- TypeScript の Language Server を有効に vim.lsp.enable('ts_ls')
やっていることとしては、
@css-modules-kit/ts-pluginを plugin として読み込むようにinit_options.pluginsの部分- これで
*.ts,*.tsx上で CSS Modules 向けの言語機能が使えるようになる
- CSS ファイル の Language Server として
typescript-language-serverが使われるようにfiletypes = ...の部分- これで
*.css上で CSS Modules 向けの言語機能が使えるようになる
filetypes = ... の部分がないと *.css に対する LSP の Request (textDocument/definition や textDocument/rename) が一切 typescript-language-server に届かず、CSS 上で色々な言語機能が機能しなくなってしまう。*.ts, *.css どちらの LSP Request も受け取れるように、このような設定が必要になる。
これで @css-modules-kit/ts-plugin が Neovim でも動くようになった。めでたしめでたし。
おまけ: vim.lsp.config の挙動
vim.lsp.config('ts_ls', {...}) と書くと、Pacakge 経由で読み込まれた config などといい感じにマージされる仕様となってる。
マージは vim.tbl_deep_extend() で行われる。テーブル (JavaScript のオブジェクトに相当) 同士をディープマージしてくれる。テーブルは再起的に辿ってマージしてくれるけど、配列はたどってくれないので、注意が必要 *4。
今回書いた設定で filetypes: vim.list_extend(ts_ls_default.filetypes, { 'css' }) と書いたのはそのため。filetypes: { 'css' } だと typescript-language-server が *.ts の Language Server として使われなくなってしまう。
おまけ: Language Server のデバッグテクニック
Neovim で :checkhealth と打つと実際に読み込まれた Language Server の起動オプションを表示できる。うまく設定できてるのか確かめる時に便利。

TypeScript Language Service Plugin や typescript-language-server が出力するログを見るには、tsserver.log を出力すると良い。TSS_LOG 環境変数を設定して Neovim を起動すれば良い。
TSS_LOG="-level verbose -file /tmp/tsserver.log" nvim .
*1:https://neovim.io/doc/user/pack.html#_creating-vim-packages でそう呼ばれてる
*2:公式ドキュメントでも https://neovim.io/doc/user/pack.html#_creating-vim-packages でこうした Package を紹介してる。
*3:ところでなんで「Package Manager」と呼ばないのでしょうね?
*4:ちなみに Lua において配列はテーブルの特殊系 (数値をインデックスに持つテーブル) で、厳密には配列もテーブルである。vim.tbl_deep_extend ではテーブルのインデックスを見て、配列っぽいテーブルなら再起的に辿らず、それ以外のテーブルなら辿る挙動になってる。ref: https://github.com/neovim/neovim/blob/6b4ec2264e1d8ba027b85f3883d532c5068be92a/runtime/lua/vim/_core/shared.lua#L552
インストールする VS Code 拡張機能を減らした
かつては VS Code 拡張機能をインストールしまくっていて 65 個あったけど、最近はできるだけインストールしないようにしてる。数が多いとセキュリティリスク高まるので。
今インストールしてるのは以下の 24 個:
alefragnani.bookmarks anthropic.claude-code dbaeumer.vscode-eslint esbenp.prettier-vscode github.codespaces github.copilot github.copilot-chat github.vscode-pull-request-github golang.go hediet.vscode-drawio intellsmi.comment-translate mizdra.css-modules-kit-vscode monokai.theme-monokai-pro-vscode ms-ceintl.vscode-language-pack-ja ms-vscode-remote.remote-containers ms-vsliveshare.vsliveshare oxc.oxc-vscode pnp.polacode redhat.vscode-yaml rust-lang.rust-analyzer streetsidesoftware.code-spell-checker stylelint.vscode-stylelint vadimcn.vscode-lldb vitest.explorer
普段は JavaScript 書きまくっているので、それ関連の拡張機能が多め。ここ数年で変わったこととしては、Rust で書かれた JavaScript 向けツール (Oxc, Rolldown, Biome, Turbopack, ...) が増えたために rust-lang.rust-analyzer や vadimcn.vscode-lldb を使うようになったこと。ツールのコードよく読みにいくので、これがないと困る。Go で書かれたツール (typescript-go, esbuild) もよく遭遇するので、golang.go もインストールしてる。
intellsmi.comment-translate は最近 mizchi さんの記事見てインストールしたけど、めっちゃ便利だった。設定はこんな感じでやってる。source を "Copilot" にしているのがポイント。
{ "commentTranslate.targetLanguage": "ja", "commentTranslate.hover.concise": true, "commentTranslate.multiLineMerge": true, "commentTranslate.source": "Copilot" }
github.vscode-pull-request-github は色々便利な機能があるのでインストールしてる。選択してる行の GitHub 上での URL がコピーできる機能とか。Control+Command+g に keybind 設定してよく叩いてる。
以前は inline で git blame を表示するために eamodio.gitlens をインストールしていたけど、最近は VS Code に同等の機能が実装されてる。"git.blame.editorDecoration.enabled": true で有効にできる。eamodio.gitlens はアンインストールして今はこれを使ってる。

たまーに Python や Swift など他の言語のリポジトリを触ることになって、色々追加で拡張機能を入れないといけなくなった時は、VS Code の Profile を新たに作ってそっちにインストールすることで凌いでる。こんな感じで、Settings や Keyboard Shortcuts は Default Profile のものを引き継ぐけど、Extension だけ別にしてる。

Default Profile の拡張機能を引き継ぎつつ、新しい Profile 向けに専用の拡張機能をインストールする...みたいなことはできないので、そこがネックではある。今は新しい Profile を作成したあと、Default Profile にあったものでどうしても必要なものを新しい Profile にもインストールし直してる。面倒ではあるけどまあ何とかなってる。
拡張機能のリストは code --list-extensions で出力できます。皆さんもこの際に整理してみてはいかがでしょうか。
css-modules-kit の内部設計: CSS Modules のパースについて
css-modules-kit は CSS Modules のためのツールセットです。何ができるのか、どんな設計で作られているのかは以下の記事を見てください。
この記事では css-modules-kit の内部設計について紹介してみます。今回は CSS Modules のパースについてです。
3種類のツールと core パッケージについて
css-modules-kit は codegen, ts-plugin, linter-plugin (eslint-plugin or stylelint-plugin) の 3 種類のツールから構成される。それぞれ独立したパッケージになってるのだけど、コア部分は core に切り出されてて、3 種類のツールから依存されてる。
.module.css のパーサー
core には色々なロジックや utility が置かれているのだけど、その中に .module.css のパーサーがある。packages/core/src/parser にそのコードがある。
内部では postcss で AST に変換して、それを走査して必要な情報を集めて返してる。一般的に「パーサー」というと AST を返すものをイメージするけど、それとはちょっと違う。css-modules-kit においては AST から余計な情報を削ぎ落としたデータ構造である CSSModule をパーサーから返してる。
CSSModule の構造は packages/core/src/type.ts で定義されてる。
// https://github.com/mizdra/css-modules-kit/blob/89f11a54af1dc86b344e151b63bc2708486c31bb/packages/core/src/type.ts#L98-L121 export interface CSSModule { fileName: string; text: string; /** `.foo {}` や `@keyframes bar {}` などで定義されるトークンの情報 */ localTokens: Token[]; /** * `@import './other.module.css';` や `@value val from './other.module.css'` などを使った * トークンの import 文の情報。今回の説明は省略。 */ tokenImporters: TokenImporter[]; /** パース時に検出された構文エラーなど。今回の説明は省略。 */ diagnostics: DiagnosticWithLocation[]; } export interface Token { name: string; loc: Location; declarationLoc?: Location; } export interface Location { start: Position; end: Position; } export interface Position { line: number; column: number; offset: number; }
「Token」という謎の用語があるけど、これは css-modules-kit の内部用語で、.module.css から export されるアイテムを表してる。クラス名とか @keyframes で定義されたキーフレームの名前とか、@value val: #123; による変数とか。export されるのはクラス名だけではないので、「Token」という用語を導入している。
それを踏まえて型を見てもらうと分かるが、トークンの名前や位置情報、構文エラーなどの情報が入ってる。それ以外の情報、例えばプロパティ に関するものは全く持たない。
独自のデータ構造を返している理由
生の postcss の AST を返さずに、独自のデータ構造を返しているのには色々な理由がある。
理由1: CSS Modules として .tsx 側に export されるもの以外の情報が不要だから
単に不要なものは持たないほうが、シンプルになって codegen などから扱いやすい。それはそう。
理由2: postcss に依存しないようにしたかったから
postcss に非依存にすることで、postcss 以外の CSS パーサーに後から差し替え可能にしたかった。
というのも今主要な CSS パーサーには 「postcss」「csstree」「lightningcss」の 3 つがある。css-modules-kit を最初に作るときにどれを使うか悩んだのだけど、結局 postcss を採用した。postcss が優れているからとかではなくて、単に
id:mizdra が以前作った happy-css-modules というツールの内部で postcss で CSS Modules をパースしていて、そのコードを参考に実装できたから。
postcss はそこそこ枯れていて良いのだけど、まあまあ微妙なところがある。例えばパフォーマンスや後方互換性を理由にプロパティの value やセレクター部分のパースが本体ではサポートされてなくて、別のライブラリに頼らないといけない。
- プロパティの value 部分のパーサー
- https://www.npmjs.com/package/postcss-value-parser
- 依存がゼロだけど 4 年前から更新が止まってる
- 古すぎて心配だけど、特にバグにも遭遇しないので css-modules-kit ではこれを使ってる
- https://www.npmjs.com/package/postcss-values-parser
- 最近も更新されている
- 依存に husky などが入ってて心配
- https://www.npmjs.com/package/postcss-value-parser
- セレクター部分のパーサー
- https://www.npmjs.com/package/postcss-selector-parser
- これ一択。css-modules-kit でもこれ使ってる。
- postcss 公式が publish してる
- postcss の Node の位置情報と postcss-selector-parser が返す Node の位置情報を足し合わせないと、ソースコード上での Node の位置情報が正しく取得できないのが面倒だけど、それ以外は特に不満ない
- https://www.npmjs.com/package/postcss-selector-parser
ここ最近のサプライチェーン攻撃の流行を考えるとあんまり依存関係増やしたくはなくて、困ってる。csstree なら value 部分もセレクターもそれだけでパースできるみたいなので、その点を考えると csstree のほうが良いなーと思ってる。
lightningcss は Rust 製で速くて良いと思うけど、パフォーマンスを理由にパーサーの API が Node.js 向けに提供されてない。Rust 向けにだけ提供されてる。もし css-modules-kit で使うなら、AST にして CSSModule 型のデータを生成するところまでを Rust で書いて、それを Node.js に転送する、みたいな実装をしないといけない。やったらできるけど面倒だし、Rust <=> Node.js 間のデータ転送にコストが掛かるので、速くなるのかよくわからない。
postcss から csstree なり lightningcss なり乗り換えるとしても、ちゃんと互換性を維持したまま乗り換えられるかは正直不透明だなと思う。css-modules-kit が欲しい情報が、AST に含まれていないと安全に移行できないと思う。クラス名やキーフレーム名の位置情報を正確に取得できるのかとかね。postcss も最初は一部情報が欠けてて、
id:mizdra が PR 送って直していたりしたくらいなので、csstree やlightningcss もきっと同じようなことになるだろう。とにかく面倒な予感がする。
- Add `CssSyntaxError.offset` and `CssSyntaxError.endOffset` · Issue #2022 · postcss/postcss · GitHub
- Calling `Node#rangeBy()`/`Node#positionBy()` with no arguments throws an error · Issue #2028 · postcss/postcss · GitHub
- Missing `offset` property of `Node.Position` in some cases · Issue #2029 · postcss/postcss · GitHub
とまあパーサーを差し替えたい気持ちはあるけど、色々な理由があって進んでない。けど将来的に差し替えられるように、データ構造は工夫してる。
typescript-go が来たら、それを受けて css-modules-kit も Go にしましょうみたいな機運が出てくるかもなとは思ってて、そうしたら lightningcss なり検討するかも。まあまだ先の話だなと思う。