├── .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 [](https://github.com/Quramy/typed-css-modules/actions) [](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 |
--------------------------------------------------------------------------------