├── .husky ├── .gitignore └── pre-commit ├── .gitattributes ├── fixtures ├── errorCss.css ├── different.css ├── testStyle.css ├── composee.css ├── empty.css ├── kebabed.css ├── kebabedUpperCase.css ├── combined │ ├── imported.css │ └── combined.css ├── composer.css ├── testStyle.css.d.ts ├── different.css.d.ts ├── invalidComposer.scss └── invalidTokenStyle.css ├── src ├── index.ts ├── is-there.d.ts ├── css-modules-loader-core │ ├── index.d.ts │ ├── index.js │ └── parser.js ├── run.test.ts ├── cli.ts ├── dts-creator.ts ├── run.ts ├── dts-creator.test.ts ├── file-system-loader.ts ├── dts-content.ts └── dts-content.test.ts ├── example ├── app.ts ├── style01.css ├── tsconfig.json └── package.json ├── .prettierignore ├── .prettierrc.yml ├── .gitignore ├── .npmignore ├── renovate.json ├── jest.config.mjs ├── tsconfig.json ├── .github └── workflows │ ├── publish.yml │ └── build.yml ├── LICENSE.txt ├── package.json ├── Contribution.md └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /fixtures/errorCss.css: -------------------------------------------------------------------------------- 1 | 2 | .class { 3 | -------------------------------------------------------------------------------- /fixtures/different.css: -------------------------------------------------------------------------------- 1 | .myClass {color: red;} 2 | -------------------------------------------------------------------------------- /fixtures/testStyle.css: -------------------------------------------------------------------------------- 1 | .myClass {color: red;} 2 | -------------------------------------------------------------------------------- /fixtures/composee.css: -------------------------------------------------------------------------------- 1 | .box { 2 | padding: 0; 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/empty.css: -------------------------------------------------------------------------------- 1 | /* 2 | .box { 3 | padding: 0; 4 | } 5 | */ 6 | -------------------------------------------------------------------------------- /fixtures/kebabed.css: -------------------------------------------------------------------------------- 1 | .my-class { 2 | color: red; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /fixtures/kebabedUpperCase.css: -------------------------------------------------------------------------------- 1 | .My-class { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/combined/imported.css: -------------------------------------------------------------------------------- 1 | .myClass { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/composer.css: -------------------------------------------------------------------------------- 1 | .root { 2 | composes: box from "./composee.css"; 3 | color: red; 4 | } 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { DtsCreator as default } from './dts-creator'; 2 | export { run } from './run'; 3 | -------------------------------------------------------------------------------- /fixtures/testStyle.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "myClass": string; 3 | }; 4 | export = styles; 5 | 6 | -------------------------------------------------------------------------------- /fixtures/different.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "differentClass": string; 3 | }; 4 | export = styles; 5 | -------------------------------------------------------------------------------- /src/is-there.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'is-there' { 2 | function isThere(path: string): boolean; 3 | export = isThere; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/invalidComposer.scss: -------------------------------------------------------------------------------- 1 | .myClass { 2 | composes: something from 'path/that/cant/be/found.scss'; 3 | background: red; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/combined/combined.css: -------------------------------------------------------------------------------- 1 | @import './imported.css'; 2 | @import '../composee.css'; 3 | 4 | .block { 5 | display: block; 6 | } 7 | -------------------------------------------------------------------------------- /example/app.ts: -------------------------------------------------------------------------------- 1 | import * as styles from './style01.css'; 2 | 3 | console.log(`
`); 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .husky/ 3 | node_modules/ 4 | lib/ 5 | __snapshots__/ 6 | coverage/ 7 | *.css.d.ts 8 | test/**/*.css 9 | fixtures/**/*.css 10 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: all 2 | tabWidth: 2 3 | semi: true 4 | singleQuote: true 5 | bracketSpacing: true 6 | printWidth: 120 7 | arrowParens: avoid 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | example/app.js 4 | example/*.css.d.ts 5 | example/bundle.js 6 | example/bundle.css 7 | *.swp 8 | *.swo 9 | lib/ 10 | coverage/ 11 | *.tsbuildinfo 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # ignore everything 2 | * 3 | 4 | # include these: 5 | !/lib/**/* 6 | !LICENSE.md 7 | !README.md 8 | 9 | /lib/**/*.test.* 10 | 11 | # exclude hidden files from the includes: 12 | .* 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "automerge": true, 3 | "automergeType": "branch", 4 | "extends": ["config:base"], 5 | "lockFileMaintenance": { "enabled": true }, 6 | "major": { 7 | "automerge": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/invalidTokenStyle.css: -------------------------------------------------------------------------------- 1 | /* while is reserved by typescript */ 2 | .while { 3 | color: red; 4 | } 5 | 6 | /* includes invalid charactor for typescript variable */ 7 | .my-class { 8 | color: red; 9 | } 10 | 11 | /* it's ok */ 12 | .myClass { 13 | color: red; 14 | } 15 | -------------------------------------------------------------------------------- /example/style01.css: -------------------------------------------------------------------------------- 1 | @value primary: green; 2 | 3 | .root { 4 | color: red; 5 | } 6 | 7 | .root:hover { 8 | color: @primary; 9 | } 10 | 11 | /* my-class1, myclass-2 are invalid token for typescript */ 12 | #some_id.main > div.my-class1.myclass-2 { 13 | width: 100px; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "noImplicitAny": true, 6 | "outDir": ".", 7 | "rootDir": ".", 8 | "sourceMap": false 9 | }, 10 | "exclude": [ 11 | "node_modules" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | transform: { 3 | '^.+\\.(ts|js)$': ['ts-jest', { diagnostics: false }], 4 | }, 5 | testRegex: '(test/.*|(src/.*\\.test))\\.ts$', 6 | testPathIgnorePatterns: ['/node_modules/', '\\.d\\.ts$', 'lib/', 'example/', 'coverage/'], 7 | moduleFileExtensions: ['js', 'ts', 'json'], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "allowJs": true, 13 | "rootDir": "src", 14 | "outDir": "lib", 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "fixtures", "lib"] 20 | } 21 | -------------------------------------------------------------------------------- /src/css-modules-loader-core/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'postcss'; 2 | 3 | type Source = 4 | | string 5 | | { 6 | toString(): string; 7 | }; 8 | 9 | type PathFetcher = (file: string, relativeTo: string, depTrace: string) => void; 10 | 11 | export interface ExportTokens { 12 | readonly [index: string]: string; 13 | } 14 | 15 | export interface Result { 16 | readonly injectableSource: string; 17 | readonly exportTokens: ExportTokens; 18 | } 19 | 20 | export default class Core { 21 | constructor(plugins?: Plugin[]); 22 | 23 | load(source: Source, sourcePath?: string, trace?: string, pathFetcher?: PathFetcher): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /src/run.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import isThere from 'is-there'; 3 | import { rimraf } from 'rimraf'; 4 | import { run } from './run'; 5 | 6 | describe(run, () => { 7 | let mockConsoleLog: jest.SpyInstance; 8 | 9 | beforeAll(() => { 10 | mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); 11 | }); 12 | 13 | beforeEach(async () => { 14 | await rimraf('example/style01.css.d.ts'); 15 | }); 16 | 17 | afterAll(() => { 18 | mockConsoleLog.mockRestore(); 19 | }); 20 | 21 | it('generates type definition files', async () => { 22 | await run('example', { watch: false }); 23 | expect(isThere(path.normalize('example/style01.css.d.ts'))).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "tcm": "node ../lib/cli.js -e -p style01.css", 8 | "tcmw": "node ../lib/cli.js -e -w -p style01.css", 9 | "compile": "npm run tcm && ./node_modules/.bin/tsc -p .", 10 | "bundle": "npm run compile && ./node_modules/.bin/browserify -o bundle.js -p [ css-modulesify -o bundle.css ] app.js", 11 | "start": "npm run bundle && node bundle.js" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "browserify": "^17.0.0", 18 | "css-modulesify": "^0.28.0", 19 | "typescript": "^5.0.0", 20 | "typed-css-modules": "file:../" 21 | }, 22 | "devDependencies": { 23 | "tslint": "5.20.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [18.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: npm 23 | - name: npm publish 24 | run: | 25 | echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" > ~/.npmrc 26 | npm whoami 27 | npm ci 28 | npm run build 29 | npm publish 30 | if: contains(github.ref, 'tags/v') 31 | env: 32 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 33 | CI: true 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, windows-latest] 14 | node-version: [18.x] 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | - name: npm install 26 | run: | 27 | npm ci 28 | - name: Lint 29 | run: | 30 | npm run lint 31 | - name: Compile 32 | run: | 33 | npm run compile 34 | - name: Test 35 | run: | 36 | npm run test:ci 37 | env: 38 | CI: true 39 | -------------------------------------------------------------------------------- /src/css-modules-loader-core/index.js: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/css-modules/css-modules-loader-core 2 | 3 | import postcss from 'postcss'; 4 | import localByDefault from 'postcss-modules-local-by-default'; 5 | import extractImports from 'postcss-modules-extract-imports'; 6 | import scope from 'postcss-modules-scope'; 7 | import values from 'postcss-modules-values'; 8 | 9 | import Parser from './parser'; 10 | 11 | export default class Core { 12 | constructor(plugins) { 13 | this.plugins = plugins || Core.defaultPlugins; 14 | } 15 | 16 | load(sourceString, sourcePath, trace, pathFetcher) { 17 | let parser = new Parser(pathFetcher, trace); 18 | 19 | return postcss(this.plugins.concat([parser.plugin])) 20 | .process(sourceString, { from: '/' + sourcePath }) 21 | .then(result => { 22 | return { 23 | injectableSource: result.css, 24 | exportTokens: parser.exportTokens, 25 | }; 26 | }); 27 | } 28 | } 29 | 30 | Core.defaultPlugins = [values, localByDefault, extractImports, scope]; 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2016] [Yosuke Kurami] 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 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-css-modules", 3 | "version": "0.9.1", 4 | "description": "Creates .d.ts files from CSS Modules .css files", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "clean": "rimraf lib/", 9 | "build": "npm run clean && tsc && chmod +x lib/cli.js", 10 | "prettier": "prettier \"*.{md,js,json.yml,yaml}\" \"{src,test}/**/*\"", 11 | "format": "npm run prettier -- --write", 12 | "lint": "npm run prettier -- --check", 13 | "compile": "tsc --noEmit", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:ci": "jest --coverage", 17 | "prepublish": "npm run build", 18 | "prepare": "husky install" 19 | }, 20 | "bin": { 21 | "tcm": "lib/cli.js" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/Quramy/typed-css-modules.git" 26 | }, 27 | "keywords": [ 28 | "css-modules", 29 | "typescript" 30 | ], 31 | "author": "quramy", 32 | "license": "MIT", 33 | "engines": { 34 | "node": ">=18.0.0" 35 | }, 36 | "dependencies": { 37 | "camelcase": "^6.0.0", 38 | "chalk": "^4.0.0", 39 | "chokidar": "^3.4.0", 40 | "glob": "^10.3.10", 41 | "icss-replace-symbols": "^1.1.0", 42 | "is-there": "^4.4.2", 43 | "mkdirp": "^3.0.0", 44 | "postcss": "^8.0.0", 45 | "postcss-modules-extract-imports": "^3.0.0", 46 | "postcss-modules-local-by-default": "^4.0.0", 47 | "postcss-modules-scope": "^3.0.0", 48 | "postcss-modules-values": "^4.0.0", 49 | "yargs": "^17.7.2" 50 | }, 51 | "devDependencies": { 52 | "@types/jest": "29.5.14", 53 | "@types/node": "20.19.25", 54 | "@types/yargs": "17.0.35", 55 | "husky": "9.1.7", 56 | "jest": "29.7.0", 57 | "prettier": "3.6.2", 58 | "pretty-quick": "4.2.2", 59 | "rimraf": "6.1.2", 60 | "ts-jest": "29.4.5", 61 | "typescript": "5.5.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/css-modules-loader-core/parser.js: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/css-modules/css-modules-loader-core 2 | 3 | const importRegexp = /^:import\((.+)\)$/; 4 | import replaceSymbols from 'icss-replace-symbols'; 5 | 6 | export default class Parser { 7 | constructor(pathFetcher, trace) { 8 | this.pathFetcher = pathFetcher; 9 | this.plugin = this.plugin.bind(this); 10 | this.exportTokens = {}; 11 | this.translations = {}; 12 | this.trace = trace; 13 | } 14 | 15 | plugin(css) { 16 | return Promise.all(this.fetchAllImports(css)) 17 | .then(() => this.linkImportedSymbols(css)) 18 | .then(() => this.extractExports(css)); 19 | } 20 | 21 | fetchAllImports(css) { 22 | let imports = []; 23 | css.each(node => { 24 | if (node.type == 'rule' && node.selector.match(importRegexp)) { 25 | imports.push(this.fetchImport(node, css.source.input.from, imports.length)); 26 | } 27 | }); 28 | return imports; 29 | } 30 | 31 | linkImportedSymbols(css) { 32 | replaceSymbols(css, this.translations); 33 | } 34 | 35 | extractExports(css) { 36 | css.each(node => { 37 | if (node.type == 'rule' && node.selector == ':export') this.handleExport(node); 38 | }); 39 | } 40 | 41 | handleExport(exportNode) { 42 | exportNode.each(decl => { 43 | if (decl.type == 'decl') { 44 | Object.keys(this.translations).forEach(translation => { 45 | decl.value = decl.value.replace(translation, this.translations[translation]); 46 | }); 47 | this.exportTokens[decl.prop] = decl.value; 48 | } 49 | }); 50 | exportNode.remove(); 51 | } 52 | 53 | fetchImport(importNode, relativeTo, depNr) { 54 | let file = importNode.selector.match(importRegexp)[1], 55 | depTrace = this.trace + String.fromCharCode(depNr); 56 | return this.pathFetcher(file, relativeTo, depTrace).then( 57 | exports => { 58 | importNode.each(decl => { 59 | if (decl.type == 'decl') { 60 | this.translations[decl.prop] = exports[decl.value]; 61 | } 62 | }); 63 | importNode.remove(); 64 | }, 65 | err => console.log(err), 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs/yargs'; 4 | import { hideBin } from 'yargs/helpers'; 5 | import { run } from './run'; 6 | 7 | const yarg = yargs(hideBin(process.argv)) 8 | .usage('Create .css.d.ts from CSS modules *.css files.\nUsage: $0 [options] ') 9 | .example('$0 src/styles', '') 10 | .example('$0 src -o dist', '') 11 | .example('$0 -p styles/**/*.css -w', '') 12 | .detectLocale(false) 13 | .demand(['_']) 14 | .options({ 15 | p: { 16 | desc: 'Glob pattern with css files', 17 | type: 'string', 18 | alias: 'pattern', 19 | }, 20 | o: { 21 | desc: 'Output directory', 22 | type: 'string', 23 | alias: 'outDir', 24 | }, 25 | l: { 26 | desc: 'List any files that are different than those that would be generated. If any are different, exit with a status code 1.', 27 | type: 'boolean', 28 | alias: 'listDifferent', 29 | }, 30 | w: { 31 | desc: "Watch input directory's css files or pattern", 32 | type: 'boolean', 33 | alias: 'watch', 34 | }, 35 | c: { 36 | desc: "Watch input directory's css files or pattern", 37 | type: 'boolean', 38 | alias: 'camelCase', 39 | }, 40 | e: { 41 | type: 'boolean', 42 | desc: 'Use named exports as opposed to default exports to enable tree shaking.', 43 | alias: 'namedExports', 44 | }, 45 | a: { 46 | type: 'boolean', 47 | desc: 'Use the ".d.css.ts" extension to be compatible with the equivalent TypeScript option', 48 | alias: 'allowArbitraryExtensions', 49 | }, 50 | d: { 51 | type: 'boolean', 52 | desc: "'Drop the input files extension'", 53 | alias: 'dropExtension', 54 | }, 55 | s: { 56 | type: 'boolean', 57 | alias: 'silent', 58 | desc: 'Silent output. Do not show "files written" messages', 59 | }, 60 | }) 61 | .alias('h', 'help') 62 | .help('h') 63 | .version(require('../package.json').version); 64 | 65 | main(); 66 | 67 | async function main(): Promise { 68 | const argv = await yarg.argv; 69 | 70 | if (argv.h) { 71 | yarg.showHelp(); 72 | return; 73 | } 74 | 75 | let searchDir: string; 76 | if (argv._ && argv._[0]) { 77 | searchDir = `${argv._[0]}`; 78 | } else if (argv.p) { 79 | searchDir = './'; 80 | } else { 81 | yarg.showHelp(); 82 | return; 83 | } 84 | 85 | await run(searchDir, { 86 | pattern: argv.p, 87 | outDir: argv.o, 88 | watch: argv.w, 89 | camelCase: argv.c, 90 | namedExports: argv.e, 91 | dropExtension: argv.d, 92 | allowArbitraryExtensions: argv.a, 93 | silent: argv.s, 94 | listDifferent: argv.l, 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /src/dts-creator.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import path from 'node:path'; 3 | import os from 'node:os'; 4 | 5 | import { Plugin } from 'postcss'; 6 | 7 | import FileSystemLoader from './file-system-loader'; 8 | import { DtsContent, CamelCaseOption } from './dts-content'; 9 | 10 | interface DtsCreatorOptions { 11 | rootDir?: string; 12 | searchDir?: string; 13 | outDir?: string; 14 | camelCase?: CamelCaseOption; 15 | namedExports?: boolean; 16 | allowArbitraryExtensions?: boolean; 17 | dropExtension?: boolean; 18 | EOL?: string; 19 | loaderPlugins?: Plugin[]; 20 | } 21 | 22 | export class DtsCreator { 23 | private rootDir: string; 24 | private searchDir: string; 25 | private outDir: string; 26 | private loader: FileSystemLoader; 27 | private inputDirectory: string; 28 | private camelCase: CamelCaseOption; 29 | private namedExports: boolean; 30 | private allowArbitraryExtensions: boolean; 31 | private dropExtension: boolean; 32 | private EOL: string; 33 | 34 | constructor(options?: DtsCreatorOptions) { 35 | if (!options) options = {}; 36 | this.rootDir = options.rootDir || process.cwd(); 37 | this.searchDir = options.searchDir || ''; 38 | this.outDir = options.outDir || this.searchDir; 39 | this.loader = new FileSystemLoader(this.rootDir, options.loaderPlugins); 40 | this.inputDirectory = path.join(this.rootDir, this.searchDir); 41 | this.camelCase = options.camelCase; 42 | this.namedExports = !!options.namedExports; 43 | this.allowArbitraryExtensions = !!options.allowArbitraryExtensions; 44 | this.dropExtension = !!options.dropExtension; 45 | this.EOL = options.EOL || os.EOL; 46 | } 47 | 48 | public async create( 49 | filePath: string, 50 | initialContents?: string, 51 | clearCache: boolean = false, 52 | isDelete: boolean = false, 53 | ): Promise { 54 | let rInputPath: string; 55 | if (path.isAbsolute(filePath)) { 56 | rInputPath = path.relative(this.inputDirectory, filePath); 57 | } else { 58 | rInputPath = path.relative(this.inputDirectory, path.join(process.cwd(), filePath)); 59 | } 60 | if (clearCache) { 61 | this.loader.tokensByFile = {}; 62 | } 63 | 64 | let keys: string[] = []; 65 | if (!isDelete) { 66 | const res = await this.loader.fetch(filePath, '/', undefined, initialContents); 67 | if (!res) throw res; 68 | 69 | keys = Object.keys(res); 70 | } 71 | 72 | const content = new DtsContent({ 73 | dropExtension: this.dropExtension, 74 | rootDir: this.rootDir, 75 | searchDir: this.searchDir, 76 | outDir: this.outDir, 77 | rInputPath, 78 | rawTokenList: keys, 79 | namedExports: this.namedExports, 80 | allowArbitraryExtensions: this.allowArbitraryExtensions, 81 | camelCase: this.camelCase, 82 | EOL: this.EOL, 83 | }); 84 | 85 | return content; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import chokidar from 'chokidar'; 3 | import { glob } from 'glob'; 4 | import { DtsCreator } from './dts-creator'; 5 | import { DtsContent } from './dts-content'; 6 | 7 | interface RunOptions { 8 | pattern?: string; 9 | outDir?: string; 10 | watch?: boolean; 11 | camelCase?: boolean; 12 | namedExports?: boolean; 13 | allowArbitraryExtensions?: boolean; 14 | dropExtension?: boolean; 15 | silent?: boolean; 16 | listDifferent?: boolean; 17 | } 18 | 19 | export async function run(searchDir: string, options: RunOptions = {}): Promise { 20 | const filesPattern = searchDir.replace(/\\/g, '/') + '/' + (options.pattern || '**/*.css'); 21 | 22 | const creator = new DtsCreator({ 23 | rootDir: process.cwd(), 24 | searchDir, 25 | outDir: options.outDir, 26 | camelCase: options.camelCase, 27 | namedExports: options.namedExports, 28 | allowArbitraryExtensions: options.allowArbitraryExtensions, 29 | dropExtension: options.dropExtension, 30 | }); 31 | 32 | const checkFile = async (f: string): Promise => { 33 | try { 34 | const content: DtsContent = await creator.create(f, undefined, false); 35 | return await content.checkFile(); 36 | } catch (error) { 37 | console.error(chalk.red(`[ERROR] An error occurred checking '${f}':\n${error}`)); 38 | return false; 39 | } 40 | }; 41 | 42 | const writeFile = async (f: string): Promise => { 43 | try { 44 | const content: DtsContent = await creator.create(f, undefined, !!options.watch); 45 | await content.writeFile(); 46 | 47 | if (!options.silent) { 48 | console.log('Wrote ' + chalk.green(content.outputFilePath)); 49 | } 50 | } catch (error) { 51 | console.error(chalk.red('[Error] ' + error)); 52 | } 53 | }; 54 | 55 | const deleteFile = async (f: string): Promise => { 56 | try { 57 | const content: DtsContent = await creator.create(f, undefined, !!options.watch, true); 58 | 59 | await content.deleteFile(); 60 | 61 | console.log('Delete ' + chalk.green(content.outputFilePath)); 62 | } catch (error) { 63 | console.error(chalk.red('[Error] ' + error)); 64 | } 65 | }; 66 | 67 | if (options.listDifferent) { 68 | const files = await glob(filesPattern); 69 | const hasErrors = (await Promise.all(files.map(checkFile))).includes(false); 70 | if (hasErrors) { 71 | process.exit(1); 72 | } 73 | return; 74 | } 75 | 76 | if (!options.watch) { 77 | const files = await glob(filesPattern); 78 | await Promise.all(files.map(writeFile)); 79 | } else { 80 | console.log('Watch ' + filesPattern + '...'); 81 | 82 | const watcher = chokidar.watch([filesPattern]); 83 | watcher.on('add', writeFile); 84 | watcher.on('change', writeFile); 85 | watcher.on('unlink', deleteFile); 86 | await waitForever(); 87 | } 88 | } 89 | 90 | async function waitForever(): Promise { 91 | return new Promise(() => {}); 92 | } 93 | -------------------------------------------------------------------------------- /src/dts-creator.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import assert from 'node:assert'; 3 | 4 | import { DtsCreator } from './dts-creator'; 5 | 6 | describe(DtsCreator, () => { 7 | describe('#create', () => { 8 | it('returns DtsContent instance simple css', async () => { 9 | const content = await new DtsCreator().create('fixtures/testStyle.css'); 10 | assert.equal(content.contents.length, 1); 11 | assert.equal(content.contents[0], 'readonly "myClass": string;'); 12 | }); 13 | 14 | it('rejects an error with invalid CSS', async () => { 15 | await expect(() => new DtsCreator().create('fixtures/errorCss.css')).rejects.toMatchObject({ 16 | name: 'CssSyntaxError', 17 | }); 18 | }); 19 | 20 | it('returns DtsContent instance from composing css', async () => { 21 | const content = await new DtsCreator().create('fixtures/composer.css'); 22 | assert.equal(content.contents.length, 1); 23 | assert.equal(content.contents[0], 'readonly "root": string;'); 24 | }); 25 | 26 | it('returns DtsContent instance from composing css whose has invalid import/composes', async () => { 27 | const content = await new DtsCreator().create('fixtures/invalidComposer.scss'); 28 | assert.equal(content.contents.length, 1); 29 | assert.equal(content.contents[0], 'readonly "myClass": string;'); 30 | }); 31 | 32 | it('returns DtsContent instance from the pair of path and contents', async () => { 33 | const content = await new DtsCreator().create('fixtures/somePath', `.myClass { color: red }`); 34 | assert.equal(content.contents.length, 1); 35 | assert.equal(content.contents[0], 'readonly "myClass": string;'); 36 | }); 37 | 38 | it('returns DtsContent instance combined css', async () => { 39 | const content = await new DtsCreator().create('fixtures/combined/combined.css'); 40 | assert.equal(content.contents.length, 3); 41 | assert.equal(content.contents[0], 'readonly "block": string;'); 42 | assert.equal(content.contents[1], 'readonly "box": string;'); 43 | assert.equal(content.contents[2], 'readonly "myClass": string;'); 44 | }); 45 | }); 46 | 47 | describe('#modify path', () => { 48 | it('can be set outDir', async () => { 49 | const content = await new DtsCreator({ searchDir: 'fixtures', outDir: 'dist' }).create( 50 | path.normalize('fixtures/testStyle.css'), 51 | ); 52 | assert.equal(path.relative(process.cwd(), content.outputFilePath), path.normalize('dist/testStyle.css.d.ts')); 53 | }); 54 | }); 55 | 56 | describe('#allow arbitrary extensions', () => { 57 | it('can be set allowArbitraryExtensions', async () => { 58 | const content = await new DtsCreator({ 59 | searchDir: 'fixtures', 60 | outDir: 'dist', 61 | allowArbitraryExtensions: true, 62 | }).create(path.normalize('fixtures/testStyle.css')); 63 | assert.equal(path.relative(process.cwd(), content.outputFilePath), path.normalize('dist/testStyle.d.css.ts')); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /Contribution.md: -------------------------------------------------------------------------------- 1 | # typed-css-modules Contribution Guidelines 2 | 3 | Welcome to the typed-css-modules project! We appreciate your interest in contributing to the project. This document outlines the guidelines for contributing to the project to help maintain a healthy and collaborative development environment. 4 | 5 | ## Table of Contents 6 | 7 | - Getting Started 8 | - Code of Conduct 9 | - How to Contribute 10 | - Reporting Bugs 11 | - Submitting Enhancements 12 | - Code Contributions 13 | - Style Guidelines 14 | - License 15 | 16 | ## Getting Started 17 | 18 | Before you begin contributing, please make sure you have: 19 | 20 | 1. Node.js and npm installed on your system. 21 | 2. A GitHub account for version control and issue tracking. 22 | 3. Make sure you are using the newly released version. 23 | 4. Familiarize yourself with the project by reviewing the example in this repository `example` folder and understanding its goals. 24 | 25 | ## Code of Conduct 26 | 27 | Please maintain the expected behavior and conduct within the project's community. 28 | 29 | ## How to Contribute 30 | 31 | We welcome contributions in the following ways: 32 | 33 | ### Reporting Bugs 34 | 35 | If you find any bugs or issues with the project, please [submit a new issue](https://github.com/Quramy/typed-css-modules/issues/new) on GitHub. Make sure to provide detailed information about the bug and steps to reproduce it. 36 | 37 | ### Submitting Enhancements 38 | 39 | If you have an idea for an enhancement or a new feature, create an enhancement proposal in the [Issues](https://github.com/Quramy/typed-css-modules/issues) section. Discuss your proposal with the community before you start working on it. 40 | 41 | ### Code Contributions 42 | 43 | If you would like to contribute code to the project, please follow these steps: 44 | 45 | - Fork the repository on GitHub. 46 | - Clone your fork locally: 47 | `git clone https://github.com/your-username/your-repo.git` 48 | - Create a new branch for your changes: 49 | `git checkout -b feature/your-feature` 50 | 51 | - Make your changes and commit them with clear, concise messages. 52 | 53 | - Push your changes to your fork on GitHub: 54 | `git push origin feature/your-feature` 55 | - Create a Pull Request (PR) in the project repository, providing a detailed description of your changes and linking to any relevant issues. 56 | - Participate in the review process and make any necessary updates. 57 | 58 | ## Style Guidelines 59 | 60 | To maintain a consistent codebase, we follow a set of style guidelines for our code. These include but are not limited to: 61 | 62 | - Using TypeScript for all code. 63 | - Following [CSS Modules TypeScript Demo](https://quramy.github.io/typescript-css-modules-demo/) guidelines of working demonstration of CSS Modules with React and TypeScript. 64 | - Adhering to consistent code formatting (we use Prettier) and indentation. 65 | - Writing clear and informative comments in code. 66 | 67 | ## License 68 | 69 | By contributing to this project, you agree that your contributions will be licensed under the [MIT License](LICENSE.txt). 70 | 71 | Thank you for considering contributing to the CSS Modules TypeScript project. Your contributions are valuable and help make the project better for everyone! 72 | -------------------------------------------------------------------------------- /src/file-system-loader.ts: -------------------------------------------------------------------------------- 1 | /* this file is forked from https://raw.githubusercontent.com/css-modules/css-modules-loader-core/master/src/file-system-loader.js */ 2 | 3 | import fs from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | 6 | import type { Plugin } from 'postcss'; 7 | 8 | import Core, { type ExportTokens } from './css-modules-loader-core'; 9 | 10 | type Dictionary = { 11 | [key: string]: T | undefined; 12 | }; 13 | 14 | export default class FileSystemLoader { 15 | private root: string; 16 | private sources: Dictionary; 17 | private importNr: number; 18 | private core: Core; 19 | public tokensByFile: Dictionary; 20 | 21 | constructor(root: string, plugins?: Plugin[]) { 22 | this.root = root; 23 | this.sources = {}; 24 | this.importNr = 0; 25 | this.core = new Core(plugins); 26 | this.tokensByFile = {}; 27 | } 28 | 29 | public async fetch( 30 | _newPath: string, 31 | relativeTo: string, 32 | _trace?: string, 33 | initialContents?: string, 34 | ): Promise { 35 | const newPath = _newPath.replace(/^["']|["']$/g, ''); 36 | const trace = _trace || String.fromCharCode(this.importNr++); 37 | 38 | const relativeDir = path.dirname(relativeTo); 39 | const rootRelativePath = path.resolve(relativeDir, newPath); 40 | let fileRelativePath = path.resolve(path.join(this.root, relativeDir), newPath); 41 | 42 | const isNodeModule = (fileName: string) => fileName[0] !== '.' && fileName[0] !== '/'; 43 | 44 | // if the path is not relative or absolute, try to resolve it in node_modules 45 | if (isNodeModule(newPath)) { 46 | try { 47 | fileRelativePath = require.resolve(newPath); 48 | } catch (e) {} 49 | } 50 | 51 | let source: string; 52 | 53 | if (!initialContents) { 54 | const tokens = this.tokensByFile[fileRelativePath]; 55 | if (tokens) { 56 | return tokens; 57 | } 58 | 59 | try { 60 | source = await fs.readFile(fileRelativePath, 'utf-8'); 61 | } catch (error) { 62 | if (relativeTo && relativeTo !== '/') { 63 | return {}; 64 | } 65 | 66 | throw error; 67 | } 68 | } else { 69 | source = initialContents; 70 | } 71 | 72 | const { injectableSource, exportTokens } = await this.core.load( 73 | source, 74 | rootRelativePath, 75 | trace, 76 | this.fetch.bind(this), 77 | ); 78 | 79 | const re = new RegExp(/@import\s'(\D+?)';/, 'gm'); 80 | 81 | let importTokens: ExportTokens = {}; 82 | 83 | let result; 84 | 85 | while ((result = re.exec(injectableSource))) { 86 | const importFile = result?.[1]; 87 | 88 | if (importFile) { 89 | let importFilePath = isNodeModule(importFile) 90 | ? importFile 91 | : path.resolve(path.dirname(fileRelativePath), importFile); 92 | 93 | const localTokens = await this.fetch(importFilePath, relativeTo, undefined, initialContents); 94 | Object.assign(importTokens, localTokens); 95 | } 96 | } 97 | 98 | const tokens = { ...exportTokens, ...importTokens }; 99 | 100 | this.sources[trace] = injectableSource; 101 | this.tokensByFile[fileRelativePath] = tokens; 102 | return tokens; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/dts-content.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | 4 | import isThere from 'is-there'; 5 | import { mkdirp } from 'mkdirp'; 6 | import camelcase from 'camelcase'; 7 | import chalk from 'chalk'; 8 | 9 | export type CamelCaseOption = boolean | 'dashes' | undefined; 10 | 11 | interface DtsContentOptions { 12 | dropExtension: boolean; 13 | rootDir: string; 14 | searchDir: string; 15 | outDir: string; 16 | rInputPath: string; 17 | rawTokenList: string[]; 18 | namedExports: boolean; 19 | allowArbitraryExtensions: boolean; 20 | camelCase: CamelCaseOption; 21 | EOL: string; 22 | } 23 | 24 | export class DtsContent { 25 | private dropExtension: boolean; 26 | private rootDir: string; 27 | private searchDir: string; 28 | private outDir: string; 29 | private rInputPath: string; 30 | private rawTokenList: string[]; 31 | private namedExports: boolean; 32 | private allowArbitraryExtensions: boolean; 33 | private camelCase: CamelCaseOption; 34 | private resultList: string[]; 35 | private EOL: string; 36 | 37 | constructor(options: DtsContentOptions) { 38 | this.dropExtension = options.dropExtension; 39 | this.rootDir = options.rootDir; 40 | this.searchDir = options.searchDir; 41 | this.outDir = options.outDir; 42 | this.rInputPath = options.rInputPath; 43 | this.rawTokenList = options.rawTokenList; 44 | this.namedExports = options.namedExports; 45 | this.allowArbitraryExtensions = options.allowArbitraryExtensions; 46 | this.camelCase = options.camelCase; 47 | this.EOL = options.EOL; 48 | 49 | // when using named exports, camelCase must be enabled by default 50 | // (see https://webpack.js.org/loaders/css-loader/#namedexport) 51 | // we still accept external control for the 'dashes' option, 52 | // so we only override in case is false or undefined 53 | if (this.namedExports && !this.camelCase) { 54 | this.camelCase = true; 55 | } 56 | 57 | this.resultList = this.createResultList(); 58 | } 59 | 60 | public get contents(): string[] { 61 | return this.resultList; 62 | } 63 | 64 | public get formatted(): string { 65 | if (!this.resultList || !this.resultList.length) return 'export {};'; 66 | 67 | if (this.namedExports) { 68 | return ( 69 | ['export const __esModule: true;', ...this.resultList.map(line => 'export ' + line), ''].join(this.EOL) + 70 | this.EOL 71 | ); 72 | } 73 | 74 | return ( 75 | ['declare const styles: {', ...this.resultList.map(line => ' ' + line), '};', 'export = styles;', ''].join( 76 | this.EOL, 77 | ) + this.EOL 78 | ); 79 | } 80 | 81 | public get tokens(): string[] { 82 | return this.rawTokenList; 83 | } 84 | 85 | public get outputFilePath(): string { 86 | return path.join(this.rootDir, this.outDir, this.outputFileName); 87 | } 88 | 89 | public get relativeOutputFilePath(): string { 90 | return path.join(this.outDir, this.outputFileName); 91 | } 92 | 93 | public get inputFilePath(): string { 94 | return path.join(this.rootDir, this.searchDir, this.rInputPath); 95 | } 96 | 97 | public get relativeInputFilePath(): string { 98 | return path.join(this.searchDir, this.rInputPath); 99 | } 100 | 101 | public async checkFile(postprocessor = (formatted: string) => formatted): Promise { 102 | if (!isThere(this.outputFilePath)) { 103 | console.error(chalk.red(`[ERROR] Type file needs to be generated for '${this.relativeInputFilePath}'`)); 104 | return false; 105 | } 106 | 107 | const finalOutput = postprocessor(this.formatted); 108 | const fileContent = (await fs.readFile(this.outputFilePath)).toString(); 109 | 110 | if (fileContent !== finalOutput) { 111 | console.error(chalk.red(`[ERROR] Check type definitions for '${this.relativeOutputFilePath}'`)); 112 | return false; 113 | } 114 | return true; 115 | } 116 | 117 | public async writeFile( 118 | postprocessor: (formatted: string) => string | PromiseLike = formatted => formatted, 119 | ): Promise { 120 | const finalOutput = await postprocessor(this.formatted); 121 | 122 | const outPathDir = path.dirname(this.outputFilePath); 123 | if (!isThere(outPathDir)) { 124 | await mkdirp(outPathDir); 125 | } 126 | 127 | let isDirty = false; 128 | 129 | if (!isThere(this.outputFilePath)) { 130 | isDirty = true; 131 | } else { 132 | const content = (await fs.readFile(this.outputFilePath)).toString(); 133 | 134 | if (content !== finalOutput) { 135 | isDirty = true; 136 | } 137 | } 138 | 139 | if (isDirty) { 140 | await fs.writeFile(this.outputFilePath, finalOutput, 'utf8'); 141 | } 142 | } 143 | 144 | public async deleteFile() { 145 | if (isThere(this.outputFilePath)) { 146 | await fs.unlink(this.outputFilePath); 147 | } 148 | } 149 | 150 | private createResultList(): string[] { 151 | const convertKey = this.getConvertKeyMethod(this.camelCase); 152 | 153 | const result = this.rawTokenList 154 | .map(k => convertKey(k)) 155 | .map(k => (!this.namedExports ? 'readonly "' + k + '": string;' : 'const ' + k + ': string;')) 156 | .sort(); 157 | 158 | return result; 159 | } 160 | 161 | private getConvertKeyMethod(camelCaseOption: CamelCaseOption): (str: string) => string { 162 | switch (camelCaseOption) { 163 | case true: 164 | return camelcase; 165 | case 'dashes': 166 | return this.dashesCamelCase; 167 | default: 168 | return key => key; 169 | } 170 | } 171 | 172 | /** 173 | * Replaces only the dashes and leaves the rest as-is. 174 | * 175 | * Mirrors the behaviour of the css-loader: 176 | * https://github.com/webpack-contrib/css-loader/blob/1fee60147b9dba9480c9385e0f4e581928ab9af9/lib/compile-exports.js#L3-L7 177 | */ 178 | private dashesCamelCase(str: string): string { 179 | return str.replace(/-+(\w)/g, (_, firstLetter) => firstLetter.toUpperCase()); 180 | } 181 | 182 | private get outputFileName(): string { 183 | // Original extension must be dropped when using the allowArbitraryExtensions option 184 | const outputFileName = 185 | this.dropExtension || this.allowArbitraryExtensions ? removeExtension(this.rInputPath) : this.rInputPath; 186 | /** 187 | * Handles TypeScript 5.0 addition of arbitrary file extension patterns for ESM compatibility 188 | * https://www.typescriptlang.org/tsconfig#allowArbitraryExtensions 189 | */ 190 | return outputFileName + (this.allowArbitraryExtensions ? '.d.css.ts' : '.d.ts'); 191 | } 192 | } 193 | 194 | function removeExtension(filePath: string): string { 195 | const ext = path.extname(filePath); 196 | return filePath.replace(new RegExp(ext + '$'), ''); 197 | } 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typed-css-modules [![github actions](https://github.com/Quramy/typed-css-modules/workflows/build/badge.svg)](https://github.com/Quramy/typed-css-modules/actions) [![npm version](https://badge.fury.io/js/typed-css-modules.svg)](http://badge.fury.io/js/typed-css-modules) 2 | 3 | Creates TypeScript definition files from [CSS Modules](https://github.com/css-modules/css-modules) .css files. 4 | 5 | If you have the following css, 6 | 7 | ```css 8 | /* styles.css */ 9 | 10 | @value primary: red; 11 | 12 | .myClass { 13 | color: primary; 14 | } 15 | ``` 16 | 17 | typed-css-modules creates the following .d.ts files from the above css: 18 | 19 | ```ts 20 | /* styles.css.d.ts */ 21 | declare const styles: { 22 | readonly primary: string; 23 | readonly myClass: string; 24 | }; 25 | export = styles; 26 | ``` 27 | 28 | So, you can import CSS modules' class or variable into your TypeScript sources: 29 | 30 | ```ts 31 | /* app.ts */ 32 | import styles from './styles.css'; 33 | console.log(`
`); 34 | console.log(`
`); 35 | ``` 36 | 37 | ## CLI 38 | 39 | ```sh 40 | npm install -g typed-css-modules 41 | ``` 42 | 43 | And exec `tcm ` command. 44 | For example, if you have .css files under `src` directory, exec the following: 45 | 46 | ```sh 47 | tcm src 48 | ``` 49 | 50 | Then, this creates `*.css.d.ts` files under the directory which has the original .css file. 51 | 52 | ```text 53 | (your project root) 54 | - src/ 55 | | myStyle.css 56 | | myStyle.css.d.ts [created] 57 | ``` 58 | 59 | #### output directory 60 | 61 | Use `-o` or `--outDir` option. 62 | 63 | For example: 64 | 65 | ```sh 66 | tcm -o dist src 67 | ``` 68 | 69 | ```text 70 | (your project root) 71 | - src/ 72 | | myStyle.css 73 | - dist/ 74 | | myStyle.css.d.ts [created] 75 | ``` 76 | 77 | #### file name pattern 78 | 79 | By default, this tool searches `**/*.css` files under ``. 80 | If you can customize the glob pattern, you can use `--pattern` or `-p` option. 81 | Note the quotes around the glob to `-p` (they are required, so that your shell does not perform the expansion). 82 | 83 | ```sh 84 | tcm -p 'src/**/*.css' . 85 | ``` 86 | 87 | #### watch 88 | 89 | With `-w` or `--watch`, this CLI watches files in the input directory. 90 | 91 | #### validating type files 92 | 93 | With `-l` or `--listDifferent`, list any files that are different than those that would be generated. 94 | If any are different, exit with a status code 1. 95 | 96 | #### camelize CSS token 97 | 98 | With `-c` or `--camelCase`, kebab-cased CSS classes(such as `.my-class {...}`) are exported as camelized TypeScript variable name(`export const myClass: string`). 99 | 100 | You can pass `--camelCase dashes` to only camelize dashes in the class name. Since version `0.27.1` in the 101 | webpack `css-loader`. This will keep upperCase class names intact, e.g.: 102 | 103 | ```css 104 | .SomeComponent { 105 | height: 10px; 106 | } 107 | ``` 108 | 109 | becomes 110 | 111 | ```typescript 112 | declare const styles: { 113 | readonly SomeComponent: string; 114 | }; 115 | export = styles; 116 | ``` 117 | 118 | See also [webpack css-loader's camelCase option](https://github.com/webpack/css-loader#camelcase). 119 | 120 | #### named exports (enable tree shaking) 121 | 122 | With `-e` or `--namedExports`, types are exported as named exports as opposed to default exports. 123 | This enables support for the `namedExports` css-loader feature, required for webpack to tree shake the final CSS (learn more [here](https://webpack.js.org/loaders/css-loader/#namedexport)). 124 | 125 | Use this option in combination with https://webpack.js.org/loaders/css-loader/#namedexport and https://webpack.js.org/loaders/style-loader/#namedexport (if you use `style-loader`). 126 | 127 | When this option is enabled, the type definition changes to support named exports. 128 | 129 | _NOTE: this option enables camelcase by default._ 130 | 131 | ```css 132 | .SomeComponent { 133 | height: 10px; 134 | } 135 | ``` 136 | 137 | **Standard output:** 138 | 139 | ```typescript 140 | declare const styles: { 141 | readonly SomeComponent: string; 142 | }; 143 | export = styles; 144 | ``` 145 | 146 | **Named exports output:** 147 | 148 | ```typescript 149 | export const someComponent: string; 150 | ``` 151 | 152 | #### arbitrary file extensions 153 | 154 | With `-a` or `--allowArbitraryExtensions`, output filenames will be compatible with the "arbitrary file extensions" feature that was introduce in TypeScript 5.0. See [the docs](https://www.typescriptlang.org/tsconfig#allowArbitraryExtensions) for more info. 155 | 156 | In essence, the `*.css.d.ts` extension now becomes `*.d.css.ts` so that you can import CSS modules in projects using ESM module resolution. 157 | 158 | ## API 159 | 160 | ```sh 161 | npm install typed-css-modules 162 | ``` 163 | 164 | ```js 165 | import DtsCreator from 'typed-css-modules'; 166 | let creator = new DtsCreator(); 167 | creator.create('src/style.css').then(content => { 168 | console.log(content.tokens); // ['myClass'] 169 | console.log(content.formatted); // 'export const myClass: string;' 170 | content.writeFile(); // writes this content to "src/style.css.d.ts" 171 | }); 172 | ``` 173 | 174 | ### class DtsCreator 175 | 176 | DtsCreator instance processes the input CSS and creates TypeScript definition contents. 177 | 178 | #### `new DtsCreator(option)` 179 | 180 | You can set the following options: 181 | 182 | - `option.rootDir`: Project root directory(default: `process.cwd()`). 183 | - `option.searchDir`: Directory which includes target `*.css` files(default: `'./'`). 184 | - `option.outDir`: Output directory(default: `option.searchDir`). 185 | - `option.camelCase`: Camelize CSS class tokens. 186 | - `option.namedExports`: Use named exports as opposed to default exports to enable tree shaking. Requires `import * as style from './file.module.css';` (default: `false`) 187 | - `option.allowArbitraryExtensions`: Output filenames that will be compatible with the "arbitrary file extensions" TypeScript feature 188 | - `option.EOL`: EOL (end of line) for the generated `d.ts` files. Possible values `'\n'` or `'\r\n'`(default: `os.EOL`). 189 | 190 | #### `create(filepath, contents) => Promise(dtsContent)` 191 | 192 | Returns `DtsContent` instance. 193 | 194 | - `filepath`: path of target .css file. 195 | - `contents`(optional): the CSS content of the `filepath`. If set, DtsCreator uses the contents instead of the original contents of the `filepath`. 196 | 197 | ### class DtsContent 198 | 199 | DtsContent instance has `*.d.ts` content, final output path, and function to write the file. 200 | 201 | #### `writeFile(postprocessor) => Promise(dtsContent)` 202 | 203 | Writes the DtsContent instance's content to a file. Returns the DtsContent instance. 204 | 205 | - `postprocessor` (optional): a function that takes the formatted definition string and returns a modified string that will be the final content written to the file. 206 | 207 | You could use this, for example, to pass generated definitions through a formatter like Prettier, or to add a comment to the top of generated files: 208 | 209 | ```js 210 | dtsContent.writeFile(definition => `// Generated automatically, do not edit\n${definition}`); 211 | ``` 212 | 213 | #### `tokens` 214 | 215 | An array of tokens is retrieved from the input CSS file. 216 | e.g. `['myClass']` 217 | 218 | #### `contents` 219 | 220 | An array of TypeScript definition expressions. 221 | e.g. `['export const myClass: string;']`. 222 | 223 | #### `formatted` 224 | 225 | A string of TypeScript definition expressions. 226 | 227 | e.g. 228 | 229 | ```ts 230 | export const myClass: string; 231 | ``` 232 | 233 | #### `messageList` 234 | 235 | An array of messages. The messages contain invalid token information. 236 | e.g. `['my-class is not valid TypeScript variable name.']`. 237 | 238 | #### `outputFilePath` 239 | 240 | Final output file path. 241 | 242 | ## Remarks 243 | 244 | If your input CSS file has the following class names, these invalid tokens are not written to output `.d.ts` file. 245 | 246 | ```css 247 | /* TypeScript reserved word */ 248 | .while { 249 | color: red; 250 | } 251 | 252 | /* invalid TypeScript variable */ 253 | /* If camelCase option is set, this token will be converted to 'myClass' */ 254 | .my-class { 255 | color: red; 256 | } 257 | 258 | /* it's ok */ 259 | .myClass { 260 | color: red; 261 | } 262 | ``` 263 | 264 | ## Example 265 | 266 | There is a minimum example in this repository `example` folder. Clone this repository and run `cd example; npm i; npm start`. 267 | 268 | Or please see [https://github.com/Quramy/typescript-css-modules-demo](https://github.com/Quramy/typescript-css-modules-demo). It's a working demonstration of CSS Modules with React and TypeScript. 269 | 270 | ## License 271 | 272 | This software is released under the MIT License, see LICENSE.txt. 273 | -------------------------------------------------------------------------------- /src/dts-content.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import assert from 'node:assert'; 4 | 5 | import isThere from 'is-there'; 6 | import { rimraf } from 'rimraf'; 7 | 8 | import { DtsCreator } from './dts-creator'; 9 | 10 | describe('DtsContent', () => { 11 | describe('#tokens', () => { 12 | it('returns original tokens', async () => { 13 | const content = await new DtsCreator().create('fixtures/testStyle.css'); 14 | assert.equal(content.tokens[0], 'myClass'); 15 | }); 16 | }); 17 | 18 | describe('#inputFilePath', () => { 19 | it('returns original CSS file name', async () => { 20 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 21 | assert.equal(path.relative(process.cwd(), content.inputFilePath), path.normalize('fixtures/testStyle.css')); 22 | }); 23 | }); 24 | 25 | describe('#relativeInputFilePath', () => { 26 | it('returns relative original CSS file name', async () => { 27 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 28 | assert.equal(content.relativeInputFilePath, path.normalize('fixtures/testStyle.css')); 29 | }); 30 | }); 31 | 32 | describe('#outputFilePath', () => { 33 | it('adds d.ts to the original filename', async () => { 34 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 35 | assert.equal(path.relative(process.cwd(), content.outputFilePath), path.normalize('fixtures/testStyle.css.d.ts')); 36 | }); 37 | 38 | it('can drop the original extension when asked', async () => { 39 | const content = await new DtsCreator({ dropExtension: true }).create(path.normalize('fixtures/testStyle.css')); 40 | assert.equal(path.relative(process.cwd(), content.outputFilePath), path.normalize('fixtures/testStyle.d.ts')); 41 | }); 42 | 43 | it('can comply with TypeScript allowArbitraryExtensions when asked', async () => { 44 | const content = await new DtsCreator({ allowArbitraryExtensions: true }).create( 45 | path.normalize('fixtures/testStyle.css'), 46 | ); 47 | assert.equal(path.relative(process.cwd(), content.outputFilePath), path.normalize('fixtures/testStyle.d.css.ts')); 48 | }); 49 | }); 50 | 51 | describe('#relativeOutputFilePath', () => { 52 | it('adds d.ts to the original filename', async () => { 53 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 54 | assert.equal( 55 | path.relative(process.cwd(), content.relativeOutputFilePath), 56 | path.normalize('fixtures/testStyle.css.d.ts'), 57 | ); 58 | }); 59 | 60 | it('can drop the original extension when asked', async () => { 61 | const content = await new DtsCreator({ dropExtension: true }).create(path.normalize('fixtures/testStyle.css')); 62 | assert.equal( 63 | path.relative(process.cwd(), content.relativeOutputFilePath), 64 | path.normalize('fixtures/testStyle.d.ts'), 65 | ); 66 | }); 67 | }); 68 | 69 | describe('#formatted', () => { 70 | it('returns formatted .d.ts string', async () => { 71 | const content = await new DtsCreator({ EOL: '\n' }).create('fixtures/testStyle.css'); 72 | assert.equal( 73 | content.formatted, 74 | `\ 75 | declare const styles: { 76 | readonly "myClass": string; 77 | }; 78 | export = styles; 79 | 80 | `, 81 | ); 82 | }); 83 | 84 | it('returns named exports formatted .d.ts string', async () => { 85 | const content = await new DtsCreator({ namedExports: true, EOL: '\n' }).create('fixtures/testStyle.css'); 86 | assert.equal( 87 | content.formatted, 88 | `\ 89 | export const __esModule: true; 90 | export const myClass: string; 91 | 92 | `, 93 | ); 94 | }); 95 | 96 | it('returns camelcase names when using named exports as formatted .d.ts string', async () => { 97 | const content = await new DtsCreator({ namedExports: true, EOL: '\n' }).create('fixtures/kebabedUpperCase.css'); 98 | assert.equal( 99 | content.formatted, 100 | `\ 101 | export const __esModule: true; 102 | export const myClass: string; 103 | 104 | `, 105 | ); 106 | }); 107 | 108 | it('returns empty object exportion when the result list has no items', async () => { 109 | const content = await new DtsCreator({ EOL: '\n' }).create('fixtures/empty.css'); 110 | assert.equal(content.formatted, 'export {};'); 111 | }); 112 | 113 | describe('#camelCase option', () => { 114 | it('camelCase == true: returns camelized tokens for lowercase classes', async () => { 115 | const content = await new DtsCreator({ camelCase: true, EOL: '\n' }).create('fixtures/kebabed.css'); 116 | assert.equal( 117 | content.formatted, 118 | `\ 119 | declare const styles: { 120 | readonly "myClass": string; 121 | }; 122 | export = styles; 123 | 124 | `, 125 | ); 126 | }); 127 | 128 | it('camelCase == true: returns camelized tokens for uppercase classes ', async () => { 129 | const content = await new DtsCreator({ camelCase: true, EOL: '\n' }).create('fixtures/kebabedUpperCase.css'); 130 | assert.equal( 131 | content.formatted, 132 | `\ 133 | declare const styles: { 134 | readonly "myClass": string; 135 | }; 136 | export = styles; 137 | 138 | `, 139 | ); 140 | }); 141 | 142 | it('camelCase == "dashes": returns camelized tokens for dashes only', async () => { 143 | const content = await new DtsCreator({ camelCase: 'dashes', EOL: '\n' }).create( 144 | 'fixtures/kebabedUpperCase.css', 145 | ); 146 | assert.equal( 147 | content.formatted, 148 | `\ 149 | declare const styles: { 150 | readonly "MyClass": string; 151 | }; 152 | export = styles; 153 | 154 | `, 155 | ); 156 | }); 157 | }); 158 | }); 159 | 160 | describe('#checkFile', () => { 161 | let mockExit: jest.SpyInstance; 162 | let mockConsoleLog: jest.SpyInstance; 163 | let mockConsoleError: jest.SpyInstance; 164 | 165 | beforeAll(() => { 166 | mockExit = jest.spyOn(process, 'exit').mockImplementation(exitCode => { 167 | throw new Error(`process.exit: ${exitCode}`); 168 | }); 169 | mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); 170 | mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); 171 | }); 172 | 173 | beforeEach(() => { 174 | jest.clearAllMocks(); 175 | }); 176 | 177 | afterAll(() => { 178 | mockExit.mockRestore(); 179 | mockConsoleLog.mockRestore(); 180 | mockConsoleError.mockRestore(); 181 | }); 182 | 183 | it('return false if type file is missing', async () => { 184 | const content = await new DtsCreator({ EOL: '\n' }).create(path.normalize('fixtures/empty.css')); 185 | const result = await content.checkFile(); 186 | assert.equal(result, false); 187 | }); 188 | 189 | it('returns false if type file content is different', async () => { 190 | const content = await new DtsCreator({ EOL: '\n' }).create(path.normalize('fixtures/different.css')); 191 | const result = await content.checkFile(); 192 | assert.equal(result, false); 193 | }); 194 | 195 | it('returns true if type files match', async () => { 196 | const content = await new DtsCreator({ EOL: '\n' }).create(path.normalize('fixtures/testStyle.css')); 197 | const result = await content.checkFile(); 198 | assert.equal(result, true); 199 | }); 200 | }); 201 | 202 | describe('#writeFile', () => { 203 | beforeEach(async () => { 204 | await rimraf(path.normalize('fixtures/testStyle.css.d.ts')); 205 | }); 206 | 207 | it('accepts a postprocessor sync function', async () => { 208 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 209 | await content.writeFile(formatted => `// this banner was added to the .d.ts file automatically.\n${formatted}`); 210 | }); 211 | 212 | it('accepts a postprocessor async function', async () => { 213 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 214 | await content.writeFile( 215 | async formatted => `// this banner was added to the .d.ts file automatically.\n${formatted}`, 216 | ); 217 | }); 218 | 219 | it('writes a .d.ts file', async () => { 220 | const content = await new DtsCreator().create(path.normalize('fixtures/testStyle.css')); 221 | await content.writeFile(); 222 | expect(isThere(path.normalize('fixtures/testStyle.css.d.ts'))).toBeTruthy(); 223 | }); 224 | }); 225 | 226 | describe('#deleteFile', () => { 227 | beforeEach(async () => { 228 | await fs.writeFile(path.normalize('fixtures/none.css.d.ts'), '', 'utf8'); 229 | }); 230 | 231 | it('delete a .d.ts file', async () => { 232 | const content = await new DtsCreator().create(path.normalize('fixtures/none.css'), undefined, false, true); 233 | await content.deleteFile(); 234 | expect(isThere(path.normalize('fixtures/none.css.d.ts'))).toBeFalsy(); 235 | }); 236 | 237 | afterAll(async () => { 238 | await rimraf(path.normalize('fixtures/none.css.d.ts')); 239 | }); 240 | }); 241 | }); 242 | --------------------------------------------------------------------------------