mizdra's blog

ぽよぐらみんぐ

ghq+peco とゴミ置き場

ghq は、git clone したリポジトリを特定のディレクトリ規則に従って管理するコマンドです。そして peco は、インクリメンタル検索をするコマンドです。この 2 つを組み合わせると、git clone したリポジトリを簡単に cd できます。

# ~/.gitconfig
[ghq]
    root = ~/src
# ~/.zshrc
function peco-src() {
  local selected_dir=$(ghq list | peco --query "$LBUFFER")
  if [[ -n "$selected_dir" ]]; then
    BUFFER="cd $(ghq root)/$selected_dir"
    zle accept-line
  fi
  zle redisplay
}
zle -N peco-src
stty stop undef # disable builtin "^s" keybind
bindkey '^s' peco-src

ghq+peco でディレクトリを切り替える様子。

数多くのブログで紹介されている有名なテクニックですので、使っている人も多いでしょう。使ってない人は是非使ってみて下さい。

ゴミ置き場

ghq+peco により、バージョン管理システム (VCS) で管理されるリポジトリを整理できる訳ですが、現実には VCS で管理されないものも扱いたくなります。例えば、ライブラリの動作確認のためのリポジトリや、ライブラリの不具合を再現するためのリポジトリとかです。ちょっとしたコードは書きたいけど、わざわざ git 管理するほどでもない、みたいなやつです。

そうしたリポジトリを管理するため、id:mizdra~/src/localhost/gomi ディレクトリを作ってます。

~/src
├── github.com
│   ├── x-motemen
│   │   └── repository-1
│   └── mizdra
│        ├── repository-1
│        └── repository-2
└── localhost
     └── gomi
          ├── repository-1
          └── repository-2

ゴミ置き場なので gomi です。直球ですね。

気軽に ~/src/localhost/gomi に移動できるよう、zsh で auto_cd と名前付きディレクトリを設定しています。これで ~gomi と打つだけで移動できます *1

# ~/.zshrc
setopt auto_cd
hash -d gomi=~/src/localhost/gomi

VCS 管理しないものは ~gomi に置いていき、不定期に rm -rf ~gomi/* で削除しています。もし何らかの事情で ~gomi に置いたものを VCS 管理したくなったら、git push => rm ~gomi/repo => ghq get ... で ghq 管理のディレクトリへと移動する運用にしてます。

~gomi と ghq+peco の相性問題

~gomi は便利ではあるのですが、冒頭で紹介した ghq+peco を使ったディレクトリ移動のテクニックと組み合わせると、ちょっとした問題が起きます。~gomi にあるリポジトリが一覧されないのです。

~gomi 配下にリポジトリがあるが、ghq+peco によるリポジトリ切り替えの候補にそれが表示されない様子。

これは、リポジトリの一覧を取得するコマンドに ghq list を使用していることが原因です。ghq list は VCS 管理されているディレクトリしか返してくれないのです。ghq は VCS 管理されるリポジトリを整理するためのコマンドなので、自然な挙動ではあります。

とはいえ、~gomi のようなディレクトリを作っている身としては困ります。そこで id:mizdrafind $(ghq root) -mindepth 3 -maxdepth 3 -type d でリポジトリの一覧を取得しています。

# ~/.zshrc
function peco-src() {
  local ghq_root="$(ghq root)"
  local selected_dir=$(find $ghq_root -mindepth 3 -maxdepth 3 -type d | sed "s|$ghq_root/||" | peco --query "$LBUFFER")
  if [[ -n "$selected_dir" ]]; then
    BUFFER="cd $ghq_root/$selected_dir"
    zle accept-line
  fi
  zle redisplay
}
zle -N peco-src
stty stop undef # disable builtin "^s" keybind
bindkey '^s' peco-src

これで、ghq+peco によるリポジトリ切り替えの候補に ~gomi 配下のものが表示されます。

ちょっとした注意点

今回紹介したスクリプトは、https://<domain>/subpath/<org>/<repo>.git というサブパス付き URLを持ち、~/src/<domain>/subpath/<org>/<repo> に配置されるリポジトリには対応してません。-mindepth 3 -maxdepth 3 で深さ 3 にあるディレクトリを抽出してるからです。

サブパスを URL に含むリポジトリなんてそう滅多にないので、ほとんどの場合は問題ないはず...。どうしてもサブパスを URL に含むリポジトリを扱いたい人は、ghq listfind $ghq_root/localhost -mindepth 3 -maxdepth 3 -type d を組み合わせてみてください。ghq list はサブパスを持つディレクトリに対応してるので、それで問題を回避できるはずです。

*1:https://pocke.hatenablog.com/entry/2014/07/23/173811 も合わせて読むと面白いでしょう

Node.js におけるファイル読み取りエラーのクロスプラットフォーム対応の仕組み

Windows の Node.js で存在しないファイルを fs.readFileSync で読み取ろうとすると ENOENT が返ってくる。けど ENOENT は POSIX で定義されてるエラーコードであって、Windows のものではない。どこかで正規化されてるのか? という疑問が出てきたので調べてみた。

答え

Node.js の公式ドキュメントの error.errno の説明に答えが書いてあった。

https://nodejs.org/api/errors.html#errorerrno

On Windows the error number provided by the system will be normalized by libuv.

どうも libuv でエラーコードの正規化がされてるらしい。確かに libuv のコードを見ると、int uv_translate_sys_error(int sys_errno) 関数の中にエラーコードを正規化するコードが書かれていた。

// https://github.com/libuv/libuv/blob/12d1ed1380c59c5ec27503cf149833de6f0e6bb0/src/win/error.c#L134-L144 より
int uv_translate_sys_error(int sys_errno) {
  // ...
  switch (sys_errno) {
    // ...
    case ERROR_BAD_PATHNAME:                return UV_ENOENT;
    case ERROR_DIRECTORY:                   return UV_ENOENT;
    case ERROR_ENVVAR_NOT_FOUND:            return UV_ENOENT;
    case ERROR_FILE_NOT_FOUND:              return UV_ENOENT;
    case ERROR_INVALID_NAME:                return UV_ENOENT;
    case ERROR_INVALID_DRIVE:               return UV_ENOENT;
    case ERROR_INVALID_REPARSE_DATA:        return UV_ENOENT;
    case ERROR_MOD_NOT_FOUND:               return UV_ENOENT;
    case ERROR_PATH_NOT_FOUND:              return UV_ENOENT;
    case WSAHOST_NOT_FOUND:                 return UV_ENOENT;
    case WSANO_DATA:                        return UV_ENOENT;
    // ...
  }
  // ...
}

あれでも libuv ってイベントループを実装するためのライブラリじゃなかったっけ? fs.readFileSync は同期的な API でイベントループ関係ない気がするんだけど。 同期的な API も libuv に依存してるの?

答え2

...という疑問を持ったので調べてみたら、どうも libuv にはファイルシステムに関する機能もあるようだった。README の「Feature highlights」を見ると、「File system events」「Child processes」「Thread pool」など色々書かれてる。

libuv のコードを見てみると、Windows 向けに fs__read という関数が実装されてて、ここでファイルの読み取りやらエラーコードの正規化をしていることがわかる。

そしてこの fs__read 関数が fs.readFileSync の内部で呼ばれている、という訳。

同期的な API の中でも libuv の API が呼び出されてるの知らなかったので、勉強になった。

VS Code でデバッガーを使って oxc の挙動を観察したい

oxc の挙動を観察したいな〜と突然思って、oxc のデバッグ環境を VS Code で用意した。ちょっと躓いたのでメモを残しておく。

やりたいこと

  • oxc のテストを VS Code から実行したい
  • しかも VS Code 上から breakpoint を設定して、テストをステップ実行したい

Requirements

以下は事前にインストールしておく。

  • VS Code
  • Rust
  • rust-analyzer 拡張機能
    • rust-analyzer という Rust 向けの Language Server があって、それを VS Code から使うための拡張機能
    • VS Code からテストを実行するための UI も提供してくれる
      • プロジェクト内のテストケースを抽出して「Test Explorer」にそれを表示してくれたり
      • #[test] のすぐ下にインラインで「Run Test」「Debug (デバッガーを attach しながらテストを実行するモード)」ボタンを表示してくれたり
  • CodeLLDB 拡張機能
    • LLDB というデバッガーがあって、それを VS Code から使うための拡張機能

あと oxc のリポジトリも git clone して Contribution Guide を見ながらセットアップしておく。

breakpoint で止まらない

「Debug」ボタンからテストを実行しても、何故か breakpoint で止まらずに完走してしまう。

「Debug」ボタンからテストを実行したのに、breakpoint で止まらずに完走してしまう様子

最初は rust-analyzer/CodeLLDB 拡張機能の設定が不十分なのかと思ったけど、特に怪しい設定もない。となるとテスト時に実行されるバイナリがなんかおかしいのか? と疑い始める。

テスト時に実行されるバイナリ

じゃあテスト時に実行されるバイナリはどこにあるのか。これは CodeLLDB 拡張機能のログを見るとわかる。

まず VS Code の下部のメニューの「OUTPUT」をクリックし、その次に拡張機能のリストから「LLDB」を選択。そうして表示されるログの先頭のほうに、program という項目があり、その値がテスト時に実行されるバイナリのパスになってる。
OUTPUT > LLDB と辿っていた先に拡張機能のログがあり、そこに書いてある

この program の部分がテスト時に実行されるバイナリになってる。実際に引数を伴って実行してみると、テストが実行できる。

$ ./target/debug/deps/oxc_parser-992051aabfe995a6 module_record::module_record_tests::import_default --exact --show-output

running 1 test
test module_record::module_record_tests::import_default ... ok

successes:

successes:
    module_record::module_record_tests::import_default

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 53 filtered out; finished in 0.00s

バイナリの中身を見る

まあ多分シンボル情報が入ってないんじゃないか、と思うので中身を見てみる。macOS なら dsymutil コマンドなどでできるらしい。

$ dsymutil --statistics target/debug/deps/oxc_parser-992051aabfe995a6
warning: no debug symbols in executable (-arch arm64)
.debug_info section size (in bytes)
-------------------------------------------------------------------------------
Filename                                           Object         dSYM   Change
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
Total                                                  0b           0b    0.00%
-------------------------------------------------------------------------------

warning: no debug symbols in executable を見るに、やっぱりシンボル情報入ってなかったね... これなら breakpoint 仕掛けても止まらないの当然だ。

何故シンボル情報が含まれてないのか

普通テストを実行する際はデバッグビルドが実行されて、シンボル情報が埋め込まれるはず。なのに何故埋め込まれていないのか。その理由は oxc リポジトリの Cargo.toml に書いてあった

// https://github.com/oxc-project/oxc/blob/ea3f362173247454190a737b9c0737fb26f43a3f/Cargo.toml#L241-L244 より引用
[profile.test]
# Disabling debug info speeds up local and CI builds,
# and we don't rely on it for debugging that much.
debug = false

コメントなるほどね... debug = true にして無事解決しました。

tsx と Node.js Type Stripping の違い

tsx は TypeScript コードを事前トランスパイルすることなく、直接 Node.js で実行するためのツール。

ところで最近の Node.js には Type Stripping という機能が入った。これを使うと、tsx なしで TypeScript コードを事前トランスパイルせずに実行できる。

両者の違い

一見すると両者は機能的に同じものかのように思うけど、実は結構違いがある。

import specifier の指定方法が異なる

最も大きな違いは、「import specifier」の指定方法。import specifier というのは、以下の部分のこと。

import { add } './math';
//             ^^^^^^^^ こことか

const { sleep } = await import('./util');
//                             ^^^^^^^^ ここのこと

tsx は import specifier の様々な指定方法に対応しているが、Node.js Type Stripping はかなり限られている。math.ts というモジュールを参照する場合を例にすると...

指定方法 tsx Node.js Type Stripping
'./math' OK NG
'./math.ts' OK OK
'./math.js' OK NG

tsx は bundler がサポートしているような指定方法を同じようにサポートしてて、多くの人が慣れ親しんだ挙動になってる。一方 Node.js Type Stripping は .ts の指定が必須で、クセが強い。

何故このような仕様になってるかというと、1つは実行時のオーバーヘッドを減らすため。というのも、Node.js の ESM には import speficier の拡張子を明示しなければならないという制約がある。

A file extension must be provided when using the import keyword to resolve relative or absolute specifiers.

https://nodejs.org/api/esm.html#mandatory-file-extensions

拡張子が明示されていれば、CJS or ESM をすぐに判定できる。しかし省略されてると、推測するためのオーバーヘッドが掛かる。それを嫌って、拡張子の明示が強制されている。Type Stripping でも同じで、拡張子が明示されていれば CJS or ESM をすぐ判定できる。それに JavaScript or TypeScript どちらとして処理すれば良いかも、すぐ判定できる。そのために Type Stripping で拡張子の明示を必須としたい、という動機があるらしい。

他にも色々理由があるらしい。拡張子の省略を許可すると require() に破壊的変更を加えることになってしまうから避けたいとか、math.tsmath.js が共存してる時どっちを読むのか曖昧で良くないとか。以下の issue に書いてある。

以下の関連する issue にも様々な理由が書かれていそうだった。けどコメントが多すぎて僕には追いきれなかった...。

tsx は JSX 対応してるが、Node.js Type Stripping は非対応

Node.js Type Stripping は型注釈の削除だけをやる軽量な実装になっており、JSX がサポートされてない。

Node.js Type Stripping では TypeScript 固有の機能に非対応

Enum, experimentalDecorators, namespaces などには対応してない。Node.js Type Stripping は型注釈の削除だけをやるので、こういう JavaScript にない機能は一切サポートしない。

補足: node --experimental-transform-types

実は --experimental-transform-types を渡すと、単なる型注釈の削除だけでなく、TypeScript 固有の機能のトランスパイルもされる。これを使うと、enum や namespace なども使えるようになる。

とはいえ enum や namespace は esbuild や swc といったトランスパイラでもサポートされておらず、現代では使うべきではないと言われている。よって --experimental-transform-types を使わずに済むなら、そうしたほうが良いと思う。

Node.js Type Stripping は tsconfig.json の paths に非対応

tsx は対応してるけど、Node.js Type Stripping は非対応。

Node.js Type Stripping...というか Node.js 自体が Subpath patterns import に対応してるので、それを使うと import alias っぽいことはできる。

どっちを使えば良いの?

せっかく Node.js に組み込まれてる機能があるのだから、Type Stripping が使えるならそうしたほうが良いと思う。しかし、それができないものもある。

バックエンドサーバー

Node.js で実行されているものなので、Node.js Type Stripping を使ったら良いと思う。

npm package

原則として tsx も Node.js Type Stripping も使うべきではない。というのも、npm にはトランスパイル済みのコードをアップロードするべきだから。Node.js も Type Stripping のおかげで直接 .ts を実行できるようにはなったが、今のところ npm package の .ts は Type Stripping の対象外としている。

To discourage package authors from publishing packages written in TypeScript, Node.js will by default refuse to handle TypeScript files inside folders under a node_modules path. https://nodejs.org/docs/latest/api/typescript.html

tsx や Node.js Type Stripping で開発をして、そのままトランスパイルせずに npm に公開する...ということをしたところで、ユーザの手元で動かない。そのため npm package の開発では tsx や Node.js Type Stripping を使わずに、tsc で事前ビルドするほうが良いと思う。

とはいえどうしても Node.js Type Stripping 使いたい時もあると思う。テスト実行のために Node.js Type Stripping 使いたいとか。そのような場合は、以下のような構成にすると良い。

  • コードベース全体を .ts 付きの import specifier で書く
  • node --test "src/**/*.test.ts" でテスト実行
  • --rewriteRelativeImportExtensions を有効にして tsc で本番向けビルドをする

これでコード上は import specifier を.ts 付きで書けて、最終的な成果物では .js にできる。しかし、一部ややこしい挙動もある。

一応 Node.js Type Stripping 使えなくはないし、使っても良いとは思うけど、オススメするかというと悩ましいな〜。どうしても Node.js Type Stripping を使いたくて、ややこしい挙動を理解した上で使うなら良いと思う。

スクリプトファイル

バッチファイルとか one-time script とかそういうの。基本的に Node.js で実行するものなので、Node.js Type Stripping が使えるならそうしたら良いと思う。

しかし Next.js を使っているプロジェクトで、スクリプトファイルと Next.js で一部モジュールを共有してる、とかだと話が変わってくる。例えば、以下のようなコードがあるとする。

// lib/prisma.ts
import { PrismaClient, type User } from '@prisma/client';
import { getDatabaseURL } from '@/lib/database';

export const prisma = new PrismaClient({
  datasourceUrl: getDatabaseURL(),
});

// 実際はこんな中身のない prisma の wrapper 書かないとは思うけど、仮でこういうのがあるとする。
export async function findUserById(id: string): Promise<User> {
  return prisma.user.findUnique({ where: id });
}

このモジュールは Next.js のコードから使われてて、かつスクリプトファイルからも使われているとする。

// scripts/ユーザ調査.ts
import { findUserById } from '@/lib/prisma';
console.log(await findUserById('123'));

何の変哲も無いスクリプトファイルに見えるけど、node scripts/ユーザ調査.ts するとコケる。

まず import { findUserById } from '@/lib/prisma'; が良くない。拡張子を省略をせず、tsconfig.jsonpaths も使わず、以下のように書くべき。

// scripts/ユーザ調査.ts
-import { findUserById } from '@/lib/database';
+import { findUserById } from '../lib/prisma.ts';
 console.log(await findUserById('123'));

依存先の lib/prisma.ts で拡張子の省略が行われるのも良くない。以下のように書くべき。

// lib/prisma.ts
 import { PrismaClient, type User } from '@prisma/client';
-import { getDatabaseURL } from '@/lib/database';
+import { getDatabaseURL } from './database.ts';
 // ...

このように bundler で実行してる部分とモジュールの共用をしようと思うと、コードベースの書き換えが必要になる。正直面倒だし、ややこしい。

拡張子は明示する、paths やめる、と書き換えていっても良いとは思うけど、それくらいならスクリプトファイルを tsx で実行したほうが楽な気はする。

CLI ツールの設定ファイル

eslint.config.ts, prettier.config.ts, vitest.config.ts など。これは場合による。そもそもこれらのファイルはユーザが実行するというよりは、CLI ツールが内部で読み取って、実行するタイプのもの。CLI ツール側で Node.js Type Stripping を使ったり、tsx を使ったりして実行している。TypeScript の実行に何を使っているかは、CLI ツールによって異なる。

例えば eslint.config.ts は jiti (tsx と同じようなやつ)、もしくは Node.js Type Stripping で実行される。

prettier.config.ts は Node.js Type Stripping で実行される。

vitest.config.ts は特殊で、Vite で bundle して .js に変換した後、Node.js で実行される。

vitest.config.ts は拡張子の省略はできるけど、prettier.config.ts はできない。eslint.config.ts は jiti で動かしてるなら省略できるけど、Node.js Type Stripping ならできない。難しいね...

まあ通常これらのファイルから他のモジュールを import することは稀なので、あんまり困らないとは思う。

おまけ: エディタによる import 文の補完を制御する

エディタ...というか TypeScript の Language Server には import 文を補完する機能がある。その補完で拡張子を省略するのか、明記するのかを制御するオプションが実はある。VS Code なら以下のオプションで制御できる。

  • "typescript.preferences.importModuleSpecifierEnding"
  • "javascript.preferences.importModuleSpecifierEnding"

あとは "@/lib/math" と補完するのか、"./math" と補完するのかを制御するオプションもある。

  • "typescript.preferences.importModuleSpecifier"
  • "javascript.preferences.importModuleSpecifier"

こういうのを上手く使うと、プロジェクト内で import 文の補完方法を上手く制御できるはず。

CSS Modules の拡張構文について

CSS Modules は、CSS をローカルスコープ化する仕組み。*.module.css に CSS を記述すると、bundler がクラスセレクターなどをユニークなものへと変換してくれる。クラスセレクターなどが *.module.css ファイルごとに異なる名前に変換され、擬似的にローカルスコープ化が実現される。

developer.hatenastaff.com

CSS Modules では、基本的には CSS の標準の構文をそのまま利用する。しかし、一部 CSS Modules 独自の構文がある。実際どのようなものがあるのかというのを、紹介する。

CSS Modules の公式ドキュメント (仕様) のリンクを貼りつつ紹介するが、公式ドキュメントの仕様は非常に緩く書かれているので注意すること。真の仕様を把握するには、postcss-modules などの実装も必ず見るように。

:local(), :global() 擬似クラス

擬似クラスの一種で、クラスセレクターをローカルスコープ化するのか、グローバルスコープ化するのかを切り替えるための構文。以下の公式ドキュメントで言及されてる。

以下のようにしてクラスセレクターを囲むと、そのスコープモードを変更できる。

:local(.button) {/* ... */}
/* Bootstrap の .btn-primary を使いたいので、:global(...) で囲む */
:global(.btn-primary) {/* ... */}

:global(...) は UI ライブラリ側で決め打ちされてるクラス名をそのまま使いたいなど、ローカルスコープ化されると困るときによく使う。一方 :local(...) については、基本的に付けても意味がない。通常 CSS Modules ではデフォルトでローカルスコープであるため。しかし css-loader ではデフォルトのスコープモードを変更することができる。デフォルトのスコープモードが "global" の場合、ローカルスコープ化するために :local(...) が使われる。

とはいえデフォルトのスコープモードを "global" にすることまずないので、:local(...) の出番はほぼないと思って良い。

参考情報:

:local, :global 擬似クラス

さっきのやつの括弧 () が無いバージョン。以下の公式ドキュメントで言及されてる。

括弧有りとの違いは、スコープモードの変更範囲である。括弧有りでは「括弧の中のものだけ」が、括弧無しでは「疑似クラスに続くもの全て」がスコープの変更対象となる。

/* a_1 はローカル、a_2, a_3 はグローバル */
.a_1 :global .a_2 .a_3 {}

/**
 * :local で途中でスコープモードを変更可能。
 * b_1, b_4 はローカル、b_2, b_3 はグローバル。
 */
.b_1 :global .b_2 .b_3 :local b_4 {}

...というのが表面的な仕様なのだけど、(CSS Modules の主要な実装である) postcss-modules ではもっと複雑な仕様になってる。セレクターリストや擬似クラスと組み合わせると、非常に奇妙な動作をする。ここで紹介するには難しすぎるので、興味ある人は css-modules-kit のテストケースにその挙動の説明が書いてあるので、それを見てほしい。

参考情報:

@value アットルール

アットルールの一種で、変数を宣言するための構文。以下の公式ドキュメントで言及されてる。

以下のようにして使う。

/* Header.module.css */
@value red: #FF0000;
@value headerHeight: 30px;

.header {
  height: headerHeight;
  background: red;
}

Value/Variables といっても bundle 時に展開されるもので、「bundle 時定数」「コンパイル時定数」などと言ったほうがわかりやすいかも。Sass の Variables にかなり近い。

ただし Sass の Variables と違い、変数が *.module.css から export される。以下のようにして、コンポーネントファイルから変数を参照できる。export される型は常に string

// Header.tsx
import styles from './Header.module.css';

styles.red; // '#FF0000'
styles.headerHeight; // '30px'

また、他の *.module.css から変数を import することもできる。

/* common.module.css */
@value red: #FF0000;
@value white: #FFFFFF;
/* Header.module.css */
@value red, white from './common.module.css';
@value headerHeight: 30px;

.header {
  height: headerHeight;
  background: red;
  color: white;
}

参考情報:

composes プロパティ

mixin みたいなことをするやつ。以下の公式ドキュメントで言及されてる。

あるルールセットで定義されてるプロパティを、別のルールセットに取り込める。

/* https://github.com/css-modules/css-modules/blob/master/docs/composition.md より引用 */
.className {
  color: green;
  background: red;
}

.otherClassName {
  composes: className;
  color: yellow;
}

上記のコードは以下と同じ意味になる。

.className {
  color: green;
  background: red;
}

.otherClassName {
  background: red;
  color: yellow;
}

複数のルールセットをまとめて取り込んだり、複数の他のファイルのものを取り込むこともできる。

.a_1 {
  color: green;
  background: red;
}
.a_2 {
  composes: common_1 from './common.module.css';
}
.a_3 {
  composes: a_1 a_2;
}

composes: global_1 from global; みたいにグローバルから取り込む機能もあるらしいが、正直どういう挙動をするのか id:mizdra はよくわかってない。どのスタイルシートから取り込まれるの?

@keyframes :local(<custom-ident>), @keyframes :global(<custom-ident>)

実は CSS Modules は、デフォルトで @keyframes で定義した名前をローカルスコープ化する。ローカルスコープ化されるということは、それをグローバルスコープ化したい時もある訳で、そのための構文がこれ。CSS 標準では @keyframes <custom-ident> だけど、CSS Modules ではそれを拡張して :local():global() で囲めるようにしてる。

公式ドキュメントでの言及は僅かで、以下で @keyframes :global(<custom-ident>) という構文があることが匂わされてるくらい。

@keyframes :global(<custom-ident>) だけでなく、デフォルトのスコープモードが "global" の時のために、@keyframes :local(<custom-ident>) も用意されてる。

参考情報:

animation, animation-name プロパティ

@keyframes で定義した名前は animation-name と、その一括指定プロパティである animation からも参照できるので、それも拡張されてる。公式ドキュメントでの言及はちょっとだけ。

@keyframes と同じく、アニメーションの名前を :local(), :global() で囲めるような拡張がされてるだけ。

var(<custom-property-name> from <string>)

実は lgithningcss では、カスタムプロパティ もローカルスコープ化される。

/* https://lightningcss.dev/css-modules.html#local-css-variables より引用 */
:root {
  --accent-color: hotpink;
}
.button {
  background: var(--accent-color);
}

加えて他の *.module.css のカスタムプロパティを参照する機能もある。そのための拡張構文が var(<custom-property-name> from <string>) である。

/* https://lightningcss.dev/css-modules.html#local-css-variables より引用・一部改変 */
.button {
  background: var(--accent-color from './vars.module.css');
}

ちなみにこの機能は lightningcss 独自のもので、postcss-modules などの他の実装ではサポートされてない。CSS Modules の公式ドキュメントでも一切言及がない。

:import, :export

ICSS で使われる構文として、:import, :export がある。ICSS は postcss-modules 内で使われる中間表現で、import/export するクラスセレクターなどが明示的に記述される。その明示的な記述を行うための拡張構文がこれ。

詳しくは以下のリポジトリの README を見ると雰囲気がわかる。

まず人間が手で書くことはないので、忘れて良い。

拡張構文の問題点

これらの拡張構文だが、問題点が色々ある。例えば @keyframes :global(.foo) が奇妙という点。セレクターを書く場所じゃないのに、擬似クラスの構文 (:global()) が使われていて変だと思う。

xxx from './vars.module.css' のような構文が animation プロパティなどで使えなくて、一貫性がないのも微妙。そもそも仮に animation: slide-in from './vars.module.css' と書けたとして、どれがアニメーションの名前で、easing-function の名前なのか定まらなくて、破綻してしまうと思う。

拡張構文があることで将来の CSS の新構文と衝突してしまう恐れがあるのも問題だと思う。@value アットルールや composes プロパティなど全く独自のものならともかく、var(), @keyframes, animation-name など既存の構文を拡張しているものは、結構危ういと思う。できるだけ CSS の標準の構文を使用することで CSS の仕様変更に追従しやすい、というのが CSS Modules の良さだと個人的には思ってて、そこが損なわれてるのが勿体ないと思う。

理想的にはどうあるべきか

:global(), xxx from global, xxx from <string> といった記法を廃止すべきだと思う。代わりに、以下のように「ある識別子をグローバルスコープにするのか、他のファイルから import してくるのか」を示せる構文を追加する。

@cm-global btn-primary, slide-in;
@cm-import red, flex from './common.module.css';

/* a_1 はローカル, btn-primary はグローバル */
.a_1 .btn-primary {}

/* slide-in はグローバル */
@keyframes slide-in {}

/* a_2 はローカル, red は common.module.css のもの */
.a_2 .red {}

.a_3 {
  /* flex は common.module.css のもの */
  composes: flex;
}

一応似たような提案が以下の Issue で行われているようだった。議論が進むと良いなー。

TSKaigi 2025 に参加しました

色々セッションを聴いてきたので、その感想です。沢山ありすぎたので、聴いてて気になったものだけ感想書いてます。

The New Powerful ESLint Config with Type Safety

https://talks.antfu.me/2025/tskaigi/1

ESLint の Flat Config についてのセッション。新 config への移行を補助するツール (@eslint/migrate-config, Config Inspector) が色々紹介されていた。存在そのものは知っていたけど、実際どういうことができるのかとか、動いている様子とかは見たことがなかったので、そういうのがセッションで見れて良かった。

@eslint/migrate-config はまあ良さそうだけど、Config Inspector はそんなに必要な状況あるかなあとは思った。そもそもそれが必要になるほど config のデバッグに苦労する状況が良くないと思う。複雑な config は避けて、シンプルで挙動が予測しやすいものをまず志向すべきだと思う。まあでも本当にどうしようもなく難しい config を作ってしまった時に、デバッグするのには便利そう。

config の合成を補助する eslint-flat-config-utils というのも紹介されてたけど、これも本当に必要かな?と思った。config の合成が簡単にできるのは良いけど、ESLint の標準とは異なる方法で合成するから、その合成のルールを学ばないといけないと思う。新しいこと覚えるの手間なので、ESLint の標準的な方法で合成するので十分じゃないかと思う。

あと plugin で追加された rule の名前を rules の補完候補として出す「eslint-typegen」は面白かった!僕も補完できるようにしたいなとずっと思ってた。eslint 実行時に plugin が動的に型定義ファイルを生成して、その型定義を元に rules の補完ができるようにする、というのがだいぶカッコ良い。動的に型定義を生成する仕組み、だいぶハックっぽいのでもうちょっと単純な仕組みになって、かつ ESLint 側に組み込まれたらより良いかなと思った。

SignalとObservable―新たなデータモデルを解きほぐす

https://blog.lacolaco.net/posts/tskaigi-2025-slide/

Angular/Vue.js/SolidJS/Preact など現代的な View フレームワークに実装されている Singals は、実は昔から似たようなものがあるんですよ、という話がされていて良かった。現代的な View フレームワークから入った人の多くは知らないだろうから、そういう人に見てもらう資料として良いなと思った。

後半は Observable の解説があって、実は DOM API に追加されようとしてるんです、という話があってへーとなってた。あんまりこの辺の動向追ってなかったので、色々知れて良かった。

Language Serverと喋ろう

https://speakerdeck.com/pizzacat83/language-server-todie-rou-tskaigi-2025

エディタの Show Call Hierarchy 的なことをやって、ある処理が呼び出される経路を一覧するツールを作る、という話。TypeScript なら ts-morph 使えばシュッと実装できるけど、そうではなくて、どのような言語でも動くようにしたい。そのために Language Server Protocol を使う、という発想がすごく面白かった。

エディタの中で動くツールならまだしも、それ以外のツール (CLI ツールとか) で Language Server を使う事例は殆ど聞いたことないので、こういう使い方もできるんだなと思った。真面目な話をすると、自作ツールから Language Server を起動することで発生する面倒事も色々ありそうだなと思った。エディタは Language Server を起動するための推奨オプションを知ってるけど、自作ツールは知らない訳で、エディタの設定を見ながらオプションをハードコードすることになるはず。

AI Coding Agent Enablement in TypeScript

https://speakerdeck.com/yukukotani/ai-coding-agents-enablement-in-typescript

型チェックや Linter を駆使して、AI Coding Agent が脱線せずコーディングできるようにしましょう、という話。Linter が効くという話はよく効くけど、このトークでは実践的な話が色々なされていて面白かった。AI を激詰めしてから lint rule 作ってとお願いするのは今日からすぐ使えるテクニックだなと思った。

TypeScriptとReactで、WAI-ARIAの属性を正しく利用する

https://docs.google.com/presentation/d/1rzznSwA7da7S_lU6qyAFuCN9IC1uDJe44PvDg-uqHjQ/mobilepresent?slide=id.p

DAY1 で一番面白かったセッションだった。TypeScript コンパイラ側の仕様で、WAI-ARIA 属性の型チェックが緩くなる問題があり、それを解決するために色々なテクニックやツールを駆使する、という話だった。

問題の回避の方法が結構巧妙で、聞いていてなるほどと思った。良さそうと思いつつ、コードの書き方を変える必要があるから (親から受け取った props を aria-attribute-types で変換する) 、中々導入しづらい気はする。しかし他に良い方法もなさそうだし...うーん...と考えさせられるセッションだった。

コードの書き方はそのままで、問題を回避する方法があれば良いんですけどねえ。Linter や TypeScript Language Service Plugin 使ったら何とかならないかなあ。...と考えて色々 X にポストを投稿してた。けどやっぱり良い方法思いつかない!

Rust製JavaScript/TypeScript Linterにおけるプラグイン実装の裏側

https://speakerdeck.com/unvalley/typescript-linters

Rust 製の Linter で、Plugin を JavaScriptで書こうとすると、Rust => JavaScript に AST を転送する際に高コストなデシリアライズが必要になり、遅くなってしまう問題がある。それをどう解決するか、という話だった。この発表で紹介された話は https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-11/ で解説されてて、僕はそれを読んだことがあったので、ふんふんと頷きながら聴いていた。

AST を json-like な object にして、それを JSON としてデシリアライズすると高コストになってしまう。だから AST を1次元の数値の配列に何とかして変換して、それを送る。1次元の数値の配列なら、低コストで転送でき、遅延デシリアライズと組み合わせると、問題が解決する、というのがこのセッションの面白ポイントだった。AST の一部をどんどん簡略化していって、最終的に1次元の数値の配列になるというのがわかりやすく解説されてて良かった (まあ扱う問題が中々難しいので、なんとなくの理解に留まっている方も結構居そう)。

常にこういうアプローチを取れる訳ではなくて、lint rule は多くの場合 AST の全ての Node を走査することがない。rule の実装に必要な最小限の Node だけ走査されるので、その Node だけ遅延デシリアライズしたら良い。そういう lint rule 特有の実行特性があるからこのアプローチが取れる。AST の全 Node を走査する必要がある JavaScript Plugin システムでは、同様のアプローチを採用しても、パフォーマンス向上に寄与しない...という理解を僕はしている。それがこのアプローチの面白い点だと思っていて、折角なのでそういう話もセッションでされると良かったなーと思った。

TypeScriptネイティブ移植観察レポート TSKaigi 2025

https://speakerdeck.com/berlysia/typescript-native-porting-observation-tskaigi-2025

tsgo がどのようなもので、どういう戦略で開発されてるか、今どういう開発状況なのかがまとまってて良かった。P42 のまとめスライドが良いことばかり書いてあって良かった。『「本家本元がやる」だけでは埋まらない不安が「ちゃんと動いていて本当に速い」で吹き飛ぶ』というのは本当にそう思う。

TypeScript Language Service Plugin で CSS Modules の開発体験を改善する

聴いたというよりは発表しました。

www.mizdra.net

技術書をソフトウェア開発する - jsprimerの10年から学ぶ継続的メンテナンスの技術

https://azu.github.io/slide/2025/tskaigi/jsprimer.html

JavaScript の仕様が変化していく中で技術書側をそれに追従させる工夫が、様々な観点で紹介されていて面白かった。単に丁寧にやっていくという話ではなくて、ツールを作って導入したり、章の流れを可視化して章の順序を決めるのに役立てたりなど、普通の書籍の執筆では見れれないような工夫が紹介されてたのが印象的だった。本当にソフトウェアを開発するかのように技術書の執筆をしていてすごい。

君だけのオリジナル async / await を作ろう

https://speakerdeck.com/susisu/tskaigi-2025

同僚の発表ということで聴いた。ジェネレータを使えば async/await っぽいことができる、というのは知っていたけど、実際どうそれを作るのかは知らなかったので、へーと言いながら聴いていた。意外とこれくらい単純なコードで実装できるんだなー。題材が難しかったけど、資料が分かりやすかったのですんなり頭に入ってきてよかった。

TS特化Clineプログラミング

https://tskaigi.mizchi.workers.dev/

AI Coding Agent 向けの実践的なテクニック集という感じだった。途中で TDD が大事という話があったけど、説明の根拠を聞く限りは TDD が大事というよりテストがあることが大事というふうにも理解できて、どうなんでしょうねと思った。TDD じゃなくてもテストがあれば、Agent が生成するコードの質そんな変わらないんじゃないのかな。

In Source Testing で、テストと実装を同じファイルに書くことで、Agent がテストコードと実装を一度に読み取れて扱いやすい、というのは確かになと思った。In Source Testing、流行ってるけどどうなんでしょうね。テスト向けのコードが production コードに bundle されてしまう恐れとかはないのかな。bundler が production ビルドする時に、テスト向けのコードをちゃんと dead code elimination なり tree shaking なりしてくれれば良いけど、どこまでやってくれるんでしょうね。エッジケースが怖くて僕はまだ In Source Testing 使えてない... まあでもどこかで試してみると良い気がする。

令和最新版TypeScriptでのnpmパッケージ開発

https://speakerdeck.com/lycorptech_jp/tskaigi-2025-odan

色々ビルドツールがあるけど、どういう時何使えば良いの? という疑問に答えてくれる発表だった。あと過剰に package 分割すべきではないよねという話がなされていて、うんうんと頷いてた。

最近 monorepo がトレンドになって、どんどんパッケージ分割するのが良い、みたいな風潮があるけど、そんな訳ないと思う。例えば関数1つごとにパッケージ1つ作ったら良いかというと、そんなわけがない。dependencies が増えてきたら分けるという人も居るけど、本来1つの package に収まっていて綺麗に書けていたものを、どうにかして分割すると、無理が出てくると思う。package を分けて運用するコストは無料じゃない。僕は運用したことないけど、マイクロサービスとかとも無理に分けまくって困るというのはよく聞く話で、それと似た話題だと思う。開発チームが違うとか、デプロイの単位が違うとか、責務が違うとか、そういう意味のある単位で分けて欲しい。分けるべき時が来たら分けたら良い。それまでは分けずに粘るべき。...という話を Ask the Speaker で話して盛り上がってた。

Project Referencesを活用した実行環境ごとのtsconfig最適化

https://speakerdeck.com/itatchi3/project-referenceswohuo-yong-sitashi-xing-huan-jing-gotonotsconfigzui-shi-hua

Project References...というかSolution Style tsconfig を利用した、tsconfig.json の分割方法についての発表だった。簡潔にまとまってて、他の人にオススメしやすい資料だった。

types: [] を付けると、@types ディレクトリ配下の型定義ファイル全て (@types/node 以外のものも) 読み込めなくなる気がするけど、大丈夫なのかな、トラブル起きないのかなと思ったけど、どうなんでしょうね。今まで自動で読み込まれていたはずの型定義を、明示的に指定して手動で読み込む必要があるので、そこがユーザ的には混乱しそう。

少し話が変わるけど、最近 tsc --init で生成されるデフォルトの tsconfig.json を更新しようという動きがある。

実はこの議論の中で、tsc --init で生成される設定に types: [] を追加しようと提案されてる。そして自動で型定義読み込まないことで混乱が起きるんじゃないか、とちゃんとユーザから突っ込まれてた。それに対しメンテナーの方は、理解を示しつつも自動で型定義読み込むほうが厄介な問題引き起こしてるので、自動を読み込み禁止したい、とりあえず試して様子を見たい、と言っているようだった。

tsconfig.json 周りはまだしばらく大きな変化がありそうだなと思った。

総括

型の話ばっかりという感じではなくて、色々な方面の話が聞けたので面白かった。一方でレベル感的にはちょっと物足りなさも少し感じた。もうちょっと難しい話があっても良かったかも? どうかな? 折角 3 トラックあるので、難しい話が多少あっても、上手い具合に人が分かれてくれる気はする。まあでも人によって聞きたいレベルの感覚違うし、今ぐらいが丁度良いのかなー。

あと TypeScript ではないけど Flow の話とかしたら絶対盛り上がるなと思った。興味ある方よろしくお願いします。

TSKaigi 2025 で CSS Modules Kit について発表しました

「TypeScript Language Service Plugin で CSS Modules の開発体験を改善する」というタイトルで発表しました。

speakerdeck.com

CSS Modules には、Find All References などの言語機能が動かないといった問題があります。本発表では、その問題を解決するためのツールキット「CSS Modules Kit」を紹介しました。

github.com

CSS Modules Kit は、Find All References といった主要なな言語機能をサポートしています。更に、VS Code 以外の様々なエディタで動くという特徴があります。その裏側では TypeScript Language Service Plugin と Volar.js を駆使した、非常に巧妙な実装がなされています。

CSS Modules を使っている方にとって、興味深い発表になっているかと思います。CSS Modules ユーザ以外の方にとっても、エディタの言語機能の仕組みを知ることができる、良い発表になってると思います。是非読んでみてください。

頂いた質問とその回答

X でいくつか質問頂いたので、それぞれ回答させてもらいます。

質問: codegen はどこに .module.css.d.ts を出力する?

デフォルトでは、プロジェクトルート *1 直下の generated ディレクトリにされます。例えば、以下のようなプロジェクトがある時、

project/
├─ src/
│  ├─ App.module.css
│  ├─ App.tsx
│  ├─ components/
│  │  ├─ Button.tsx
│  │  ├─ Button.module.css

codegen を実行すると、以下のように generated ディレクトリが生成されます。

project/
├─ src/
│  ├─ App.module.css
│  ├─ App.tsx
│  ├─ components/
│  │  ├─ Button.tsx
│  │  ├─ Button.module.css
├─ generated/
│  ├─ src/
│  │  ├─ components/
│  │  │  ├─ Button.module.css.d.ts
│  │  ├─ App.module.css.d.ts

ただ generated ディレクトリに生成しただけでは、tsc はその型定義を .module.css の型定義として使ってくれません。そこで CSS Modules Kit では src/** にあるモジュールの型定義を、generated/src/** から読み込むよう、tsconfig.jsonrootDirs オプションを設定する前提になってます。

happy-css-modules *2 (CSS Modules Kit の前身のツール) はデフォルトではコンポーネントの横に .module.css.d.ts を出力していて、src/ 配下に自動生成ファイルが混じって煩わしくなっていました。CSS Modules Kit ではそこが解消されていて、快適にお使いいただけるようになってます。

質問: Language Server が LPS request を受け取る優先度は変えられる?

エディタによっては変えられます! Zed はできます。というかスライドの P63 で紹介した設定がまさにそれですね。

ここでは .css の LS の優先度を vtsls (tsserver のラッパー) > それ以外の Language Server に変更してます。これにより、Zed で Rename request の取り合い問題 (P65) を回避してます。

Zed ではこれが可能ですが、残念ながら VS Code はできません。そのため VS Code では Rename request の取り合い問題が起きているのです...。NeoVim、Emacs は試してないので分からないです。設定柔軟に書けるし、多分変えられるんじゃないかなー。

ちなみに Vue.js や Astro にも CSS Modules Kit と同じように TypeScript Language Service Plugin を使った言語ツール (@vue/typescript-plugin, @astrojs/ts-plugin) がありますが、そちらでは Rename request の取り合い問題は起きていません。何故なら .vue/.astro などの独自の拡張子のファイルには、エディタ組み込みの Language Server がないからです。tsserver と Rename request を取り合う相手が居ないのです。

一方 CSS Modules Kit の場合は、.css の Language Server に tsserver と vscode-css-languageserver の2つが居ます。Rename request の取り合いは、CSS Modules Kit が .css に言語機能を提供するために起きる、特有の現象な訳です。

もしかすると VS Code 開発チームに CSS Modules Kit のユースケースを伝えれば、VS Code 側で何かしらの対策を取ってもらえるかもしれません。問題の解決策が提供されてないのは、単に今までユースケースが無かったから、というのも理由の1つだと思うんですよね。そのうち要望出そうかなと考えてます。

質問: :local():global() (CSS Modules の拡張構文) はサポートしてる?

サポートしてます!ただ、一部制限があります。:local(.button):global(.button) のように括弧を使った記法はサポートしてますが、:local .button:global .button のような括弧を使わない記法はサポートしてません。

括弧なしの記法も当初はサポートするつもりだったのですが、あまりに仕様が複雑だったので諦めました...。postcss-modules-local-by-default:local / :global の実質的な仕様になってるのですが、なんというか実装に引きずられて変な仕様になってるんですよね... 具体的にどこが変なのかは CSS Modules Kit のテストケースにメモを書いてるので、それ見てください。

びっくりする挙動が多いので、そもそもこの記法を使うべきではない、と id:mizdra は考えてます。そのため、CSS Modules Kit では意図的に括弧なしの記法をサポートしてません。

ちなみに、同じく CSS Modules の拡張構文である @value にも対応してます。@value で定義された変数は、.module.css.d.ts の型定義に含まれます。また、Bundler には @import ... で import したスタイルシートを、bundle 時に import 元へと展開する機能がありますが、それにも対応してます。@import ... で import したスタイルシートに書かれていたクラスも、.module.css.d.ts の型定義に含まれます。

CSS Modules Kit は他のツールからの乗り換えを容易にするために、エコシステムの分断を避けるために、既存のツールとの互換性を重視してます。足りない機能があったら実装を検討するので教えて下さい。:local .button:global .button のように、必ずしも実装するとは限らないですが。

質問: LSP の仕様で言語横断の言語機能を提供する仕組みが規定されていないのは何故?

言語横断の言語機能を提供したい、あるいは言語Aと言語Bの Language Server 同士で通信したい、といった要望は以前からあって、LSP の仕様リポジトリの issue で議論されてるようです。

このうち #636 のコメントを見ると、言語横断の言語機能を提供したければ、複数の言語の LS を束ねるメタ LS (proxy のようなもの) を作るよう推奨されています。CSS Modules Kit の ts-plugin もこれに分類されるものですね。

現状メタ LS のようなものがあればやりたいことは実現できますし、複雑な機能を入れるよりは、仕様をシンプルに保ちたい、という意図があるのだと思います。

また、そもそも複数の LS を束ねた時の挙動が曖昧すぎて、仕様で定めるのが難しいというのもあると思います。やろうと思えば completion response が複数の LS から返ってくるようにできますが、それのどちらを優先するのか、マージするのか、マージするにしてもどの順でマージするのか、決めようにも決めにくいところがあるはずです。ユースケースによってどうすべきか変わりますし、仕様で決めるよりはメタ LS 側で自由に決められるほうが、色々と都合が良いんじゃないかなと思います。

さいごに

当日は多くの方に発表を聴きに来ていただきました。多くの人に CSS Modules Kit の取り組みや仕組みを知ってもらえて嬉しかったです。ありがとうございました!

実は CSS Modules Kit には、発表では話せなかった面白トピックが他にも沢山あります。そちらは順次ブログに書いていこうと思います。お楽しみに。

聴いたセッションの感想はこちら。

www.mizdra.net

*1:tsconfig.json のあるディレクトリのことです。

*2:これも id:mizdra が作ったものです。https://www.mizdra.net/entry/2022/11/14/102506 を読んでください。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.