Open17

typed-css-modulesの仕組みを調べる

iricoirico

run.tsにて
DtsCreatorのインスタンスを生成

const creator = new DtsCreator()

chokidar.watchによってcss fileの追加や編集をwatch

const watcher = chokidar.watch([filesPattern.replace(/\\/g, '/')]);
watcher.on('add', writeFile);
watcher.on('change', writeFile);
await waitForever();

creator.createでDtsContentのインスタンスを生成

const content: DtsContent = await creator.create(f, undefined, false);

変更や追加があればDtsContentのwriteFileを実行する

iricoirico

ここでファイルの中身を記述している
resultListの抜き出しが肝っぽい

  public get formatted(): string {
    if (!this.resultList || !this.resultList.length) return '';

    return (
      [
        'declare const styles: {',
        ...this.resultList.map((line) => '  ' + line),
        '};',
        'export = styles;',
        ''
      ].join(os.EOL) + this.EOL
    );
  }
iricoirico
  private createResultList(): string[] {
    const result = this.rawTokenList.map(
      (k) => 'readonly "' + k + '": string;'
    );
    return result;
  }

iricoirico

rawTokenListはこうやって生成される

    const res = await this.loader.fetch(filePath, '/', undefined, initialContents);
    if (res) {
      const tokens = res;
      const keys = Object.keys(tokens);
iricoirico
source = await readFile(fileRelativePath, 'utf-8');

でファイルの中身取り出して、

    const { injectableSource, exportTokens } = await this.core.load(
      source,
      rootRelativePath,
      trace,
      this.fetch.bind(this)
    );

で何やらtokenっぽいものを取得している。 this.core.loadとは

iricoirico
this.core = new Core();

コンストラクタで宣言されているこの人。

import Core from 'css-modules-loader-core';

にたどり着く

iricoirico

css-modules-loader-core
css-loaderから「exportされているクラスセレクターを抽出するロジック」を切り出してきたライブラリ

との記述が。
参考:https://developer.hatenastaff.com/entry/2022/09/01/093000

typed-css-modules
CSS Modulesファイルの型定義ファイル(TypeScript)を生成するCLIツール
詳しくはQiitaの記事「TypeScript + React JSX + CSS Modules で実現するタイプセーフなWeb開発」を参照
内部でcss-modules-loader-coreを使っている

とも(調べなくてよかった説)

iricoirico

core.load( sourceString , sourcePath , pathFetcher ) =>
Promise({ injectableSource, exportTokens })
Processes the input CSS sourceString, looking for dependencies such as @import or :import. Any localisation will happen by prefixing a sanitised version of sourcePath When dependencies are found, it will ask the pathFetcher for each dependency, resolve & inline any imports, and return the following object:

injectableSource: the final, merged CSS file without @import or :import statements
exportTokens: the mapping from local name to scoped name, as described in the file's :export block
These should map nicely to what your build-tool-specific loader needs to do its job.

とのこと
https://www.npmjs.com/package/css-modules-loader-core

iricoirico

実際に出てくるのはこういうもの

injectableSource ._applications_flow_src_features_AppForm_Canvas_Field_fieldComponentVariants_MultipleLineText_MultipleLineText_module__appForm-fields-multipleLineText {
  height: 100%;
  padding: 0px 8px 4px 8px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
}

._applications_flow_src_features_AppForm_Canvas_Field_fieldComponentVariants_MultipleLineText_MultipleLineText_module__appForm-dragDroppableField {
  height: fit-content;
  display: flex;
  position: relative;
  width: fit-content;
}

._applications_flow_src_features_AppForm_Canvas_Field_fieldComponentVariants_MultipleLineText_MultipleLineText_module__appForm-dragDroppableField--highlighted {
  background: var(--c-malibu);
}
exportTokens {
  'appForm-fieldLabel-title': '_applications_flow_src_features_AppForm_Canvas_Field_FieldLabel_FieldLabel_module__appForm-fieldLabel-title',
  'appForm-fieldLabel-required': '_applications_flow_src_features_AppForm_Canvas_Field_FieldLabel_FieldLabel_module__appForm-fieldLabel-required'
}
iricoirico
    const re = new RegExp(/@import\s'(\D+?)';/, 'gm');

    const importTokens: Core.ExportTokens = {};

    let result;

    while ((result = re.exec(injectableSource))) {
      const importFile = result?.[1];

      if (importFile) {
        const importFilePath = isNodeModule(importFile)
          ? importFile
          : path.resolve(path.dirname(fileRelativePath), importFile);

        const localTokens = await this.fetch(importFilePath, relativeTo);
        Object.assign(importTokens, localTokens);
      }
    }

    const tokens = { ...exportTokens, ...importTokens };

    this.sources[trace] = injectableSource;
    this.tokensByFile[fileRelativePath] = tokens;
    return tokens;

この辺の記述でtoken(クラスネーム)を取得してる?

iricoirico

基本はcss-modules-loader-coreからtokenを取得してるけど、import文がある場合は
再帰的にそのファイルを読んでtokenを取得してるっぽい

iricoirico

this.tokensByFile[fileRelativePath] = tokens;
でキャッシュ?しつつtokenを返却。
それがDtsContentのrawTokenListに詰められる

iricoirico

基本的な仕組みは以上っぽい
this.sources[trace] = injectableSource;
って記述があったけどこれは何に使ってるんだろう

iricoirico

const trace = String.fromCharCode(this.importNr++);
で渡された数値から UTF-16の文字列を生成してる。(コンストラクタ)
importNrはカウントアップされていくけどなぜ数値のままじゃダメなの?
履歴見たりリポジトリ内検索かけたけどわからなかった

sourcesは今のところ使われてない。念の為コンストラクタで持ってるだけかも

iricoirico

おまけとして、css modules削除時にこの仕組みだと型ファイルはゴミとして残ってしまう。

これはrun.tsのchokidar.watchあたりにdeleteの処理を追加してあげれば良い。

    watcher.on('add', writeFile);
    watcher.on('change', writeFile);
  +  watcher.on('unlink', deleteFile);
  const deleteFile = async (f: string): Promise<void> => {
    try {
      const content: DtsContent = await creator.create(
        f,
        !!options.watch,
        true
      );

      await content.deleteFile();

      console.log('Delete ' + chalk.green(content.outputFilePath));
    } catch (error) {
      console.error(chalk.red('[Error] ' + error));
    }
  };
iricoirico

ただ、存在しないファイルのcreator.createは無条件で失敗するようになっているので、フラグなどを追加していい感じにしてあげる必要がある。

dts-creator.ts

    let keys: string[] = [];
    if (!isDelete) {
      const res = await this.loader.fetch(filePath, '/');
      if (!res) throw res;

      keys = Object.keys(res);
    }