├── .changeset ├── README.md └── config.json ├── .clinerules ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── crates └── zed │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── extension.toml │ └── src │ └── lib.rs ├── docs ├── get-started.md └── glossary.md ├── eslint.config.js ├── example ├── .vscode │ └── settings.json ├── css-modules.css-data.json ├── eslint.config.js ├── generated │ └── src │ │ ├── a.module.css.d.ts │ │ ├── b.module.css.d.ts │ │ ├── c.module.css.d.ts │ │ └── d.module.css.d.ts ├── src │ ├── AppHeader.tsx │ ├── a.module.css │ ├── a.tsx │ ├── b.module.css │ ├── c.module.css │ └── index.tsx ├── stylelint.config.js └── tsconfig.json ├── package-lock.json ├── package.json ├── packages ├── codegen │ ├── CHANGELOG.md │ ├── README.md │ ├── bin │ │ └── cmk.mjs │ ├── e2e │ │ └── index.test.ts │ ├── package.json │ ├── src │ │ ├── 3rd-party │ │ │ └── typescript.ts │ │ ├── LICENSE │ │ ├── cli.test.ts │ │ ├── cli.ts │ │ ├── dts-writer.test.ts │ │ ├── dts-writer.ts │ │ ├── error.ts │ │ ├── index.ts │ │ ├── logger │ │ │ ├── formatter.test.ts │ │ │ ├── formatter.ts │ │ │ ├── logger.test.ts │ │ │ └── logger.ts │ │ ├── runner.test.ts │ │ ├── runner.ts │ │ └── test │ │ │ ├── fixture.ts │ │ │ ├── logger.ts │ │ │ └── process.ts │ └── tsconfig.build.json ├── core │ ├── CHANGELOG.md │ ├── LICENSE │ ├── package.json │ ├── src │ │ ├── checker.test.ts │ │ ├── checker.ts │ │ ├── config.test.ts │ │ ├── config.ts │ │ ├── diagnostic.ts │ │ ├── dts-creator.test.ts │ │ ├── dts-creator.ts │ │ ├── error.ts │ │ ├── export-builder.test.ts │ │ ├── export-builder.ts │ │ ├── file.test.ts │ │ ├── file.ts │ │ ├── index.ts │ │ ├── parser │ │ │ ├── at-import-parser.test.ts │ │ │ ├── at-import-parser.ts │ │ │ ├── at-value-parser.test.ts │ │ │ ├── at-value-parser.ts │ │ │ ├── css-module-parser.test.ts │ │ │ ├── css-module-parser.ts │ │ │ ├── rule-parser.test.ts │ │ │ └── rule-parser.ts │ │ ├── path.test.ts │ │ ├── path.ts │ │ ├── resolver.test.ts │ │ ├── resolver.ts │ │ ├── test │ │ │ ├── ast.ts │ │ │ ├── css-module.ts │ │ │ ├── fixture.ts │ │ │ └── token.ts │ │ ├── type.ts │ │ ├── typing │ │ │ └── typescript.d.ts │ │ ├── util.test.ts │ │ └── util.ts │ └── tsconfig.build.json ├── eslint-plugin │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── docs │ │ └── rules │ │ │ ├── no-missing-component-file.md │ │ │ └── no-unused-class-names.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── rules │ │ │ ├── no-missing-component-file.test.ts │ │ │ ├── no-missing-component-file.ts │ │ │ ├── no-unused-class-names.test.ts │ │ │ └── no-unused-class-names.ts │ │ ├── test │ │ │ ├── eslint.ts │ │ │ └── fixture.ts │ │ └── util.ts │ └── tsconfig.build.json ├── language-server │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.build.json ├── stylelint-plugin │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── recommended.ts │ │ ├── rules │ │ │ ├── no-missing-component-file.test.ts │ │ │ ├── no-missing-component-file.ts │ │ │ ├── no-unused-class-names.test.ts │ │ │ └── no-unused-class-names.ts │ │ ├── test │ │ │ ├── fixture.ts │ │ │ └── stylelint.ts │ │ └── util.ts │ └── tsconfig.build.json ├── ts-plugin │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── e2e │ │ ├── code-fix.test.ts │ │ ├── completion.test.ts │ │ ├── find-all-references.test.ts │ │ ├── go-to-definition.test.ts │ │ ├── invalid-syntax.test.ts │ │ ├── refactor.test.ts │ │ ├── regression │ │ │ └── pure-css-file.test.ts │ │ ├── rename-file.test.ts │ │ ├── rename-symbol.test.ts │ │ ├── semantic-diagnostics.test.ts │ │ ├── syntactic-diagnostics.test.ts │ │ └── test │ │ │ ├── fixture.ts │ │ │ └── tsserver.ts │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── language-plugin.ts │ │ ├── language-service │ │ │ ├── feature │ │ │ │ ├── code-fix.ts │ │ │ │ ├── completion.ts │ │ │ │ ├── refactor.ts │ │ │ │ ├── semantic-diagnostic.ts │ │ │ │ └── syntactic-diagnostic.ts │ │ │ └── proxy.ts │ │ └── util.ts │ └── tsconfig.build.json └── vscode │ ├── .vscodeignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.build.json ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json └── vitest.workspace.mts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "privatePackages": { 12 | "version": true, 13 | "tag": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.clinerules: -------------------------------------------------------------------------------- 1 | # プロジェクト構成 2 | 3 | ## プロジェクトの概要 4 | 5 | - このプロジェクトは CSS Modules に関するツールセットです 6 | - このプロジェクトは、以下のパッケージから構成されています 7 | - `core`: 他のパッケージから使われる共通のコードを含むパッケージ 8 | - `codegen`: コード生成ツール。`*.module.css` ファイルから TypeScript の型定義ファイルを生成します 9 | - `stylelint-plugin`: CSS Modules 用の stylelint プラグイン 10 | - `ts-plugin`: CSS Modules 用の TypeScript Language Service Plugin 11 | - `vscode`: `ts-plugin` を組み込んだ VS Code 拡張機能 12 | 13 | # ディレクトリ構造 14 | 15 | ## サンプルディレクトリ 16 | 17 | - `example` ディレクトリには、`codegen`, `stylelint-plugin`, `vscode` を試すためのサンプルが含まれています 18 | 19 | ## ファイル名とディレクトリ名にはケバブケースを使う 20 | 21 | - ファイル名とディレクトリ名にはケバブケースを使ってください 22 | - 例: `my-file.ts`, `my-file.test.ts`, `my-directory` 23 | 24 | ## barrel file は作らない 25 | 26 | - 他のファイルのアイテムを import して、そのまま export するだけの barrel file は作らないでください 27 | 28 | ```ts 29 | // Bad 30 | // src/index.ts 31 | export * from './my-file-1.js'; 32 | export { myFunction } from './my-file-2.js'; 33 | ``` 34 | 35 | ## テストファイルは、テスト対象のファイルと同じディレクトリに配置する 36 | 37 | - テストファイルは、テスト対象のファイルと同じディレクトリに配置してください 38 | - テストファイルの名前は、テスト対象のファイルの名前に `.test.ts` を付けたものにしてください 39 | - 例: `src/my-file.ts` のテストファイルは `src/my-file.test.ts` 40 | 41 | ## E2E テストファイルは `e2e` ディレクトリに配置する 42 | 43 | - テストファイルの中でも、E2E テストのファイルは `e2e` ディレクトリに配置してください 44 | - 例: `e2e/my-e2e.test.ts` 45 | 46 | ## テストでのみ利用する utility 関数は、`src/test` ディレクトリに配置する 47 | 48 | - テストでのみ利用する utility 関数は、`src/test` ディレクトリに配置してください 49 | - 例: `src/test/ast.ts` 50 | 51 | ## 3rd-party ライブラリの型を拡張するための型定義ファイルは `typing` ディレクトリに配置する 52 | 53 | - 3rd-party ライブラリの型を拡張するための型定義ファイルは `typing` ディレクトリに配置してください 54 | - 例: `typing/my-library.d.ts` 55 | 56 | # ビルドと実行 57 | 58 | ## npm-scripts 59 | 60 | - `npm run build`: コードをビルドします 61 | - `npm run lint`: formatter や linter、型チェッカーを実行します 62 | - `npm t`: ユニットテストを実行します 63 | - `npm run e2e`: コードをビルドした上で E2E テストを実行します 64 | 65 | ## VS Code Launch Configuration 66 | 67 | - `codegen: debug`: `example` ディレクトリに対して、`codegen` package をデバッグモードで実行します 68 | - `stylelint-plugin: debug`: `example` ディレクトリに対して、`stylelint-plugin` package をデバッグモードで実行します 69 | - `vscode: debug`: `example` ディレクトリに対して、`vscode` package をデバッグモードで実行します 70 | 71 | # コーディングスタイル 72 | 73 | ## クラスは原則使用しない 74 | 75 | - `class` は原則使用しないでください 76 | - 使って良いのはエラークラスのみです 77 | - 状態を持つオブジェクトを作る場合は、`createXxx` という関数を使ってください 78 | - 関数の返り値の型は、`interface` で定義してください 79 | 80 | ```ts 81 | // Bad 82 | class User { 83 | private name: string; 84 | constructor(name: string) { 85 | this.name = name; 86 | } 87 | getName() { 88 | return this.name; 89 | } 90 | } 91 | 92 | // Good 93 | interface User { 94 | name: string; 95 | getName(): string; 96 | } 97 | function createUser(name: string): User { 98 | return { 99 | name, 100 | getName() { 101 | return name; 102 | }, 103 | }; 104 | } 105 | ``` 106 | 107 | ## throw するエラーは Error クラスを継承したクラスを使う 108 | 109 | - エラーを throw する場合は、`Error` クラスを継承したクラスを使ってください 110 | - エラークラスは、`src/error.ts` に配置してください 111 | 112 | ```ts 113 | // src/error.ts 114 | class AuthError extends Error { 115 | constructor(message: string) { 116 | super(message); 117 | this.name = 'AuthError'; 118 | } 119 | } 120 | ``` 121 | 122 | ## エラーを throw する時は `@throws` を使う 123 | 124 | - 例外を throw する場合は、throw する 関数の JSDoc に `@throws` で例外の説明を記述してください 125 | - `@throws` には、例外の型と、その例外がどういう時に throw されるかを記述してください 126 | 127 | ```ts 128 | /** 129 | * @throws {AuthError} When the user is not authorized 130 | */ 131 | function myFunction() { 132 | if (!user.isAuthorized()) { 133 | throw new AuthError('User is not authorized'); 134 | } 135 | } 136 | ``` 137 | 138 | ## エラーが throw される可能性のある関数を呼び出す場合は、呼び出し元の関数にも `@throws` を記述する 139 | 140 | - エラーが throw される可能性のある関数を呼び出す場合は、呼び出し元の関数の JSDoc にも `@throws` を記述してください 141 | - `@throws` には、呼び出される関数が throw する可能性のある例外の型と、その例外がどういう時に throw されるかを記述してください 142 | - ただし、呼び出し元の関数で、呼び出される関数が throw する例外を catch して処理する場合は、`@throws` は不要です 143 | 144 | ```ts 145 | /** 146 | * @throws {AuthError} When the user is not authorized 147 | */ 148 | function myFunction1() { 149 | anotherFunction(); 150 | } 151 | 152 | function myFunction2() { 153 | try { 154 | anotherFunction(); 155 | } catch (e) { 156 | // エラー処理 157 | } 158 | } 159 | 160 | /** 161 | * @throws {AuthError} When the user is not authorized 162 | */ 163 | function anotherFunction() { 164 | if (!user.isAuthorized()) { 165 | throw new AuthError('User is not authorized'); 166 | } 167 | } 168 | ``` 169 | 170 | ## テストからのみ import する関数は、そのまま export せずに `xxxForTest` という名前で export する 171 | 172 | - テストからのみ import する関数は、そのまま export せずに `xxxForTest` という名前で export してください 173 | 174 | ```ts 175 | // src/my-file.ts 176 | function myFunction() { 177 | // ... 178 | } 179 | 180 | export { myFunction as myFunctionForTest }; 181 | ``` 182 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-24.04-arm 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | cache: 'npm' 18 | - uses: actions/cache@v4 19 | with: 20 | path: | 21 | node_modules/.cache/prettier/.prettier-cache 22 | tsconfig.tsbuildinfo 23 | packages/*/{dist,tsconfig.build.tsbuildinfo} 24 | .eslintcache 25 | key: toolcache-lint-${{ runner.os }}-${{ github.sha }} 26 | restore-keys: toolcache-lint-${{ runner.os }} 27 | - run: npm install 28 | - run: npm run lint 29 | build: 30 | runs-on: ubuntu-24.04-arm 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: 22 36 | cache: 'npm' 37 | - run: npm install 38 | - run: npm run build 39 | test: 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | node: [22] 44 | os: [ubuntu-24.04-arm, windows-2025] 45 | runs-on: ${{ matrix.os }} 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-node@v4 49 | with: 50 | node-version: ${{ matrix.node }} 51 | cache: 'npm' 52 | - uses: actions/cache@v4 53 | with: 54 | path: node_modules/.vite/vitest 55 | key: toolcache-test-${{ runner.os }}-node${{ matrix.node }}-${{ github.sha }} 56 | restore-keys: toolcache-test-${{ runner.os }}-node${{ matrix.node }} 57 | - run: npm install 58 | - run: npm run test 59 | e2e: 60 | strategy: 61 | fail-fast: false 62 | matrix: 63 | node: [22] 64 | os: [ubuntu-24.04-arm, windows-2025] 65 | runs-on: ${{ matrix.os }} 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: actions/setup-node@v4 69 | with: 70 | node-version: ${{ matrix.node }} 71 | cache: 'npm' 72 | - uses: actions/cache@v4 73 | with: 74 | path: | 75 | node_modules/.vite/vitest 76 | packages/*/{dist,tsconfig.build.tsbuildinfo} 77 | key: toolcache-e2e-${{ runner.os }}-node${{ matrix.node }}-${{ github.sha }} 78 | restore-keys: toolcache-e2e-${{ runner.os }}-node${{ matrix.node }} 79 | - run: npm install 80 | - run: npm run e2e 81 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | id-token: write # Required for provenance 10 | contents: write # Required for changesets/action 11 | pull-requests: write # Required for changesets/action 12 | 13 | concurrency: ${{ github.workflow }}-${{ github.ref }} 14 | 15 | jobs: 16 | release: 17 | name: Release 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | cache: 'npm' 25 | - run: npm install 26 | - run: npm run build 27 | - name: Create Release Pull Request or Publish to npm 28 | id: changesets 29 | uses: changesets/action@v1 30 | with: 31 | publish: npx @changesets/cli publish 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | NPM_CONFIG_PROVENANCE: true 36 | - run: npx vsce publish --no-git-tag-version --skip-duplicate 37 | working-directory: packages/vscode 38 | env: 39 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 40 | - run: npx ovsx publish --skip-duplicate 41 | working-directory: packages/vscode 42 | env: 43 | OVSX_PAT: ${{ secrets.OVSX_PAT }} 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Generated by gibo (https://github.com/simonwhitaker/gibo) 2 | ### https://raw.github.com/github/gitignore/274c32303f1f86a42db55e2dab3107e560714010/Node.gitignore 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | ### Generated by gibo (https://github.com/simonwhitaker/gibo) 135 | ### https://raw.github.com/github/gitignore/274c32303f1f86a42db55e2dab3107e560714010/Rust.gitignore 136 | 137 | # Generated by Cargo 138 | # will have compiled files and executables 139 | debug/ 140 | target/ 141 | 142 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 143 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 144 | Cargo.lock 145 | 146 | # These are backup files generated by rustfmt 147 | **/*.rs.bk 148 | 149 | # MSVC Windows builds of rustc generate these, which store debugging information 150 | *.pdb 151 | 152 | # RustRover 153 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 154 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 155 | # and can be added to the global gitignore or merged into this file. For a more nuclear 156 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 157 | #.idea/ 158 | 159 | ### User 160 | /crates/zed/extension.wasm 161 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | /example/generated 3 | /example/src 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "vitest.explorer", 6 | "mizdra.css-modules-kit-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "codegen: debug", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceFolder}/example", 9 | "runtimeExecutable": "node", 10 | "runtimeArgs": ["../packages/codegen/bin/cmk.mjs"], 11 | "console": "integratedTerminal", 12 | "preLaunchTask": "npm: build - packages/codegen" 13 | }, 14 | { 15 | "name": "vscode: debug", 16 | "type": "extensionHost", 17 | "request": "launch", 18 | "args": [ 19 | "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", 20 | "--profile-temp", 21 | "--disable-extension=vscode.css-language-features", 22 | "--skip-welcome", 23 | "--folder-uri=${workspaceFolder}/example", 24 | "${workspaceFolder}/example/src/index.tsx" 25 | ], 26 | "outFiles": [ 27 | "${workspaceFolder}/packages/vscode/dist/**/*.js", 28 | "${workspaceFolder}/packages/vscode/dist/**/*.cjs", 29 | "${workspaceFolder}/packages/vscode/dist/**/*.mjs" 30 | ], 31 | "preLaunchTask": "npm: build - packages/vscode", 32 | "env": { 33 | "TSS_DEBUG": "5859" 34 | } 35 | }, 36 | { 37 | "name": "Attach debugger to tsserver", 38 | "type": "node", 39 | "request": "attach", 40 | "port": 5859, 41 | "sourceMaps": true 42 | }, 43 | { 44 | "name": "stylelint-plugin: debug", 45 | "type": "node", 46 | "request": "launch", 47 | "cwd": "${workspaceFolder}/example", 48 | "runtimeExecutable": "npx", 49 | "runtimeArgs": ["stylelint", "src/**/*.css"], 50 | "console": "integratedTerminal", 51 | "preLaunchTask": "npm: build - packages/stylelint-plugin" 52 | }, 53 | { 54 | "name": "eslint-plugin: debug", 55 | "type": "node", 56 | "request": "launch", 57 | "cwd": "${workspaceFolder}/example", 58 | "runtimeExecutable": "npx", 59 | "runtimeArgs": ["eslint", "src/**/*.css"], 60 | "console": "integratedTerminal", 61 | "preLaunchTask": "npm: build - packages/eslint-plugin" 62 | } 63 | ], 64 | "compounds": [ 65 | { 66 | "name": "vscode: debug + Attach debugger to tsserver", 67 | "configurations": ["vscode: debug", "Attach debugger to tsserver"], 68 | "stopAll": true 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | }, 8 | "typescript.preferences.importModuleSpecifier": "project-relative", 9 | "javascript.preferences.importModuleSpecifier": "project-relative", 10 | "typescript.preferences.importModuleSpecifierEnding": "js", 11 | "javascript.preferences.importModuleSpecifierEnding": "js", 12 | "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^node:test"], 13 | "javascript.preferences.autoImportSpecifierExcludeRegexes": ["^node:test"] 14 | } 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This is a guide for contributors. 4 | 5 | ## How to dev 6 | 7 | - `npm run build`: Build for production 8 | - `npm run dev`: Run for development 9 | - `npm run lint`: Run static-checking 10 | - `npm run test`: Run tests 11 | 12 | ## How to release 13 | 14 | - Wait for passing CI... 15 | - ```bash 16 | git switch main && git pull 17 | ``` 18 | - ```bash 19 | npm run build -- --clean && npm run build 20 | ``` 21 | - ```bash 22 | npx @changesets/cli version 23 | ``` 24 | - ```bash 25 | npx @changesets/cli publish 26 | ``` 27 | - ```bash 28 | git push --follow-tags 29 | ``` 30 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/zed/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "css-modules-kit-zed" 3 | version = "0.0.1" 4 | edition = "2024" 5 | license = "MIT" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | serde = { version = "1.0.219", features = ["derive"] } 12 | zed_extension_api = "0.5.0" 13 | -------------------------------------------------------------------------------- /crates/zed/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/zed/README.md: -------------------------------------------------------------------------------- 1 | # css-modules-kit-zed 2 | 3 | The Zed extension for CSS Modules Kit 4 | 5 | ## Installation 6 | 7 | 1. Install "CSS Modules Kit" extension on Zed. 8 | 2. Add the following to your `~./config/zed/settings.json` file: 9 | ```json 10 | { 11 | "languages": { 12 | "CSS": { 13 | "language_servers": ["vtsls", "..."] 14 | } 15 | } 16 | } 17 | ``` 18 | 3. Restart Zed. 19 | -------------------------------------------------------------------------------- /crates/zed/extension.toml: -------------------------------------------------------------------------------- 1 | id = "css-modules-kit" 2 | name = "CSS Modules Kit" 3 | version = "0.0.1" 4 | schema_version = 1 5 | authors = ["mizdra "] 6 | description = "The Zed extension for CSS Modules Kit" 7 | repository = "https://github.com/mizdra/css-modules-kit" 8 | 9 | [language_servers.css-modules-kit-language-server] 10 | name = "CSS Modules Kit Language Server" 11 | languages = ["TypeScript", "TSX", "JavaScript", "CSS"] 12 | -------------------------------------------------------------------------------- /docs/get-started.md: -------------------------------------------------------------------------------- 1 | # Get Started 2 | 3 | ## Install ts-plugin 4 | 5 | To enable CSS Modules language features in your editor, you need to install [`@css-modules-kit/ts-plugin`](../packages/ts-plugin/README.md) (ts-plugin). The installation method varies by editor. 6 | 7 | - For VS Code: 8 | - Install the [CSS Modules Kit extension](https://marketplace.visualstudio.com/items?itemName=mizdra.css-modules-kit-vscode) 9 | - For Neovim: 10 | - Install [`@css-modules-kit/ts-plugin`](../packages/ts-plugin/README.md#installation) and [set up the configuration](../packages/ts-plugin/README.md) 11 | - For Emacs: 12 | - Not yet supported 13 | - For Zed: 14 | - See [crates/zed/README.md](../crates/zed/README.md) 15 | - For WebStorm: 16 | - Not yet supported 17 | - For StackBlitz Codeflow: 18 | - Install the [CSS Modules Kit extension](https://open-vsx.org/extension/mizdra/css-modules-kit-vscode) 19 | 20 | ## Install codegen 21 | 22 | The ts-plugin provides type definitions for `styles` as `{ foo: string, bar: string }`. However, these types are not applied during type-checking with the `tsc` command. 23 | 24 | To ensure `tsc` properly type-checks, you need to generate `*.module.css.d.ts` files. This is handled by codegen. 25 | 26 | To install codegen, run the following command: 27 | 28 | ```bash 29 | npm i -D @css-modules-kit/codegen 30 | ``` 31 | 32 | Configure npm-script to run `cmk` command before building and type checking. This command generates `*.module.css.d.ts` files in `generated` directory. 33 | 34 | ```json 35 | { 36 | "scripts": { 37 | "gen": "cmk", 38 | "build": "run-s -c gen build:*", 39 | "build:vite": "vite build", 40 | "lint": "run-s -c gen lint:*", 41 | "lint:eslint": "eslint .", 42 | "lint:tsc": "tsc --noEmit", 43 | "lint:prettier": "prettier --check ." 44 | } 45 | } 46 | ``` 47 | 48 | ## Configure `tsconfig.json` 49 | 50 | Finally, you need to configure your tsconfig.json so that the ts-plugin and codegen work correctly. 51 | 52 | - Set the `include` option so that files like `*.module.css` are considered for type-checking: 53 | - For example: `["src"]`, `["src/**/*"]`, or omitting the `include` option (which is equivalent to `["**/*"]`) 54 | - Not recommended: `["src/**/*.ts"]`, `["src/index.ts"]` 55 | - Set the `rootDirs` option to include both the directory containing `tsconfig.json` and the `generated` directory. 56 | - Example: `[".", "generated"]` 57 | 58 | Below is an example configuration: 59 | 60 | ```jsonc 61 | { 62 | // Omitting the `include` option is equivalent to using `["**/*"]` 63 | "compilerOptions": { 64 | /* Projects */ 65 | "rootDirs": [".", "generated"], 66 | 67 | /* Language and Environment */ 68 | "target": "ESNext", 69 | "lib": ["ESNext"], 70 | 71 | /* Modules */ 72 | "module": "NodeNext", 73 | "moduleResolution": "NodeNext", 74 | 75 | /* Emit */ 76 | "noEmit": true, 77 | 78 | /* Interop Constraints */ 79 | "verbatimModuleSyntax": true, 80 | "esModuleInterop": true, 81 | "forceConsistentCasingInFileNames": true, 82 | 83 | /* Type Checking */ 84 | "strict": true, 85 | 86 | /* Completeness */ 87 | "skipLibCheck": true, 88 | }, 89 | } 90 | ``` 91 | 92 | This completes the minimal setup. 93 | 94 | ## Install linter plugin (Optional) 95 | 96 | We provide linter plugin for CSS Modules. Currently, we support the following linters: 97 | 98 | - [stylelint-plugin](../packages/stylelint-plugin/README.md) 99 | - [eslint-plugin](../packages/eslint-plugin/README.md) 100 | 101 | All linter plugins offer the same set of rules. So please choose and install one. 102 | 103 | ## Customization (Optional) 104 | 105 | You can customize the behavior of codegen by adding the `cmkOptions` option to your `tsconfig.json`. For more details, please refer to [Configuration](../README.md#configuration). 106 | -------------------------------------------------------------------------------- /docs/glossary.md: -------------------------------------------------------------------------------- 1 | # Glossary 2 | 3 | ## Token 4 | 5 | The internal name of the item being exported from `*.module.css`. 6 | 7 | For example, consider the following CSS file: 8 | 9 | ```css 10 | @value a_1: red; 11 | @import b_1, b_2 as b_2_alias from './b.module.css'; 12 | .a_2 { 13 | color: red; 14 | } 15 | .a_3, 16 | .a_4 { 17 | color: red; 18 | } 19 | :root { 20 | --a-5: red; 21 | } 22 | ``` 23 | 24 | In this case, `a_1`, `a_2`, `a_3`, `a_4`, `b_1` and `b_2_alias` are tokens. If `dashedIdents` option is `true`, `--a-5` is also a token. 25 | 26 | ## Corresponding Component File 27 | 28 | It refers to a file with the same name as the CSS Module file but with a `.tsx` or `.jsx` extension. For example, the corresponding component file for `Button.module.css` would be `Button.tsx` or `Button.jsx`. 29 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import mizdra from '@mizdra/eslint-config-mizdra'; 2 | 3 | /** @type {import('eslint').Linter.Config[]} */ 4 | export default [ 5 | { ignores: ['**/dist', 'example'] }, 6 | ...mizdra.baseConfigs, 7 | ...mizdra.typescriptConfigs, 8 | ...mizdra.nodeConfigs, 9 | { 10 | files: ['**/*.{js,jsx,mjs,cjs}', '**/*.{ts,tsx,cts,mts}'], 11 | rules: { 12 | 'simple-import-sort/imports': [ 13 | 'error', 14 | { 15 | // Remove blank lines between import groups 16 | // ref: https://github.com/lydell/eslint-plugin-simple-import-sort?tab=readme-ov-file#how-do-i-use-this-with-dprint 17 | groups: [['^\\u0000', '^node:', '^@?\\w', '^', '^\\.']], 18 | }, 19 | ], 20 | 'no-restricted-globals': [ 21 | 'error', 22 | { 23 | name: 'Buffer', 24 | message: 'Use Uint8Array instead.', 25 | }, 26 | ], 27 | 'no-restricted-imports': [ 28 | 'error', 29 | { 30 | paths: [ 31 | { 32 | name: 'buffer', 33 | message: 'Use Uint8Array instead.', 34 | }, 35 | { 36 | name: 'node:buffer', 37 | message: 'Use Uint8Array instead.', 38 | }, 39 | { 40 | name: 'node:path', 41 | message: 'Use original path package instead.', 42 | }, 43 | ], 44 | patterns: [ 45 | { 46 | group: ['**/src', '!**/../src'], 47 | message: 'Do not import internal modules directly.', 48 | }, 49 | ], 50 | }, 51 | ], 52 | // Disable because it does not work in the workspace 53 | // ref: https://github.com/eslint-community/eslint-plugin-n/issues/209 54 | 'n/no-extraneous-import': 'off', 55 | }, 56 | }, 57 | mizdra.prettierConfig, 58 | ]; 59 | -------------------------------------------------------------------------------- /example/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsserver.log": "verbose", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "typescript.tsdk": "../node_modules/typescript/lib", 5 | "git.openRepositoryInParentFolders": "never", 6 | "css.customData": ["./css-modules.css-data.json"], 7 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "css"] 8 | } 9 | -------------------------------------------------------------------------------- /example/css-modules.css-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.1, 3 | "properties": [{ "name": "composes", "description": "Extend styles from another rule" }], 4 | "atDirectives": [{ "name": "@value", "description": "Define values with CSS Modules" }] 5 | } 6 | -------------------------------------------------------------------------------- /example/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config'; 2 | import css from '@eslint/css'; 3 | import cssModulesKit from '@css-modules-kit/eslint-plugin'; 4 | 5 | export default defineConfig([ 6 | { 7 | files: ['**/*.css'], 8 | language: 'css/css', 9 | languageOptions: { 10 | tolerant: true, 11 | }, 12 | extends: [css.configs.recommended, cssModulesKit.configs.recommended], 13 | }, 14 | ]); 15 | -------------------------------------------------------------------------------- /example/generated/src/a.module.css.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | declare const styles = { 3 | a_1: '' as readonly string, 4 | a_2: '' as readonly string, 5 | a_2: '' as readonly string, 6 | a_3: '' as readonly string, 7 | ...(await import('./b.module.css')).default, 8 | c_1: (await import('./c.module.css')).default.c_1, 9 | c_alias: (await import('./c.module.css')).default.c_2, 10 | }; 11 | export default styles; 12 | -------------------------------------------------------------------------------- /example/generated/src/b.module.css.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | declare const styles = { 3 | b_1: '' as readonly string, 4 | b_2: '' as readonly string, 5 | }; 6 | export default styles; 7 | -------------------------------------------------------------------------------- /example/generated/src/c.module.css.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | declare const styles = { 3 | c_1: '' as readonly string, 4 | c_2: '' as readonly string, 5 | }; 6 | export default styles; 7 | -------------------------------------------------------------------------------- /example/generated/src/d.module.css.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | declare const styles = { 3 | d_1: '' as readonly string, 4 | }; 5 | export default styles; 6 | -------------------------------------------------------------------------------- /example/src/AppHeader.tsx: -------------------------------------------------------------------------------- 1 | export function AppHeader() { 2 | return ( 3 |
4 |

My App

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /example/src/a.module.css: -------------------------------------------------------------------------------- 1 | @import './b.module.css'; 2 | @value c_1, c_2 as c_alias from './c.module.css'; 3 | .a_1 { color: red; } 4 | .a_2 { color: red; } 5 | .a_2 { color: red; } 6 | @value a_3: red; 7 | -------------------------------------------------------------------------------- /example/src/a.tsx: -------------------------------------------------------------------------------- 1 | import styles from './a.module.css'; 2 | styles.a_1; 3 | -------------------------------------------------------------------------------- /example/src/b.module.css: -------------------------------------------------------------------------------- 1 | .b_1 { color: red; composes: b_2; } 2 | @value b_2: red; 3 | -------------------------------------------------------------------------------- /example/src/c.module.css: -------------------------------------------------------------------------------- 1 | @value c_1: red; 2 | @value c_2: red; 3 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './a.module.css'; 2 | 3 | styles.a_1; 4 | styles.a_2; 5 | styles.a_3; 6 | styles.b_1; 7 | styles.b_2; 8 | styles.c_1; 9 | styles.c_alias; 10 | -------------------------------------------------------------------------------- /example/stylelint.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('stylelint').Config} */ 2 | export default { 3 | extends: ['@css-modules-kit/stylelint-plugin/recommended'], 4 | rules: { 5 | 'at-rule-no-unknown': true, 6 | 'property-no-unknown': true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ES5", // Simplify tsserver.log 5 | "lib": ["ES5"], // Simplify tsserver.log 6 | "module": "Preserve", 7 | "moduleResolution": "bundler", 8 | "jsx": "react-jsx", 9 | 10 | "noEmit": true, 11 | "incremental": false, 12 | "paths": { "@/*": ["./*"] }, 13 | "rootDirs": [".", "generated"], 14 | "types": [] // Simplify tsserver.log 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-modules-kit-monorepo", 3 | "type": "module", 4 | "repository": "https://github.com/mizdra/css-modules-kit.git", 5 | "author": "mizdra ", 6 | "license": "MIT", 7 | "private": true, 8 | "workspaces": [ 9 | "packages/core", 10 | "packages/codegen", 11 | "packages/ts-plugin", 12 | "packages/language-server", 13 | "packages/vscode", 14 | "packages/stylelint-plugin", 15 | "packages/eslint-plugin" 16 | ], 17 | "scripts": { 18 | "build": "tsc -b tsconfig.build.json", 19 | "lint": "run-s -c lint:*", 20 | "lint:tsc": "tsc -b", 21 | "lint:eslint": "eslint --cache --cache-strategy content .", 22 | "lint:prettier": "prettier --cache --check .", 23 | "test": "vitest --project unit", 24 | "e2e": "run-s -c e2e:build e2e:vitest", 25 | "e2e:build": "npm run build", 26 | "e2e:vitest": "vitest --project e2e" 27 | }, 28 | "prettier": "@mizdra/prettier-config-mizdra", 29 | "devDependencies": { 30 | "@changesets/cli": "^2.28.1", 31 | "@eslint/css": "^0.4.0", 32 | "@mizdra/eslint-config-mizdra": "^6.1.0", 33 | "@mizdra/inline-fixture-files": "^2.1.1", 34 | "@mizdra/prettier-config-mizdra": "^2.0.0", 35 | "@types/eslint": "^9.6.1", 36 | "@types/node": "^22.13.10", 37 | "@types/postcss-safe-parser": "^5.0.4", 38 | "@types/react": "^19.0.10", 39 | "@types/vscode": "^1.98.0", 40 | "@typescript/server-harness": "^0.3.5", 41 | "@vscode/vsce": "^3.2.2", 42 | "dedent": "^1.5.3", 43 | "eslint": "^9.22.0", 44 | "npm-run-all2": "^7.0.2", 45 | "ovsx": "^0.10.1", 46 | "prettier": "^3.5.3", 47 | "stylelint": "^16.17.0", 48 | "typescript": "^5.8.2", 49 | "vitest": "^3.0.8" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/codegen/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @css-modules-kit/codegen 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - 385bdc3: refactor: change diagnostic interface 8 | - 2b1f0fe: feat: implement resolver cache 9 | - 2fde8ec: feat: format diagnostics and system errors by TypeScript Compiler API 10 | - bf7d0d8: feat: support `--pretty` option 11 | - 1512c07: feat!: drop support for `NODE_DISABLE_COLORS` and `FORCE_COLOR` 12 | - 9ce6d25: feat: print error cause of `SystemError` 13 | 14 | ### Patch Changes 15 | 16 | - b8c8198: fix: handle CLI argument parsing errors 17 | - 2bd2165: refactor: remove unused property of `Diagnostic` 18 | - 3772c14: fix: fix invalid `cause` object of `SystemError` 19 | - Updated dependencies [385bdc3] 20 | - Updated dependencies [2b1f0fe] 21 | - Updated dependencies [6ecc738] 22 | - Updated dependencies [2fde8ec] 23 | - Updated dependencies [819e023] 24 | - Updated dependencies [2bd2165] 25 | - @css-modules-kit/core@0.2.0 26 | 27 | ## 0.1.4 28 | 29 | ### Patch Changes 30 | 31 | - Updated dependencies [e899e5e] 32 | - @css-modules-kit/core@0.1.0 33 | 34 | ## 0.1.3 35 | 36 | ### Patch Changes 37 | 38 | - 7df2e70: Release test 39 | - Updated dependencies [7df2e70] 40 | - @css-modules-kit/core@0.0.5 41 | 42 | ## 0.1.2 43 | 44 | ### Patch Changes 45 | 46 | - 2eb908f: Resolve import specifiers taking into account `baseUrl` and `imports` 47 | - 984ae9e: Fix problem with `tsc` reporting an error with `--skipLibCheck=false` 48 | - Updated dependencies [2eb908f] 49 | - @css-modules-kit/core@0.0.4 50 | 51 | ## 0.1.1 52 | 53 | ### Patch Changes 54 | 55 | - 508b4b6: Retry publishing 56 | - Updated dependencies [508b4b6] 57 | - @css-modules-kit/core@0.0.3 58 | 59 | ## 0.1.0 60 | 61 | ### Minor Changes 62 | 63 | - 47b769c: Add `--help`/`--version`/`--project` options 64 | 65 | ### Patch Changes 66 | 67 | - fa1d6a9: refactor: remove unused codes 68 | - Updated dependencies [251ba5b] 69 | - Updated dependencies [fa1d6a9] 70 | - @css-modules-kit/core@0.0.2 71 | 72 | ## 0.0.1 73 | 74 | ### Patch Changes 75 | 76 | - 434f3da: first release 77 | - Updated dependencies [434f3da] 78 | - @css-modules-kit/core@0.0.1 79 | -------------------------------------------------------------------------------- /packages/codegen/README.md: -------------------------------------------------------------------------------- 1 | # `@css-modules-kit/codegen` 2 | 3 | A tool for generating `*.d.ts` files for `*.module.css`. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i -D @css-modules-kit/codegen 9 | ``` 10 | 11 | ## Requirements 12 | 13 | Set `cmkOptions.dtsOutDir` and `"."` to `rootDirs`. This is necessary for the `tsc` command to load the generated `*.d.ts` files. 14 | 15 | ```json 16 | { 17 | "compilerOptions": { 18 | "rootDirs": [".", "generated"] // Required 19 | }, 20 | "cmkOptions": { 21 | "dtsOutDir": "generated" // Default is `"generated"`, so it can be omitted 22 | } 23 | } 24 | ``` 25 | 26 | ## Usage 27 | 28 | From the command line, run the `cmk` command. 29 | 30 | ```bash 31 | $ # Generate .d.ts for .module.css 32 | $ npx cmk 33 | 34 | $ # Show help 35 | $ npx cmk --help 36 | Usage: cmk [options] 37 | 38 | Options: 39 | --help, -h Show help information 40 | --version, -v Show version number 41 | --project, -p The path to its configuration file, or to a folder with a 'tsconfig.json'. 42 | --pretty Enable color and formatting in output to make errors easier to read. 43 | ``` 44 | 45 | ## Configuration 46 | 47 | See [css-modules-kit's README](https://github.com/mizdra/css-modules-kit?tab=readme-ov-file#configuration). 48 | -------------------------------------------------------------------------------- /packages/codegen/bin/cmk.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable n/no-process-exit */ 3 | 4 | import { SystemError } from '@css-modules-kit/core'; 5 | import { createLogger, parseCLIArgs, printHelpText, printVersion, runCMK, shouldBePretty } from '../dist/index.js'; 6 | 7 | const cwd = process.cwd(); 8 | let logger = createLogger(cwd, shouldBePretty(undefined)); 9 | 10 | try { 11 | const args = parseCLIArgs(process.argv.slice(2), cwd); 12 | logger = createLogger(cwd, shouldBePretty(args.pretty)); 13 | 14 | if (args.help) { 15 | printHelpText(); 16 | process.exit(0); 17 | } else if (args.version) { 18 | printVersion(); 19 | process.exit(0); 20 | } 21 | 22 | await runCMK(args.project, logger); 23 | } catch (e) { 24 | if (e instanceof SystemError) { 25 | logger.logSystemError(e); 26 | process.exit(1); 27 | } 28 | throw e; 29 | } 30 | -------------------------------------------------------------------------------- /packages/codegen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-modules-kit/codegen", 3 | "description": "A tool for generating `*.d.ts` files for `*.module.css`.", 4 | "version": "0.2.0", 5 | "type": "commonjs", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/codegen" 11 | }, 12 | "author": "mizdra ", 13 | "license": "MIT", 14 | "private": false, 15 | "exports": { 16 | ".": { 17 | "default": { 18 | "types": "./dist/index.d.ts", 19 | "default": "./dist/index.js" 20 | } 21 | } 22 | }, 23 | "scripts": { 24 | "build": "tsc -b tsconfig.build.json" 25 | }, 26 | "engines": { 27 | "node": ">=22.0.0" 28 | }, 29 | "publishConfig": { 30 | "access": "public", 31 | "registry": "https://registry.npmjs.org/" 32 | }, 33 | "bin": { 34 | "cmk": "bin/cmk.mjs" 35 | }, 36 | "keywords": [ 37 | "css-modules", 38 | "typescript" 39 | ], 40 | "files": [ 41 | "bin", 42 | "src", 43 | "!src/**/*.test.ts", 44 | "!src/**/__snapshots__", 45 | "!src/test", 46 | "dist" 47 | ], 48 | "dependencies": { 49 | "@css-modules-kit/core": "^0.2.0" 50 | }, 51 | "peerDependencies": { 52 | "typescript": "^5.7.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/codegen/src/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/codegen/src/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from '@css-modules-kit/core'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { parseCLIArgs } from './cli.js'; 4 | import { ParseCLIArgsError } from './error.js'; 5 | 6 | const cwd = '/app'; 7 | 8 | describe('parseCLIArgs', () => { 9 | it('should return default values when no options are provided', () => { 10 | const args = parseCLIArgs([], cwd); 11 | expect(args).toStrictEqual({ 12 | help: false, 13 | version: false, 14 | project: resolve(cwd), 15 | pretty: undefined, 16 | }); 17 | }); 18 | it('should parse --help option', () => { 19 | expect(parseCLIArgs(['--help'], cwd).help).toBe(true); 20 | }); 21 | it('should parse --version option', () => { 22 | expect(parseCLIArgs(['--version'], cwd).version).toBe(true); 23 | }); 24 | describe('should parse --project option', () => { 25 | it.each([ 26 | [['--project', 'tsconfig.json'], resolve(cwd, 'tsconfig.json')], 27 | [['--project', 'tsconfig.base.json'], resolve(cwd, 'tsconfig.base.json')], 28 | [['--project', '.'], resolve(cwd)], 29 | [['--project', 'src'], resolve(cwd, 'src')], 30 | ])('%s %s', (argv, expected) => { 31 | const args = parseCLIArgs(argv, cwd); 32 | expect(args.project).toStrictEqual(expected); 33 | }); 34 | }); 35 | it('should parse --pretty option', () => { 36 | expect(parseCLIArgs(['--pretty'], cwd).pretty).toBe(true); 37 | expect(parseCLIArgs(['--no-pretty'], cwd).pretty).toBe(false); 38 | }); 39 | it('should throw ParseCLIArgsError for invalid options', () => { 40 | expect(() => parseCLIArgs(['--invalid-option'], cwd)).toThrow(ParseCLIArgsError); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/codegen/src/cli.ts: -------------------------------------------------------------------------------- 1 | import { parseArgs } from 'node:util'; 2 | import { resolve } from '@css-modules-kit/core'; 3 | import packageJson from '../package.json'; 4 | import { ParseCLIArgsError } from './error.js'; 5 | 6 | const helpText = ` 7 | Usage: cmk [options] 8 | 9 | Options: 10 | --help, -h Show help information 11 | --version, -v Show version number 12 | --project, -p The path to its configuration file, or to a folder with a 'tsconfig.json'. 13 | --pretty Enable color and formatting in output to make errors easier to read. 14 | `; 15 | 16 | export function printHelpText(): void { 17 | // eslint-disable-next-line no-console 18 | console.log(helpText); 19 | } 20 | 21 | export function printVersion(): void { 22 | // eslint-disable-next-line no-console 23 | console.log(packageJson.version); 24 | } 25 | 26 | export interface ParsedArgs { 27 | help: boolean; 28 | version: boolean; 29 | project: string; 30 | pretty: boolean | undefined; 31 | } 32 | 33 | /** 34 | * Parse command-line arguments. 35 | * @throws {ParseCLIArgsError} If failed to parse CLI arguments. 36 | */ 37 | export function parseCLIArgs(args: string[], cwd: string): ParsedArgs { 38 | try { 39 | const { values } = parseArgs({ 40 | args, 41 | options: { 42 | help: { type: 'boolean', short: 'h', default: false }, 43 | version: { type: 'boolean', short: 'v', default: false }, 44 | project: { type: 'string', short: 'p', default: '.' }, 45 | pretty: { type: 'boolean' }, 46 | }, 47 | allowNegative: true, 48 | }); 49 | return { 50 | help: values.help, 51 | version: values.version, 52 | project: resolve(cwd, values.project), 53 | pretty: values.pretty, 54 | }; 55 | } catch (cause) { 56 | throw new ParseCLIArgsError(cause); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/codegen/src/dts-writer.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from '@css-modules-kit/core'; 2 | import dedent from 'dedent'; 3 | import { describe, expect, test } from 'vitest'; 4 | import { getDtsFilePath, writeDtsFile } from './dts-writer.js'; 5 | import { WriteDtsFileError } from './error.js'; 6 | import { createIFF } from './test/fixture.js'; 7 | 8 | describe('getDtsFilePath', () => { 9 | const options = { basePath: '/app', outDir: '/app/dist', arbitraryExtensions: false }; 10 | test('cwd', () => { 11 | expect(getDtsFilePath('/app1/src/dir/a.module.css', { ...options, basePath: '/app1' })).toBe( 12 | resolve('/app/dist/src/dir/a.module.css.d.ts'), 13 | ); 14 | }); 15 | test('outDir', () => { 16 | expect(getDtsFilePath('/app/src/dir/a.module.css', { ...options, outDir: '/app/dist/dir' })).toBe( 17 | resolve('/app/dist/dir/src/dir/a.module.css.d.ts'), 18 | ); 19 | }); 20 | test('arbitraryExtensions', () => { 21 | expect(getDtsFilePath('/app/src/dir/a.module.css', { ...options, arbitraryExtensions: true })).toBe( 22 | resolve('/app/dist/src/dir/a.module.d.css.ts'), 23 | ); 24 | }); 25 | }); 26 | 27 | describe('writeDtsFile', () => { 28 | test('writes a d.ts file', async () => { 29 | const iff = await createIFF({}); 30 | await writeDtsFile( 31 | dedent` 32 | declare const styles: { local1: string }; 33 | export default styles; 34 | `, 35 | iff.join('src/a.module.css'), 36 | { 37 | outDir: iff.join('generated'), 38 | basePath: iff.rootDir, 39 | arbitraryExtensions: false, 40 | }, 41 | ); 42 | expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` 43 | "declare const styles: { local1: string }; 44 | export default styles;" 45 | `); 46 | }); 47 | test('throws an error when the file cannot be written', async () => { 48 | const iff = await createIFF({ 49 | // A directory exists at the same path 50 | 'generated/src/a.module.css.d.ts': {}, 51 | }); 52 | await expect( 53 | writeDtsFile( 54 | dedent` 55 | declare const styles: { local1: string }; 56 | export default styles; 57 | `, 58 | iff.join('src/a.module.css'), 59 | { 60 | outDir: iff.join('generated'), 61 | basePath: iff.rootDir, 62 | arbitraryExtensions: false, 63 | }, 64 | ), 65 | ).rejects.toThrow(WriteDtsFileError); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/codegen/src/dts-writer.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, writeFile } from 'node:fs/promises'; 2 | import { dirname, join, parse, relative, resolve } from '@css-modules-kit/core'; 3 | import { WriteDtsFileError } from './error.js'; 4 | 5 | /** 6 | * Get .d.ts file path. 7 | * @param cssModuleFileName The path to the CSS Module file (i.e. `/src/foo.module.css`). It is absolute. 8 | * @param options Output directory options 9 | * @returns The path to the .d.ts file. It is absolute. 10 | */ 11 | export function getDtsFilePath(cssModuleFileName: string, options: WriteDtsFileOption): string { 12 | const relativePath = relative(options.basePath, cssModuleFileName); 13 | const outputFilePath = resolve(options.outDir, relativePath); 14 | 15 | if (options.arbitraryExtensions) { 16 | const { dir, name, ext } = parse(outputFilePath); 17 | return join(dir, `${name}.d${ext}.ts`); 18 | } else { 19 | return `${outputFilePath}.d.ts`; 20 | } 21 | } 22 | 23 | export interface WriteDtsFileOption { 24 | /** Directory to write the d.ts file. This is an absolute path. */ 25 | outDir: string; 26 | basePath: string; 27 | /** Generate `.d.css.ts` instead of `.css.d.ts`. */ 28 | arbitraryExtensions: boolean; 29 | } 30 | 31 | /** 32 | * Write a d.ts file to the file system. 33 | * @param text The d.ts text to write. 34 | * @param cssModuleFileName The filename of the CSS module file. 35 | * @param options Options for writing the d.ts file. 36 | * @throws {WriteDtsFileError} When the file cannot be written. 37 | */ 38 | export async function writeDtsFile( 39 | text: string, 40 | cssModuleFileName: string, 41 | options: WriteDtsFileOption, 42 | ): Promise { 43 | const dtsFileName = getDtsFilePath(cssModuleFileName, options); 44 | try { 45 | await mkdir(dirname(dtsFileName), { recursive: true }); 46 | await writeFile(dtsFileName, text); 47 | } catch (error) { 48 | throw new WriteDtsFileError(dtsFileName, error); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/codegen/src/error.ts: -------------------------------------------------------------------------------- 1 | import { SystemError } from '@css-modules-kit/core'; 2 | 3 | export class ParseCLIArgsError extends SystemError { 4 | constructor(cause: unknown) { 5 | super('PARSE_CLI_ARGS_ERROR', `Failed to parse CLI arguments.`, cause); 6 | } 7 | } 8 | 9 | export class WriteDtsFileError extends SystemError { 10 | constructor(fileName: string, cause: unknown) { 11 | super('WRITE_DTS_FILE_ERROR', `Failed to write .d.ts file ${fileName}.`, cause); 12 | } 13 | } 14 | 15 | export class ReadCSSModuleFileError extends SystemError { 16 | constructor(fileName: string, cause: unknown) { 17 | super('READ_CSS_MODULE_FILE_ERROR', `Failed to read CSS Module file ${fileName}.`, cause); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/codegen/src/index.ts: -------------------------------------------------------------------------------- 1 | export { runCMK } from './runner.js'; 2 | export { type Logger, createLogger } from './logger/logger.js'; 3 | export { WriteDtsFileError, ReadCSSModuleFileError } from './error.js'; 4 | export { parseCLIArgs, printHelpText, printVersion } from './cli.js'; 5 | export { shouldBePretty } from './3rd-party/typescript.js'; 6 | -------------------------------------------------------------------------------- /packages/codegen/src/logger/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import ts from 'typescript'; 3 | import { describe, expect, it } from 'vitest'; 4 | import { formatDiagnostics } from './formatter'; 5 | 6 | describe('formatDiagnostics', () => { 7 | const file = ts.createSourceFile( 8 | '/app/test.module.css', 9 | dedent` 10 | .a_1 { color: red; } 11 | .a_2 { color: red; } 12 | `, 13 | ts.ScriptTarget.JSON, 14 | undefined, 15 | ts.ScriptKind.Unknown, 16 | ); 17 | const host: ts.FormatDiagnosticsHost = { 18 | getCurrentDirectory: () => '/app', 19 | getCanonicalFileName: (fileName) => fileName, 20 | getNewLine: () => '\n', 21 | }; 22 | const diagnostics: ts.Diagnostic[] = [ 23 | { 24 | file, 25 | start: 1, 26 | length: 3, 27 | messageText: '`a_1` is not allowed', 28 | category: ts.DiagnosticCategory.Error, 29 | code: 0, 30 | }, 31 | { 32 | file, 33 | start: 22, 34 | length: 3, 35 | messageText: '`a_2` is not allowed', 36 | category: ts.DiagnosticCategory.Error, 37 | code: 0, 38 | }, 39 | ]; 40 | 41 | it('formats diagnostics with color and context when pretty is true', () => { 42 | const result = formatDiagnostics(diagnostics, host, true); 43 | expect(result).toMatchInlineSnapshot(` 44 | "test.module.css:1:2 - error: \`a_1\` is not allowed 45 | 46 | 1 .a_1 { color: red; } 47 |    ~~~ 48 | 49 | test.module.css:2:2 - error: \`a_2\` is not allowed 50 | 51 | 2 .a_2 { color: red; } 52 |    ~~~ 53 | 54 | " 55 | `); 56 | }); 57 | 58 | it('formats diagnostics without color and context when pretty is false', () => { 59 | const result = formatDiagnostics(diagnostics, host, false); 60 | expect(result).toMatchInlineSnapshot(` 61 | "test.module.css(1,2): error: \`a_1\` is not allowed 62 | 63 | test.module.css(2,2): error: \`a_2\` is not allowed 64 | 65 | " 66 | `); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/codegen/src/logger/formatter.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | export function formatDiagnostics( 4 | diagnostics: ts.Diagnostic[], 5 | host: ts.FormatDiagnosticsHost, 6 | pretty: boolean, 7 | ): string { 8 | const format = pretty ? ts.formatDiagnosticsWithColorAndContext : ts.formatDiagnostics; 9 | let result = ''; 10 | for (const diagnostic of diagnostics) { 11 | result += format([diagnostic], host).replace(` TS${diagnostic.code}`, '') + host.getNewLine(); 12 | } 13 | return result; 14 | } 15 | -------------------------------------------------------------------------------- /packages/codegen/src/logger/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { stripVTControlCharacters } from 'node:util'; 2 | import { type Diagnostic, SystemError } from '@css-modules-kit/core'; 3 | import { describe, expect, test, vi } from 'vitest'; 4 | import { ReadCSSModuleFileError } from '../error.js'; 5 | import { createLogger } from './logger.js'; 6 | 7 | const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); 8 | const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); 9 | 10 | const cwd = '/app'; 11 | 12 | describe('createLogger', () => { 13 | test('logDiagnostics', () => { 14 | const logger = createLogger(cwd, false); 15 | const diagnostics: Diagnostic[] = [ 16 | { text: 'text1', category: 'error' }, 17 | { text: 'text2', category: 'error' }, 18 | { 19 | text: 'text3', 20 | category: 'error', 21 | file: { fileName: '/app/a.module.css', text: '.foo {}' }, 22 | start: { line: 1, column: 2 }, 23 | length: 3, 24 | }, 25 | ]; 26 | logger.logDiagnostics(diagnostics); 27 | expect(stripVTControlCharacters(stderrWriteSpy.mock.lastCall![0] as string)).toMatchInlineSnapshot(` 28 | "error: text1 29 | 30 | error: text2 31 | 32 | a.module.css(1,2): error: text3 33 | 34 | " 35 | `); 36 | }); 37 | test('logSystemError', () => { 38 | const logger = createLogger(cwd, false); 39 | logger.logSystemError(new SystemError('CODE', 'message')); 40 | expect(stripVTControlCharacters(stderrWriteSpy.mock.lastCall![0] as string)).toMatchInlineSnapshot(` 41 | "error: message 42 | 43 | " 44 | `); 45 | logger.logSystemError( 46 | new ReadCSSModuleFileError('/app/a.module.css', new Error('EACCES: permission denied, open ...')), 47 | ); 48 | expect(stripVTControlCharacters(stderrWriteSpy.mock.lastCall![0] as string)).toMatchInlineSnapshot(` 49 | "error: Failed to read CSS Module file /app/a.module.css.: EACCES: permission denied, open ... 50 | 51 | " 52 | `); 53 | }); 54 | test('logMessage', () => { 55 | const logger = createLogger(cwd, false); 56 | logger.logMessage('message'); 57 | expect(stdoutWriteSpy).toHaveBeenCalledWith('message\n'); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/codegen/src/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import type { DiagnosticSourceFile } from '@css-modules-kit/core'; 2 | import { convertDiagnostic, convertSystemError, type Diagnostic, type SystemError } from '@css-modules-kit/core'; 3 | import ts from 'typescript'; 4 | import { formatDiagnostics } from './formatter.js'; 5 | 6 | export interface Logger { 7 | logDiagnostics(diagnostics: Diagnostic[]): void; 8 | logSystemError(error: SystemError): void; 9 | logMessage(message: string): void; 10 | } 11 | 12 | export function createLogger(cwd: string, pretty: boolean): Logger { 13 | const host: ts.FormatDiagnosticsHost = { 14 | getCurrentDirectory: () => cwd, 15 | getCanonicalFileName: (fileName) => (ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase()), 16 | getNewLine: () => ts.sys.newLine, 17 | }; 18 | 19 | function getSourceFile(file: DiagnosticSourceFile): ts.SourceFile { 20 | return ts.createSourceFile(file.fileName, file.text, ts.ScriptTarget.JSON, undefined, ts.ScriptKind.Unknown); 21 | } 22 | 23 | return { 24 | logDiagnostics(diagnostics: Diagnostic[]): void { 25 | const result = formatDiagnostics( 26 | diagnostics.map((d) => convertDiagnostic(d, getSourceFile)), 27 | host, 28 | pretty, 29 | ); 30 | process.stderr.write(result); 31 | }, 32 | logSystemError(error: SystemError): void { 33 | const result = formatDiagnostics([convertSystemError(error)], host, pretty); 34 | process.stderr.write(result); 35 | }, 36 | logMessage(message: string): void { 37 | process.stdout.write(`${message}\n`); 38 | }, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/codegen/src/runner.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import type { 3 | CMKConfig, 4 | CSSModule, 5 | Diagnostic, 6 | DiagnosticWithLocation, 7 | MatchesPattern, 8 | ParseCSSModuleResult, 9 | Resolver, 10 | } from '@css-modules-kit/core'; 11 | import { 12 | checkCSSModule, 13 | createDts, 14 | createExportBuilder, 15 | createMatchesPattern, 16 | createResolver, 17 | getFileNamesByPattern, 18 | parseCSSModule, 19 | readConfigFile, 20 | } from '@css-modules-kit/core'; 21 | import ts from 'typescript'; 22 | import { writeDtsFile } from './dts-writer.js'; 23 | import { ReadCSSModuleFileError } from './error.js'; 24 | import type { Logger } from './logger/logger.js'; 25 | 26 | /** 27 | * @throws {ReadCSSModuleFileError} When failed to read CSS Module file. 28 | */ 29 | async function parseCSSModuleByFileName(fileName: string): Promise { 30 | let text: string; 31 | try { 32 | text = await readFile(fileName, 'utf-8'); 33 | } catch (error) { 34 | throw new ReadCSSModuleFileError(fileName, error); 35 | } 36 | return parseCSSModule(text, { fileName, safe: false }); 37 | } 38 | 39 | /** 40 | * @throws {WriteDtsFileError} 41 | */ 42 | async function writeDtsByCSSModule( 43 | cssModule: CSSModule, 44 | { dtsOutDir, basePath, arbitraryExtensions }: CMKConfig, 45 | resolver: Resolver, 46 | matchesPattern: MatchesPattern, 47 | ): Promise { 48 | const dts = createDts(cssModule, { resolver, matchesPattern }); 49 | await writeDtsFile(dts.text, cssModule.fileName, { 50 | outDir: dtsOutDir, 51 | basePath, 52 | arbitraryExtensions, 53 | }); 54 | } 55 | 56 | /** 57 | * Run css-modules-kit .d.ts generation. 58 | * @param project The absolute path to the project directory or the path to `tsconfig.json`. 59 | * @throws {ReadCSSModuleFileError} When failed to read CSS Module file. 60 | * @throws {WriteDtsFileError} 61 | */ 62 | export async function runCMK(project: string, logger: Logger): Promise { 63 | const config = readConfigFile(project); 64 | if (config.diagnostics.length > 0) { 65 | logger.logDiagnostics(config.diagnostics); 66 | // eslint-disable-next-line n/no-process-exit 67 | process.exit(1); 68 | } 69 | 70 | const getCanonicalFileName = (fileName: string) => 71 | ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(); 72 | const moduleResolutionCache = ts.createModuleResolutionCache( 73 | config.basePath, 74 | getCanonicalFileName, 75 | config.compilerOptions, 76 | ); 77 | const resolver = createResolver(config.compilerOptions, moduleResolutionCache); 78 | const matchesPattern = createMatchesPattern(config); 79 | 80 | const cssModuleMap = new Map(); 81 | const syntacticDiagnostics: DiagnosticWithLocation[] = []; 82 | 83 | const fileNames = getFileNamesByPattern(config); 84 | if (fileNames.length === 0) { 85 | logger.logDiagnostics([ 86 | { 87 | category: 'warning', 88 | text: `The file specified in tsconfig.json not found.`, 89 | }, 90 | ]); 91 | return; 92 | } 93 | const parseResults = await Promise.all(fileNames.map(async (fileName) => parseCSSModuleByFileName(fileName))); 94 | for (const parseResult of parseResults) { 95 | cssModuleMap.set(parseResult.cssModule.fileName, parseResult.cssModule); 96 | syntacticDiagnostics.push(...parseResult.diagnostics); 97 | } 98 | 99 | if (syntacticDiagnostics.length > 0) { 100 | logger.logDiagnostics(syntacticDiagnostics); 101 | // eslint-disable-next-line n/no-process-exit 102 | process.exit(1); 103 | } 104 | 105 | const getCSSModule = (path: string) => cssModuleMap.get(path); 106 | const exportBuilder = createExportBuilder({ getCSSModule, matchesPattern, resolver }); 107 | const semanticDiagnostics: Diagnostic[] = []; 108 | for (const { cssModule } of parseResults) { 109 | const diagnostics = checkCSSModule(cssModule, exportBuilder, matchesPattern, resolver, getCSSModule); 110 | semanticDiagnostics.push(...diagnostics); 111 | } 112 | 113 | if (semanticDiagnostics.length > 0) { 114 | logger.logDiagnostics(semanticDiagnostics); 115 | // eslint-disable-next-line n/no-process-exit 116 | process.exit(1); 117 | } 118 | 119 | await Promise.all( 120 | parseResults.map(async (parseResult) => 121 | writeDtsByCSSModule(parseResult.cssModule, config, resolver, matchesPattern), 122 | ), 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /packages/codegen/src/test/fixture.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { join } from '@css-modules-kit/core'; 4 | import { defineIFFCreator } from '@mizdra/inline-fixture-files'; 5 | 6 | const fixtureDir = join(tmpdir(), 'css-modules-kit', process.env['VITEST_POOL_ID']!); 7 | export const createIFF = defineIFFCreator({ 8 | generateRootDir: () => join(fixtureDir, randomUUID()), 9 | unixStylePath: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/codegen/src/test/logger.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | import type { Logger } from '../logger/logger.js'; 3 | 4 | export function createLoggerSpy() { 5 | return { 6 | logDiagnostics: vi.fn(), 7 | logSystemError: vi.fn(), 8 | logMessage: vi.fn(), 9 | } satisfies Logger; 10 | } 11 | -------------------------------------------------------------------------------- /packages/codegen/src/test/process.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export class ProcessExitError extends Error { 4 | exitCode: string | number | null | undefined; 5 | constructor(exitCode: string | number | null | undefined) { 6 | super(); 7 | this.exitCode = exitCode; 8 | } 9 | } 10 | 11 | export function mockProcessExit() { 12 | vi.spyOn(process, 'exit').mockImplementation((code) => { 13 | throw new ProcessExitError(code); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /packages/codegen/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | }, 16 | "references": [{ "path": "../core/tsconfig.build.json" }] 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @css-modules-kit/core 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - 385bdc3: refactor: change diagnostic interface 8 | - 2b1f0fe: feat: implement resolver cache 9 | - 6ecc738: refactor: move types to `type.ts` 10 | - 2fde8ec: feat: format diagnostics and system errors by TypeScript Compiler API 11 | - 819e023: feat: show source of diagnostic 12 | 13 | ### Patch Changes 14 | 15 | - 2bd2165: refactor: remove unused property of `Diagnostic` 16 | 17 | ## 0.1.0 18 | 19 | ### Minor Changes 20 | 21 | - e899e5e: refactor: move `findUsedTokenNames` to core 22 | 23 | ## 0.0.5 24 | 25 | ### Patch Changes 26 | 27 | - 7df2e70: Release test 28 | 29 | ## 0.0.4 30 | 31 | ### Patch Changes 32 | 33 | - 2eb908f: Resolve import specifiers taking into account `baseUrl` and `imports` 34 | 35 | ## 0.0.3 36 | 37 | ### Patch Changes 38 | 39 | - 508b4b6: Retry publishing 40 | 41 | ## 0.0.2 42 | 43 | ### Patch Changes 44 | 45 | - 251ba5b: Fix infinite loop when the module graph has circular dependencies 46 | - fa1d6a9: refactor: remove unused codes 47 | 48 | ## 0.0.1 49 | 50 | ### Patch Changes 51 | 52 | - 434f3da: first release 53 | -------------------------------------------------------------------------------- /packages/core/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-modules-kit/core", 3 | "description": "The core of css-modules-kit", 4 | "version": "0.2.0", 5 | "type": "commonjs", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/core" 11 | }, 12 | "author": "mizdra ", 13 | "license": "MIT", 14 | "private": false, 15 | "exports": { 16 | ".": { 17 | "default": { 18 | "types": "./dist/index.d.ts", 19 | "default": "./dist/index.js" 20 | } 21 | } 22 | }, 23 | "scripts": { 24 | "build": "tsc -b tsconfig.build.json" 25 | }, 26 | "engines": { 27 | "node": ">=22.0.0" 28 | }, 29 | "publishConfig": { 30 | "access": "public", 31 | "registry": "https://registry.npmjs.org/" 32 | }, 33 | "keywords": [ 34 | "css-modules", 35 | "typescript" 36 | ], 37 | "files": [ 38 | "bin", 39 | "src", 40 | "!src/**/*.test.ts", 41 | "!src/**/__snapshots__", 42 | "!src/test", 43 | "dist" 44 | ], 45 | "dependencies": { 46 | "postcss": "^8.4.49", 47 | "postcss-safe-parser": "^7.0.1", 48 | "postcss-selector-parser": "^7.1.0", 49 | "postcss-value-parser": "^4.2.0" 50 | }, 51 | "peerDependencies": { 52 | "typescript": "^5.7.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/core/src/checker.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { checkCSSModule } from './checker.js'; 3 | import { createResolver } from './resolver.js'; 4 | import { fakeCSSModule } from './test/css-module.js'; 5 | import { fakeAtImportTokenImporter, fakeAtValueTokenImporter } from './test/token.js'; 6 | import type { ExportBuilder } from './type.js'; 7 | 8 | const resolver = createResolver({}, undefined); 9 | 10 | describe('checkCSSModule', () => { 11 | test('report diagnostics for non-existing module', () => { 12 | const cssModule = fakeCSSModule({ 13 | fileName: '/a.module.css', 14 | tokenImporters: [ 15 | fakeAtImportTokenImporter('./b.module.css'), 16 | fakeAtValueTokenImporter('./c.module.css', ['c_1']), 17 | ], 18 | }); 19 | const exportBuilder: ExportBuilder = { 20 | build: () => ({ allTokens: [] }), 21 | clearCache: () => {}, 22 | }; 23 | const matchesPattern = () => true; 24 | const getCSSModule = () => undefined; 25 | const diagnostics = checkCSSModule(cssModule, exportBuilder, matchesPattern, resolver, getCSSModule); 26 | expect(diagnostics).toMatchInlineSnapshot(` 27 | [ 28 | { 29 | "category": "error", 30 | "file": { 31 | "fileName": "/a.module.css", 32 | "text": "", 33 | }, 34 | "length": 0, 35 | "start": { 36 | "column": 1, 37 | "line": 1, 38 | }, 39 | "text": "Cannot import module './b.module.css'", 40 | }, 41 | { 42 | "category": "error", 43 | "file": { 44 | "fileName": "/a.module.css", 45 | "text": "", 46 | }, 47 | "length": 0, 48 | "start": { 49 | "column": 1, 50 | "line": 1, 51 | }, 52 | "text": "Cannot import module './c.module.css'", 53 | }, 54 | ] 55 | `); 56 | }); 57 | test('report diagnostics for non-exported token', () => { 58 | const cssModule = fakeCSSModule({ 59 | fileName: '/a.module.css', 60 | tokenImporters: [fakeAtValueTokenImporter('./b.module.css', ['b_1', 'b_2'])], 61 | }); 62 | const exportBuilder: ExportBuilder = { 63 | build: () => ({ allTokens: ['b_1'] }), 64 | clearCache: () => {}, 65 | }; 66 | const matchesPattern = () => true; 67 | const getCSSModule = () => cssModule; 68 | const diagnostics = checkCSSModule(cssModule, exportBuilder, matchesPattern, resolver, getCSSModule); 69 | expect(diagnostics).toMatchInlineSnapshot(` 70 | [ 71 | { 72 | "category": "error", 73 | "file": { 74 | "fileName": "/a.module.css", 75 | "text": "", 76 | }, 77 | "length": 0, 78 | "start": { 79 | "column": 1, 80 | "line": 1, 81 | }, 82 | "text": "Module './b.module.css' has no exported token 'b_2'.", 83 | }, 84 | ] 85 | `); 86 | }); 87 | test('ignore token importers for unresolvable modules', () => { 88 | const cssModule = fakeCSSModule({ 89 | fileName: '/a.module.css', 90 | tokenImporters: [fakeAtImportTokenImporter('./unresolvable.module.css')], 91 | }); 92 | const exportBuilder: ExportBuilder = { 93 | build: () => ({ allTokens: [] }), 94 | clearCache: () => {}, 95 | }; 96 | const matchesPattern = () => true; 97 | const resolver = () => undefined; 98 | const getCSSModule = () => undefined; 99 | const diagnostics = checkCSSModule(cssModule, exportBuilder, matchesPattern, resolver, getCSSModule); 100 | expect(diagnostics).toEqual([]); 101 | }); 102 | test('ignore token importers that do not match the pattern', () => { 103 | const cssModule = fakeCSSModule({ 104 | fileName: '/a.module.css', 105 | tokenImporters: [ 106 | fakeAtImportTokenImporter('./b.module.css'), 107 | fakeAtValueTokenImporter('./c.module.css', ['c_1']), 108 | ], 109 | }); 110 | const exportBuilder: ExportBuilder = { 111 | build: () => ({ allTokens: [] }), 112 | clearCache: () => {}, 113 | }; 114 | const matchesPattern = () => false; 115 | const getCSSModule = () => undefined; 116 | const diagnostics = checkCSSModule(cssModule, exportBuilder, matchesPattern, resolver, getCSSModule); 117 | expect(diagnostics).toEqual([]); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/core/src/checker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AtValueTokenImporter, 3 | AtValueTokenImporterValue, 4 | CSSModule, 5 | Diagnostic, 6 | ExportBuilder, 7 | MatchesPattern, 8 | Resolver, 9 | TokenImporter, 10 | } from './type.js'; 11 | 12 | export function checkCSSModule( 13 | cssModule: CSSModule, 14 | exportBuilder: ExportBuilder, 15 | matchesPattern: MatchesPattern, 16 | resolver: Resolver, 17 | getCSSModule: (path: string) => CSSModule | undefined, 18 | ): Diagnostic[] { 19 | const diagnostics: Diagnostic[] = []; 20 | 21 | for (const tokenImporter of cssModule.tokenImporters) { 22 | const from = resolver(tokenImporter.from, { request: cssModule.fileName }); 23 | if (!from || !matchesPattern(from)) continue; 24 | const imported = getCSSModule(from); 25 | if (!imported) { 26 | diagnostics.push(createCannotImportModuleDiagnostic(cssModule, tokenImporter)); 27 | continue; 28 | } 29 | 30 | if (tokenImporter.type === 'value') { 31 | const exportRecord = exportBuilder.build(imported); 32 | for (const value of tokenImporter.values) { 33 | if (!exportRecord.allTokens.includes(value.name)) { 34 | diagnostics.push(createModuleHasNoExportedTokenDiagnostic(cssModule, tokenImporter, value)); 35 | } 36 | } 37 | } 38 | } 39 | return diagnostics; 40 | } 41 | 42 | function createCannotImportModuleDiagnostic(cssModule: CSSModule, tokenImporter: TokenImporter): Diagnostic { 43 | return { 44 | text: `Cannot import module '${tokenImporter.from}'`, 45 | category: 'error', 46 | file: { fileName: cssModule.fileName, text: cssModule.text }, 47 | start: { line: tokenImporter.fromLoc.start.line, column: tokenImporter.fromLoc.start.column }, 48 | length: tokenImporter.fromLoc.end.offset - tokenImporter.fromLoc.start.offset, 49 | }; 50 | } 51 | 52 | function createModuleHasNoExportedTokenDiagnostic( 53 | cssModule: CSSModule, 54 | tokenImporter: AtValueTokenImporter, 55 | value: AtValueTokenImporterValue, 56 | ): Diagnostic { 57 | return { 58 | text: `Module '${tokenImporter.from}' has no exported token '${value.name}'.`, 59 | category: 'error', 60 | file: { fileName: cssModule.fileName, text: cssModule.text }, 61 | start: { line: value.loc.start.line, column: value.loc.start.column }, 62 | length: value.loc.end.offset - value.loc.start.offset, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /packages/core/src/diagnostic.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { SystemError } from './error.js'; 3 | import type { Diagnostic, DiagnosticSourceFile, DiagnosticWithLocation } from './type.js'; 4 | 5 | /** The error code used by tsserver to display the css-modules-kit error in the editor. */ 6 | const TS_ERROR_CODE = 0; 7 | 8 | const TS_ERROR_SOURCE = 'css-modules-kit'; 9 | 10 | function convertErrorCategory(category: 'error' | 'warning' | 'suggestion'): ts.DiagnosticCategory { 11 | switch (category) { 12 | case 'error': 13 | return ts.DiagnosticCategory.Error; 14 | case 'warning': 15 | return ts.DiagnosticCategory.Warning; 16 | case 'suggestion': 17 | return ts.DiagnosticCategory.Suggestion; 18 | default: 19 | throw new Error(`Unknown category: ${String(category)}`); 20 | } 21 | } 22 | 23 | export function convertDiagnostic( 24 | diagnostic: Diagnostic, 25 | getSourceFile: (file: DiagnosticSourceFile) => ts.SourceFile, 26 | ): ts.Diagnostic { 27 | if ('file' in diagnostic) { 28 | return convertDiagnosticWithLocation(diagnostic, getSourceFile); 29 | } else { 30 | return { 31 | file: undefined, 32 | start: undefined, 33 | length: undefined, 34 | category: convertErrorCategory(diagnostic.category), 35 | messageText: diagnostic.text, 36 | code: TS_ERROR_CODE, 37 | source: TS_ERROR_SOURCE, 38 | }; 39 | } 40 | } 41 | 42 | export function convertDiagnosticWithLocation( 43 | diagnostic: DiagnosticWithLocation, 44 | getSourceFile: (file: DiagnosticSourceFile) => ts.SourceFile, 45 | ): ts.DiagnosticWithLocation { 46 | const sourceFile = getSourceFile(diagnostic.file); 47 | const start = ts.getPositionOfLineAndCharacter(sourceFile, diagnostic.start.line - 1, diagnostic.start.column - 1); 48 | return { 49 | file: sourceFile, 50 | start, 51 | length: diagnostic.length, 52 | category: convertErrorCategory(diagnostic.category), 53 | messageText: diagnostic.text, 54 | code: TS_ERROR_CODE, 55 | source: TS_ERROR_SOURCE, 56 | }; 57 | } 58 | 59 | export function convertSystemError(systemError: SystemError): ts.Diagnostic { 60 | let messageText = systemError.message; 61 | if (systemError.cause) { 62 | if (systemError.cause instanceof Error) { 63 | messageText += `: ${systemError.cause.message}`; 64 | } else { 65 | messageText += `: ${JSON.stringify(systemError.cause)}`; 66 | } 67 | } 68 | return { 69 | file: undefined, 70 | start: undefined, 71 | length: undefined, 72 | category: ts.DiagnosticCategory.Error, 73 | messageText, 74 | code: TS_ERROR_CODE, 75 | source: TS_ERROR_SOURCE, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /packages/core/src/error.ts: -------------------------------------------------------------------------------- 1 | export class SystemError extends Error { 2 | code: string; 3 | constructor(code: string, message: string, cause?: unknown) { 4 | super(message, { cause }); 5 | this.code = code; 6 | } 7 | } 8 | 9 | export class TsConfigFileNotFoundError extends SystemError { 10 | constructor() { 11 | super('TS_CONFIG_NOT_FOUND', 'No tsconfig.json found.'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/export-builder.ts: -------------------------------------------------------------------------------- 1 | import type { CSSModule, ExportBuilder, ExportRecord, MatchesPattern, Resolver } from './type.js'; 2 | 3 | export interface ExportBuilderHost { 4 | matchesPattern: MatchesPattern; 5 | getCSSModule: (path: string) => CSSModule | undefined; 6 | resolver: Resolver; 7 | } 8 | 9 | /** 10 | * A builder for exported token records of CSS modules. 11 | */ 12 | // TODO: Handle same token name from different modules 13 | export function createExportBuilder(host: ExportBuilderHost): ExportBuilder { 14 | const cache = new Map(); 15 | 16 | function build(cssModule: CSSModule): ExportRecord { 17 | const cached = cache.get(cssModule.fileName); 18 | if (cached) return cached; 19 | 20 | // Set an empty record to prevent infinite loop 21 | // when the module graph has circular dependencies. 22 | cache.set(cssModule.fileName, { allTokens: [] }); 23 | 24 | const result: ExportRecord = { allTokens: [...cssModule.localTokens.map((t) => t.name)] }; 25 | 26 | for (const tokenImporter of cssModule.tokenImporters) { 27 | const from = host.resolver(tokenImporter.from, { request: cssModule.fileName }); 28 | if (!from || !host.matchesPattern(from)) continue; 29 | const imported = host.getCSSModule(from); 30 | if (!imported) continue; 31 | 32 | const importedResult = build(imported); 33 | if (tokenImporter.type === 'import') { 34 | result.allTokens.push(...importedResult.allTokens); 35 | } else { 36 | for (const value of tokenImporter.values) { 37 | result.allTokens.push(value.name); 38 | } 39 | } 40 | } 41 | cache.set(cssModule.fileName, result); 42 | return result; 43 | } 44 | function clearCache() { 45 | cache.clear(); 46 | } 47 | return { build, clearCache }; 48 | } 49 | -------------------------------------------------------------------------------- /packages/core/src/file.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import { join, parse } from './path.js'; 3 | import type { MatchesPattern } from './type.js'; 4 | 5 | export const CSS_MODULE_EXTENSION = '.module.css'; 6 | const COMPONENT_EXTENSIONS = ['.tsx', '.jsx']; 7 | 8 | export function isCSSModuleFile(fileName: string): boolean { 9 | return fileName.endsWith(CSS_MODULE_EXTENSION); 10 | } 11 | 12 | export function getCssModuleFileName(tsFileName: string): string { 13 | const { dir, name } = parse(tsFileName); 14 | return join(dir, `${name}${CSS_MODULE_EXTENSION}`); 15 | } 16 | 17 | export function isComponentFileName(fileName: string): boolean { 18 | // NOTE: Do not check whether it is an upper camel case or not, since lower camel case (e.g. `page.tsx`) is used in Next.js. 19 | return COMPONENT_EXTENSIONS.some((ext) => fileName.endsWith(ext)); 20 | } 21 | 22 | export async function findComponentFile( 23 | cssModuleFileName: string, 24 | readFile: (path: string) => Promise, 25 | ): Promise<{ fileName: string; text: string } | undefined> { 26 | const pathWithoutExtension = cssModuleFileName.slice(0, -CSS_MODULE_EXTENSION.length); 27 | for (const path of COMPONENT_EXTENSIONS.map((ext) => pathWithoutExtension + ext)) { 28 | try { 29 | // TODO: Cache the result of readFile 30 | // eslint-disable-next-line no-await-in-loop 31 | const text = await readFile(path); 32 | return { fileName: path, text }; 33 | } catch { 34 | continue; 35 | } 36 | } 37 | return undefined; 38 | } 39 | 40 | export function findComponentFileSync( 41 | cssModuleFileName: string, 42 | readFileSync: (path: string) => string, 43 | ): { fileName: string; text: string } | undefined { 44 | const pathWithoutExtension = cssModuleFileName.slice(0, -CSS_MODULE_EXTENSION.length); 45 | for (const path of COMPONENT_EXTENSIONS.map((ext) => pathWithoutExtension + ext)) { 46 | try { 47 | // TODO: Cache the result of readFile 48 | const text = readFileSync(path); 49 | return { fileName: path, text }; 50 | } catch { 51 | continue; 52 | } 53 | } 54 | return undefined; 55 | } 56 | 57 | /** 58 | * Create a function that checks whether the given file name matches the pattern. 59 | * This does not access the file system. 60 | * @param options 61 | * @returns 62 | */ 63 | export function createMatchesPattern(options: { includes: string[]; excludes: string[] }): MatchesPattern { 64 | // Setup utilities to check for pattern matches without accessing the file system 65 | const realpath = (path: string) => path; 66 | const getFileSystemEntries = (path: string): ts.FileSystemEntries => { 67 | return { 68 | files: [path], 69 | directories: [], 70 | }; 71 | }; 72 | 73 | return (fileName: string) => { 74 | const matchedFileNames = ts.matchFiles( 75 | fileName, 76 | [CSS_MODULE_EXTENSION], 77 | options.excludes, 78 | options.includes, 79 | ts.sys.useCaseSensitiveFileNames, 80 | '', // `fileName`, `includes`, and `excludes` are absolute paths, so `currentDirectory` is not needed. 81 | undefined, 82 | getFileSystemEntries, 83 | realpath, 84 | ); 85 | return matchedFileNames.length > 0; 86 | }; 87 | } 88 | 89 | /** 90 | * Get files matched by the pattern. 91 | */ 92 | export function getFileNamesByPattern(options: { basePath: string; includes: string[]; excludes: string[] }): string[] { 93 | // ref: https://github.com/microsoft/TypeScript/blob/caf1aee269d1660b4d2a8b555c2d602c97cb28d7/src/compiler/commandLineParser.ts#L3929 94 | 95 | // MEMO: `ts.sys.readDirectory` catch errors internally. So we don't need to wrap with try-catch. 96 | // https://github.com/microsoft/TypeScript/blob/caf1aee269d1660b4d2a8b555c2d602c97cb28d7/src/compiler/sys.ts#L1877-L1879 97 | 98 | return ts.sys.readDirectory(options.basePath, [CSS_MODULE_EXTENSION], options.excludes, options.includes); 99 | } 100 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { CMKConfig } from './config.js'; 2 | export { readConfigFile } from './config.js'; 3 | export { TsConfigFileNotFoundError, SystemError } from './error.js'; 4 | export { parseCSSModule, type ParseCSSModuleOptions, type ParseCSSModuleResult } from './parser/css-module-parser.js'; 5 | export { parseRule } from './parser/rule-parser.js'; 6 | export { 7 | type Location, 8 | type Position, 9 | type CSSModule, 10 | type Token, 11 | type AtImportTokenImporter, 12 | type TokenImporter, 13 | type AtValueTokenImporter, 14 | type AtValueTokenImporterValue, 15 | type Resolver, 16 | type MatchesPattern, 17 | type ExportBuilder, 18 | type DiagnosticSourceFile, 19 | type Diagnostic, 20 | type DiagnosticWithLocation, 21 | type DiagnosticCategory, 22 | type DiagnosticPosition, 23 | } from './type.js'; 24 | export { type CreateDtsOptions, createDts, STYLES_EXPORT_NAME } from './dts-creator.js'; 25 | export { createResolver } from './resolver.js'; 26 | export { 27 | CSS_MODULE_EXTENSION, 28 | getCssModuleFileName, 29 | isComponentFileName, 30 | isCSSModuleFile, 31 | findComponentFile, 32 | findComponentFileSync, 33 | createMatchesPattern, 34 | getFileNamesByPattern, 35 | } from './file.js'; 36 | export { checkCSSModule } from './checker.js'; 37 | export { createExportBuilder } from './export-builder.js'; 38 | export { join, resolve, relative, dirname, basename, parse, matchesGlob, isAbsolute } from './path.js'; 39 | export { findUsedTokenNames } from './util.js'; 40 | export { convertDiagnostic, convertDiagnosticWithLocation, convertSystemError } from './diagnostic.js'; 41 | -------------------------------------------------------------------------------- /packages/core/src/parser/at-import-parser.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { expect, test } from 'vitest'; 3 | import { fakeAtImports, fakeRoot } from '../test/ast.js'; 4 | import { parseAtImport } from './at-import-parser.js'; 5 | 6 | test('parseAtImport', () => { 7 | const atImports = fakeAtImports( 8 | fakeRoot(dedent` 9 | @import; 10 | @import "test.css"; 11 | @import url("test.css"); 12 | @import url(test.css); 13 | @import "test.css" print; 14 | `), 15 | ); 16 | expect(atImports.map(parseAtImport)).toMatchInlineSnapshot(` 17 | [ 18 | undefined, 19 | { 20 | "from": "test.css", 21 | "fromLoc": { 22 | "end": { 23 | "column": 18, 24 | "line": 2, 25 | "offset": 26, 26 | }, 27 | "start": { 28 | "column": 10, 29 | "line": 2, 30 | "offset": 18, 31 | }, 32 | }, 33 | }, 34 | { 35 | "from": "test.css", 36 | "fromLoc": { 37 | "end": { 38 | "column": 22, 39 | "line": 3, 40 | "offset": 50, 41 | }, 42 | "start": { 43 | "column": 14, 44 | "line": 3, 45 | "offset": 42, 46 | }, 47 | }, 48 | }, 49 | { 50 | "from": "test.css", 51 | "fromLoc": { 52 | "end": { 53 | "column": 21, 54 | "line": 4, 55 | "offset": 74, 56 | }, 57 | "start": { 58 | "column": 13, 59 | "line": 4, 60 | "offset": 66, 61 | }, 62 | }, 63 | }, 64 | { 65 | "from": "test.css", 66 | "fromLoc": { 67 | "end": { 68 | "column": 18, 69 | "line": 5, 70 | "offset": 94, 71 | }, 72 | "start": { 73 | "column": 10, 74 | "line": 5, 75 | "offset": 86, 76 | }, 77 | }, 78 | }, 79 | ] 80 | `); 81 | }); 82 | -------------------------------------------------------------------------------- /packages/core/src/parser/at-import-parser.ts: -------------------------------------------------------------------------------- 1 | import type { AtRule } from 'postcss'; 2 | import postcssValueParser from 'postcss-value-parser'; 3 | import type { Location } from '../type.js'; 4 | 5 | interface ParsedAtImport { 6 | from: string; 7 | fromLoc: Location; 8 | } 9 | 10 | /** 11 | * Parse the `@import` rule. 12 | * @param atImport The `@import` rule to parse. 13 | * @returns The specifier of the imported file. 14 | */ 15 | export function parseAtImport(atImport: AtRule): ParsedAtImport | undefined { 16 | const firstNode = postcssValueParser(atImport.params).nodes[0]; 17 | if (firstNode === undefined) return undefined; 18 | if (firstNode.type === 'string') return convertParsedAtImport(atImport, firstNode); 19 | if (firstNode.type === 'function' && firstNode.value === 'url') { 20 | if (firstNode.nodes[0] === undefined) return undefined; 21 | if (firstNode.nodes[0].type === 'string') return convertParsedAtImport(atImport, firstNode.nodes[0]); 22 | if (firstNode.nodes[0].type === 'word') return convertParsedAtImport(atImport, firstNode.nodes[0]); 23 | } 24 | return undefined; 25 | } 26 | 27 | function convertParsedAtImport( 28 | atImport: AtRule, 29 | node: postcssValueParser.StringNode | postcssValueParser.WordNode, 30 | ): ParsedAtImport { 31 | // The length of the `@import ` part in `@import '...'` 32 | const baseLength = 7 + (atImport.raws.afterName?.length ?? 0); 33 | const start = { 34 | line: atImport.source!.start!.line, 35 | column: atImport.source!.start!.column + baseLength + node.sourceIndex + (node.type === 'string' ? 1 : 0), 36 | offset: atImport.source!.start!.offset + baseLength + node.sourceIndex + (node.type === 'string' ? 1 : 0), 37 | }; 38 | const end = { 39 | line: start.line, 40 | column: start.column + node.value.length, 41 | offset: start.offset + node.value.length, 42 | }; 43 | return { 44 | from: node.value, 45 | fromLoc: { start, end }, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /packages/core/src/parser/css-module-parser.ts: -------------------------------------------------------------------------------- 1 | import type { AtRule, Node, Root, Rule } from 'postcss'; 2 | import { CssSyntaxError, parse } from 'postcss'; 3 | import safeParser from 'postcss-safe-parser'; 4 | import type { 5 | CSSModule, 6 | DiagnosticWithDetachedLocation, 7 | DiagnosticWithLocation, 8 | Token, 9 | TokenImporter, 10 | } from '../type.js'; 11 | import { parseAtImport } from './at-import-parser.js'; 12 | import { parseAtValue } from './at-value-parser.js'; 13 | import { parseRule } from './rule-parser.js'; 14 | 15 | type AtImport = AtRule & { name: 'import' }; 16 | type AtValue = AtRule & { name: 'value' }; 17 | 18 | function isAtRuleNode(node: Node): node is AtRule { 19 | return node.type === 'atrule'; 20 | } 21 | 22 | function isAtImportNode(node: Node): node is AtImport { 23 | return isAtRuleNode(node) && node.name === 'import'; 24 | } 25 | 26 | function isAtValueNode(node: Node): node is AtValue { 27 | return isAtRuleNode(node) && node.name === 'value'; 28 | } 29 | 30 | function isRuleNode(node: Node): node is Rule { 31 | return node.type === 'rule'; 32 | } 33 | 34 | /** 35 | * Collect tokens from the AST. 36 | */ 37 | function collectTokens(ast: Root) { 38 | const allDiagnostics: DiagnosticWithDetachedLocation[] = []; 39 | const localTokens: Token[] = []; 40 | const tokenImporters: TokenImporter[] = []; 41 | ast.walk((node) => { 42 | if (isAtImportNode(node)) { 43 | const parsed = parseAtImport(node); 44 | if (parsed !== undefined) { 45 | tokenImporters.push({ type: 'import', ...parsed }); 46 | } 47 | } else if (isAtValueNode(node)) { 48 | const { atValue, diagnostics } = parseAtValue(node); 49 | allDiagnostics.push(...diagnostics); 50 | if (atValue === undefined) return; 51 | if (atValue.type === 'valueDeclaration') { 52 | localTokens.push({ name: atValue.name, loc: atValue.loc }); 53 | } else if (atValue.type === 'valueImportDeclaration') { 54 | tokenImporters.push({ ...atValue, type: 'value' }); 55 | } 56 | } else if (isRuleNode(node)) { 57 | const { classSelectors, diagnostics } = parseRule(node); 58 | allDiagnostics.push(...diagnostics); 59 | for (const classSelector of classSelectors) { 60 | localTokens.push(classSelector); 61 | } 62 | } 63 | }); 64 | return { localTokens, tokenImporters, diagnostics: allDiagnostics }; 65 | } 66 | 67 | export interface ParseCSSModuleOptions { 68 | fileName: string; 69 | safe: boolean; 70 | } 71 | 72 | export interface ParseCSSModuleResult { 73 | cssModule: CSSModule; 74 | diagnostics: DiagnosticWithLocation[]; 75 | } 76 | 77 | export function parseCSSModule(text: string, { fileName, safe }: ParseCSSModuleOptions): ParseCSSModuleResult { 78 | let ast: Root; 79 | const diagnosticSourceFile = { fileName, text }; 80 | try { 81 | const parser = safe ? safeParser : parse; 82 | ast = parser(text, { from: fileName }); 83 | } catch (e) { 84 | if (e instanceof CssSyntaxError) { 85 | const start = { line: e.line ?? 1, column: e.column ?? 1 }; 86 | return { 87 | cssModule: { fileName, text, localTokens: [], tokenImporters: [] }, 88 | diagnostics: [ 89 | { 90 | file: diagnosticSourceFile, 91 | start, 92 | // TODO: Assign correct length (e.g. `e.endOffset - e.offset`) 93 | length: 1, 94 | text: e.reason, 95 | category: 'error', 96 | }, 97 | ], 98 | }; 99 | } 100 | throw e; 101 | } 102 | const { localTokens, tokenImporters, diagnostics } = collectTokens(ast); 103 | const cssModule = { 104 | fileName, 105 | text, 106 | localTokens, 107 | tokenImporters, 108 | }; 109 | return { cssModule, diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic, file: diagnosticSourceFile })) }; 110 | } 111 | -------------------------------------------------------------------------------- /packages/core/src/path.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { slash } from './path.js'; 3 | 4 | describe('slash', () => { 5 | test('should convert backslashes to slashes', () => { 6 | expect(slash('a\\b\\c')).toBe('a/b/c'); 7 | expect(slash('C:\\\\a\\b\\c')).toBe('C:/a/b/c'); 8 | expect(slash('\\\\server\\share\\file')).toBe('//server/share/file'); 9 | expect(slash('\\\\?\\C:\\a\\b\\c')).toBe('//?/C:/a/b/c'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/core/src/path.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-imports 2 | import type { ParsedPath } from 'node:path'; 3 | // eslint-disable-next-line no-restricted-imports 4 | import nodePath from 'node:path'; 5 | import ts from 'typescript'; 6 | 7 | export function slash(path: string): string { 8 | return ts.server.toNormalizedPath(path); 9 | } 10 | 11 | export function join(...paths: string[]): string { 12 | return slash(nodePath.join(...paths)); 13 | } 14 | 15 | export function resolve(...paths: string[]): string { 16 | return slash(nodePath.resolve(...paths)); 17 | } 18 | 19 | export function relative(from: string, to: string): string { 20 | return slash(nodePath.relative(from, to)); 21 | } 22 | 23 | export function dirname(path: string): string { 24 | return slash(nodePath.dirname(path)); 25 | } 26 | 27 | export function basename(path: string): string { 28 | return slash(nodePath.basename(path)); 29 | } 30 | 31 | export function parse(path: string): ParsedPath { 32 | const { root, dir, base, name, ext } = nodePath.parse(path); 33 | return { root: slash(root), dir: slash(dir), base, name, ext }; 34 | } 35 | 36 | // eslint-disable-next-line n/no-unsupported-features/node-builtins, @typescript-eslint/unbound-method 37 | export const matchesGlob = nodePath.matchesGlob; 38 | 39 | // eslint-disable-next-line @typescript-eslint/unbound-method 40 | export const isAbsolute = nodePath.isAbsolute; 41 | -------------------------------------------------------------------------------- /packages/core/src/resolver.test.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { join } from './path.js'; 4 | import { createResolver } from './resolver.js'; 5 | import { createIFF } from './test/fixture.js'; 6 | 7 | type CompilerOptions = { 8 | moduleResolution?: 'bundler'; 9 | paths?: Record; 10 | baseUrl?: string; 11 | }; 12 | 13 | function normalizeCompilerOptions(compilerOptions: CompilerOptions, rootDir: string): ts.CompilerOptions { 14 | const config = ts.parseJsonConfigFileContent({ compilerOptions }, ts.sys, rootDir); 15 | return config.options; 16 | } 17 | 18 | describe('createResolver', async () => { 19 | const iff = await createIFF({ 20 | 'request.module.css': '', 21 | 'a.module.css': '', 22 | 'dir/a.module.css': '', 23 | 'paths1/a.module.css': '', 24 | 'paths2/b.module.css': '', 25 | 'paths3/c.module.css': '', 26 | 'package.json': '{ "imports": { "#*": "./*" } }', 27 | }); 28 | const request = iff.paths['request.module.css']; 29 | test('resolves relative path', () => { 30 | const resolve = createResolver(normalizeCompilerOptions({}, iff.rootDir), undefined); 31 | expect(resolve('./a.module.css', { request })).toBe(iff.paths['a.module.css']); 32 | expect(resolve('./dir/a.module.css', { request })).toBe(iff.paths['dir/a.module.css']); 33 | }); 34 | describe('resolve with `paths` option', () => { 35 | test('basic', () => { 36 | const resolve = createResolver( 37 | normalizeCompilerOptions( 38 | { 39 | paths: { 40 | '@/*': ['./paths1/*', './paths2/*'], 41 | '#/*': ['./paths3/*'], 42 | }, 43 | }, 44 | iff.rootDir, 45 | ), 46 | undefined, 47 | ); 48 | expect(resolve('@/a.module.css', { request })).toBe(iff.paths['paths1/a.module.css']); 49 | expect(resolve('@/b.module.css', { request })).toBe(iff.paths['paths2/b.module.css']); 50 | expect(resolve('#/c.module.css', { request })).toBe(iff.paths['paths3/c.module.css']); 51 | expect(resolve('@/d.module.css', { request })).toBe(undefined); 52 | }); 53 | test('with `baseUrl` option', () => { 54 | const resolve = createResolver( 55 | normalizeCompilerOptions( 56 | { 57 | paths: { 58 | '@/*': ['./*'], 59 | }, 60 | baseUrl: join(iff.rootDir, 'dir'), 61 | }, 62 | iff.rootDir, 63 | ), 64 | undefined, 65 | ); 66 | expect(resolve('@/a.module.css', { request })).toBe(iff.paths['dir/a.module.css']); 67 | }); 68 | }); 69 | test('resolve with `imports` field', () => { 70 | const resolve = createResolver( 71 | normalizeCompilerOptions( 72 | { 73 | moduleResolution: 'bundler', 74 | }, 75 | iff.rootDir, 76 | ), 77 | undefined, 78 | ); 79 | expect(resolve('#a.module.css', { request })).toBe(iff.paths['a.module.css']); 80 | expect(resolve('#dir/a.module.css', { request })).toBe(iff.paths['dir/a.module.css']); 81 | }); 82 | test('resolve with `baseUrl` option', () => { 83 | const resolve = createResolver( 84 | normalizeCompilerOptions( 85 | { 86 | baseUrl: join(iff.rootDir, 'dir'), 87 | }, 88 | iff.rootDir, 89 | ), 90 | undefined, 91 | ); 92 | expect(resolve('a.module.css', { request })).toBe(iff.paths['dir/a.module.css']); 93 | }); 94 | test('does not resolve invalid path', () => { 95 | const resolve = createResolver(normalizeCompilerOptions({}, iff.rootDir), undefined); 96 | expect(resolve('http://example.com', { request })).toBe(undefined); 97 | expect(resolve('package', { request })).toBe(undefined); 98 | expect(resolve('@scope/package', { request })).toBe(undefined); 99 | expect(resolve('~package', { request })).toBe(undefined); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /packages/core/src/resolver.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, pathToFileURL } from 'node:url'; 2 | import type { CompilerOptions } from 'typescript'; 3 | import ts from 'typescript'; 4 | import { isAbsolute, resolve } from './path.js'; 5 | import type { Resolver, ResolverOptions } from './type.js'; 6 | 7 | export function createResolver( 8 | compilerOptions: CompilerOptions, 9 | moduleResolutionCache: ts.ModuleResolutionCache | undefined, 10 | ): Resolver { 11 | return (_specifier: string, options: ResolverOptions) => { 12 | let specifier = _specifier; 13 | 14 | const host: ts.ModuleResolutionHost = { 15 | ...ts.sys, 16 | fileExists: (fileName) => { 17 | if (fileName.endsWith('.module.d.css.ts')) { 18 | return ts.sys.fileExists(fileName.replace(/\.module\.d\.css\.ts$/u, '.module.css')); 19 | } 20 | return ts.sys.fileExists(fileName); 21 | }, 22 | }; 23 | const { resolvedModule } = ts.resolveModuleName( 24 | specifier, 25 | options.request, 26 | compilerOptions, 27 | host, 28 | moduleResolutionCache, 29 | ); 30 | if (resolvedModule) { 31 | // TODO: Logging that the paths is used. 32 | specifier = resolvedModule.resolvedFileName.replace(/\.module\.d\.css\.ts$/u, '.module.css'); 33 | } 34 | if (isAbsolute(specifier)) { 35 | return resolve(specifier); 36 | } else if (isRelativeSpecifier(specifier)) { 37 | // Convert the specifier to an absolute path 38 | // NOTE: Node.js resolves relative specifier with standard relative URL resolution semantics. So we will follow that here as well. 39 | // ref: https://nodejs.org/docs/latest-v23.x/api/esm.html#terminology 40 | return resolve(fileURLToPath(new URL(specifier, pathToFileURL(options.request)).href)); 41 | } else { 42 | // Do not support URL or bare specifiers 43 | // TODO: Logging that the specifier could not resolve. 44 | return undefined; 45 | } 46 | }; 47 | } 48 | 49 | /** 50 | * Check if the specifier is a relative specifier. 51 | * @see https://nodejs.org/docs/latest-v23.x/api/esm.html#terminology 52 | */ 53 | function isRelativeSpecifier(specifier: string): boolean { 54 | return specifier.startsWith('./') || specifier.startsWith('../'); 55 | } 56 | -------------------------------------------------------------------------------- /packages/core/src/test/ast.ts: -------------------------------------------------------------------------------- 1 | import type { AtRule, Root, Rule } from 'postcss'; 2 | import { parse } from 'postcss'; 3 | import type { ClassName } from 'postcss-selector-parser'; 4 | import selectorParser from 'postcss-selector-parser'; 5 | 6 | export function fakeRoot(text: string, from?: string): Root { 7 | return parse(text, { from: from || '/test/test.css' }); 8 | } 9 | 10 | export function fakeAtImports(root: Root): AtRule[] { 11 | const results: AtRule[] = []; 12 | root.walkAtRules('import', (atImport) => { 13 | results.push(atImport); 14 | }); 15 | return results; 16 | } 17 | 18 | export function fakeAtValues(root: Root): AtRule[] { 19 | const results: AtRule[] = []; 20 | root.walkAtRules('value', (atValue) => { 21 | results.push(atValue); 22 | }); 23 | return results; 24 | } 25 | 26 | export function fakeRules(root: Root): Rule[] { 27 | const results: Rule[] = []; 28 | root.walkRules((rule) => { 29 | results.push(rule); 30 | }); 31 | return results; 32 | } 33 | 34 | export function fakeClassSelectors(root: Root): { rule: Rule; classSelector: ClassName }[] { 35 | const results: { rule: Rule; classSelector: ClassName }[] = []; 36 | root.walkRules((rule) => { 37 | selectorParser((selectors) => { 38 | selectors.walk((selector) => { 39 | if (selector.type === 'class') { 40 | results.push({ rule, classSelector: selector }); 41 | } 42 | }); 43 | }).processSync(rule); 44 | }); 45 | return results; 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/src/test/css-module.ts: -------------------------------------------------------------------------------- 1 | import type { CSSModule } from '../type.js'; 2 | 3 | export function fakeCSSModule(args?: Partial): CSSModule { 4 | return { 5 | fileName: '/test.module.css', 6 | text: '', 7 | localTokens: [], 8 | tokenImporters: [], 9 | ...args, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/src/test/fixture.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { defineIFFCreator } from '@mizdra/inline-fixture-files'; 4 | import { join } from '../path.js'; 5 | 6 | const fixtureDir = join(tmpdir(), 'css-modules-kit', process.env['VITEST_POOL_ID']!); 7 | export const createIFF = defineIFFCreator({ 8 | generateRootDir: () => join(fixtureDir, randomUUID()), 9 | unixStylePath: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/core/src/test/token.ts: -------------------------------------------------------------------------------- 1 | import type { AtImportTokenImporter, AtValueTokenImporter, Token } from '../type.js'; 2 | 3 | const fakeLoc = { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 1, offset: 0 } }; 4 | 5 | export function fakeToken(name: string): Token { 6 | return { name, loc: fakeLoc }; 7 | } 8 | 9 | export function fakeAtImportTokenImporter(from: string): AtImportTokenImporter { 10 | return { 11 | type: 'import', 12 | from, 13 | fromLoc: fakeLoc, 14 | }; 15 | } 16 | 17 | export function fakeAtValueTokenImporter(from: string, valueNames: string[]): AtValueTokenImporter { 18 | return { 19 | type: 'value', 20 | from, 21 | values: valueNames.map((name) => ({ name, loc: fakeLoc })), 22 | fromLoc: fakeLoc, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/typing/typescript.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare module 'typescript' { 4 | interface FileSystemEntries { 5 | readonly files: readonly string[]; 6 | readonly directories: readonly string[]; 7 | } 8 | export function matchFiles( 9 | path: string, 10 | extensions: readonly string[] | undefined, 11 | excludes: readonly string[] | undefined, 12 | includes: readonly string[] | undefined, 13 | useCaseSensitiveFileNames: boolean, 14 | currentDirectory: string, 15 | depth: number | undefined, 16 | getFileSystemEntries: (path: string) => FileSystemEntries, 17 | realpath: (path: string) => string, 18 | ): string[]; 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/util.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { expect, test } from 'vitest'; 3 | import { findUsedTokenNames } from './util.js'; 4 | 5 | test('findUsedTokenNames', () => { 6 | const text = dedent` 7 | import styles from './a.module.css'; 8 | styles.a_1; 9 | styles.a_1; 10 | styles.a_2; 11 | styles['a_3']; 12 | styles["a_4"]; 13 | styles[\`a_5\`]; 14 | // styles.a_6; // false positive, but it is acceptable for simplicity of implementation 15 | styles['a_7; 16 | styles['a_8"]; 17 | styles; 18 | `; 19 | const expected = new Set(['a_1', 'a_2', 'a_6']); 20 | expect(findUsedTokenNames(text)).toEqual(expected); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/core/src/util.ts: -------------------------------------------------------------------------------- 1 | export function isPosixRelativePath(path: string): boolean { 2 | return path.startsWith(`./`) || path.startsWith(`../`); 3 | } 4 | 5 | /** 6 | * The syntax pattern for consuming tokens imported from CSS Module. 7 | * @example `styles.foo` 8 | */ 9 | // TODO(#125): Support `styles['foo']` and `styles["foo"]` 10 | // MEMO: The `xxxStyles.foo` format is not supported, because the css module file for current component file is usually imported with `styles`. 11 | // It is sufficient to support only the `styles.foo` format. 12 | const TOKEN_CONSUMER_PATTERN = /styles\.([$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*)/gu; 13 | 14 | export function findUsedTokenNames(componentText: string): Set { 15 | const usedClassNames = new Set(); 16 | let match; 17 | while ((match = TOKEN_CONSUMER_PATTERN.exec(componentText)) !== null) { 18 | usedClassNames.add(match[1]!); 19 | } 20 | return usedClassNames; 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/eslint-plugin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @css-modules-kit/eslint-plugin 2 | 3 | ## 0.1.1 4 | 5 | ### Patch Changes 6 | 7 | - 2093fb8: docs: update recommended values in `eslint.validate` 8 | 9 | ## 0.1.0 10 | 11 | ### Minor Changes 12 | 13 | - a1b4c70: feat: add documentation link for lint rules 14 | 15 | ### Patch Changes 16 | 17 | - Updated dependencies [385bdc3] 18 | - Updated dependencies [2b1f0fe] 19 | - Updated dependencies [6ecc738] 20 | - Updated dependencies [2fde8ec] 21 | - Updated dependencies [819e023] 22 | - Updated dependencies [2bd2165] 23 | - @css-modules-kit/core@0.2.0 24 | 25 | ## 0.0.1 26 | 27 | ### Patch Changes 28 | 29 | - 9846b76: feat: add eslint-plugin 30 | - Updated dependencies [e899e5e] 31 | - @css-modules-kit/core@0.1.0 32 | -------------------------------------------------------------------------------- /packages/eslint-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/eslint-plugin/README.md: -------------------------------------------------------------------------------- 1 | # `@css-modules-kit/eslint-plugin` 2 | 3 | A eslint plugin for CSS Modules 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i -D @css-modules-kit/eslint-plugin 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | // eslint.config.js 15 | import { defineConfig } from 'eslint/config'; 16 | import css from '@eslint/css'; 17 | import cssModulesKit from '@css-modules-kit/eslint-plugin'; 18 | 19 | export default defineConfig([ 20 | { 21 | files: ['**/*.css'], 22 | language: 'css/css', 23 | languageOptions: { 24 | tolerant: true, // Required if you use `@value` rule or `composes` property 25 | }, 26 | extends: [css.configs.recommended, cssModulesKit.configs.recommended], 27 | }, 28 | ]); 29 | ``` 30 | 31 | For vscode-eslint users, you need to add the following configuration to your `settings.json`: 32 | 33 | ```jsonc 34 | // .vscode/settings.json 35 | { 36 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "css"], 37 | } 38 | ``` 39 | 40 | ## Rules 41 | 42 | - [`css-modules-kit/no-unused-class-names`](https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/docs/rules/no-unused-class-names.md) 43 | - [`css-modules-kit/no-missing-component-file`](https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/docs/rules/no-missing-component-file.md) 44 | -------------------------------------------------------------------------------- /packages/eslint-plugin/docs/rules/no-missing-component-file.md: -------------------------------------------------------------------------------- 1 | # css-modules-kit/no-missing-component-file 2 | 3 | Enforce the existence of corresponding component files for CSS module files 4 | 5 | ## Rule Details 6 | 7 | This rule checks for the existence of component files that correspond to CSS module files. It reports an error when a CSS module file does not have the [corresponding component file](https://github.com/mizdra/css-modules-kit/blob/main/docs/glossary.md#corresponding-component-file). 8 | 9 | ### ✓ Correct Examples 10 | 11 | ``` 12 | project/ 13 | ├── Button.module.css 14 | └── Button.tsx (or Button.jsx) 15 | ``` 16 | 17 | ### ✗ Incorrect Examples 18 | 19 | ``` 20 | project/ 21 | └── Button.module.css (Error: The corresponding component file is not found.) 22 | ``` 23 | -------------------------------------------------------------------------------- /packages/eslint-plugin/docs/rules/no-unused-class-names.md: -------------------------------------------------------------------------------- 1 | # css-modules-kit/no-unused-class-names 2 | 3 | Disallow unused class names in CSS module files 4 | 5 | ## Rule Details 6 | 7 | This rule checks for unused CSS class names in CSS module files. It warns when class names are defined in a CSS module file but not used in the [corresponding component file](https://github.com/mizdra/css-modules-kit/blob/main/docs/glossary.md#corresponding-component-file). 8 | 9 | ### ✓ Correct Examples 10 | 11 | ```css 12 | /* a.module.css */ 13 | .used { 14 | color: red; 15 | } 16 | ``` 17 | 18 | ```tsx 19 | // a.tsx 20 | import styles from './a.module.css'; 21 | styles.used; 22 | ``` 23 | 24 | ### ✗ Incorrect Examples 25 | 26 | ```css 27 | /* a.module.css */ 28 | .unused { 29 | color: red; 30 | } /* Error: "unused" is defined but never used in "a.tsx" */ 31 | ``` 32 | 33 | ```tsx 34 | // a.tsx 35 | import styles from './a.module.css'; 36 | ``` 37 | 38 | ## When Not To Use It 39 | 40 | If you want to keep unused class names in your CSS module files for future use, you might want to disable this rule. 41 | 42 | ## Known Limitations 43 | 44 | - Whether a class name is used or not is determined by a partial match with the text in the file. 45 | - For example, if a component file contains the characters `styles.foo`, then `foo` is considered to be used. 46 | - `:global(.className)` selectors are ignored and not reported as unused. 47 | - If no corresponding component file is found, all class names are assumed to be used (treated as a shared CSS module file). 48 | -------------------------------------------------------------------------------- /packages/eslint-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-modules-kit/eslint-plugin", 3 | "description": "A eslint plugin for CSS Modules", 4 | "version": "0.1.1", 5 | "type": "commonjs", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/eslint-plugin" 11 | }, 12 | "author": "mizdra ", 13 | "license": "MIT", 14 | "private": false, 15 | "main": "./dist/index.js", 16 | "scripts": { 17 | "build": "tsc -b tsconfig.build.json" 18 | }, 19 | "engines": { 20 | "node": ">=22.0.0" 21 | }, 22 | "publishConfig": { 23 | "access": "public", 24 | "registry": "https://registry.npmjs.org/" 25 | }, 26 | "keywords": [ 27 | "css-modules", 28 | "eslint", 29 | "eslint-plugin" 30 | ], 31 | "files": [ 32 | "bin", 33 | "src", 34 | "!src/**/*.test.ts", 35 | "!src/**/__snapshots__", 36 | "!src/test", 37 | "dist" 38 | ], 39 | "dependencies": { 40 | "@css-modules-kit/core": "^0.2.0", 41 | "postcss-safe-parser": "^7.0.1" 42 | }, 43 | "peerDependencies": { 44 | "@eslint/css": ">=0.4.0", 45 | "eslint": ">=9.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ESLint } from 'eslint'; 2 | import packageJson from '../package.json'; 3 | import { noMissingComponentFile } from './rules/no-missing-component-file.js'; 4 | import { noUnusedClassNames } from './rules/no-unused-class-names.js'; 5 | 6 | const plugin = { 7 | meta: { 8 | name: packageJson.name, 9 | version: packageJson.version, 10 | }, 11 | rules: { 12 | 'no-missing-component-file': noMissingComponentFile, 13 | 'no-unused-class-names': noUnusedClassNames, 14 | }, 15 | configs: { 16 | recommended: { 17 | languageOptions: { 18 | customSyntax: { 19 | atrules: { 20 | value: { 21 | // Example: 22 | // - `@value a: #123;` 23 | // - `@value empty:;` 24 | // - `@value withoutSemicolon #123;` 25 | // - `@value a from './test.module.css';` 26 | // - `@value a, b from './test.module.css';` 27 | // - `@value a as aliased_a from './test.module.css';` 28 | // 29 | // CSS Modules Kit does not support the following for implementation simplicity: 30 | // - `@value (a, b) from '...';` 31 | // - `@value a from moduleName;` 32 | // 33 | // ref: https://github.com/css-modules/postcss-icss-values/blob/acdf34a62cc2537a9507b1e9fd34db486e5cb0f8/test/test.js 34 | prelude: 35 | ' :? ? | [ [ [ as ]? ]# from ]', 36 | }, 37 | }, 38 | properties: { 39 | composes: { 40 | // Example: 41 | // - `composes: a;` 42 | // - `composes: a from './test.module.css';` 43 | // - `composes: a, b from './test.module.css';` 44 | // - `composes: a b from './test.module.css';` 45 | // - `composes: global(a) from './test.module.css';` 46 | // 47 | // ref: https://github.com/css-modules/postcss-modules-extract-imports/blob/16f9c570e517cf3558b88cf96dcadf794230965a/src/index.js 48 | syntax: '[ [ | global() ] ,? ]+ [ from ]?', 49 | }, 50 | }, 51 | }, 52 | }, 53 | plugins: { 54 | 'css-modules-kit': {}, 55 | }, 56 | rules: { 57 | 'css-modules-kit/no-missing-component-file': 'error', 58 | 'css-modules-kit/no-unused-class-names': 'error', 59 | }, 60 | }, 61 | }, 62 | } satisfies ESLint.Plugin; 63 | plugin.configs.recommended.plugins['css-modules-kit'] = plugin; 64 | 65 | export = plugin; 66 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/no-missing-component-file.test.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { createESLint, formatLintResults } from '../test/eslint.js'; 4 | import { createIFF } from '../test/fixture.js'; 5 | 6 | const config: Linter.Config = { 7 | rules: { 'css-modules-kit/no-missing-component-file': 'error' }, 8 | }; 9 | 10 | describe('no-missing-component-file', () => { 11 | test('warns missing component file', async () => { 12 | const iff = await createIFF({ 13 | 'a.module.css': '.foo {}', 14 | }); 15 | const eslint = createESLint(iff.rootDir, config); 16 | const results = await eslint.lintFiles(iff.rootDir); 17 | expect(formatLintResults(results, iff.rootDir)).toMatchInlineSnapshot(` 18 | [ 19 | { 20 | "filePath": "/a.module.css", 21 | "messages": [ 22 | { 23 | "column": 0, 24 | "endColumn": undefined, 25 | "endLine": undefined, 26 | "line": 1, 27 | "message": "The corresponding component file is not found.", 28 | "ruleId": "css-modules-kit/no-missing-component-file", 29 | }, 30 | ], 31 | }, 32 | ] 33 | `); 34 | }); 35 | test('does not warn when component file exists', async () => { 36 | const iff = await createIFF({ 37 | 'a.module.css': '', 38 | 'a.tsx': '', 39 | }); 40 | const eslint = createESLint(iff.rootDir, config); 41 | const results = await eslint.lintFiles(iff.rootDir); 42 | expect(formatLintResults(results, iff.rootDir)).toMatchInlineSnapshot(` 43 | [ 44 | { 45 | "filePath": "/a.module.css", 46 | "messages": [], 47 | }, 48 | ] 49 | `); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/no-missing-component-file.ts: -------------------------------------------------------------------------------- 1 | import { findComponentFileSync, isCSSModuleFile } from '@css-modules-kit/core'; 2 | import type { Rule } from 'eslint'; 3 | import { readFile } from '../util.js'; 4 | 5 | export const noMissingComponentFile: Rule.RuleModule = { 6 | meta: { 7 | type: 'problem', 8 | language: 'css/css', 9 | messages: { 10 | disallow: 'The corresponding component file is not found.', 11 | }, 12 | docs: { 13 | description: 'Enforce the existence of corresponding component files for CSS module files', 14 | recommended: true, 15 | url: 'https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/docs/rules/no-missing-component-file.md', 16 | }, 17 | }, 18 | create(context) { 19 | const fileName = context.filename; 20 | if (fileName === undefined || !isCSSModuleFile(fileName)) return {}; 21 | 22 | const componentFile = findComponentFileSync(fileName, readFile); 23 | 24 | if (componentFile === undefined) { 25 | context.report({ 26 | loc: { line: 1, column: 0 }, 27 | messageId: 'disallow', 28 | }); 29 | } 30 | return {}; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/no-unused-class-names.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import type { Linter } from 'eslint'; 3 | import { describe, expect, test } from 'vitest'; 4 | import { createESLint, formatLintResults } from '../test/eslint.js'; 5 | import { createIFF } from '../test/fixture.js'; 6 | 7 | const config: Linter.Config = { 8 | rules: { 'css-modules-kit/no-unused-class-names': 'error' }, 9 | }; 10 | 11 | describe('no-unused-class-names', () => { 12 | test('warns unused class names', async () => { 13 | const iff = await createIFF({ 14 | 'a.module.css': dedent` 15 | .local1 {} 16 | .local2 {} 17 | .local3 {} 18 | `, 19 | 'a.tsx': dedent` 20 | import styles from './a.module.css'; 21 | styles.local1; 22 | `, 23 | }); 24 | const eslint = createESLint(iff.rootDir, config); 25 | const results = await eslint.lintFiles(iff.rootDir); 26 | expect(formatLintResults(results, iff.rootDir)).toMatchInlineSnapshot(` 27 | [ 28 | { 29 | "filePath": "/a.module.css", 30 | "messages": [ 31 | { 32 | "column": 2, 33 | "endColumn": 8, 34 | "endLine": 2, 35 | "line": 2, 36 | "message": ""local2" is defined but never used in "a.tsx"", 37 | "ruleId": "css-modules-kit/no-unused-class-names", 38 | }, 39 | { 40 | "column": 2, 41 | "endColumn": 8, 42 | "endLine": 3, 43 | "line": 3, 44 | "message": ""local3" is defined but never used in "a.tsx"", 45 | "ruleId": "css-modules-kit/no-unused-class-names", 46 | }, 47 | ], 48 | }, 49 | ] 50 | `); 51 | }); 52 | test('does not warn global class names', async () => { 53 | const iff = await createIFF({ 54 | 'a.module.css': dedent` 55 | .local1, :global(.global1) {} 56 | `, 57 | 'a.ts': dedent` 58 | import styles from './a.module.css'; 59 | styles.local1; 60 | `, 61 | }); 62 | const eslint = createESLint(iff.rootDir, config); 63 | const results = await eslint.lintFiles(iff.rootDir); 64 | expect(formatLintResults(results, iff.rootDir)).toMatchInlineSnapshot(` 65 | [ 66 | { 67 | "filePath": "/a.module.css", 68 | "messages": [], 69 | }, 70 | ] 71 | `); 72 | }); 73 | test('does not warn if ts file is not found', async () => { 74 | const iff = await createIFF({ 75 | 'a.module.css': dedent` 76 | .local1 {} 77 | `, 78 | }); 79 | const eslint = createESLint(iff.rootDir, config); 80 | const results = await eslint.lintFiles(iff.rootDir); 81 | expect(formatLintResults(results, iff.rootDir)).toMatchInlineSnapshot(` 82 | [ 83 | { 84 | "filePath": "/a.module.css", 85 | "messages": [], 86 | }, 87 | ] 88 | `); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/rules/no-unused-class-names.ts: -------------------------------------------------------------------------------- 1 | import { basename, findComponentFileSync, findUsedTokenNames, isCSSModuleFile, parseRule } from '@css-modules-kit/core'; 2 | import type { Rule } from 'eslint'; 3 | import safeParser from 'postcss-safe-parser'; 4 | import { readFile } from '../util.js'; 5 | 6 | export const noUnusedClassNames: Rule.RuleModule = { 7 | meta: { 8 | type: 'problem', 9 | language: 'css/css', 10 | messages: { 11 | disallow: '"{{className}}" is defined but never used in "{{componentFileName}}"', 12 | }, 13 | docs: { 14 | description: 'Disallow unused class names in CSS module files', 15 | recommended: true, 16 | url: 'https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/docs/rules/no-unused-class-names.md', 17 | }, 18 | }, 19 | create(context) { 20 | const fileName = context.filename; 21 | if (fileName === undefined || !isCSSModuleFile(fileName)) return {}; 22 | 23 | const componentFile = findComponentFileSync(fileName, readFile); 24 | 25 | // If the corresponding component file is not found, it is treated as a CSS Module file shared by the entire project. 26 | // It is difficult to determine where class names in a shared CSS Module file are used. Therefore, it is 27 | // assumed that all class names are used. 28 | if (componentFile === undefined) return {}; 29 | 30 | const usedTokenNames = findUsedTokenNames(componentFile.text); 31 | 32 | const root = safeParser(context.sourceCode.text, { from: fileName }); 33 | root.walkRules((rule) => { 34 | const { classSelectors } = parseRule(rule); 35 | 36 | for (const classSelector of classSelectors) { 37 | if (!usedTokenNames.has(classSelector.name)) { 38 | context.report({ 39 | loc: { 40 | start: { 41 | line: classSelector.loc.start.line, 42 | column: classSelector.loc.start.column, 43 | }, 44 | end: { 45 | line: classSelector.loc.end.line, 46 | column: classSelector.loc.end.column, 47 | }, 48 | }, 49 | messageId: 'disallow', 50 | data: { 51 | className: classSelector.name, 52 | componentFileName: basename(componentFile.fileName), 53 | }, 54 | }); 55 | } 56 | } 57 | }); 58 | return {}; 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/test/eslint.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from '@css-modules-kit/core'; 2 | import css from '@eslint/css'; 3 | import type { Linter } from 'eslint'; 4 | import { ESLint } from 'eslint'; 5 | import plugin from '../index.js'; 6 | 7 | function filterMessage(warning: Linter.LintMessage) { 8 | return { 9 | line: warning.line, 10 | column: warning.column, 11 | endLine: warning.endLine, 12 | endColumn: warning.endColumn, 13 | ruleId: warning.ruleId, 14 | message: warning.message, 15 | }; 16 | } 17 | 18 | function formatLintResult(lintResult: ESLint.LintResult, rootDir: string) { 19 | return { 20 | filePath: resolve(lintResult.filePath).replace(rootDir, ''), 21 | messages: lintResult.messages.map(filterMessage), 22 | }; 23 | } 24 | 25 | export function formatLintResults(results: ESLint.LintResult[], rootDir: string) { 26 | return results.map((result) => formatLintResult(result, rootDir)); 27 | } 28 | 29 | export function createESLint(rootDir: string, config: Linter.Config) { 30 | return new ESLint({ 31 | cwd: rootDir, 32 | overrideConfigFile: true, 33 | baseConfig: [ 34 | { 35 | files: ['**/*.css'], 36 | language: 'css/css', 37 | languageOptions: { 38 | tolerant: true, 39 | }, 40 | plugins: { 41 | css, 42 | 'css-modules-kit': plugin, 43 | }, 44 | }, 45 | config, 46 | ], 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/test/fixture.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { join } from '@css-modules-kit/core'; 4 | import { defineIFFCreator } from '@mizdra/inline-fixture-files'; 5 | 6 | const fixtureDir = join(tmpdir(), 'css-modules-kit', process.env['VITEST_POOL_ID']!); 7 | export const createIFF = defineIFFCreator({ 8 | generateRootDir: () => join(fixtureDir, randomUUID()), 9 | unixStylePath: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/eslint-plugin/src/util.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | export function readFile(path: string): string { 4 | return fs.readFileSync(path, 'utf-8'); 5 | } 6 | -------------------------------------------------------------------------------- /packages/eslint-plugin/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | }, 16 | "references": [{ "path": "../core/tsconfig.build.json" }] 17 | } 18 | -------------------------------------------------------------------------------- /packages/language-server/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @css-modules-kit/language-server 2 | 3 | ## 0.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 7df2e70: Release test 8 | -------------------------------------------------------------------------------- /packages/language-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-modules-kit/language-server", 3 | "description": "Language Server for CSS Modules", 4 | "version": "0.0.1", 5 | "type": "commonjs", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/language-server" 11 | }, 12 | "author": "mizdra ", 13 | "license": "MIT", 14 | "private": false, 15 | "main": "./dist/index.js", 16 | "scripts": { 17 | "build": "tsc -b tsconfig.build.json" 18 | }, 19 | "engines": { 20 | "node": ">=22.0.0" 21 | }, 22 | "publishConfig": { 23 | "access": "public", 24 | "registry": "https://registry.npmjs.org/" 25 | }, 26 | "keywords": [ 27 | "css-modules", 28 | "typescript" 29 | ], 30 | "files": [ 31 | "bin", 32 | "src", 33 | "!src/**/*.test.ts", 34 | "!src/**/__snapshots__", 35 | "!src/test", 36 | "dist" 37 | ], 38 | "dependencies": { 39 | "@volar/language-server": "^2.4.11", 40 | "volar-service-css": "^0.0.62" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/language-server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createSimpleProject } from '@volar/language-server/lib/project/simpleProject'; 2 | import { createConnection, createServer } from '@volar/language-server/node'; 3 | import { create as createCssService } from 'volar-service-css'; 4 | 5 | // MEMO: Maybe @volar/language-server and volar-service-css are not needed. We may only need vscode-css-languageservice. 6 | 7 | const connection = createConnection(); 8 | const server = createServer(connection); 9 | 10 | connection.listen(); 11 | 12 | connection.onInitialize((params) => { 13 | const cssService = createCssService({ 14 | getCustomData(_context) { 15 | return [ 16 | { 17 | provideProperties: () => [], 18 | provideAtDirectives: () => [{ name: '@value', description: 'Define values with CSS Modules' }], 19 | providePseudoClasses: () => [], 20 | providePseudoElements: () => [], 21 | }, 22 | ]; 23 | }, 24 | }); 25 | 26 | // Disable rename provider due to conflict with ts-plugin 27 | // TODO: Allow rename operations that do not conflict with ts-plugin 28 | // TODO: Do not disable provider in CSS files that are not CSS Modules 29 | delete cssService.capabilities.renameProvider; 30 | // Disable references provider due to conflict with ts-plugin 31 | // TODO: Allow references operations that do not conflict with ts-plugin 32 | // TODO: Do not disable provider in CSS files that are not CSS Modules 33 | delete cssService.capabilities.referencesProvider; 34 | 35 | return server.initialize(params, createSimpleProject([]), [cssService]); 36 | }); 37 | 38 | connection.onInitialized(server.initialized.bind(server)); 39 | connection.onShutdown(server.shutdown.bind(server)); 40 | -------------------------------------------------------------------------------- /packages/language-server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @css-modules-kit/stylelint-plugin 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - af72925: feat!: rename stylelint config 8 | - a1b4c70: feat: add documentation link for lint rules 9 | - 3dfa5fd: feat: add `@css-modules-kit/stylelint-plugin/config` 10 | 11 | ### Patch Changes 12 | 13 | - Updated dependencies [385bdc3] 14 | - Updated dependencies [2b1f0fe] 15 | - Updated dependencies [6ecc738] 16 | - Updated dependencies [2fde8ec] 17 | - Updated dependencies [819e023] 18 | - Updated dependencies [2bd2165] 19 | - @css-modules-kit/core@0.2.0 20 | 21 | ## 0.1.0 22 | 23 | ### Minor Changes 24 | 25 | - e899e5e: refactor: move `findUsedTokenNames` to core 26 | 27 | ### Patch Changes 28 | 29 | - Updated dependencies [e899e5e] 30 | - @css-modules-kit/core@0.1.0 31 | 32 | ## 0.0.5 33 | 34 | ### Patch Changes 35 | 36 | - 7df2e70: Release test 37 | - Updated dependencies [7df2e70] 38 | - @css-modules-kit/core@0.0.5 39 | 40 | ## 0.0.4 41 | 42 | ### Patch Changes 43 | 44 | - Updated dependencies [2eb908f] 45 | - @css-modules-kit/core@0.0.4 46 | 47 | ## 0.0.3 48 | 49 | ### Patch Changes 50 | 51 | - 508b4b6: Retry publishing 52 | - Updated dependencies [508b4b6] 53 | - @css-modules-kit/core@0.0.3 54 | 55 | ## 0.0.2 56 | 57 | ### Patch Changes 58 | 59 | - Updated dependencies [251ba5b] 60 | - Updated dependencies [fa1d6a9] 61 | - @css-modules-kit/core@0.0.2 62 | 63 | ## 0.0.1 64 | 65 | ### Patch Changes 66 | 67 | - 434f3da: first release 68 | - Updated dependencies [434f3da] 69 | - @css-modules-kit/core@0.0.1 70 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/README.md: -------------------------------------------------------------------------------- 1 | # `@css-modules-kit/stylelint-plugin` 2 | 3 | A stylelint plugin for CSS Modules 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i -D @css-modules-kit/stylelint-plugin 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | // stylelint.config.js 15 | /** @type {import('stylelint').Config} */ 16 | export default { 17 | extends: ['@css-modules-kit/stylelint-plugin/recommended'], 18 | }; 19 | ``` 20 | 21 | ## Rules 22 | 23 | The same rules as eslint-plugin are provided. See [the eslint-plugin documentation](https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/README.md#rules) for a list of rules. 24 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-modules-kit/stylelint-plugin", 3 | "description": "A stylelint plugin for CSS Modules", 4 | "version": "0.2.0", 5 | "type": "commonjs", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/stylelint-plugin" 11 | }, 12 | "author": "mizdra ", 13 | "license": "MIT", 14 | "private": false, 15 | "exports": { 16 | ".": { 17 | "import": { 18 | "types": "./dist/index.d.ts", 19 | "default": "./dist/index.js" 20 | } 21 | }, 22 | "./recommended": { 23 | "import": { 24 | "types": "./dist/recommended.d.ts", 25 | "default": "./dist/recommended.js" 26 | } 27 | } 28 | }, 29 | "scripts": { 30 | "build": "tsc -b tsconfig.build.json" 31 | }, 32 | "engines": { 33 | "node": ">=22.0.0" 34 | }, 35 | "publishConfig": { 36 | "access": "public", 37 | "registry": "https://registry.npmjs.org/" 38 | }, 39 | "keywords": [ 40 | "css-modules", 41 | "stylelint", 42 | "stylelint-plugin" 43 | ], 44 | "files": [ 45 | "bin", 46 | "src", 47 | "!src/**/*.test.ts", 48 | "!src/**/__snapshots__", 49 | "!src/test", 50 | "dist" 51 | ], 52 | "dependencies": { 53 | "@css-modules-kit/core": "^0.2.0" 54 | }, 55 | "peerDependencies": { 56 | "stylelint": "^16.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import { noMissingComponentFile } from './rules/no-missing-component-file.js'; 2 | import { noUnusedClassNames } from './rules/no-unused-class-names.js'; 3 | 4 | export = [noUnusedClassNames, noMissingComponentFile]; 5 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/recommended.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'stylelint'; 2 | 3 | const recommendedConfig: Config = { 4 | plugins: ['@css-modules-kit/stylelint-plugin'], 5 | languageOptions: { 6 | syntax: { 7 | atRules: { 8 | value: { 9 | // Example: 10 | // - `@value a: #123;` 11 | // - `@value empty:;` 12 | // - `@value withoutSemicolon #123;` 13 | // - `@value a from './test.module.css';` 14 | // - `@value a, b from './test.module.css';` 15 | // - `@value a as aliased_a from './test.module.css';` 16 | // 17 | // CSS Modules Kit does not support the following for implementation simplicity: 18 | // - `@value (a, b) from '...';` 19 | // - `@value a from moduleName;` 20 | // 21 | // ref: https://github.com/css-modules/postcss-icss-values/blob/acdf34a62cc2537a9507b1e9fd34db486e5cb0f8/test/test.js 22 | prelude: 23 | ' :? ? | [ [ [ as ]? ]# from ]', 24 | }, 25 | }, 26 | properties: { 27 | // Example: 28 | // - `composes: a;` 29 | // - `composes: a from './test.module.css';` 30 | // - `composes: a, b from './test.module.css';` 31 | // - `composes: a b from './test.module.css';` 32 | // - `composes: global(a) from './test.module.css';` 33 | // 34 | // ref: https://github.com/css-modules/postcss-modules-extract-imports/blob/16f9c570e517cf3558b88cf96dcadf794230965a/src/index.js 35 | composes: '[ [ | global() ] ,? ]+ [ from ]?', 36 | }, 37 | }, 38 | }, 39 | rules: { 40 | 'css-modules-kit/no-unused-class-names': true, 41 | 'css-modules-kit/no-missing-component-file': true, 42 | }, 43 | }; 44 | 45 | export = recommendedConfig; 46 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/rules/no-missing-component-file.test.ts: -------------------------------------------------------------------------------- 1 | import stylelint from 'stylelint'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { createIFF } from '../test/fixture.js'; 4 | import { formatLinterResult } from '../test/stylelint.js'; 5 | import { noMissingComponentFile } from './no-missing-component-file.js'; 6 | 7 | async function lint(rootDir: string) { 8 | return stylelint.lint({ 9 | config: { 10 | plugins: [noMissingComponentFile], 11 | rules: { 12 | 'css-modules-kit/no-missing-component-file': true, 13 | }, 14 | }, 15 | files: ['**/*.module.css'], 16 | cwd: rootDir, 17 | }); 18 | } 19 | 20 | describe('no-missing-component-file', () => { 21 | test('warns missing component file', async () => { 22 | const iff = await createIFF({ 23 | 'a.module.css': '.foo {}', 24 | }); 25 | const results = await lint(iff.rootDir); 26 | expect(formatLinterResult(results, iff.rootDir)).toMatchInlineSnapshot(` 27 | [ 28 | { 29 | "source": "/a.module.css", 30 | "warnings": [ 31 | { 32 | "column": 1, 33 | "endColumn": 2, 34 | "endLine": 1, 35 | "line": 1, 36 | "rule": "css-modules-kit/no-missing-component-file", 37 | "text": "The corresponding component file is not found. (css-modules-kit/no-missing-component-file)", 38 | }, 39 | ], 40 | }, 41 | ] 42 | `); 43 | }); 44 | test('does not warn when component file exists', async () => { 45 | const iff = await createIFF({ 46 | 'a.module.css': '', 47 | 'a.tsx': '', 48 | }); 49 | const results = await lint(iff.rootDir); 50 | expect(formatLinterResult(results, iff.rootDir)).toMatchInlineSnapshot(` 51 | [ 52 | { 53 | "source": "/a.module.css", 54 | "warnings": [], 55 | }, 56 | ] 57 | `); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/rules/no-missing-component-file.ts: -------------------------------------------------------------------------------- 1 | import { findComponentFile, isCSSModuleFile } from '@css-modules-kit/core'; 2 | import type { Rule, RuleMeta } from 'stylelint'; 3 | import stylelint from 'stylelint'; 4 | import { readFile } from '../util.js'; 5 | 6 | const { createPlugin, utils } = stylelint; 7 | 8 | const ruleName = 'css-modules-kit/no-missing-component-file'; 9 | 10 | const messages = utils.ruleMessages(ruleName, { 11 | disallow: () => `The corresponding component file is not found.`, 12 | }); 13 | 14 | const meta: RuleMeta = { 15 | url: 'https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/docs/rules/no-missing-component-file.md', 16 | }; 17 | 18 | const ruleFunction: Rule = (_primaryOptions, _secondaryOptions, _context) => { 19 | return async (root, result) => { 20 | const fileName = root.source?.input.file; 21 | if (fileName === undefined || !isCSSModuleFile(fileName)) return; 22 | 23 | const componentFile = await findComponentFile(fileName, readFile); 24 | 25 | if (componentFile === undefined) { 26 | utils.report({ 27 | result, 28 | ruleName, 29 | message: messages.disallow(), 30 | node: root, 31 | index: 0, 32 | endIndex: 0, 33 | }); 34 | } 35 | }; 36 | }; 37 | 38 | ruleFunction.ruleName = ruleName; 39 | ruleFunction.messages = messages; 40 | ruleFunction.meta = meta; 41 | 42 | export const noMissingComponentFile = createPlugin(ruleName, ruleFunction); 43 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/rules/no-unused-class-names.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import stylelint from 'stylelint'; 3 | import { describe, expect, test } from 'vitest'; 4 | import { createIFF } from '../test/fixture.js'; 5 | import { formatLinterResult } from '../test/stylelint.js'; 6 | import { noUnusedClassNames } from './no-unused-class-names.js'; 7 | 8 | async function lint(rootDir: string) { 9 | return stylelint.lint({ 10 | config: { 11 | plugins: [noUnusedClassNames], 12 | rules: { 13 | 'css-modules-kit/no-unused-class-names': true, 14 | }, 15 | }, 16 | files: ['**/*.module.css'], 17 | cwd: rootDir, 18 | }); 19 | } 20 | 21 | describe('no-unused-class-names', () => { 22 | test('warns unused class names', async () => { 23 | const iff = await createIFF({ 24 | 'a.module.css': dedent` 25 | .local1 {} 26 | .local2 {} 27 | .local3 {} 28 | `, 29 | 'a.tsx': dedent` 30 | import styles from './a.module.css'; 31 | styles.local1; 32 | `, 33 | }); 34 | const results = await lint(iff.rootDir); 35 | expect(formatLinterResult(results, iff.rootDir)).toMatchInlineSnapshot(` 36 | [ 37 | { 38 | "source": "/a.module.css", 39 | "warnings": [ 40 | { 41 | "column": 2, 42 | "endColumn": 8, 43 | "endLine": 2, 44 | "line": 2, 45 | "rule": "css-modules-kit/no-unused-class-names", 46 | "text": ""local2" is defined but never used in "a.tsx" (css-modules-kit/no-unused-class-names)", 47 | }, 48 | { 49 | "column": 2, 50 | "endColumn": 8, 51 | "endLine": 3, 52 | "line": 3, 53 | "rule": "css-modules-kit/no-unused-class-names", 54 | "text": ""local3" is defined but never used in "a.tsx" (css-modules-kit/no-unused-class-names)", 55 | }, 56 | ], 57 | }, 58 | ] 59 | `); 60 | }); 61 | test('does not warn global class names', async () => { 62 | const iff = await createIFF({ 63 | 'a.module.css': dedent` 64 | .local1, :global(.global1) {} 65 | `, 66 | 'a.ts': dedent` 67 | import styles from './a.module.css'; 68 | styles.local1; 69 | `, 70 | }); 71 | const results = await lint(iff.rootDir); 72 | expect(formatLinterResult(results, iff.rootDir)).toMatchInlineSnapshot(` 73 | [ 74 | { 75 | "source": "/a.module.css", 76 | "warnings": [], 77 | }, 78 | ] 79 | `); 80 | }); 81 | test('does not warn if ts file is not found', async () => { 82 | const iff = await createIFF({ 83 | 'a.module.css': dedent` 84 | .local1 {} 85 | `, 86 | }); 87 | const results = await lint(iff.rootDir); 88 | expect(formatLinterResult(results, iff.rootDir)).toMatchInlineSnapshot(` 89 | [ 90 | { 91 | "source": "/a.module.css", 92 | "warnings": [], 93 | }, 94 | ] 95 | `); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/rules/no-unused-class-names.ts: -------------------------------------------------------------------------------- 1 | import { basename, findComponentFile, findUsedTokenNames, isCSSModuleFile, parseRule } from '@css-modules-kit/core'; 2 | import type { Rule, RuleMeta } from 'stylelint'; 3 | import stylelint from 'stylelint'; 4 | import { readFile } from '../util.js'; 5 | 6 | const { createPlugin, utils } = stylelint; 7 | 8 | const ruleName = 'css-modules-kit/no-unused-class-names'; 9 | 10 | const messages = utils.ruleMessages(ruleName, { 11 | disallow: (className: string, componentFileName: string) => 12 | `"${className}" is defined but never used in "${basename(componentFileName)}"`, 13 | }); 14 | 15 | const meta: RuleMeta = { 16 | url: 'https://github.com/mizdra/css-modules-kit/blob/main/packages/eslint-plugin/docs/rules/no-unused-class-names.md', 17 | }; 18 | 19 | const ruleFunction: Rule = (_primaryOptions, _secondaryOptions, _context) => { 20 | return async (root, result) => { 21 | const fileName = root.source?.input.file; 22 | if (fileName === undefined || !isCSSModuleFile(fileName)) return; 23 | 24 | const componentFile = await findComponentFile(fileName, readFile); 25 | 26 | // If the corresponding component file is not found, it is treated as a CSS Module file shared by the entire project. 27 | // It is difficult to determine where class names in a shared CSS Module file are used. Therefore, it is 28 | // assumed that all class names are used. 29 | if (componentFile === undefined) return; 30 | 31 | const usedTokenNames = findUsedTokenNames(componentFile.text); 32 | 33 | root.walkRules((rule) => { 34 | const { classSelectors } = parseRule(rule); 35 | 36 | for (const classSelector of classSelectors) { 37 | if (!usedTokenNames.has(classSelector.name)) { 38 | utils.report({ 39 | result, 40 | ruleName, 41 | message: messages.disallow(classSelector.name, componentFile.fileName), 42 | node: rule, 43 | index: classSelector.loc.start.offset, 44 | endIndex: classSelector.loc.end.offset, 45 | word: classSelector.name, 46 | }); 47 | } 48 | } 49 | }); 50 | }; 51 | }; 52 | 53 | ruleFunction.ruleName = ruleName; 54 | ruleFunction.messages = messages; 55 | ruleFunction.meta = meta; 56 | 57 | export const noUnusedClassNames = createPlugin(ruleName, ruleFunction); 58 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/test/fixture.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { join } from '@css-modules-kit/core'; 4 | import { defineIFFCreator } from '@mizdra/inline-fixture-files'; 5 | 6 | const fixtureDir = join(tmpdir(), 'css-modules-kit', process.env['VITEST_POOL_ID']!); 7 | export const createIFF = defineIFFCreator({ 8 | generateRootDir: () => join(fixtureDir, randomUUID()), 9 | unixStylePath: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/test/stylelint.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from '@css-modules-kit/core'; 2 | import type stylelint from 'stylelint'; 3 | 4 | function filterWarning(warning: stylelint.Warning) { 5 | return { 6 | column: warning.column, 7 | endColumn: warning.endColumn, 8 | endLine: warning.endLine, 9 | line: warning.line, 10 | rule: warning.rule, 11 | text: warning.text, 12 | }; 13 | } 14 | 15 | function formatLintResult(lintResult: stylelint.LintResult, rootDir: string) { 16 | return { 17 | source: resolve(lintResult.source!).replace(rootDir, ''), 18 | warnings: lintResult.warnings.map(filterWarning), 19 | }; 20 | } 21 | 22 | export function formatLinterResult(linterResult: stylelint.LinterResult, rootDir: string) { 23 | return linterResult.results.map((result) => formatLintResult(result, rootDir)); 24 | } 25 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/src/util.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | 3 | export async function readFile(path: string): Promise { 4 | return fs.readFile(path, 'utf-8'); 5 | } 6 | -------------------------------------------------------------------------------- /packages/stylelint-plugin/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | }, 16 | "references": [{ "path": "../core/tsconfig.build.json" }] 17 | } 18 | -------------------------------------------------------------------------------- /packages/ts-plugin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @css-modules-kit/ts-plugin 2 | 3 | ## 0.1.1 4 | 5 | ### Patch Changes 6 | 7 | - 2e460bf: Fix className completion entry to support single quotes 8 | - a91b6fe: Prevent ts-plugin from processing `xxx.css` 9 | 10 | ## 0.1.0 11 | 12 | ### Minor Changes 13 | 14 | - 385bdc3: refactor: change diagnostic interface 15 | - 2b1f0fe: feat: implement resolver cache 16 | 17 | ### Patch Changes 18 | 19 | - 88c9868: refactor: rename `diagnostics` of `SyntacticDiagnostic` to `syntacticDiagnostics` 20 | - Updated dependencies [385bdc3] 21 | - Updated dependencies [2b1f0fe] 22 | - Updated dependencies [6ecc738] 23 | - Updated dependencies [2fde8ec] 24 | - Updated dependencies [819e023] 25 | - Updated dependencies [2bd2165] 26 | - @css-modules-kit/core@0.2.0 27 | 28 | ## 0.0.6 29 | 30 | ### Patch Changes 31 | 32 | - Updated dependencies [e899e5e] 33 | - @css-modules-kit/core@0.1.0 34 | 35 | ## 0.0.5 36 | 37 | ### Patch Changes 38 | 39 | - 7df2e70: Release test 40 | - Updated dependencies [7df2e70] 41 | - @css-modules-kit/core@0.0.5 42 | 43 | ## 0.0.4 44 | 45 | ### Patch Changes 46 | 47 | - 2eb908f: Resolve import specifiers taking into account `baseUrl` and `imports` 48 | - Updated dependencies [2eb908f] 49 | - @css-modules-kit/core@0.0.4 50 | 51 | ## 0.0.3 52 | 53 | ### Patch Changes 54 | 55 | - 508b4b6: Retry publishing 56 | - Updated dependencies [508b4b6] 57 | - @css-modules-kit/core@0.0.3 58 | 59 | ## 0.0.2 60 | 61 | ### Patch Changes 62 | 63 | - 251ba5b: Fix infinite loop when the module graph has circular dependencies 64 | - fa1d6a9: refactor: remove unused codes 65 | - Updated dependencies [251ba5b] 66 | - Updated dependencies [fa1d6a9] 67 | - @css-modules-kit/core@0.0.2 68 | 69 | ## 0.0.1 70 | 71 | ### Patch Changes 72 | 73 | - 434f3da: first release 74 | - Updated dependencies [434f3da] 75 | - @css-modules-kit/core@0.0.1 76 | -------------------------------------------------------------------------------- /packages/ts-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/ts-plugin/README.md: -------------------------------------------------------------------------------- 1 | # `@css-modules-kit/ts-plugin` 2 | 3 | A TypeScript Language Service Plugin for CSS Modules 4 | 5 | ## What is this? 6 | 7 | `@css-modules-kit/ts-plugin` is a TypeScript Language Service Plugin that extends tsserver to handle `*.module.css` files. As a result, many language features like code navigation and rename refactoring become available. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm i -D @css-modules-kit/ts-plugin 13 | ``` 14 | 15 | ## How to setup your editor 16 | 17 | ### Neovim 18 | 19 | ```lua 20 | local lspconfig = require('lspconfig') 21 | 22 | lspconfig.ts_ls.setup { 23 | init_options = { 24 | plugins = { 25 | { 26 | name = '@css-modules-kit/ts-plugin', 27 | languages = { 'css' }, 28 | }, 29 | }, 30 | }, 31 | filetypes = { 'typescript', 'javascript', 'javascriptreact', 'typescriptreact', 'css' }, 32 | } 33 | ``` 34 | 35 | ## Configuration 36 | 37 | See [css-modules-kit's README](https://github.com/mizdra/css-modules-kit?tab=readme-ov-file#configuration). 38 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/code-fix.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { PROPERTY_DOES_NOT_EXIST_ERROR_CODE } from '../src/language-service/feature/code-fix.js'; 4 | import { createIFF } from './test/fixture.js'; 5 | import { formatPath, launchTsserver } from './test/tsserver.js'; 6 | 7 | describe('Get Code Fixes', async () => { 8 | const tsserver = launchTsserver(); 9 | const iff = await createIFF({ 10 | 'a.tsx': dedent` 11 | import styles from './a.module.css'; 12 | import bStyles from './b.module.css'; 13 | styles.a_1; 14 | bStyles.b_2; 15 | `, 16 | 'a.module.css': '', 17 | 'b.module.css': dedent` 18 | .b_1 { 19 | color: red; 20 | } 21 | `, 22 | 'tsconfig.json': dedent` 23 | { 24 | "compilerOptions": {}, 25 | "cmkOptions": { 26 | "dtsOutDir": "generated" 27 | } 28 | } 29 | `, 30 | }); 31 | await tsserver.sendUpdateOpen({ 32 | openFiles: [{ file: iff.paths['tsconfig.json'] }], 33 | }); 34 | test.each([ 35 | { 36 | name: 'styles.a_1', 37 | file: iff.paths['a.tsx'], 38 | line: 3, 39 | offset: 11, 40 | expected: [ 41 | { 42 | fixName: 'fixMissingCSSRule', 43 | description: `Add missing CSS rule '.a_1'`, 44 | changes: [ 45 | { 46 | fileName: formatPath(iff.paths['a.module.css']), 47 | textChanges: [{ start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: '\n.a_1 {\n \n}' }], 48 | }, 49 | ], 50 | }, 51 | ], 52 | }, 53 | { 54 | name: 'bStyles.b_2', 55 | file: iff.paths['a.tsx'], 56 | line: 4, 57 | offset: 12, 58 | expected: [ 59 | { 60 | fixName: 'fixMissingCSSRule', 61 | description: `Add missing CSS rule '.b_2'`, 62 | changes: [ 63 | { 64 | fileName: formatPath(iff.paths['b.module.css']), 65 | textChanges: [{ start: { line: 3, offset: 2 }, end: { line: 3, offset: 2 }, newText: '\n.b_2 {\n \n}' }], 66 | }, 67 | ], 68 | }, 69 | ], 70 | }, 71 | ])('$name', async ({ file, line, offset, expected }) => { 72 | const res = await tsserver.sendGetCodeFixes({ 73 | errorCodes: [PROPERTY_DOES_NOT_EXIST_ERROR_CODE], 74 | file, 75 | startLine: line, 76 | startOffset: offset, 77 | endLine: line, 78 | endOffset: offset, 79 | }); 80 | expect(res.body).toStrictEqual(expected); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/completion.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from '@css-modules-kit/core'; 2 | import dedent from 'dedent'; 3 | import type ts from 'typescript'; 4 | import { describe, expect, test } from 'vitest'; 5 | import { createIFF } from './test/fixture.js'; 6 | import { formatPath, launchTsserver } from './test/tsserver.js'; 7 | 8 | // eslint-disable-next-line n/no-extraneous-require 9 | const reactDtsPath = join(require.resolve('@types/react/package.json'), '../index.d.ts'); 10 | 11 | describe('Completion', async () => { 12 | function simplifyEntry(entries: readonly ts.server.protocol.CompletionEntry[]) { 13 | return entries.map((entry) => { 14 | return { 15 | name: entry.name, 16 | sortText: entry.sortText, 17 | ...('source' in entry ? { source: entry.source } : {}), 18 | ...('insertText' in entry ? { insertText: entry.insertText } : {}), 19 | }; 20 | }); 21 | } 22 | const tsserver = launchTsserver(); 23 | const iff = await createIFF({ 24 | 'a.tsx': dedent` 25 | styles; 26 | const jsx =
; 27 | `, 28 | 'b.tsx': dedent` 29 | import styles from './b.module.css'; 30 | const jsx =
; 31 | `, 32 | 'a.module.css': '', 33 | 'b.module.css': '', 34 | 'tsconfig.json': dedent` 35 | { 36 | "compilerOptions": { 37 | "jsx": "react-jsx", 38 | "types": ["${reactDtsPath}"] 39 | }, 40 | "cmkOptions": { 41 | "dtsOutDir": "generated" 42 | } 43 | } 44 | `, 45 | }); 46 | await tsserver.sendUpdateOpen({ 47 | openFiles: [{ file: iff.paths['tsconfig.json'] }], 48 | }); 49 | await tsserver.sendConfigure({ 50 | preferences: { 51 | includeCompletionsForModuleExports: true, 52 | includeCompletionsWithSnippetText: true, 53 | includeCompletionsWithInsertText: true, 54 | jsxAttributeCompletionStyle: 'auto', 55 | quotePreference: 'single', 56 | }, 57 | }); 58 | test.each([ 59 | { 60 | name: 'styles', 61 | entryName: 'styles', 62 | file: iff.paths['a.tsx'], 63 | line: 1, 64 | offset: 7, 65 | expected: [ 66 | { name: 'styles', sortText: '0', source: formatPath(iff.paths['a.module.css']) }, 67 | { name: 'styles', sortText: '16', source: formatPath(iff.paths['b.module.css']) }, 68 | ], 69 | }, 70 | { 71 | name: "className with `quotePreference: 'double'`", 72 | entryName: 'className', 73 | quotePreference: 'double' as const, 74 | file: iff.paths['a.tsx'], 75 | line: 2, 76 | offset: 27, 77 | expected: [{ name: 'className', insertText: 'className={$1}', sortText: expect.anything() }], 78 | }, 79 | { 80 | name: "className with `quotePreference: 'single'`", 81 | entryName: 'className', 82 | quotePreference: 'single' as const, 83 | file: iff.paths['b.tsx'], 84 | line: 2, 85 | offset: 27, 86 | expected: [{ name: 'className', insertText: 'className={$1}', sortText: expect.anything() }], 87 | }, 88 | ])('Completions for $name', async ({ entryName, quotePreference, file, line, offset, expected }) => { 89 | await tsserver.sendConfigure({ 90 | preferences: { 91 | quotePreference: quotePreference ?? 'auto', 92 | }, 93 | }); 94 | const res = await tsserver.sendCompletionInfo({ 95 | file, 96 | line, 97 | offset, 98 | }); 99 | expect( 100 | simplifyEntry(res.body?.entries.filter((entry) => entry.name === entryName) ?? []).sort(compareEntries), 101 | ).toStrictEqual(expected.sort(compareEntries)); 102 | }); 103 | }); 104 | 105 | function compareEntries( 106 | a: Partial, 107 | b: Partial, 108 | ) { 109 | return a.sortText?.localeCompare(b.sortText ?? '') || 0; 110 | } 111 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/invalid-syntax.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { createIFF } from './test/fixture.js'; 4 | import { formatPath, launchTsserver, simplifyDefinitions, sortDefinitions } from './test/tsserver.js'; 5 | 6 | describe('handle invalid syntax CSS without crashing', async () => { 7 | const tsserver = launchTsserver(); 8 | const iff = await createIFF({ 9 | 'index.ts': dedent` 10 | import styles from './a.module.css'; 11 | styles.a_1; 12 | `, 13 | 'a.module.css': dedent` 14 | .a_1 { color: red; } 15 | .a_2 { 16 | `, 17 | 'tsconfig.json': dedent` 18 | { 19 | "compilerOptions": {}, 20 | "cmkOptions": { 21 | "dtsOutDir": "generated" 22 | } 23 | } 24 | `, 25 | }); 26 | await tsserver.sendUpdateOpen({ 27 | openFiles: [{ file: iff.paths['index.ts'] }], 28 | }); 29 | test('can get definition and bound span', async () => { 30 | const res = await tsserver.sendDefinitionAndBoundSpan({ 31 | file: iff.paths['index.ts'], 32 | line: 2, 33 | offset: 8, 34 | }); 35 | const expected = [ 36 | { 37 | file: formatPath(iff.paths['a.module.css']), 38 | start: { line: 1, offset: 2 }, 39 | end: { line: 1, offset: 5 }, 40 | }, 41 | ]; 42 | expect(sortDefinitions(simplifyDefinitions(res.body?.definitions ?? []))).toStrictEqual(sortDefinitions(expected)); 43 | }); 44 | test('does not report syntactic diagnostics', async () => { 45 | // NOTE: The standard CSS Language Server reports invalid syntax errors. 46 | // Therefore, if ts-plugin also reports it, the same error is reported twice. 47 | // To avoid this, ts-plugin does not report invalid syntax errors. 48 | const res = await tsserver.sendSyntacticDiagnosticsSync({ 49 | file: iff.paths['a.module.css'], 50 | }); 51 | expect(res.body).toMatchInlineSnapshot(`[]`); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/refactor.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { createCssModuleFileRefactor } from '../src/language-service/feature/refactor.js'; 4 | import { createIFF } from './test/fixture.js'; 5 | import { launchTsserver } from './test/tsserver.js'; 6 | 7 | describe('Refactor', async () => { 8 | const tsserver = launchTsserver(); 9 | const iff = await createIFF({ 10 | 'a.tsx': '', 11 | 'b.ts': '', 12 | 'tsconfig.json': dedent` 13 | { 14 | "compilerOptions": { "jsx": "react-jsx" }, 15 | "cmkOptions": { 16 | "dtsOutDir": "generated" 17 | } 18 | } 19 | `, 20 | }); 21 | await tsserver.sendUpdateOpen({ 22 | openFiles: [{ file: iff.paths['a.tsx'] }], 23 | }); 24 | test.each([ 25 | { 26 | name: 'a.tsx', 27 | file: iff.paths['a.tsx'], 28 | line: 1, 29 | offset: 1, 30 | expected: [createCssModuleFileRefactor], 31 | }, 32 | { 33 | name: 'b.ts', 34 | file: iff.paths['b.ts'], 35 | line: 1, 36 | offset: 1, 37 | expected: [], 38 | }, 39 | ])('Get Applicable Refactors for $name', async ({ file, line, offset, expected }) => { 40 | const res = await tsserver.sendGetApplicableRefactors({ 41 | file, 42 | line, 43 | offset, 44 | }); 45 | expect(res.body).toStrictEqual(expected); 46 | }); 47 | test.each([ 48 | { 49 | name: 'a.tsx', 50 | file: iff.paths['a.tsx'], 51 | line: 1, 52 | offset: 1, 53 | expected: [ 54 | { 55 | fileName: iff.join('a.module.css'), 56 | textChanges: [{ start: { line: 0, offset: 0 }, end: { line: 0, offset: 0 }, newText: '' }], 57 | }, 58 | ], 59 | }, 60 | ])('Get Edits For Refactor for $name', async ({ file, line, offset, expected }) => { 61 | const res = await tsserver.sendGetEditsForRefactor({ 62 | refactor: createCssModuleFileRefactor.name, 63 | action: createCssModuleFileRefactor.actions[0].name, 64 | file, 65 | line, 66 | offset, 67 | }); 68 | expect(res.body?.edits).toStrictEqual(expected); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/regression/pure-css-file.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { expect, test } from 'vitest'; 3 | import { createIFF } from '../test/fixture.js'; 4 | import { launchTsserver } from '../test/tsserver.js'; 5 | 6 | test('ts-plugin does not process pure css files', async () => { 7 | // ref: https://github.com/mizdra/css-modules-kit/issues/170 8 | const tsserver = launchTsserver(); 9 | const iff = await createIFF({ 10 | 'global.css': dedent` 11 | * { margin: 0; } 12 | `, 13 | 'tsconfig.json': '{}', 14 | }); 15 | await tsserver.sendUpdateOpen({ 16 | openFiles: [{ file: iff.paths['tsconfig.json'] }], 17 | }); 18 | const res1 = await tsserver.sendSyntacticDiagnosticsSync({ 19 | file: iff.paths['global.css'], 20 | }); 21 | expect(res1.body?.length).toBe(0); 22 | const res2 = await tsserver.sendSemanticDiagnosticsSync({ 23 | file: iff.paths['global.css'], 24 | }); 25 | expect(res2.body?.length).toBe(0); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/rename-file.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { createIFF } from './test/fixture.js'; 4 | import { formatPath, launchTsserver } from './test/tsserver.js'; 5 | 6 | describe('Rename File', async () => { 7 | const tsserver = launchTsserver(); 8 | const iff = await createIFF({ 9 | 'index.ts': dedent` 10 | import styles from './a.module.css'; 11 | `, 12 | 'a.module.css': dedent` 13 | @import './b.module.css'; 14 | @value c_1 from './c.module.css'; 15 | `, 16 | 'b.module.css': dedent` 17 | .b_1 { color: red; } 18 | `, 19 | 'c.module.css': dedent` 20 | @value c_1: red; 21 | `, 22 | 'tsconfig.json': dedent` 23 | { 24 | "compilerOptions": { 25 | "paths": { "@/*": ["./*"] } 26 | }, 27 | "cmkOptions": { 28 | "dtsOutDir": "generated" 29 | } 30 | } 31 | `, 32 | }); 33 | await tsserver.sendUpdateOpen({ 34 | openFiles: [{ file: iff.paths['index.ts'] }], 35 | }); 36 | test.each([ 37 | { 38 | name: 'a.module.css', 39 | oldFilePath: iff.paths['a.module.css'], 40 | newFilePath: iff.join('aa.module.css'), 41 | expected: [ 42 | { 43 | fileName: formatPath(iff.paths['index.ts']), 44 | textChanges: [{ start: { line: 1, offset: 21 }, end: { line: 1, offset: 35 }, newText: './aa.module.css' }], 45 | }, 46 | ], 47 | }, 48 | { 49 | name: 'b.module.css', 50 | oldFilePath: iff.paths['b.module.css'], 51 | newFilePath: iff.join('bb.module.css'), 52 | expected: [ 53 | { 54 | fileName: formatPath(iff.paths['a.module.css']), 55 | textChanges: [{ start: { line: 1, offset: 10 }, end: { line: 1, offset: 24 }, newText: './bb.module.css' }], 56 | }, 57 | ], 58 | }, 59 | { 60 | name: 'c.module.css', 61 | oldFilePath: iff.paths['c.module.css'], 62 | newFilePath: iff.join('cc.module.css'), 63 | expected: [ 64 | { 65 | fileName: formatPath(iff.paths['a.module.css']), 66 | textChanges: [{ start: { line: 2, offset: 18 }, end: { line: 2, offset: 32 }, newText: './cc.module.css' }], 67 | }, 68 | ], 69 | }, 70 | ])('for $name', async ({ oldFilePath, newFilePath, expected }) => { 71 | const res = await tsserver.sendGetEditsForFileRename({ oldFilePath, newFilePath }); 72 | expect(res.body).toStrictEqual(expected); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/semantic-diagnostics.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { expect, test } from 'vitest'; 3 | import { createIFF } from './test/fixture.js'; 4 | import { launchTsserver } from './test/tsserver.js'; 5 | 6 | test('Semantic Diagnostics', async () => { 7 | const tsserver = launchTsserver(); 8 | const iff = await createIFF({ 9 | 'index.ts': dedent` 10 | import styles from './a.module.css'; 11 | type Expected = { a_1: string, a_2: string, b_1: string, c_1: string, c_alias: string }; 12 | const t1: Expected = styles; 13 | const t2: typeof styles = 0 as any as Expected; 14 | styles.unknown; 15 | `, 16 | 'a.module.css': dedent` 17 | @import './b.module.css'; 18 | @value c_1, c_2 as c_alias, c_3 from './c.module.css'; 19 | .a_1 { color: red; } 20 | @value a_2: red; 21 | @import './d.module.css'; 22 | `, 23 | 'b.module.css': dedent` 24 | .b_1 { color: red; } 25 | `, 26 | 'c.module.css': dedent` 27 | @value c_1: red; 28 | @value c_2: red; 29 | `, 30 | 'tsconfig.json': dedent` 31 | { 32 | "compilerOptions": {}, 33 | "cmkOptions": { 34 | "dtsOutDir": "generated" 35 | } 36 | } 37 | `, 38 | }); 39 | await tsserver.sendUpdateOpen({ 40 | openFiles: [{ file: iff.paths['index.ts'] }], 41 | }); 42 | const res1 = await tsserver.sendSemanticDiagnosticsSync({ 43 | file: iff.paths['index.ts'], 44 | }); 45 | // TODO: Report type errors 46 | expect(res1.body).toMatchInlineSnapshot(`[]`); 47 | 48 | const res2 = await tsserver.sendSemanticDiagnosticsSync({ 49 | file: iff.paths['a.module.css'], 50 | }); 51 | expect(res2.body).toMatchInlineSnapshot(` 52 | [ 53 | { 54 | "category": "error", 55 | "code": 0, 56 | "end": { 57 | "line": 2, 58 | "offset": 32, 59 | }, 60 | "source": "css-modules-kit", 61 | "start": { 62 | "line": 2, 63 | "offset": 29, 64 | }, 65 | "text": "Module './c.module.css' has no exported token 'c_3'.", 66 | }, 67 | { 68 | "category": "error", 69 | "code": 0, 70 | "end": { 71 | "line": 5, 72 | "offset": 24, 73 | }, 74 | "source": "css-modules-kit", 75 | "start": { 76 | "line": 5, 77 | "offset": 10, 78 | }, 79 | "text": "Cannot import module './d.module.css'", 80 | }, 81 | ] 82 | `); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/syntactic-diagnostics.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { expect, test } from 'vitest'; 3 | import { createIFF } from './test/fixture.js'; 4 | import { launchTsserver } from './test/tsserver.js'; 5 | 6 | test('Syntactic Diagnostics', async () => { 7 | const tsserver = launchTsserver(); 8 | const iff = await createIFF({ 9 | 'a.module.css': dedent` 10 | @value; 11 | :local(:global(.a_1)) { color: red; } 12 | :local .a_2 { color: red; } 13 | .a-3 { color: red; } 14 | `, 15 | 'tsconfig.json': dedent` 16 | { 17 | "compilerOptions": {}, 18 | "cmkOptions": { 19 | "dtsOutDir": "generated" 20 | } 21 | } 22 | `, 23 | }); 24 | await tsserver.sendUpdateOpen({ 25 | openFiles: [{ file: iff.paths['a.module.css'] }], 26 | }); 27 | const res1 = await tsserver.sendSyntacticDiagnosticsSync({ 28 | file: iff.paths['a.module.css'], 29 | }); 30 | expect(res1.body).toMatchInlineSnapshot(` 31 | [ 32 | { 33 | "category": "error", 34 | "code": 0, 35 | "end": { 36 | "line": 1, 37 | "offset": 8, 38 | }, 39 | "source": "css-modules-kit", 40 | "start": { 41 | "line": 1, 42 | "offset": 1, 43 | }, 44 | "text": "\`@value\` is a invalid syntax.", 45 | }, 46 | { 47 | "category": "error", 48 | "code": 0, 49 | "end": { 50 | "line": 2, 51 | "offset": 21, 52 | }, 53 | "source": "css-modules-kit", 54 | "start": { 55 | "line": 2, 56 | "offset": 8, 57 | }, 58 | "text": "A \`:global(...)\` is not allowed inside of \`:local(...)\`.", 59 | }, 60 | { 61 | "category": "error", 62 | "code": 0, 63 | "end": { 64 | "line": 3, 65 | "offset": 7, 66 | }, 67 | "source": "css-modules-kit", 68 | "start": { 69 | "line": 3, 70 | "offset": 1, 71 | }, 72 | "text": "\`:local\` is not supported. Use \`:local(...)\` instead.", 73 | }, 74 | { 75 | "category": "error", 76 | "code": 0, 77 | "end": { 78 | "line": 4, 79 | "offset": 5, 80 | }, 81 | "source": "css-modules-kit", 82 | "start": { 83 | "line": 4, 84 | "offset": 1, 85 | }, 86 | "text": "\`a-3\` is not allowed because it is not a valid JavaScript identifier.", 87 | }, 88 | ] 89 | `); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/test/fixture.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { tmpdir } from 'node:os'; 3 | import { join } from '@css-modules-kit/core'; 4 | import { defineIFFCreator } from '@mizdra/inline-fixture-files'; 5 | 6 | const fixtureDir = join(tmpdir(), '@css-modules-kit/ts-plugin', process.env['VITEST_POOL_ID']!); 7 | export const createIFF = defineIFFCreator({ 8 | generateRootDir: () => join(fixtureDir, randomUUID()), 9 | unixStylePath: true, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/ts-plugin/e2e/test/tsserver.ts: -------------------------------------------------------------------------------- 1 | import serverHarness from '@typescript/server-harness'; 2 | import type { server } from 'typescript'; 3 | import ts from 'typescript'; 4 | 5 | interface Tsserver { 6 | sendUpdateOpen(args: server.protocol.UpdateOpenRequest['arguments']): Promise; 7 | sendConfigure(args: server.protocol.ConfigureRequest['arguments']): Promise; 8 | sendDefinitionAndBoundSpan( 9 | args: server.protocol.FileLocationRequestArgs, 10 | ): Promise; 11 | sendReferences(args: server.protocol.ReferencesRequest['arguments']): Promise; 12 | sendRename(args: server.protocol.RenameRequest['arguments']): Promise; 13 | sendSemanticDiagnosticsSync( 14 | args: server.protocol.SemanticDiagnosticsSyncRequest['arguments'], 15 | ): Promise; 16 | sendSyntacticDiagnosticsSync( 17 | args: server.protocol.SyntacticDiagnosticsSyncRequest['arguments'], 18 | ): Promise; 19 | sendGetEditsForFileRename( 20 | args: server.protocol.GetEditsForFileRenameRequest['arguments'], 21 | ): Promise; 22 | sendGetApplicableRefactors( 23 | args: server.protocol.GetApplicableRefactorsRequest['arguments'], 24 | ): Promise; 25 | sendGetEditsForRefactor( 26 | args: server.protocol.GetEditsForRefactorRequest['arguments'], 27 | ): Promise; 28 | sendCompletionInfo( 29 | args: server.protocol.CompletionsRequest['arguments'], 30 | ): Promise; 31 | sendGetCodeFixes(args: server.protocol.CodeFixRequest['arguments']): Promise; 32 | } 33 | 34 | export function launchTsserver(): Tsserver { 35 | const server = serverHarness.launchServer( 36 | require.resolve('typescript/lib/tsserver.js'), 37 | [ 38 | '--disableAutomaticTypingAcquisition', 39 | '--globalPlugins', 40 | '@css-modules-kit/ts-plugin', 41 | '--pluginProbeLocations', 42 | __dirname, 43 | ], 44 | [], 45 | ); 46 | let seq = 0; 47 | async function sendRequest( 48 | command: string, 49 | args?: unknown, 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | ): Promise { 52 | const res: server.protocol.Response = await server.message({ 53 | seq: seq++, 54 | type: 'request', 55 | command, 56 | arguments: args, 57 | }); 58 | if (!res.success) { 59 | throw new Error(`Expected success response, got ${JSON.stringify(res)}`); 60 | } 61 | return res; 62 | } 63 | 64 | return { 65 | sendUpdateOpen: async (args) => sendRequest(ts.server.protocol.CommandTypes.UpdateOpen, args), 66 | sendConfigure: async (args) => sendRequest(ts.server.protocol.CommandTypes.Configure, args), 67 | sendDefinitionAndBoundSpan: async (args) => 68 | sendRequest(ts.server.protocol.CommandTypes.DefinitionAndBoundSpan, args), 69 | sendReferences: async (args) => sendRequest(ts.server.protocol.CommandTypes.References, args), 70 | sendRename: async (args) => sendRequest(ts.server.protocol.CommandTypes.Rename, args), 71 | sendSemanticDiagnosticsSync: async (args) => 72 | sendRequest(ts.server.protocol.CommandTypes.SemanticDiagnosticsSync, args), 73 | sendSyntacticDiagnosticsSync: async (args) => 74 | sendRequest(ts.server.protocol.CommandTypes.SyntacticDiagnosticsSync, args), 75 | sendGetEditsForFileRename: async (args) => sendRequest(ts.server.protocol.CommandTypes.GetEditsForFileRename, args), 76 | sendGetApplicableRefactors: async (args) => 77 | sendRequest(ts.server.protocol.CommandTypes.GetApplicableRefactors, args), 78 | sendGetEditsForRefactor: async (args) => sendRequest(ts.server.protocol.CommandTypes.GetEditsForRefactor, args), 79 | sendCompletionInfo: async (args) => sendRequest(ts.server.protocol.CommandTypes.CompletionInfo, args), 80 | sendGetCodeFixes: async (args) => sendRequest(ts.server.protocol.CommandTypes.GetCodeFixes, args), 81 | }; 82 | } 83 | 84 | export function formatPath(path: string) { 85 | // In windows, tsserver returns paths with '/' instead of '\\'. 86 | return path.replaceAll('\\', '/'); 87 | } 88 | 89 | export function simplifyDefinitions(definitions: readonly ts.server.protocol.DefinitionInfo[]) { 90 | return definitions.map((definition) => { 91 | return { 92 | file: formatPath(definition.file), 93 | start: definition.start, 94 | end: definition.end, 95 | }; 96 | }); 97 | } 98 | export function sortDefinitions(definitions: readonly ts.server.protocol.DefinitionInfo[]) { 99 | return definitions.toSorted((a, b) => { 100 | return a.file.localeCompare(b.file) || a.start.line - b.start.line || a.start.offset - b.start.offset; 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /packages/ts-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-modules-kit/ts-plugin", 3 | "description": "A TypeScript Language Service Plugin for CSS Modules", 4 | "version": "0.1.1", 5 | "type": "commonjs", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/ts-plugin" 11 | }, 12 | "author": "mizdra ", 13 | "license": "MIT", 14 | "private": false, 15 | "main": "./dist/index.js", 16 | "scripts": { 17 | "build": "tsc -b tsconfig.build.json" 18 | }, 19 | "engines": { 20 | "node": ">=22.0.0" 21 | }, 22 | "publishConfig": { 23 | "access": "public", 24 | "registry": "https://registry.npmjs.org/" 25 | }, 26 | "keywords": [ 27 | "css-modules", 28 | "typescript", 29 | "language service" 30 | ], 31 | "files": [ 32 | "bin", 33 | "src", 34 | "!src/**/*.test.ts", 35 | "!src/**/__snapshots__", 36 | "!src/test", 37 | "dist" 38 | ], 39 | "dependencies": { 40 | "@volar/language-core": "^2.4.11", 41 | "@volar/typescript": "^2.4.11", 42 | "@css-modules-kit/core": "^0.2.0" 43 | }, 44 | "peerDependencies": { 45 | "typescript": ">=5.6.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { CMKConfig } from '@css-modules-kit/core'; 2 | import { createMatchesPattern, createResolver, readConfigFile } from '@css-modules-kit/core'; 3 | import { TsConfigFileNotFoundError } from '@css-modules-kit/core'; 4 | import { createLanguageServicePlugin } from '@volar/typescript/lib/quickstart/createLanguageServicePlugin.js'; 5 | import { createCSSLanguagePlugin } from './language-plugin.js'; 6 | import { proxyLanguageService } from './language-service/proxy.js'; 7 | 8 | const plugin = createLanguageServicePlugin((ts, info) => { 9 | if (info.project.projectKind !== ts.server.ProjectKind.Configured) { 10 | info.project.projectService.logger.info(`[@css-modules-kit/ts-plugin] info: Project is not configured`); 11 | return { languagePlugins: [] }; 12 | } 13 | 14 | let config: CMKConfig; 15 | try { 16 | config = readConfigFile(info.project.getProjectName()); 17 | // TODO: Report diagnostics 18 | info.project.projectService.logger.info( 19 | `[@css-modules-kit/ts-plugin] info: Config file is found '${config.configFileName}'`, 20 | ); 21 | } catch (error) { 22 | // If the config file is not found, disable the plugin. 23 | if (error instanceof TsConfigFileNotFoundError) { 24 | return { languagePlugins: [] }; 25 | } else { 26 | let msg = `[@css-modules-kit/ts-plugin] error: Fail to read config file`; 27 | if (error instanceof Error) { 28 | msg += `\n: ${error.message}`; 29 | msg += `\n${error.stack}`; 30 | } 31 | info.project.projectService.logger.info(msg); 32 | return { languagePlugins: [] }; 33 | } 34 | } 35 | 36 | const moduleResolutionCache = info.languageServiceHost.getModuleResolutionCache?.(); 37 | const resolver = createResolver(config.compilerOptions, moduleResolutionCache); 38 | const matchesPattern = createMatchesPattern(config); 39 | 40 | return { 41 | languagePlugins: [createCSSLanguagePlugin(resolver, matchesPattern)], 42 | setup: (language) => { 43 | info.languageService = proxyLanguageService( 44 | language, 45 | info.languageService, 46 | info.project, 47 | resolver, 48 | matchesPattern, 49 | ); 50 | }, 51 | }; 52 | }); 53 | 54 | export = plugin; 55 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { CSSModule, DiagnosticWithLocation, MatchesPattern, Resolver } from '@css-modules-kit/core'; 2 | import { createDts, parseCSSModule } from '@css-modules-kit/core'; 3 | import type { LanguagePlugin, SourceScript, VirtualCode } from '@volar/language-core'; 4 | import type {} from '@volar/typescript'; 5 | import ts from 'typescript'; 6 | 7 | export const LANGUAGE_ID = 'css'; 8 | 9 | export const CMK_DATA_KEY = Symbol('css-modules-kit-data'); 10 | 11 | interface CSSModuleVirtualCode extends VirtualCode { 12 | [CMK_DATA_KEY]: { 13 | cssModule: CSSModule; 14 | syntacticDiagnostics: DiagnosticWithLocation[]; 15 | }; 16 | } 17 | 18 | export interface CSSModuleScript extends SourceScript { 19 | generated: SourceScript['generated'] & { 20 | root: CSSModuleVirtualCode; 21 | }; 22 | } 23 | 24 | export function createCSSLanguagePlugin( 25 | resolver: Resolver, 26 | matchesPattern: MatchesPattern, 27 | ): LanguagePlugin { 28 | return { 29 | getLanguageId(scriptId) { 30 | if (!scriptId.endsWith('.css')) return undefined; 31 | return LANGUAGE_ID; 32 | }, 33 | createVirtualCode(scriptId, languageId, snapshot): VirtualCode | CSSModuleVirtualCode | undefined { 34 | if (languageId !== LANGUAGE_ID) return undefined; 35 | if (!matchesPattern(scriptId)) { 36 | // `scriptId` is CSS, but not a CSS module. 37 | // If an empty VirtualCode is not returned for a CSS file, tsserver will treat it as TypeScript code. 38 | // ref: https://github.com/mizdra/css-modules-kit/issues/170 39 | return { 40 | id: 'main', 41 | languageId, 42 | snapshot, 43 | mappings: [], 44 | }; 45 | } 46 | 47 | const length = snapshot.getLength(); 48 | const cssModuleCode = snapshot.getText(0, length); 49 | const { cssModule, diagnostics } = parseCSSModule(cssModuleCode, { 50 | fileName: scriptId, 51 | // The CSS in the process of being written in an editor often contains invalid syntax. 52 | // So, ts-plugin uses a fault-tolerant Parser to parse CSS. 53 | safe: true, 54 | }); 55 | const { text, mapping, linkedCodeMapping } = createDts(cssModule, { resolver, matchesPattern }); 56 | return { 57 | id: 'main', 58 | languageId: LANGUAGE_ID, 59 | snapshot: { 60 | getText: (start, end) => text.slice(start, end), 61 | getLength: () => text.length, 62 | getChangeRange: () => undefined, 63 | }, 64 | // `mappings` are required to support "Go to Definition" and renaming 65 | mappings: [{ ...mapping, data: { navigation: true } }], 66 | // `linkedCodeMappings` are required to support "Go to Definition" and renaming for the imported tokens 67 | linkedCodeMappings: [{ ...linkedCodeMapping, data: undefined }], 68 | [CMK_DATA_KEY]: { 69 | cssModule, 70 | syntacticDiagnostics: diagnostics, 71 | }, 72 | }; 73 | }, 74 | typescript: { 75 | extraFileExtensions: [ 76 | { 77 | extension: 'css', 78 | isMixedContent: true, 79 | scriptKind: ts.ScriptKind.TS, 80 | }, 81 | ], 82 | getServiceScript(root) { 83 | return { 84 | code: root, 85 | extension: ts.Extension.Ts, 86 | scriptKind: ts.ScriptKind.TS, 87 | }; 88 | }, 89 | }, 90 | }; 91 | } 92 | 93 | export function isCSSModuleScript(script: SourceScript | undefined): script is CSSModuleScript { 94 | return ( 95 | !!script && script.languageId === LANGUAGE_ID && !!script.generated?.root && CMK_DATA_KEY in script.generated.root 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-service/feature/code-fix.ts: -------------------------------------------------------------------------------- 1 | import { isComponentFileName } from '@css-modules-kit/core'; 2 | import type { Language } from '@volar/language-core'; 3 | import ts from 'typescript'; 4 | import { isCSSModuleScript } from '../../language-plugin.js'; 5 | 6 | // ref: https://github.com/microsoft/TypeScript/blob/220706eb0320ff46fad8bf80a5e99db624ee7dfb/src/compiler/diagnosticMessages.json#L2051-L2054 7 | export const PROPERTY_DOES_NOT_EXIST_ERROR_CODE = 2339; 8 | 9 | export function getCodeFixesAtPosition( 10 | language: Language, 11 | languageService: ts.LanguageService, 12 | project: ts.server.Project, 13 | ): ts.LanguageService['getCodeFixesAtPosition'] { 14 | // eslint-disable-next-line max-params 15 | return (fileName, start, end, errorCodes, formatOptions, preferences) => { 16 | const prior = Array.from( 17 | languageService.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences) ?? [], 18 | ); 19 | 20 | if (isComponentFileName(fileName)) { 21 | // If a user is trying to use a non-existent token (e.g. `styles.nonExistToken`), provide a code fix to add the token. 22 | if (errorCodes.includes(PROPERTY_DOES_NOT_EXIST_ERROR_CODE)) { 23 | const tokenConsumer = getTokenConsumerAtPosition(fileName, start, language, languageService, project); 24 | if (tokenConsumer) { 25 | prior.push({ 26 | fixName: 'fixMissingCSSRule', 27 | description: `Add missing CSS rule '.${tokenConsumer.tokenName}'`, 28 | changes: [createInsertRuleFileChange(tokenConsumer.from, tokenConsumer.tokenName, language)], 29 | }); 30 | } 31 | } 32 | } 33 | 34 | return prior; 35 | }; 36 | } 37 | 38 | interface TokenConsumer { 39 | /** The token name (e.g. `foo` in `styles.foo`) */ 40 | tokenName: string; 41 | /** The file path of the CSS module that defines the token */ 42 | from: string; 43 | } 44 | 45 | /** 46 | * Get the token consumer at the specified position. 47 | * If the position is at `styles.foo`, it returns `{ tokenName: 'foo', from: '/path/to/a.module.css' }`. 48 | */ 49 | function getTokenConsumerAtPosition( 50 | fileName: string, 51 | position: number, 52 | language: Language, 53 | languageService: ts.LanguageService, 54 | project: ts.server.Project, 55 | ): TokenConsumer | undefined { 56 | const sourceFile = project.getSourceFile(project.projectService.toPath(fileName)); 57 | if (!sourceFile) return undefined; 58 | const propertyAccessExpression = getPropertyAccessExpressionAtPosition(sourceFile, position); 59 | if (!propertyAccessExpression) return undefined; 60 | 61 | // Check if the expression of property access expression (e.g. `styles` in `styles.foo`) is imported from a CSS module. 62 | 63 | // `expression` is the expression of the property access expression (e.g. `styles` in `styles.foo`). 64 | const expression = propertyAccessExpression.expression; 65 | 66 | const definitions = languageService.getDefinitionAtPosition(fileName, expression.getStart()); 67 | if (definitions && definitions[0]) { 68 | const script = language.scripts.get(definitions[0].fileName); 69 | if (isCSSModuleScript(script)) { 70 | return { tokenName: propertyAccessExpression.name.text, from: definitions[0].fileName }; 71 | } 72 | } 73 | return undefined; 74 | } 75 | 76 | /** Get the property access expression at the specified position. (e.g. `obj.foo`, `styles.foo`) */ 77 | function getPropertyAccessExpressionAtPosition( 78 | sourceFile: ts.SourceFile, 79 | position: number, 80 | ): ts.PropertyAccessExpression | undefined { 81 | function getPropertyAccessExpressionImpl(node: ts.Node): ts.PropertyAccessExpression | undefined { 82 | if (node.pos <= position && position <= node.end && ts.isPropertyAccessExpression(node)) { 83 | return node; 84 | } 85 | return ts.forEachChild(node, getPropertyAccessExpressionImpl); 86 | } 87 | return getPropertyAccessExpressionImpl(sourceFile); 88 | } 89 | 90 | function createInsertRuleFileChange( 91 | cssModuleFileName: string, 92 | className: string, 93 | language: Language, 94 | ): ts.FileTextChanges { 95 | const script = language.scripts.get(cssModuleFileName); 96 | if (script) { 97 | return { 98 | fileName: cssModuleFileName, 99 | textChanges: [{ span: { start: script.snapshot.getLength(), length: 0 }, newText: `\n.${className} {\n \n}` }], 100 | isNewFile: false, 101 | }; 102 | } else { 103 | return { 104 | fileName: cssModuleFileName, 105 | textChanges: [{ span: { start: 0, length: 0 }, newText: `.${className} {\n \n}\n\n` }], 106 | isNewFile: true, 107 | }; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-service/feature/completion.ts: -------------------------------------------------------------------------------- 1 | import { getCssModuleFileName, isComponentFileName, STYLES_EXPORT_NAME } from '@css-modules-kit/core'; 2 | import ts from 'typescript'; 3 | 4 | export function getCompletionsAtPosition( 5 | languageService: ts.LanguageService, 6 | ): ts.LanguageService['getCompletionsAtPosition'] { 7 | return (fileName, position, options, formattingSettings) => { 8 | const prior = languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); 9 | 10 | if (isComponentFileName(fileName) && prior) { 11 | const cssModuleFileName = getCssModuleFileName(fileName); 12 | for (const entry of prior.entries) { 13 | if (isStylesEntryForCSSModuleFile(entry, cssModuleFileName)) { 14 | // Prioritize the completion of the `styles' import for the current .ts file for usability. 15 | // NOTE: This is a hack to make the completion item appear at the top 16 | entry.sortText = '0'; 17 | } else if (isClassNamePropEntry(entry)) { 18 | // Complete `className={...}` instead of `className="..."` for usability. 19 | entry.insertText = 'className={$1}'; 20 | } 21 | } 22 | } 23 | return prior; 24 | }; 25 | } 26 | 27 | /** 28 | * Check if the completion entry is the `styles` entry for the CSS module file. 29 | */ 30 | function isStylesEntryForCSSModuleFile(entry: ts.CompletionEntry, cssModuleFileName: string) { 31 | return ( 32 | entry.name === STYLES_EXPORT_NAME && 33 | entry.data && 34 | // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison 35 | entry.data.exportName === ts.InternalSymbolName.Default && 36 | entry.data.fileName && 37 | entry.data.fileName === cssModuleFileName 38 | ); 39 | } 40 | 41 | function isClassNamePropEntry(entry: ts.CompletionEntry) { 42 | return ( 43 | entry.name === 'className' && 44 | entry.kind === ts.ScriptElementKind.memberVariableElement && 45 | (entry.insertText === 'className="$1"' || entry.insertText === "className='$1'") && 46 | entry.isSnippet 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-service/feature/refactor.ts: -------------------------------------------------------------------------------- 1 | import { getCssModuleFileName, isComponentFileName } from '@css-modules-kit/core'; 2 | import type ts from 'typescript'; 3 | 4 | export const createCssModuleFileRefactor = { 5 | name: 'Create CSS Module file', 6 | description: 'Create CSS Module file', 7 | actions: [{ name: 'Create CSS Module file', description: 'Create CSS Module file for current file' }], 8 | } as const satisfies ts.ApplicableRefactorInfo; 9 | 10 | export function getApplicableRefactors( 11 | languageService: ts.LanguageService, 12 | project: ts.server.Project, 13 | ): ts.LanguageService['getApplicableRefactors'] { 14 | return (fileName, positionOrRange, preferences) => { 15 | const prior = languageService.getApplicableRefactors(fileName, positionOrRange, preferences) ?? []; 16 | if (isComponentFileName(fileName)) { 17 | // If the CSS Module file does not exist, provide a refactor to create it. 18 | if (!project.fileExists(getCssModuleFileName(fileName))) { 19 | prior.push(createCssModuleFileRefactor); 20 | } 21 | } 22 | return prior; 23 | }; 24 | } 25 | 26 | export function getEditsForRefactor(languageService: ts.LanguageService): ts.LanguageService['getEditsForRefactor'] { 27 | // eslint-disable-next-line max-params 28 | return (fileName, formatOptions, positionOrRange, refactorName, actionName, preferences) => { 29 | const prior = languageService.getEditsForRefactor( 30 | fileName, 31 | formatOptions, 32 | positionOrRange, 33 | refactorName, 34 | actionName, 35 | preferences, 36 | ) ?? { edits: [] }; 37 | if (isComponentFileName(fileName)) { 38 | if (refactorName === createCssModuleFileRefactor.name) { 39 | prior.edits.push(createNewCssModuleFileChange(getCssModuleFileName(fileName))); 40 | } 41 | } 42 | return prior; 43 | }; 44 | } 45 | 46 | function createNewCssModuleFileChange(cssFilename: string): ts.FileTextChanges { 47 | return { 48 | fileName: cssFilename, 49 | textChanges: [{ span: { start: 0, length: 0 }, newText: '' }], 50 | isNewFile: true, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-service/feature/semantic-diagnostic.ts: -------------------------------------------------------------------------------- 1 | import type { CSSModule, ExportBuilder, MatchesPattern, Resolver } from '@css-modules-kit/core'; 2 | import { checkCSSModule, convertDiagnostic } from '@css-modules-kit/core'; 3 | import type { Language } from '@volar/language-core'; 4 | import type ts from 'typescript'; 5 | import { CMK_DATA_KEY, isCSSModuleScript } from '../../language-plugin.js'; 6 | 7 | // eslint-disable-next-line max-params 8 | export function getSemanticDiagnostics( 9 | language: Language, 10 | languageService: ts.LanguageService, 11 | exportBuilder: ExportBuilder, 12 | resolver: Resolver, 13 | matchesPattern: MatchesPattern, 14 | getCSSModule: (path: string) => CSSModule | undefined, 15 | ): ts.LanguageService['getSemanticDiagnostics'] { 16 | return (fileName: string) => { 17 | const prior = languageService.getSemanticDiagnostics(fileName); 18 | const script = language.scripts.get(fileName); 19 | if (isCSSModuleScript(script)) { 20 | const virtualCode = script.generated.root; 21 | const cssModule = virtualCode[CMK_DATA_KEY].cssModule; 22 | 23 | // Clear cache to update export records for all files 24 | exportBuilder.clearCache(); 25 | 26 | const diagnostics = checkCSSModule(cssModule, exportBuilder, matchesPattern, resolver, getCSSModule); 27 | const sourceFile = languageService.getProgram()!.getSourceFile(fileName)!; 28 | const tsDiagnostics = diagnostics.map((diagnostic) => convertDiagnostic(diagnostic, () => sourceFile)); 29 | prior.push(...tsDiagnostics); 30 | } 31 | return prior; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-service/feature/syntactic-diagnostic.ts: -------------------------------------------------------------------------------- 1 | import { convertDiagnosticWithLocation } from '@css-modules-kit/core'; 2 | import type { Language } from '@volar/language-core'; 3 | import type ts from 'typescript'; 4 | import { CMK_DATA_KEY, isCSSModuleScript } from '../../language-plugin.js'; 5 | 6 | export function getSyntacticDiagnostics( 7 | language: Language, 8 | languageService: ts.LanguageService, 9 | ): ts.LanguageService['getSyntacticDiagnostics'] { 10 | return (fileName: string) => { 11 | const prior = languageService.getSyntacticDiagnostics(fileName); 12 | const script = language.scripts.get(fileName); 13 | if (isCSSModuleScript(script)) { 14 | const virtualCode = script.generated.root; 15 | const diagnostics = virtualCode[CMK_DATA_KEY].syntacticDiagnostics; 16 | const sourceFile = languageService.getProgram()!.getSourceFile(fileName)!; 17 | const tsDiagnostics = diagnostics.map((diagnostic) => 18 | convertDiagnosticWithLocation(diagnostic, () => sourceFile), 19 | ); 20 | prior.push(...tsDiagnostics); 21 | } 22 | return prior; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/language-service/proxy.ts: -------------------------------------------------------------------------------- 1 | import { createExportBuilder, type MatchesPattern, type Resolver } from '@css-modules-kit/core'; 2 | import type { Language } from '@volar/language-core'; 3 | import type ts from 'typescript'; 4 | import { CMK_DATA_KEY, isCSSModuleScript } from '../language-plugin.js'; 5 | import { getCodeFixesAtPosition } from './feature/code-fix.js'; 6 | import { getCompletionsAtPosition } from './feature/completion.js'; 7 | import { getApplicableRefactors, getEditsForRefactor } from './feature/refactor.js'; 8 | import { getSemanticDiagnostics } from './feature/semantic-diagnostic.js'; 9 | import { getSyntacticDiagnostics } from './feature/syntactic-diagnostic.js'; 10 | 11 | export function proxyLanguageService( 12 | language: Language, 13 | languageService: ts.LanguageService, 14 | project: ts.server.Project, 15 | resolver: Resolver, 16 | matchesPattern: MatchesPattern, 17 | ): ts.LanguageService { 18 | const proxy: ts.LanguageService = Object.create(null); 19 | 20 | for (const k of Object.keys(languageService) as (keyof ts.LanguageService)[]) { 21 | const x = languageService[k]!; 22 | // @ts-expect-error - JS runtime trickery which is tricky to type tersely 23 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 24 | proxy[k] = (...args: {}[]) => x.apply(languageService, args); 25 | } 26 | 27 | const getCSSModule = (path: string) => { 28 | const script = language.scripts.get(path); 29 | if (isCSSModuleScript(script)) { 30 | return script.generated.root[CMK_DATA_KEY].cssModule; 31 | } 32 | return undefined; 33 | }; 34 | const exportBuilder = createExportBuilder({ getCSSModule, resolver, matchesPattern }); 35 | 36 | proxy.getSyntacticDiagnostics = getSyntacticDiagnostics(language, languageService); 37 | proxy.getSemanticDiagnostics = getSemanticDiagnostics( 38 | language, 39 | languageService, 40 | exportBuilder, 41 | resolver, 42 | matchesPattern, 43 | getCSSModule, 44 | ); 45 | proxy.getApplicableRefactors = getApplicableRefactors(languageService, project); 46 | proxy.getEditsForRefactor = getEditsForRefactor(languageService); 47 | proxy.getCompletionsAtPosition = getCompletionsAtPosition(languageService); 48 | proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project); 49 | 50 | return proxy; 51 | } 52 | -------------------------------------------------------------------------------- /packages/ts-plugin/src/util.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | /** The error code used by tsserver to display the css-modules-kit error in the editor. */ 4 | // NOTE: Use any other number than 1002 or later, as they are reserved by TypeScript's built-in errors. 5 | // ref: https://github.com/microsoft/TypeScript/blob/220706eb0320ff46fad8bf80a5e99db624ee7dfb/src/compiler/diagnosticMessages.json 6 | export const TS_ERROR_CODE_FOR_CMK_ERROR = 0; 7 | 8 | export function convertErrorCategory(category: 'error' | 'warning' | 'suggestion'): ts.DiagnosticCategory { 9 | switch (category) { 10 | case 'error': 11 | return ts.DiagnosticCategory.Error; 12 | case 'warning': 13 | return ts.DiagnosticCategory.Warning; 14 | case 'suggestion': 15 | return ts.DiagnosticCategory.Suggestion; 16 | default: 17 | throw new Error(`Unknown category: ${String(category)}`); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/ts-plugin/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | }, 16 | "references": [{ "path": "../core/tsconfig.build.json" }] 17 | } 18 | -------------------------------------------------------------------------------- /packages/vscode/.vscodeignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.build.json 3 | tsconfig.build.tsbuildinfo 4 | -------------------------------------------------------------------------------- /packages/vscode/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # css-modules-kit-vscode 2 | 3 | ## 0.0.4 4 | 5 | ### Patch Changes 6 | 7 | - 773ed57: Fix ts-plugin not loading when using workspace tsdk 8 | - Updated dependencies [2e460bf] 9 | - Updated dependencies [a91b6fe] 10 | - @css-modules-kit/ts-plugin@0.1.1 11 | 12 | ## 0.0.3 13 | 14 | ### Patch Changes 15 | 16 | - Updated dependencies [385bdc3] 17 | - Updated dependencies [2b1f0fe] 18 | - Updated dependencies [88c9868] 19 | - @css-modules-kit/ts-plugin@0.1.0 20 | 21 | ## 0.0.2 22 | 23 | ### Patch Changes 24 | 25 | - @css-modules-kit/ts-plugin@0.0.6 26 | 27 | ## 0.0.1 28 | 29 | ### Patch Changes 30 | 31 | - 7df2e70: Release test 32 | - Updated dependencies [7df2e70] 33 | - @css-modules-kit/language-server@0.0.1 34 | - @css-modules-kit/ts-plugin@0.0.5 35 | -------------------------------------------------------------------------------- /packages/vscode/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mizdra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/vscode/README.md: -------------------------------------------------------------------------------- 1 | # css-modules-kit-vscode 2 | 3 | The VS Code extension for CSS Modules 4 | 5 | ## What is this? 6 | 7 | This is an extension for using `@css-modules-kit/ts-plugin` in VS Code. 8 | -------------------------------------------------------------------------------- /packages/vscode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-modules-kit-vscode", 3 | "displayName": "CSS Modules Kit", 4 | "description": "The VS Code extension for CSS Modules", 5 | "version": "0.0.4", 6 | "type": "commonjs", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mizdra/css-modules-kit.git", 10 | "directory": "packages/vscode" 11 | }, 12 | "author": "mizdra ", 13 | "publisher": "mizdra", 14 | "license": "MIT", 15 | "private": true, 16 | "main": "./dist/index.js", 17 | "scripts": { 18 | "build": "tsc -b tsconfig.build.json", 19 | "vscode:prepublish": "run-s vscode:prepublish:rewrite-root-package-json vscode:prepublish:npm-install", 20 | "vscode:prepublish:rewrite-root-package-json": "jq 'del(.workspaces)' ../../package.json > tmp.json && mv tmp.json ../../package.json", 21 | "vscode:prepublish:npm-install": "npm i --no-package-lock --omit=dev" 22 | }, 23 | "engines": { 24 | "vscode": "^1.77.0" 25 | }, 26 | "keywords": [ 27 | "css-modules", 28 | "typescript" 29 | ], 30 | "categories": [ 31 | "Other" 32 | ], 33 | "activationEvents": [ 34 | "onLanguage:css", 35 | "onLanguage:less", 36 | "onLanguage:scss" 37 | ], 38 | "contributes": { 39 | "typescriptServerPlugins": [ 40 | { 41 | "name": "@css-modules-kit/ts-plugin", 42 | "configNamespace": "typescript", 43 | "enableForWorkspaceTypeScriptVersions": true, 44 | "languages": [ 45 | "css" 46 | ] 47 | } 48 | ], 49 | "menus": { 50 | "commandPalette": [ 51 | { 52 | "command": "typescript.openTsServerLog", 53 | "when": "editorLangId == css" 54 | }, 55 | { 56 | "command": "typescript.restartTsServer", 57 | "when": "editorLangId == css" 58 | } 59 | ] 60 | } 61 | }, 62 | "dependencies": { 63 | "@css-modules-kit/language-server": "^0.0.1", 64 | "@css-modules-kit/ts-plugin": "^0.1.1", 65 | "vscode-languageclient": "^9.0.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/vscode/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import * as vscode from 'vscode'; 4 | import * as lsp from 'vscode-languageclient/node'; 5 | 6 | let client: lsp.BaseLanguageClient; 7 | 8 | export async function activate(_context: vscode.ExtensionContext) { 9 | console.log('[css-modules-kit-vscode] Activated'); 10 | 11 | // By default, `vscode.typescript-language-features` is not activated when a user opens *.css in VS Code. 12 | // So, activate it manually. 13 | const tsExtension = vscode.extensions.getExtension('vscode.typescript-language-features'); 14 | if (tsExtension) { 15 | console.log('[css-modules-kit-vscode] Activating `vscode.typescript-language-features`'); 16 | tsExtension.activate(); 17 | } 18 | 19 | // Both vscode.css-language-features extension and tsserver receive "rename" requests for *.css. 20 | // If more than one Provider receives a "rename" request, VS Code will use one of them. 21 | // In this case, the extension is used to rename. However, we do not want this. 22 | // Without rename in tsserver, we cannot rename class selectors across *.css and *.ts. 23 | // 24 | // Also, VS Code seems to send "references" requests to both vscode.css-language-features extension 25 | // and tsserver and merge the results of both. Thus, when a user executes "Find all references" 26 | // on a class selector, the same class selector appears twice. 27 | // 28 | // To avoid this, we recommend disabling vscode.css-language-features extension. Disabling extensions is optional. 29 | // If not disabled, "rename" and "references" will behave in a way the user does not want. 30 | const cssExtension = vscode.extensions.getExtension('vscode.css-language-features'); 31 | if (cssExtension) { 32 | // Temporarily commented out 33 | // vscode.window 34 | // .showInformationMessage( 35 | // '"Rename Symbol" and "Find All References" do not work in some cases because the "CSS Language Features" extension is enabled. Disabling the extension will make them work.', 36 | // 'Show "CSS Language Features" extension', 37 | // ) 38 | // .then((selected) => { 39 | // if (selected) { 40 | // vscode.commands.executeCommand('workbench.extensions.search', '@builtin css-language-features'); 41 | // } 42 | // }); 43 | } else { 44 | // If vscode.css-language-features extension is disabled, start the customized language server for *.css, *.scss, and *.less. 45 | // The language server is based on the vscode-css-languageservice, but "rename" and "references" features are disabled. 46 | 47 | // TODO: Do not use Node.js API 48 | const serverModulePath = require.resolve('@css-modules-kit/language-server'); 49 | 50 | const serverOptions: lsp.ServerOptions = { 51 | run: { 52 | module: serverModulePath, 53 | transport: lsp.TransportKind.ipc, 54 | options: { execArgv: [] }, 55 | }, 56 | debug: { 57 | module: serverModulePath, 58 | transport: lsp.TransportKind.ipc, 59 | options: { execArgv: ['--nolazy', `--inspect=${6009}`] }, 60 | }, 61 | }; 62 | const clientOptions: lsp.LanguageClientOptions = { 63 | documentSelector: [{ language: 'css' }, { language: 'scss' }, { language: 'less' }], 64 | initializationOptions: {}, 65 | }; 66 | client = new lsp.LanguageClient('css-modules-kit-vscode', 'css-modules-kit-vscode', serverOptions, clientOptions); 67 | await client.start(); 68 | } 69 | } 70 | 71 | export function deactivate(): Thenable | undefined { 72 | return client?.stop(); 73 | } 74 | -------------------------------------------------------------------------------- /packages/vscode/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], // Avoid bin/ and configuration files. 4 | "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], 5 | "compilerOptions": { 6 | "target": "ES2022", 7 | "lib": ["ESNext"], 8 | "module": "NodeNext", 9 | 10 | "composite": true, 11 | "outDir": "dist", 12 | "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. 13 | "sourceMap": true, 14 | "declarationMap": true 15 | }, 16 | "references": [{ "path": "../ts-plugin/tsconfig.build.json" }] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Projects */ 4 | "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, 5 | 6 | /* JavaScript Support */ 7 | "checkJs": true /* Enable error reporting in type-checked JavaScript files. */, 8 | 9 | /* Interop Constraints */ 10 | "verbatimModuleSyntax": false /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */, 11 | // If --module=NodeNext, --esModuleInterop can be omitted. However, it is set just in case. 12 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 13 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 14 | "resolveJsonModule": true /* Include modules imported with .json extension. */, 15 | 16 | /* Type Checking */ 17 | "strict": true /* Enable all strict type-checking options. */, 18 | "noUnusedLocals": false /* Enable error reporting when local variables aren't read. */, 19 | "noUnusedParameters": false /* Raise an error when a function parameter isn't read. */, 20 | "exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */, 21 | "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, 22 | "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */, 23 | "noUncheckedIndexedAccess": true /* Add 'undefined' to a type when accessed using an index. */, 24 | "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */, 25 | "noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type. */, 26 | "allowUnusedLabels": false /* Disable error reporting for unused labels. */, 27 | "allowUnreachableCode": false /* Disable error reporting for unreachable code. */, 28 | 29 | /* Completeness */ 30 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "compilerOptions": { 4 | "composite": true 5 | }, 6 | "references": [ 7 | { "path": "packages/core/tsconfig.build.json" }, 8 | { "path": "packages/codegen/tsconfig.build.json" }, 9 | { "path": "packages/ts-plugin/tsconfig.build.json" }, 10 | { "path": "packages/language-server/tsconfig.build.json" }, 11 | { "path": "packages/vscode/tsconfig.build.json" }, 12 | { "path": "packages/stylelint-plugin/tsconfig.build.json" }, 13 | { "path": "packages/eslint-plugin/tsconfig.build.json" } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "exclude": ["node_modules", "**/dist", "example"], 4 | "compilerOptions": { 5 | "target": "ES2022", 6 | "lib": ["ESNext"], 7 | "module": "NodeNext", 8 | 9 | "noEmit": true 10 | }, 11 | "references": [{ "path": "tsconfig.build.json" }] 12 | } 13 | -------------------------------------------------------------------------------- /vitest.workspace.mts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-imports 2 | import { resolve } from 'node:path'; 3 | import { defineConfig, defineWorkspace } from 'vitest/config'; 4 | 5 | export default defineWorkspace([ 6 | defineConfig({ 7 | test: { 8 | name: 'unit', 9 | include: ['packages/*/src/**/*.test.ts'], 10 | }, 11 | resolve: { 12 | alias: { 13 | '@css-modules-kit/core': resolve('packages/core/src/index.ts'), 14 | }, 15 | }, 16 | }), 17 | defineConfig({ 18 | test: { 19 | name: 'e2e', 20 | include: ['packages/*/e2e/**/*.test.ts'], 21 | }, 22 | }), 23 | ]); 24 | --------------------------------------------------------------------------------