├── .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 |
7 |
8 |
9 |
10 |
11 |
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 |
161 |
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 |
--------------------------------------------------------------------------------