├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __testfixtures__ └── prefix │ ├── base.input.js │ ├── base.output.js │ └── index.module.scss ├── __tests__ ├── jest.config.mjs ├── prefix.test.ts └── tsconfig.json ├── bin └── index.js ├── logo.svg ├── package.json ├── pnpm-lock.yaml ├── scripts └── update.ts ├── src ├── context.ts ├── db-server │ ├── client.ts │ ├── config.ts │ ├── server.ts │ └── utils.ts ├── fill-tailwind-class.ts ├── get-tailwind-map.ts ├── index.ts ├── jscodeshift.ts ├── post-css │ ├── index.ts │ └── plugins │ │ └── tailwind-class.ts ├── transform.ts └── utils │ ├── db.ts │ ├── file.ts │ ├── index.ts │ ├── logger.ts │ ├── master.ts │ └── validate.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:import/recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:import/typescript', 6 | ], 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['prettier', '@typescript-eslint'], 9 | parserOptions: { 10 | ecmaVersion: 12, 11 | sourceType: 'module' 12 | }, 13 | ignorePatterns: [ 14 | '*.test.ts', 15 | '__tests__/', 16 | '__testfixtures__/', 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release to NPM 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛎️ 12 | uses: actions/checkout@v4 13 | 14 | - uses: pnpm/action-setup@v4 15 | with: 16 | version: 8.15.5 17 | 18 | - name: Use Node LTS ✨ 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | registry-url: https://registry.npmjs.org 23 | cache: pnpm 24 | 25 | - name: Install dependencies 📦️ 26 | run: pnpm install --frozen-lockfile 27 | 28 | - name: Build 🔨 29 | run: pnpm build 30 | 31 | - uses: simenandre/publish-with-pnpm@v2 32 | with: 33 | npm-auth-token: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Coverage directory used by tools like istanbul 8 | coverage 9 | 10 | 11 | # Dependency directories 12 | node_modules/ 13 | 14 | # Optional npm cache directory 15 | .npm 16 | 17 | # Optional eslint cache 18 | .eslintcache 19 | 20 | # Output of 'npm pack' 21 | *.tgz 22 | 23 | # Yarn Integrity file 24 | .yarn-integrity 25 | 26 | # dotenv environment variables file 27 | .env 28 | 29 | # artifacts 30 | build/ 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.1.0 6 | 7 | ### Added 8 | 9 | - 🎉 Everything 10 | 11 | ## 0.1.1 12 | 13 | ### Added 14 | 15 | - Docs 16 | 17 | ## 0.1.2 18 | 19 | ### Fixed 20 | 21 | - Fix the bug that the background is deleted 22 | 23 | ## 0.1.3 24 | 25 | ### Fixed 26 | 27 | - Remove console.log 28 | 29 | ## 0.1.4 30 | 31 | ### Fixed 32 | 33 | - Fix error message log 34 | - Fix nodes optional 35 | 36 | ## 0.1.5 37 | 38 | ### Fixed 39 | 40 | - Parse tsconfig by JSON5 41 | 42 | ## 0.1.6 43 | 44 | ### Fixed 45 | 46 | - Fix tsconfig alias 47 | 48 | ## 0.1.7 49 | 50 | ### Fixed 51 | 52 | - Fix import decls should not be removed 53 | 54 | ## 0.1.8 55 | 56 | ### Fixed 57 | 58 | - Import namespace specifier support 59 | 60 | ## 0.1.9 61 | 62 | ### Fixed 63 | 64 | - Remove legacy className 65 | - Remove unused css import specifiers and files 66 | - Update README.md 67 | 68 | ## 0.2.0 69 | 70 | ### Fixed 71 | 72 | - Keep useful `className` 73 | 74 | ## 0.2.1 75 | 76 | ### Fixed 77 | 78 | - Fix `className` substitution rules 79 | 80 | ## 0.3.0 81 | 82 | ### Added 83 | 84 | - 🧪 new tailwind css classes generator 85 | 86 | ### Fixed 87 | 88 | - 🔧 sorter rules 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 SSSS 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS Modules to Tailwind CSS 2 | 3 | This is a tool to convert css-modules to tailwind-css 4 | 5 |

6 | Tailwind Tool 7 |

8 |

9 | Total Downloads 10 | Latest Release 11 | License 12 |

13 | 14 | ## Support list 15 | 16 | - [x] tsconfig.json alias support, like `alias/component/index.module.css` 17 | - [x] css file circular reference 18 | - [x] project level support, just run this command: `npx css-modules-to-tailwind src/**/*.tsx` 19 | - [x] arbitrary values support, `margin: 15px` => `m-[15px]` 20 | - [ ] pseudo-classes support 21 | - [x] tailwind prefixes support, e.g. `tw:` 22 | 23 | ## About 24 | 25 | - [CSS Modules](https://github.com/css-modules/css-modules) 26 | - [Tailwind CSS](https://tailwindcss.com/) 27 | 28 | ## Install 29 | 30 | Global install: 31 | 32 | ```shell 33 | npm install css-modules-to-tailwind -g 34 | ``` 35 | 36 | Or use `npx`: 37 | 38 | ```shell 39 | npx css-modules-to-tailwind src/index.tsx 40 | // npx css-modules-to-tailwind src/**/*.tsx 41 | ``` 42 | 43 | It will check your git directory is clean, you can use '--force' to skip the check. 44 | 45 | ## How it works 46 | 47 | It uses [jscodeshift](https://github.com/facebook/jscodeshift) and [postcss](https://github.com/postcss/postcss). 48 | 49 | Try it yourself: 50 | 51 | 1. First, Create a new jsx/tsx file(index.tsx/jsx): 52 | 53 | ```tsx 54 | import React from 'react'; 55 | import style from './index.module.css'; 56 | 57 | export const User = () => ( 58 |
59 |
60 | avatar 61 | username 62 |
63 |
name
64 |
65 | ); 66 | ``` 67 | 68 | 2. Create a new css modules file: 69 | 70 | ```css 71 | .header { 72 | width: 100%; 73 | display: flex; 74 | align-items: center; 75 | justify-content: space-between; 76 | } 77 | 78 | .user { 79 | display: flex; 80 | align-items: center; 81 | font-weight: bold; 82 | } 83 | 84 | .avatar { 85 | width: 0.625rem; 86 | height: 0.625rem; 87 | } 88 | 89 | .username { 90 | font-size: 0.75rem; 91 | line-height: 1rem; 92 | color: #7DD3FC; 93 | margin-left: 0.25rem; 94 | } 95 | ``` 96 | 97 | 3. Use this tool now: 98 | 99 | ```shell 100 | npx css-modules-to-tailwind index.tsx 101 | ``` 102 | 103 | 4. You will get: 104 | 105 | ```ts 106 | // index.tsx 107 | import React from 'react'; 108 | 109 | export const User = () => ( 110 |
111 |
112 | avatar 113 | username 114 |
115 |
name
116 |
117 | ); 118 | ``` 119 | 120 | > If the css file content is empty, import specifiers and css files will be removed, unused class will be replaced with \` \`, You should search globally for \` \`, then delete them. 121 | 122 | 🙋‍♂️ Flat and single structure design makes this tool work better. 123 | 124 | ## Only css-modules? 125 | 126 | Of course not. It can also be used for less/scss modules, but it doesn't work very well, like: 127 | 128 | ```less 129 | .selector1 { 130 | selector2(); 131 | } 132 | 133 | .selector2 { 134 | font-size: 0.75rem; 135 | line-height: 1rem; 136 | } 137 | ``` 138 | 139 | It just becomes: 140 | 141 | ```less 142 | .selector1 { 143 | selector2(); 144 | } 145 | ``` 146 | 147 | I think you should use `composes`. 148 | 149 | ## Inappropriate scenes 150 | 151 | ### Unreasonable nesting 152 | 153 | ```tsx 154 | import style form 'index.module.css'; 155 | 156 | const User = () => ( 157 | <> 158 |
159 |
childrenA
160 |
161 |
162 |
childrenA
163 |
164 | 165 | ); 166 | ``` 167 | 168 | ```css 169 | .parentA { 170 | .childrenA { 171 | // some decl 172 | } 173 | } 174 | ``` 175 | 176 | You shouldn't use nesting as namespace. 177 | 178 | ### You should not write multiple/conflicting declarations in a selector 179 | 180 | ```tsx 181 | import clsx from 'clsx'; 182 | import style form 'index.module.css'; 183 | 184 | const User = () => ( 185 | <> 186 |
187 | 188 | ); 189 | ``` 190 | 191 | ```css 192 | .cls1 { 193 | margin-left: 0.5rem; 194 | display: none; 195 | } 196 | 197 | .cls2 { 198 | margin-left: 0.375rem; 199 | display: block 200 | } 201 | ``` 202 | 203 | Always, it will become like this: 204 | 205 | ```tsx 206 | const User = () => ( 207 | <> 208 |
209 | 210 | ); 211 | ``` 212 | 213 | I mean, in tailwind, "`ml-2 ml-1.5`" === "`ml-2`", but in your code, is the latter declaration overriding the former. 214 | 215 | ## Support detail 216 | 217 | ### Composes 218 | 219 | 1. Quote itself 220 | 221 | ```css 222 | .class1 { 223 | display: flex; 224 | } 225 | 226 | .class2 { 227 | compose: class1 228 | } 229 | ``` 230 | 231 | it just becomes: 232 | 233 | ```css 234 | .class1 { 235 | @apply flex; 236 | } 237 | 238 | .class2 { 239 | composes: class1 240 | } 241 | ``` 242 | 243 | 2. Other CSS file: 244 | 245 | ```css 246 | /** index1.module.css */ 247 | .test1 { 248 | display: flex; 249 | } 250 | 251 | /** index2.module.css */ 252 | .test2 { 253 | composes: test1 from './index1.module.css' 254 | } 255 | ``` 256 | 257 | `index1.module.css` will be removed, and `index2.module.css`: 258 | 259 | ```css 260 | .test2 { 261 | @apply flex; 262 | } 263 | ``` 264 | 265 | ### Multiple states 266 | 267 | For example: 268 | 269 | ```css 270 | .button { 271 | width: 1.25rem; /* 20px */ 272 | } 273 | 274 | .box .button { 275 | width: 2rem; /* 32px */ 276 | } 277 | ``` 278 | 279 | It just becomes: 280 | 281 | ```css 282 | .button { 283 | @apply w-5; /* 20px */ 284 | } 285 | 286 | .box .button { 287 | @apply w-8; /* 32px */ 288 | } 289 | ``` 290 | 291 | Classes with multiple states will not do too much processing, because I don't know if there is a conflict between the states. 292 | 293 | ### Permutations 294 | 295 | Multiple style declarations can form a Tailwind CSS class. For example: 296 | 297 | ```css 298 | .truncate { 299 | overflow: hidden; 300 | text-overflow: ellipsis; 301 | white-space: nowrap; 302 | } 303 | ``` 304 | 305 | ```tsx 306 | const Com = () =>
text
307 | ``` 308 | 309 | It will become: 310 | 311 | ```tsx 312 | const Com = () =>
text
313 | ``` 314 | 315 | Of course, it supports more complex permutations and combinations, you can try it. 316 | 317 | ### Tailwind Prefixes 318 | 319 | For example: 320 | 321 | ```css 322 | .tw-bg-red-500 { 323 | background-color: #f56565; 324 | } 325 | ``` 326 | 327 | ```tsx 328 | const Com = () =>
text
329 | ``` 330 | 331 | ``` 332 | npx css-modules-to-tailwind src/**/*.tsx --prefix=tw: 333 | ``` 334 | 335 | It will become: 336 | 337 | ```tsx 338 | const Com = () =>
text
339 | ``` 340 | -------------------------------------------------------------------------------- /__testfixtures__/prefix/base.input.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import React from 'react'; 3 | import style from './index.module.scss'; 4 | 5 | export const User = () => ( 6 |
7 |
8 | avatar 9 | username 10 |
11 |
12 | ); -------------------------------------------------------------------------------- /__testfixtures__/prefix/base.output.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import React from 'react'; 3 | 4 | export const User = () => ( 5 |
6 |
7 | avatar 8 | username 9 |
10 |
11 | ); -------------------------------------------------------------------------------- /__testfixtures__/prefix/index.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | width: 100%; 3 | display: flex; 4 | align-items: center; 5 | justify-content: space-between; 6 | } 7 | 8 | .user { 9 | display: flex; 10 | align-items: center; 11 | font-weight: bold; 12 | } 13 | 14 | .avatar { 15 | width: 0.625rem; 16 | height: 0.625rem; 17 | } 18 | 19 | .username { 20 | font-size: 0.75rem; 21 | line-height: 1rem; 22 | color: #7DD3FC; 23 | margin-left: 0.25rem; 24 | } -------------------------------------------------------------------------------- /__tests__/jest.config.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | import * as path from 'node:path'; 7 | 8 | /** @type {import('jest').Config} */ 9 | export default { 10 | collectCoverage: true, 11 | coverageDirectory: 'coverage', 12 | coveragePathIgnorePatterns: ['/node_modules/'], 13 | coverageProvider: 'v8', 14 | coverageReporters: ['text', 'lcov'], 15 | 16 | // A set of global variables that need to be available in all test environments 17 | globals: { 18 | 'ts-jest': { 19 | // ts-jest configuration goes here 20 | tsconfig: '__tests__/tsconfig.json', 21 | }, 22 | }, 23 | 24 | maxWorkers: '50%', 25 | 26 | moduleDirectories: ['node_modules'], 27 | 28 | moduleFileExtensions: ['mts', 'cts', 'jsx', 'ts', 'tsx', 'js', 'cjs', 'mjs'], 29 | 30 | notify: true, 31 | preset: 'ts-jest', 32 | 33 | roots: [path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')], 34 | 35 | // The glob patterns Jest uses to detect test files 36 | testMatch: ['**/__tests__/**/*.test.ts?(x)'], 37 | 38 | testPathIgnorePatterns: ['/node_modules/'], 39 | 40 | // The regexp pattern or array of patterns that Jest uses to detect test files 41 | // testRegex: [], 42 | 43 | watchman: true, 44 | watchPlugins: [ 45 | 'jest-watch-typeahead/filename', 46 | 'jest-watch-typeahead/testname', 47 | ], 48 | testTimeout: 10000, 49 | }; 50 | -------------------------------------------------------------------------------- /__tests__/prefix.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const defineTest = require('jscodeshift/dist/testUtils').defineTest; 6 | 7 | const tests = ['base']; 8 | 9 | describe('prefix', () => { 10 | tests.forEach((test) => 11 | defineTest( 12 | path.resolve(__dirname), 13 | './src/transform.ts', 14 | { prefix: 'tw:' }, 15 | `prefix/${test}`, 16 | ), 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest", "node"], 5 | "rootDir": "../" 6 | }, 7 | "include": ["../src", "./"] 8 | } 9 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../build/index.js'); 4 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-modules-to-tailwind", 3 | "version": "0.3.4", 4 | "description": "tailwind css convert tool", 5 | "contributors": [ 6 | "shiyangzhaoa" 7 | ], 8 | "files": [ 9 | "build", 10 | "bin" 11 | ], 12 | "license": "MIT", 13 | "main": "build/index.js", 14 | "bin": "./bin/index.js", 15 | "module": "build/index.js", 16 | "repository": "https://github.com/shiyangzhaoa/css-modules-to-tailwind.git", 17 | "bugs": "https://github.com/shiyangzhaoa/css-modules-to-tailwind/issues", 18 | "scripts": { 19 | "build": "rm -rf build && tsc -b ./tsconfig.json", 20 | "build:w": "tsc -b ./tsconfig.json -w", 21 | "test": "jest --config ./__tests__/jest.config.mjs", 22 | "start": "jest --config ./__tests__/jest.config.mjs --watchAll --runInBand", 23 | "tailwind": "ts-node ./scripts/update.ts" 24 | }, 25 | "dependencies": { 26 | "color": "^4.2.3", 27 | "commander": "^12.0.0", 28 | "css-selector-tokenizer": "^0.8.0", 29 | "find-up": "4.1.0", 30 | "is-git-clean": "^1.1.0", 31 | "jscodeshift": "^17.3.0", 32 | "json5": "^2.2.3", 33 | "postcss": "^8.4.16", 34 | "postcss-less": "^6.0.0", 35 | "postcss-scss": "^4.0.5", 36 | "tailwind-generator": "0.0.1-beta.5", 37 | "chalk": "4.1.2" 38 | }, 39 | "devDependencies": { 40 | "@types/color": "^3.0.3", 41 | "@types/css-selector-tokenizer": "^0.7.1", 42 | "@types/is-git-clean": "^1.1.0", 43 | "@types/jest": "28.1.4", 44 | "@types/jscodeshift": "^0.11.5", 45 | "@types/node": "^20", 46 | "@types/postcss-less": "^4.0.2", 47 | "@typescript-eslint/eslint-plugin": "^7.3.1", 48 | "@typescript-eslint/parser": "^7.3.1", 49 | "eslint": "^8.57.0", 50 | "eslint-plugin-import": "^2.26.0", 51 | "eslint-plugin-prettier": "^4.2.1", 52 | "jest": "28.1.2", 53 | "jest-watch-typeahead": "2.2.2", 54 | "node-notifier": "^10.0.1", 55 | "prettier": "^3.2.5", 56 | "puppeteer": "^17.1.3", 57 | "ts-jest": "28.0.5", 58 | "ts-node": "^10.9.1", 59 | "typescript": "4.9.5" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /scripts/update.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | 5 | import puppeteer from 'puppeteer'; 6 | import postcss from 'postcss'; 7 | import chalk from 'chalk'; 8 | 9 | import type { Declaration, ChildNode } from 'postcss'; 10 | 11 | const validList = [ 12 | 'Layout', 13 | 'Flexbox & Grid', 14 | 'Spacing', 15 | 'Sizing', 16 | 'Typography', 17 | 'Backgrounds', 18 | 'Borders', 19 | 'Effects', 20 | 'Filters', 21 | 'Tables', 22 | 'Transitions & Animation', 23 | 'Transforms', 24 | 'Interactivity', 25 | 'SVG', 26 | 'Accessibility' 27 | ]; 28 | 29 | const uselessUrls = [ 30 | 'https://tailwindcss.com/docs/space', 31 | 'https://tailwindcss.com/docs/container', 32 | 'https://tailwindcss.com/docs/divide-width', 33 | 'https://tailwindcss.com/docs/divide-color', 34 | 'https://tailwindcss.com/docs/divide-style', 35 | ]; 36 | 37 | const BASE_URL = 'https://tailwindcss.com'; 38 | 39 | (async () => { 40 | const browser = await puppeteer.launch(); 41 | const page = await browser.newPage(); 42 | await page.goto(`${BASE_URL}/docs/installation`, { 43 | waitUntil: 'domcontentloaded', 44 | timeout: 0 45 | }); 46 | 47 | console.log(chalk.green(`${BASE_URL}/docs/installation opened`)); 48 | 49 | const navItem = await page.$$('li.mt-12'); 50 | const urlPromises: Promise[] = []; 51 | navItem.forEach(ele => { 52 | urlPromises.push((async () => { 53 | const subTitleEle = await ele.$('h5'); 54 | if (!subTitleEle) { 55 | return; 56 | } 57 | 58 | const textContent = await subTitleEle.getProperty('textContent'); 59 | const value = await textContent.jsonValue() as string; 60 | if (!validList.includes(value)) { 61 | return; 62 | } 63 | 64 | const aEle = await ele.$$('a'); 65 | const promises: Promise[] = []; 66 | aEle.forEach(ele => { 67 | promises.push((async () => { 68 | const href = await ele.getProperty('href'); 69 | return href.jsonValue(); 70 | })()) 71 | }); 72 | return Promise.all(promises); 73 | })()) 74 | }); 75 | 76 | const urls = await Promise.all(urlPromises); 77 | const validUrls = (urls.filter(Boolean).flat() as string[]).filter(url => !uselessUrls.includes(url)); 78 | 79 | const tasks: Promise[] = []; 80 | const combinations: Record = {}; 81 | 82 | validUrls.forEach(url => { 83 | tasks.push((async () => { 84 | const page = await browser.newPage(); 85 | await page.goto(url, { 86 | waitUntil: 'domcontentloaded', 87 | timeout: 0 88 | }); 89 | 90 | console.log(chalk.green(`${url} opened`)); 91 | 92 | const heads = await page.$$('thead > tr > th'); 93 | let classIndex: number; 94 | let propsIndex: number; 95 | 96 | await Promise.all(heads.map((ele, index) => { 97 | return (async () => { 98 | const text = await (await ele.getProperty('textContent')).jsonValue(); 99 | 100 | if (text === 'Class' && classIndex === undefined) { 101 | classIndex = index; 102 | } 103 | if (text === 'Properties' && propsIndex === undefined) { 104 | propsIndex= index; 105 | } 106 | })(); 107 | })); 108 | 109 | const propsTableELe = await page.$('table'); 110 | 111 | if (!propsTableELe) { 112 | return; 113 | } 114 | 115 | const rows = await propsTableELe.$$('tbody > tr'); 116 | return Promise.all(rows.map(row => { 117 | return (async () => { 118 | const items = await row.$$('td'); 119 | const keyEle = items[classIndex]; 120 | const valueEle = items[propsIndex]; 121 | if (!keyEle || !valueEle) { 122 | return; 123 | } 124 | 125 | const keyProp = await keyEle.getProperty('textContent'); 126 | const valueProp = await valueEle.getProperty('textContent'); 127 | const key = await keyProp.jsonValue(); 128 | const value = await valueProp.jsonValue(); 129 | 130 | if (!key || !value) { 131 | return; 132 | } 133 | 134 | try { 135 | const ast = await (await postcss().process(value, { from: 'tailwind.css' })).root; 136 | const decls = (ast.nodes as ChildNode[]).filter( 137 | (node) => node.type === 'decl', 138 | ) as Declaration[]; 139 | // TODO: useful? 140 | // const atrules = (ast.nodes as ChildNode[]).filter( 141 | // (node) => node.type === 'atrule', 142 | // ) as AtRule[]; 143 | 144 | const rule: Record = {}; 145 | decls.forEach(decl => { 146 | const { prop, value } = decl; 147 | const isUglyDoc = ['0px', '0rem', '0em'].includes(value); 148 | rule[prop] = isUglyDoc ? '0' : value; 149 | }); 150 | const keys = Object.keys(rule).sort(); 151 | 152 | let acc = combinations; 153 | keys.forEach((k, i, arr) => { 154 | const validKey = JSON.stringify({ [k]: rule[k] }); 155 | if (acc[validKey]) { 156 | if (i === arr.length - 1) { 157 | acc[validKey].value = key; 158 | } 159 | } else { 160 | acc[validKey] = i === arr.length - 1 ? { 161 | value: key 162 | } : {}; 163 | } 164 | acc = acc[validKey]; 165 | }); 166 | } catch(err) { 167 | console.log(chalk.red(`${url}: ${err}`)); 168 | } 169 | })(); 170 | })); 171 | })()); 172 | }) 173 | 174 | await Promise.all(tasks); 175 | 176 | fs.writeFile(path.join(__dirname, '../src/combination.json'), JSON.stringify(combinations)) 177 | 178 | console.log(chalk.green('completed~')); 179 | 180 | await browser.close(); 181 | })() -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { EVENT_TYPE, IpcIOSession } from './db-server/client'; 4 | import { config } from './db-server/config'; 5 | 6 | import type { RequestMessage, ResponseMessage } from './db-server/config'; 7 | 8 | export type Apply = { 9 | result: Record; 10 | removed: string[]; 11 | isUnlinked?: boolean; 12 | }; 13 | 14 | const events = new EventEmitter(); 15 | let seq = 0; 16 | const taskMap = new Map(); 17 | 18 | events.on(EVENT_TYPE, ({ type, success, body, seq }: ResponseMessage) => { 19 | if (type !== 'response') return; 20 | 21 | const { resolve, reject } = taskMap.get(seq); 22 | 23 | if (success) { 24 | resolve(body.data); 25 | } else { 26 | reject(body.message); 27 | } 28 | taskMap.delete(seq); 29 | }); 30 | 31 | const IOSession = new IpcIOSession({ eventPort: config.port, events }); 32 | IOSession.listen(); 33 | 34 | const createMessage = (message: RequestMessage): Promise => { 35 | IOSession.event(message); 36 | 37 | return new Promise((resolve, reject) => { 38 | taskMap.set(message.seq, { resolve, reject }); 39 | }); 40 | }; 41 | 42 | export const getContext = async (key: string): Promise => { 43 | seq++; 44 | 45 | return createMessage({ 46 | seq, 47 | type: 'request', 48 | event: 'read', 49 | body: { 50 | key, 51 | }, 52 | }); 53 | }; 54 | 55 | export const setContext = (cacheKey: string, apply: Apply) => { 56 | seq++; 57 | 58 | return createMessage({ 59 | seq, 60 | type: 'request', 61 | event: 'write', 62 | body: { 63 | key: cacheKey, 64 | value: apply, 65 | }, 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /src/db-server/client.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | import net from 'net'; 3 | 4 | import { error, success } from '../utils/logger'; 5 | 6 | import { extractMessage, formatMessage } from './utils'; 7 | 8 | import type { RequestMessage, ResponseMessage } from './config'; 9 | 10 | interface Options { 11 | eventPort: number; 12 | events: EventEmitter; 13 | } 14 | 15 | export const EVENT_TYPE = 'message'; 16 | 17 | export class IpcIOSession { 18 | #eventPort: number | undefined; 19 | #eventSocket: net.Socket | undefined; 20 | #socketEventQueue: object[] | undefined; 21 | #events: EventEmitter | undefined; 22 | 23 | constructor(options: Options) { 24 | this.#eventPort = options.eventPort; 25 | this.#eventSocket = net.connect({ port: this.#eventPort }); 26 | this.#events = options.events; 27 | 28 | if (this.#socketEventQueue) { 29 | for (const event of this.#socketEventQueue) { 30 | this.#writeToEventSocket(event); 31 | } 32 | 33 | this.#socketEventQueue = undefined; 34 | } 35 | } 36 | 37 | #writeMessage(message: RequestMessage | ResponseMessage) { 38 | this.#events?.emit(EVENT_TYPE, message); 39 | } 40 | 41 | #writeToEventSocket(message: object) { 42 | this.#eventSocket!.write( 43 | formatMessage(message, Buffer.byteLength), 44 | 'utf-8', 45 | ); 46 | } 47 | 48 | event(message: T) { 49 | if (!this.#eventSocket) { 50 | (this.#socketEventQueue || (this.#socketEventQueue = [])).push(message); 51 | return; 52 | } else { 53 | this.#writeToEventSocket(message); 54 | } 55 | } 56 | 57 | protected toStringMessage(message: any) { 58 | return JSON.stringify(message, undefined, 2); 59 | } 60 | 61 | exit() { 62 | process.exit(0); 63 | } 64 | 65 | listen() { 66 | this.#eventSocket?.on('connect', () => { 67 | success('Client connected'); 68 | }); 69 | 70 | this.#eventSocket?.on('data', (chunk) => { 71 | extractMessage(chunk.toString()).forEach((res) => { 72 | this.#writeMessage(JSON.parse(res)); 73 | }); 74 | }); 75 | 76 | this.#eventSocket?.on('error', (err) => { 77 | error(err.message); 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/db-server/config.ts: -------------------------------------------------------------------------------- 1 | import { Apply } from "../context"; 2 | 3 | export const config = { 4 | port: 7879, 5 | }; 6 | 7 | export interface ResponseMessage { 8 | seq: number; 9 | type: 'response'; 10 | event: 'read' | 'write' | 'reset' | 'destroy' | 'init'; 11 | success: boolean; 12 | body: { 13 | message?: string; 14 | data: Apply | null; 15 | }; 16 | } 17 | 18 | export type RequestMessage = 19 | | { 20 | seq: number; 21 | type: 'request'; 22 | event: 'read'; 23 | body: { 24 | key: string; 25 | }; 26 | } 27 | | { 28 | seq: number; 29 | type: 'request'; 30 | event: 'write'; 31 | body: { 32 | key: string; 33 | value: Apply; 34 | }; 35 | } 36 | | { 37 | seq: number; 38 | type: 'request'; 39 | event: 'reset'; 40 | body: { 41 | key: string; 42 | }; 43 | } 44 | | { 45 | seq: number; 46 | type: 'request'; 47 | event: 'destroy'; 48 | body: null; 49 | } 50 | | { 51 | seq: number; 52 | type: 'request'; 53 | event: 'init'; 54 | body: null; 55 | }; 56 | -------------------------------------------------------------------------------- /src/db-server/server.ts: -------------------------------------------------------------------------------- 1 | import net from 'net'; 2 | 3 | import { error, success } from '../utils/logger'; 4 | import { DB } from '../utils/db'; 5 | 6 | import { config } from './config'; 7 | import { extractMessage, formatMessage } from './utils'; 8 | 9 | import type { RequestMessage, ResponseMessage } from './config'; 10 | import { Apply } from '../context'; 11 | 12 | const tcp = net.createServer((socket) => { 13 | socket.on('data', (chunk) => { 14 | extractMessage(chunk.toString()).forEach((res) => { 15 | perform(JSON.parse(res), socket); 16 | }); 17 | }); 18 | 19 | socket.on('error', (err) => { 20 | error(err.message); 21 | }); 22 | }); 23 | 24 | tcp.listen(config.port, () => { 25 | success('Socket on'); 26 | }); 27 | 28 | const db = new DB('cache'); 29 | 30 | async function perform( 31 | { event, body, seq }: RequestMessage, 32 | socket: net.Socket, 33 | ) { 34 | const send = (message: ResponseMessage): Promise | void => { 35 | socket.write(formatMessage(message, Buffer.byteLength), 'utf-8'); 36 | }; 37 | 38 | const createResponse = (data: Apply | null = null) => { 39 | return { 40 | seq, 41 | event, 42 | type: 'response', 43 | success: true, 44 | body: { 45 | data, 46 | message: 'success', 47 | }, 48 | } as const; 49 | }; 50 | 51 | switch (event) { 52 | case 'read': 53 | const data = await db.read(body.key); 54 | send(createResponse(data)); 55 | break; 56 | case 'write': 57 | await db.write(body.key, body.value); 58 | send(createResponse()); 59 | break; 60 | case 'init': 61 | db.init(); 62 | send(createResponse()); 63 | break; 64 | case 'destroy': 65 | db.destroy(); 66 | send(createResponse()); 67 | break; 68 | default: 69 | send({ 70 | seq, 71 | event, 72 | type: 'response', 73 | success: false, 74 | body: { 75 | data: null, 76 | message: 'invalid event', 77 | }, 78 | }); 79 | break; 80 | } 81 | } 82 | 83 | process.on('message', (action) => { 84 | if (action === 'destroy') { 85 | db.destroy(); 86 | tcp.close(); 87 | process.exit(1); 88 | } 89 | }); 90 | -------------------------------------------------------------------------------- /src/db-server/utils.ts: -------------------------------------------------------------------------------- 1 | export function extractMessage(message: string): string[] { 2 | const lines = message.split(/\r?\n/); 3 | 4 | const data: string[] = []; 5 | 6 | lines.forEach((res) => { 7 | try { 8 | JSON.parse(res); 9 | data.push(res); 10 | } catch { 11 | // 12 | } 13 | }); 14 | 15 | return data; 16 | } 17 | 18 | export function formatMessage>( 19 | msg: T, 20 | byteLength: (s: string, encoding: any) => number, 21 | ): string { 22 | const json = JSON.stringify(msg); 23 | 24 | const len = byteLength(json, 'utf8'); 25 | return `Content-Length: ${1 + len}\r\n\r\n${json}\r\n`; 26 | } 27 | -------------------------------------------------------------------------------- /src/fill-tailwind-class.ts: -------------------------------------------------------------------------------- 1 | import core from 'jscodeshift'; 2 | 3 | import { j } from './jscodeshift'; 4 | 5 | import type { Apply } from './context'; 6 | 7 | export const fillTailwindClass = ( 8 | ast: core.Collection, 9 | tailwindMap: Record, 10 | prefix?: string 11 | ) => { 12 | const styleMemberExpressions = ast.find( 13 | j.MemberExpression, 14 | (node) => 15 | j.Identifier.check(node.object) && 16 | Object.keys(tailwindMap).includes(node.object.name), 17 | ); 18 | 19 | if (styleMemberExpressions.size() !== 0) { 20 | styleMemberExpressions.forEach((styleMemberExpression) => { 21 | const { object, property } = styleMemberExpression.node; 22 | 23 | const isValidProp = 24 | j.Identifier.check(property) || j.Literal.check(property); 25 | 26 | if (j.Identifier.check(object) && isValidProp) { 27 | let validName = ''; 28 | if (j.Literal.check(property)) { 29 | validName = String(property.value); 30 | } else { 31 | validName = property.name; 32 | } 33 | 34 | const cache = tailwindMap[object.name]; 35 | 36 | const classList = cache.result[validName] ?? []; 37 | const removed = cache.removed ?? []; 38 | const isRemoved = removed.includes(validName); 39 | const isUnlinked = cache.isUnlinked || false; 40 | 41 | if (classList.length === 0 && !isUnlinked) { 42 | return; 43 | } 44 | 45 | const className = classList.map(cls => `${prefix ?? ''}${cls}`).join(' '); 46 | 47 | if (isRemoved) { 48 | const parent = styleMemberExpression.parent; 49 | 50 | // className={style.test} 51 | if (j.JSXExpressionContainer.check(parent.value)) { 52 | j(parent).replaceWith(j.literal(className)); 53 | 54 | return; 55 | } 56 | 57 | // className={clsx(style.test, aa, bb, cc)} 58 | if (j.CallExpression.check(parent.value)) { 59 | styleMemberExpression.replace(j.literal(className)); 60 | 61 | return; 62 | } 63 | 64 | // className={`${style.test} aa bb cc`} 65 | if (j.TemplateLiteral.check(parent.value)) { 66 | styleMemberExpression.replace(j.literal(className)); 67 | 68 | return; 69 | } 70 | 71 | // className={clsx([style.test, 'aa'])} 72 | if (j.ArrayExpression.check(parent.value)) { 73 | styleMemberExpression.replace(j.literal(className)); 74 | 75 | return; 76 | } 77 | 78 | // className={clsx({ [style.test]: true })} 79 | if (j.ObjectProperty.check(parent.value)) { 80 | styleMemberExpression.replace(j.literal(className)); 81 | 82 | return; 83 | } 84 | 85 | try { 86 | styleMemberExpression.replace(j.literal(className)); 87 | 88 | return; 89 | } catch { 90 | // 91 | } 92 | } 93 | 94 | const quasis = [ 95 | j.templateElement({ cooked: '', raw: '' }, false), 96 | j.templateElement( 97 | { cooked: ` ${className}`, raw: ` ${className}` }, 98 | true, 99 | ), 100 | ]; 101 | 102 | const expressions = (isUnlinked || isRemoved) ? [] : [ 103 | j.memberExpression( 104 | j.identifier(object.name), 105 | j.identifier(validName), 106 | ), 107 | ]; 108 | const tpl = j.templateLiteral(quasis, expressions); 109 | styleMemberExpression.replace(tpl); 110 | } 111 | }); 112 | } 113 | 114 | return ast.toSource({ quote: "double" }); 115 | }; 116 | -------------------------------------------------------------------------------- /src/get-tailwind-map.ts: -------------------------------------------------------------------------------- 1 | import core from 'jscodeshift'; 2 | 3 | import { j } from './jscodeshift'; 4 | import { error } from './utils/logger'; 5 | import { isStyleModules } from './utils/validate'; 6 | import { getCompletionEntries } from './utils/file'; 7 | 8 | import { cssToTailwind } from './post-css'; 9 | 10 | import type { Apply } from './context'; 11 | 12 | interface TransformCreate { 13 | key: string; 14 | value: Promise; 15 | importDecl: core.Collection; 16 | } 17 | 18 | export const getTailwindMap = async ( 19 | ast: core.Collection, 20 | dir: string, 21 | ) => { 22 | const map: Record = {}; 23 | 24 | const cssImportSpecifiers = ast.find( 25 | j.ImportDeclaration, 26 | (node) => 27 | j.StringLiteral.check(node.source) && isStyleModules(node.source.value), 28 | ); 29 | 30 | if (cssImportSpecifiers.size() !== 0) { 31 | const promises: TransformCreate[] = []; 32 | cssImportSpecifiers.forEach((path) => { 33 | const importDecl = j(path); 34 | 35 | // import style from 'index.module.css'; 36 | const importDefaultSpecifiers = importDecl 37 | .find(j.ImportDefaultSpecifier) 38 | .find(j.Identifier); 39 | // import * as style from 'index.module.css'; 40 | const importNamespaceSpecifiers = importDecl 41 | .find(j.ImportNamespaceSpecifier) 42 | .find(j.Identifier); 43 | 44 | const importCSSNodes = [...importDefaultSpecifiers.nodes(), ...importNamespaceSpecifiers.nodes()]; 45 | 46 | if (importCSSNodes.length === 0) return; 47 | 48 | const importCSSNode = importCSSNodes[0]; 49 | const importCSSName = importCSSNode.name; 50 | 51 | const sourcePath = importDecl.find(j.Literal).get().node; 52 | const cssSourcePath = sourcePath.value; 53 | const cssPath = getCompletionEntries(dir)(cssSourcePath); 54 | 55 | promises.push({ 56 | importDecl, 57 | key: importCSSName, 58 | value: cssToTailwind(cssPath), 59 | }); 60 | }); 61 | try { 62 | const classList = await Promise.all(promises.map(({ value }) => value)); 63 | promises.forEach(({ key, importDecl }, i) => { 64 | const data = classList[i]; 65 | map[key] = data; 66 | 67 | if (data.isUnlinked) { 68 | importDecl.remove(); 69 | } 70 | }); 71 | } catch (err) { 72 | error(String(err)); 73 | } 74 | } 75 | 76 | return [ast.toSource({ quote: "double" }), map] as const; 77 | }; 78 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fork, spawn } from 'child_process'; 3 | 4 | import { Command } from 'commander'; 5 | 6 | import { checkGitStatus } from './utils/master'; 7 | 8 | const jscodeshiftExecutable = require.resolve('.bin/jscodeshift'); 9 | 10 | const program = new Command(); 11 | 12 | program 13 | .name('css-modules-to-tailwind') 14 | .description('CLI to tailwind convert') 15 | .version('0.0.1'); 16 | 17 | program 18 | .argument('') 19 | .option('-n, --number ', 'specify numbers') 20 | .option('-f, --force', 'skip check git status') 21 | .option('-p, --prefix ', 'specify tailwind prefix') 22 | .action((dirs, options) => { 23 | const args: string[] = []; 24 | 25 | checkGitStatus(options.force); 26 | 27 | const worker = fork(path.join(__dirname, './db-server/server.js')); 28 | 29 | args.push(path.join(__dirname, `./transform.js`)); 30 | args.push(...dirs); 31 | if (options.prefix) { 32 | args.push(`--prefix=${options.prefix}`); 33 | } 34 | const command = `${jscodeshiftExecutable} -t ${args.join(' ')}`; 35 | const [cmd, ...restArgs] = command.split(' '); 36 | const cp = spawn(cmd, restArgs, { stdio: 'pipe' }); 37 | 38 | process.on('beforeExit', () => { 39 | if (cp.killed) { 40 | return; 41 | } 42 | cp.kill(0); 43 | }); 44 | 45 | cp.stdout?.on('data', (data) => { 46 | /** Complete stdout signal eg: Time elapsed: 7.306seconds */ 47 | const output = data.toString('utf-8'); 48 | console.log(output); 49 | if (/Time elapsed: [\d.]+/.test(output)) { 50 | worker.send?.('destroy'); 51 | } 52 | }); 53 | }); 54 | 55 | program.parse(); 56 | -------------------------------------------------------------------------------- /src/jscodeshift.ts: -------------------------------------------------------------------------------- 1 | import jscodeshit from 'jscodeshift'; 2 | 3 | export const j = jscodeshit.withParser('tsx'); 4 | -------------------------------------------------------------------------------- /src/post-css/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import fsPromises from 'fs/promises'; 3 | 4 | import postcss from 'postcss'; 5 | import scss from 'postcss-scss'; 6 | import less from 'postcss-less'; 7 | 8 | import { getContext, setContext } from '../context'; 9 | import { error, warn } from '../utils/logger'; 10 | import { isLessModules } from '../utils/validate'; 11 | 12 | import { tailwindPluginCreator } from './plugins/tailwind-class'; 13 | 14 | import type { AcceptedPlugin } from 'postcss'; 15 | 16 | export const cssToTailwind = async (cssPath: string) => { 17 | const plugins: AcceptedPlugin[] = [ 18 | tailwindPluginCreator(cssPath), 19 | // postcss-nested, if you want split nested 20 | ]; 21 | 22 | const processor = postcss(plugins); 23 | 24 | const cache = await getContext(cssPath); 25 | if (cache) { 26 | return cache; 27 | } 28 | 29 | const contents = fs.readFileSync(cssPath).toString(); 30 | 31 | const { css: result } = await processor.process(contents, { 32 | syntax: isLessModules(cssPath) ? less : scss, 33 | from: 'tailwind.css', 34 | }); 35 | 36 | let isUnlinked = false; 37 | 38 | if (!result.replace(/^\s+|\s+$/g, '')) { 39 | isUnlinked = true; 40 | 41 | fsPromises.access(cssPath) 42 | .then(() => { 43 | warn(`${cssPath} has been deleted`); 44 | fsPromises.unlink(cssPath).catch(() => { 45 | // css file has been deleted 46 | }); 47 | }) 48 | .catch(() => { 49 | // css file removed 50 | }); 51 | } else { 52 | fsPromises.writeFile(cssPath, result).catch((err) => { 53 | error(err); 54 | }); 55 | } 56 | 57 | const context = await getContext(cssPath); 58 | 59 | const newContext = { ...context, isUnlinked }; 60 | 61 | await setContext(cssPath, newContext); 62 | 63 | return newContext; 64 | }; 65 | -------------------------------------------------------------------------------- /src/post-css/plugins/tailwind-class.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import path from 'path'; 3 | 4 | import Tokenizer from 'css-selector-tokenizer'; 5 | import { AtRule } from 'postcss'; 6 | 7 | import { cssToTailwind } from '../index'; 8 | import { setContext } from '../../context'; 9 | import { getComposesValue, promiseStep } from '../../utils'; 10 | 11 | import type { Plugin, Declaration, Rule, Root, ChildNode } from 'postcss'; 12 | import { isRule } from '../../utils/validate'; 13 | import { getCompletionEntries } from '../../utils/file'; 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-var-requires 16 | const { gen } = require('tailwind-generator'); 17 | 18 | export const tailwindPluginCreator = ( 19 | cssPath: string, 20 | ): Plugin => ({ 21 | postcssPlugin: 'tailwind', 22 | async Once(root) { 23 | const dependencies: string[] = []; 24 | const polymorphisms: string[] = []; 25 | const map = new Map(); 26 | root.walkDecls((decl) => { 27 | const isDependentBySelf = 28 | decl.prop === 'composes' && !getComposesValue(decl.value); 29 | 30 | if (isDependentBySelf) { 31 | dependencies.push(...decl.value.split(/\s+/)); 32 | } 33 | }); 34 | root.walkRules((rule) => { 35 | const selectorNodes = Tokenizer.parse(rule.selector); 36 | selectorNodes.nodes.forEach((node) => 37 | node.nodes.forEach((node, i, arr) => { 38 | const isLast = i === arr.length - 1; 39 | if (node.type === 'class' && isLast) { 40 | if (map.has(node.name)) { 41 | polymorphisms.push(node.name); 42 | } 43 | map.set(node.name, true); 44 | } 45 | }), 46 | ); 47 | }); 48 | 49 | const result = {}; 50 | const rules: Rule[] = []; 51 | const atRules: Rule[] = []; 52 | void (function reduceRules(node: Root | Rule | AtRule, isAtRule = false) { 53 | const nodes = node.nodes; 54 | 55 | nodes?.forEach((node) => { 56 | switch (node.type) { 57 | case 'atrule': 58 | reduceRules(node, true); 59 | break; 60 | case 'rule': 61 | if (isAtRule) { 62 | atRules.push(node); 63 | } else { 64 | rules.push(node); 65 | } 66 | break; 67 | default: 68 | break; 69 | } 70 | }); 71 | })(root); 72 | rules.forEach((node) => { 73 | const ruleTransformResult = transformRule( 74 | node, 75 | dependencies, 76 | polymorphisms, 77 | ); 78 | Object.assign(result, ruleTransformResult); 79 | }); 80 | 81 | atRules.forEach((node) => { 82 | const ruleTransformResult = transformRule( 83 | node, 84 | dependencies, 85 | polymorphisms, 86 | true, 87 | ); 88 | Object.assign(result, ruleTransformResult); 89 | }); 90 | 91 | const removedClassnames: string[] = []; 92 | 93 | (function picking() { 94 | const removed = treeShaking(root); 95 | 96 | if (removed.length !== 0) { 97 | removedClassnames.push(...removed.filter((item) => !polymorphisms.includes(item))); 98 | 99 | picking(); 100 | } 101 | })() 102 | 103 | await setContext(cssPath, { 104 | result: result, 105 | removed: removedClassnames, 106 | }); 107 | 108 | const promises: (() => Promise)[] = []; 109 | root.walkDecls((decl) => { 110 | if (decl.prop === 'composes') { 111 | promises.push(() => convertComposes(decl, cssPath)); 112 | } 113 | }); 114 | 115 | await promiseStep(promises); 116 | 117 | return; 118 | }, 119 | }); 120 | 121 | const transformRule = ( 122 | rule: Rule, 123 | dependencies: string[], 124 | polymorphisms: string[], 125 | isUnnecessarySplit = false, 126 | ) => { 127 | const result = {}; 128 | void (function dfs( 129 | rule: Rule, 130 | isParentUnnecessarySplit = isUnnecessarySplit, 131 | isGlobalParent = false, 132 | ) { 133 | let isGlobalClass = isGlobalParent; 134 | 135 | const rules = rule.nodes.filter((node) => node.type === 'rule') as Rule[]; 136 | let selectors: string[] = []; 137 | 138 | const decls = rule.nodes.filter( 139 | (node) => node.type === 'decl', 140 | ) as Declaration[]; 141 | 142 | const selectorNodes = Tokenizer.parse(rule.selector); 143 | 144 | selectorNodes.nodes.forEach((node) => { 145 | const { nodes } = node; 146 | 147 | if (isGlobalParent) return; 148 | if ( 149 | nodes.find( 150 | (node) => node.type === 'pseudo-class' && node.name === 'global', 151 | ) 152 | ) { 153 | isGlobalClass = true; 154 | 155 | return; 156 | } 157 | 158 | const allClassNode = nodes.filter((node) => node.type === 'class'); 159 | 160 | if (allClassNode.length !== 0) { 161 | const validClass = allClassNode[allClassNode.length - 1] as any; 162 | selectors = [...selectors, validClass.name]; 163 | } 164 | }); 165 | 166 | const singleRule: Record = {}; 167 | decls.forEach(({ prop, value, important }) => { 168 | if (!important) { 169 | singleRule[prop] = value; 170 | } 171 | }); 172 | const { success, failed } = gen(singleRule); 173 | 174 | const applyListStr = success.split(' '); 175 | const applyList = decls.filter(node => !failed.includes(node.prop) && !node.important); 176 | 177 | let isUnnecessarySplit = isParentUnnecessarySplit; 178 | if (isUnnecessarySplit === false) { 179 | isUnnecessarySplit = !selectorNodes.nodes.every((node) => 180 | node.nodes.every((node) => 181 | ['class', 'invalid', 'spacing'].includes(node.type), 182 | ), 183 | ); 184 | } 185 | if (isUnnecessarySplit === false) { 186 | isUnnecessarySplit = selectorNodes.nodes.some(({ nodes }) => 187 | nodes.some((node, i, arr) => { 188 | const isValidClass = node.type === 'class' && i === arr.length - 1; 189 | if (!isValidClass) return false; 190 | 191 | return ( 192 | dependencies.includes(node.name) || 193 | polymorphisms.includes(node.name) 194 | ); 195 | }), 196 | ); 197 | } 198 | if (isUnnecessarySplit === false) { 199 | isUnnecessarySplit = isGlobalClass; 200 | } 201 | 202 | if (applyList.length === 0) { 203 | rules.forEach((rule) => { 204 | dfs(rule, isUnnecessarySplit, isGlobalClass); 205 | }); 206 | 207 | return; 208 | } 209 | 210 | if (isUnnecessarySplit) { 211 | const atRules = rule.nodes.find( 212 | (node) => node.type === 'atrule' && node.name === 'apply', 213 | ) as AtRule; 214 | if (atRules) { 215 | const validApply = [ 216 | ...new Set([...atRules.params.split(/\s+/), ...applyListStr]), 217 | ]; 218 | atRules.params = validApply.join(' '); 219 | } else if (applyListStr.length !== 0) { 220 | const applyDecl = new AtRule({ 221 | name: 'apply', 222 | params: applyListStr.join(' '), 223 | }); 224 | rule.prepend(applyDecl); 225 | } 226 | 227 | selectors = selectors.filter((selector) => selector === rule.selector); 228 | } 229 | 230 | selectors.forEach((name) => { 231 | Object.assign(result, { 232 | [name]: applyListStr, 233 | }); 234 | }); 235 | 236 | applyList.forEach((node) => { 237 | node.remove(); 238 | }); 239 | 240 | rules.forEach((rule) => { 241 | dfs(rule, isUnnecessarySplit, isGlobalClass); 242 | }); 243 | })(rule); 244 | 245 | return result; 246 | }; 247 | 248 | const treeShaking = (root: Root) => { 249 | const multiple = getMultipleClass(root); 250 | 251 | return shaking(root.nodes, multiple); 252 | }; 253 | 254 | const getMultipleClass = (root: Root) => { 255 | const map = new Map(); 256 | const result: string[] = []; 257 | 258 | root.walkRules((rule) => { 259 | const selectorNodes = Tokenizer.parse(rule.selector); 260 | selectorNodes.nodes.forEach((node) => 261 | node.nodes.forEach((node) => { 262 | if (node.type === 'class') { 263 | if (map.has(node.name)) { 264 | result.push(node.name); 265 | } 266 | 267 | map.set(node.name, true); 268 | } 269 | }), 270 | ); 271 | }); 272 | 273 | return [...new Set(result)] 274 | }; 275 | 276 | const shaking = (nodes: ChildNode[], multiple: string[], result: string[] = [], isGlobalParent = false): string[] => { 277 | let isGlobalClass = isGlobalParent; 278 | 279 | return [ 280 | ...result, 281 | ...[...nodes].reduce((acc, node) => { 282 | if (isRule(node)) { 283 | let className = ''; 284 | const validNodes = node.nodes.filter((node) => node.type !== 'comment'); 285 | 286 | const selectorNodes = Tokenizer.parse(node.selector); 287 | 288 | selectorNodes.nodes.forEach((node) => { 289 | const { nodes } = node; 290 | 291 | if (isGlobalParent) return; 292 | if ( 293 | nodes.find( 294 | (node) => node.type === 'pseudo-class' && node.name === 'global', 295 | ) 296 | ) { 297 | isGlobalClass = true; 298 | 299 | return; 300 | } 301 | 302 | const allClassNode = nodes.filter((node) => node.type === 'class'); 303 | 304 | if (allClassNode.length !== 0) { 305 | const validClass = allClassNode[allClassNode.length - 1] as any; 306 | className = validClass.name; 307 | } 308 | }); 309 | 310 | if (validNodes.length === 0) { 311 | node.remove(); 312 | 313 | if (!isGlobalClass && className && !multiple.includes(className)) { 314 | return [...acc, className]; 315 | } 316 | 317 | return acc; 318 | } 319 | 320 | return shaking(validNodes, multiple, acc, isGlobalClass); 321 | } else { 322 | return acc; 323 | } 324 | }, [] as string[]) 325 | ]; 326 | }; 327 | 328 | async function convertComposes(decl: Declaration, cssPath: string) { 329 | const { value } = decl; 330 | 331 | const result = getComposesValue(value); 332 | if (!result) { 333 | decl.value = decl.value; 334 | return; 335 | } 336 | 337 | if (result.length <= 2) return; 338 | 339 | const [, className, stylePath] = result; 340 | 341 | const cssDir = path.dirname(cssPath); 342 | const realPath = getCompletionEntries(cssDir)(stylePath); 343 | 344 | const tailWindMap = await cssToTailwind(realPath); 345 | 346 | if (tailWindMap && tailWindMap.result[className]) { 347 | const parent = decl.parent; 348 | if (parent && parent.type === 'rule') { 349 | const atRules = parent.nodes.find( 350 | (node) => node.type === 'atrule' && node.name === 'apply', 351 | ) as AtRule; 352 | const applyList = tailWindMap.result[className]; 353 | 354 | if (atRules) { 355 | const validApply = [ 356 | ...new Set([...atRules.params.split(/\s+/), ...applyList]), 357 | ]; 358 | 359 | atRules.params = validApply.join(' '); 360 | } else if (applyList.length !== 0) { 361 | const applyDecl = new AtRule({ 362 | name: 'apply', 363 | params: applyList.join(' '), 364 | }); 365 | parent.prepend(applyDecl); 366 | } 367 | } 368 | } 369 | 370 | if (tailWindMap.isUnlinked || tailWindMap.removed.includes(className)) { 371 | decl.remove(); 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import core, { type API } from 'jscodeshift'; 4 | 5 | import { j } from './jscodeshift'; 6 | 7 | import { getTailwindMap } from './get-tailwind-map'; 8 | import { fillTailwindClass } from './fill-tailwind-class'; 9 | 10 | const transform = async (fileInfo: core.FileInfo, _: API, options: { prefix?: string }) => { 11 | const ast = j(fileInfo.source); 12 | const dir = path.dirname(fileInfo.path); 13 | 14 | const result = await getTailwindMap(ast, dir); 15 | 16 | const tailwindMap = result[1]; 17 | 18 | fillTailwindClass(ast, tailwindMap, options.prefix); 19 | 20 | return ast.toSource({ quote: 'double' }); 21 | }; 22 | 23 | export default transform; 24 | -------------------------------------------------------------------------------- /src/utils/db.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { info } from './logger'; 5 | 6 | import { readJson } from './file'; 7 | import type { Apply } from '../context'; 8 | 9 | interface Queen { 10 | resolve: (val: any) => void; 11 | promise: () => Promise; 12 | } 13 | 14 | type DBData = Record 15 | 16 | function noop() { 17 | // 18 | } 19 | 20 | export class DB { 21 | #path = ''; 22 | #queen: Queen[] = []; 23 | 24 | constructor(name: string) { 25 | this.#path = path.resolve(__dirname, `./${name}.json`); 26 | 27 | this.destroy(); 28 | this.init(); 29 | } 30 | 31 | get database() { 32 | return readJson(this.#path) as DBData; 33 | } 34 | 35 | async #run() { 36 | const first = this.#queen[0]; 37 | 38 | if (!first) return; 39 | 40 | const { resolve, promise } = first; 41 | const data = await promise(); 42 | this.#queen.shift(); 43 | resolve(data); 44 | this.#run(); 45 | } 46 | 47 | #checkQueen(task: Queen) { 48 | if (this.#queen.length !== 0) { 49 | this.#queen = [...this.#queen, task]; 50 | } else { 51 | this.#queen = [task]; 52 | this.#run(); 53 | } 54 | } 55 | 56 | init() { 57 | try { 58 | fs.accessSync(this.#path); 59 | } catch { 60 | info('DB init'); 61 | fs.writeFileSync(this.#path, '{}'); 62 | } 63 | } 64 | 65 | async read(key: string) { 66 | // The json file is being updated, please waiting 67 | const task: Queen = { 68 | resolve: noop, 69 | promise: () => Promise.resolve(), 70 | }; 71 | 72 | const promiseCreator = () => { 73 | const json = this.database?.[key]; 74 | return Promise.resolve(json); 75 | }; 76 | 77 | task.promise = promiseCreator; 78 | 79 | const promise = new Promise((resolve) => { 80 | task.resolve = resolve; 81 | }); 82 | 83 | this.#checkQueen(task); 84 | 85 | const data = await promise; 86 | 87 | return data; 88 | } 89 | 90 | async write(cacheKey: string, data: Apply) { 91 | const task: Queen = { 92 | resolve: noop, 93 | promise: () => Promise.resolve(), 94 | }; 95 | const promiseCreator = () => { 96 | const json = this.database; 97 | 98 | if (!json[cacheKey]) { 99 | json[cacheKey] = { 100 | result: {}, 101 | removed: data.removed, 102 | isUnlinked: data.isUnlinked || false, 103 | }; 104 | } 105 | 106 | Object.entries(data.result).forEach(([key, value]) => { 107 | Object.assign(json[cacheKey].result, { [key]: [...new Set(value)] }); 108 | }); 109 | 110 | fs.writeFileSync(this.#path, JSON.stringify(json)); 111 | return Promise.resolve(); 112 | }; 113 | 114 | task.promise = promiseCreator; 115 | 116 | const promise = new Promise((resolve) => { 117 | task.resolve = resolve; 118 | }); 119 | 120 | this.#checkQueen(task); 121 | 122 | await promise; 123 | } 124 | 125 | destroy() { 126 | try { 127 | fs.accessSync(this.#path); 128 | fs.unlinkSync(this.#path); 129 | } catch (err) { 130 | // 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import JSON5 from 'json5'; 5 | import findUp from 'find-up'; 6 | 7 | import { warn } from './logger'; 8 | import { isString } from './validate'; 9 | 10 | export const readJson = (filePath: string) => { 11 | try { 12 | fs.accessSync(filePath); 13 | } catch { 14 | return; 15 | } 16 | 17 | try { 18 | const configBuffer = fs.readFileSync(filePath, 'utf-8'); 19 | return JSON5.parse(configBuffer.toString()); 20 | } catch (err) { 21 | warn((err as Error).message); 22 | } 23 | }; 24 | 25 | export function hasZeroOrOneAsteriskCharacter(str: string): boolean { 26 | let seenAsterisk = false; 27 | for (let i = 0; i < str.length; i++) { 28 | if (str.charCodeAt(i) === 0x2A) { 29 | if (!seenAsterisk) { 30 | seenAsterisk = true; 31 | } 32 | else { 33 | // have already seen asterisk 34 | return false; 35 | } 36 | } 37 | } 38 | return true; 39 | } 40 | 41 | export interface Pattern { 42 | prefix: string; 43 | suffix: string; 44 | } 45 | 46 | const comparePaths = (type: 'lessThan' | 'greaterThan') => (a: string, b: string) => { 47 | const patternA = tryParsePattern(a); 48 | const patternB = tryParsePattern(b); 49 | const lengthA = typeof patternA === "object" ? patternA.prefix.length : a.length; 50 | const lengthB = typeof patternB === "object" ? patternB.prefix.length : b.length; 51 | if (type === 'greaterThan') { 52 | if (lengthB === undefined) { 53 | return true; 54 | } 55 | return lengthB < lengthA; 56 | } 57 | 58 | if (type === 'lessThan') { 59 | if (lengthA === undefined) { 60 | return true; 61 | } 62 | return lengthB > lengthA; 63 | } 64 | }; 65 | 66 | function tryParsePattern(pattern: string): string | Pattern | undefined { 67 | const indexOfStar = pattern.indexOf("*"); 68 | if (indexOfStar === -1) { 69 | return pattern; 70 | } 71 | return pattern.indexOf("*", indexOfStar + 1) !== -1 72 | ? undefined 73 | : { 74 | prefix: pattern.substring(0, indexOfStar), 75 | suffix: pattern.substring(indexOfStar + 1) 76 | }; 77 | } 78 | 79 | function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) { 80 | return candidate.length >= prefix.length + suffix.length && 81 | candidate.startsWith(prefix) && 82 | candidate.endsWith(suffix); 83 | } 84 | 85 | const tryRemovePrefix = (str: string, prefix: string) => { 86 | return str.startsWith(prefix) ? str.substring(prefix.length) : undefined; 87 | }; 88 | 89 | const tsconfigPath = findUp.sync('tsconfig.json'); 90 | const options = tsconfigPath && JSON5.parse(fs.readFileSync(tsconfigPath, 'utf-8')).compilerOptions; 91 | export const completeEntriesFromPath = (fragment: string) => { 92 | try { 93 | if (options?.paths) { 94 | let pathResults: string | undefined; 95 | let matchedPath: string | undefined; 96 | Object.entries(options.paths as Record).forEach(([key, patterns]) => { 97 | if (key === ".") return; 98 | const keyWithoutLeadingDotSlash = key.replace(/^\.\//, ""); 99 | if (patterns) { 100 | const pathPattern = tryParsePattern(keyWithoutLeadingDotSlash); 101 | if (!pathPattern) return; 102 | const isMatch = typeof pathPattern === "object" && isPatternMatch(pathPattern, fragment); 103 | const isLongestMatch = isMatch && (matchedPath === undefined || comparePaths('greaterThan')(key, matchedPath)); 104 | if (isLongestMatch) { 105 | matchedPath = key; 106 | const parsed = tryParsePattern(patterns[0]); 107 | if (parsed === undefined || isString(parsed)) { 108 | return; 109 | } 110 | const pathPrefix = keyWithoutLeadingDotSlash.slice(0, keyWithoutLeadingDotSlash.length - 1); 111 | const remainingFragment = tryRemovePrefix(fragment, pathPrefix); 112 | pathResults = `${parsed.prefix}${remainingFragment}`; 113 | } 114 | if (typeof pathPattern === "string" && key === fragment) { 115 | pathResults = patterns[0]; 116 | } 117 | } 118 | }) 119 | 120 | 121 | return pathResults; 122 | } 123 | } catch(error) { 124 | console.error(error); 125 | } 126 | }; 127 | 128 | export const getCompletionEntries = (pathDir: string) => (fragment: string) => { 129 | const completionEntries = completeEntriesFromPath(fragment); 130 | 131 | if (tsconfigPath && completionEntries) { 132 | return path.resolve(path.dirname(tsconfigPath), completionEntries); 133 | } 134 | 135 | return path.resolve(pathDir, completionEntries || fragment); 136 | }; -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { RequestMessage, ResponseMessage } from '../db-server/config'; 2 | 3 | export const camelCase = (str: string) => 4 | str.replace(/[-_][^-_]/gm, (match) => match.charAt(1).toUpperCase()); 5 | 6 | export const getComposesValue = (composes: string) => 7 | composes.match(/([\S]+)\s+from\s+'([\S]+)'/); 8 | 9 | export const send = (message: RequestMessage | ResponseMessage) => { 10 | process.send?.(message); 11 | }; 12 | 13 | export const clearInvalidSuffix = (val: string) => 14 | val.replaceAll(/\.tsx?$/g, ''); 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | export const promiseStep = ( 18 | promises: (() => Promise)[], 19 | ): Promise => { 20 | return new Promise((resolve, reject) => { 21 | const result: T[] = []; 22 | const errors: Error[] = []; 23 | promises 24 | .reduce( 25 | (prev, cur) => { 26 | const promise = prev(); 27 | return () => 28 | promise.then(() => { 29 | return cur() 30 | .then((data) => { 31 | result.push(data); 32 | }) 33 | .catch((err) => { 34 | errors.push(err); 35 | }); 36 | }); 37 | }, 38 | () => Promise.resolve(), 39 | )() 40 | .then(() => { 41 | if (errors.length) { 42 | reject(errors); 43 | } else { 44 | resolve(result); 45 | } 46 | }); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export const info = (msg: string) => { 4 | console.log(chalk.yellow(`[Info]: ${msg}`)); 5 | }; 6 | 7 | export const warn = (msg: string) => { 8 | console.log(chalk.yellow(`[Warning]: ${msg}`)); 9 | }; 10 | 11 | export const error = (msg: string) => { 12 | console.log(chalk.red(`[Error]: ${msg}`)); 13 | }; 14 | 15 | export const success = (msg: string) => { 16 | console.log(chalk.green(`[Success]: ${msg}`)); 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/master.ts: -------------------------------------------------------------------------------- 1 | import isGitClean from 'is-git-clean'; 2 | 3 | import { warn, info } from './logger'; 4 | 5 | export const isTypeAny = (value: any): value is any => { 6 | return value; 7 | }; 8 | export const checkGitStatus = (force: boolean) => { 9 | let clean = false; 10 | let errorMessage = 'Unable to determine if git directory is clean'; 11 | try { 12 | clean = isGitClean.sync(process.cwd()); 13 | errorMessage = 'Git directory is not clean'; 14 | } catch (err) { 15 | if (isTypeAny(err) && err?.stderr?.includes('Not a git repository')) { 16 | clean = true; 17 | } 18 | } 19 | 20 | if (!clean) { 21 | if (force) { 22 | warn(`${errorMessage}. Forcibly continuing.`); 23 | } else { 24 | warn('Before we continue, please stash or commit your git changes.'); 25 | info('You may use the --force flag to override this safety check.'); 26 | process.exit(1); 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | import type { Rule, Node } from 'postcss'; 2 | 3 | export const isString = (val: unknown): val is string => typeof val === 'string'; 4 | 5 | export const isRule = (node: Node): node is Rule => node.type === 'rule'; 6 | 7 | export const isCssModules = (name: string) => /\.module\.css$/.test(name); 8 | 9 | export const isScssModules = (name: string) => /\.module\.scss$/.test(name); 10 | 11 | export const isLessModules = (name: string) => /\.module\.less$/.test(name); 12 | 13 | export const isStyleModules = (name: string) => 14 | isCssModules(name) || isScssModules(name) || isLessModules(name); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "strict": true, 6 | "skipLibCheck": true, 7 | "skipDefaultLibCheck": true, 8 | "target": "ES2021", 9 | "newLine": "LF", 10 | "assumeChangesOnlyAffectDirectDependencies": true, 11 | "declaration": true, 12 | "exactOptionalPropertyTypes": true, 13 | "esModuleInterop": true, 14 | "checkJs": false, 15 | "moduleResolution": "node", 16 | "forceConsistentCasingInFileNames": true, 17 | "resolveJsonModule": true, 18 | "pretty": true, 19 | "importHelpers": false, 20 | "incremental": true, 21 | "composite": true, 22 | "rootDir": "./src", 23 | "module": "commonjs", 24 | "outDir": "./build", 25 | "tsBuildInfoFile": "./build/.tsbuildinfo" 26 | } 27 | } 28 | --------------------------------------------------------------------------------