diff --git a/.github/workflows/npm-experimental.yml b/.github/workflows/npm-experimental.yml index 9ac2890..6c997a8 100644 --- a/.github/workflows/npm-experimental.yml +++ b/.github/workflows/npm-experimental.yml @@ -24,13 +24,9 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - - name: Install dependencies and build + - name: Install dependencies run: | pnpm install - pnpm run build - cp ../README.md . - cp ../LICENSE . - working-directory: ./package - name: Compute experimental version id: version @@ -38,9 +34,43 @@ jobs: HASH=$(git rev-parse --short HEAD) echo "version=0.0.0-experimental-${HASH}" >> $GITHUB_OUTPUT - - name: Publish experimental package + - name: Build and publish classnames-minifier run: | + cd packages/classnames-minifier + pnpm run build + cp ../../LICENSE . + [ ! -f README.md ] && cp ../../README.md . npm set //registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}} - npm version --no-git-tag-version ${{steps.version.outputs.version}} + npm version --no-git-tag-version ${{ steps.version.outputs.version }} npm publish --tag experimental --access public - working-directory: ./package + + - name: Wait for npm registry propagation + run: sleep 20 + + - name: Install latest classnames-minifier in other packages + run: | + VERSION=${{ steps.version.outputs.version }} + for package in packages/*/; do + if [ "$(basename "$package")" != "classnames-minifier" ]; then + echo "Installing classnames-minifier@$VERSION in $package" + cd "$package" + pnpm add classnames-minifier@$VERSION + cd - > /dev/null + fi + done + + - name: Build and publish other packages + run: | + npm set //registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}} + for package in packages/*/; do + if [ "$(basename "$package")" != "classnames-minifier" ]; then + echo "Building and publishing package: $package" + cd "$package" + pnpm run build + cp ../../LICENSE . + [ ! -f README.md ] && cp ../../README.md . + npm version --no-git-tag-version ${{ steps.version.outputs.version }} + npm publish --tag experimental --access public + cd - > /dev/null + fi + done diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b021bc0..5831202 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -20,25 +20,54 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - - name: Install dependencies and build + - name: Install dependencies run: | pnpm install - pnpm run build - cp ../README.md . - cp ../LICENSE . - working-directory: ./package - - name: Publish on main - if: "!contains(github.ref_name, 'canary')" + - name: Build and publish classnames-minifier run: | + cd packages/classnames-minifier + pnpm run build + cp ../../LICENSE . + [ ! -f README.md ] && cp ../../README.md . npm set //registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}} - npm publish --access public - working-directory: ./package + npm version --no-git-tag-version ${{github.ref_name}} + if [[ "${{github.ref_name}}" == *"canary"* ]]; then + npm publish --tag canary --access public + else + npm publish --access public + fi + + - name: Wait for npm registry propagation + run: sleep 20 - - name: Publish on canary - if: contains(github.ref_name, 'canary') + - name: Install latest classnames-minifier in other packages + run: | + for package in packages/*/; do + if [ "$(basename "$package")" != "classnames-minifier" ]; then + echo "Installing classnames-minifier@${{github.ref_name}} in $package" + cd "$package" + pnpm add classnames-minifier@${{github.ref_name}} + cd - > /dev/null + fi + done + + - name: Build and publish other packages run: | npm set //registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}} - npm version --no-git-tag-version ${{github.ref_name}} - npm publish --tag canary --access public - working-directory: ./package + for package in packages/*/; do + if [ "$(basename "$package")" != "classnames-minifier" ]; then + echo "Building and publishing package: $package" + cd "$package" + pnpm run build + cp ../../LICENSE . + [ ! -f README.md ] && cp ../../README.md . + npm version --no-git-tag-version ${{github.ref_name}} + if [[ "${{github.ref_name}}" == *"canary"* ]]; then + npm publish --tag canary --access public + else + npm publish --access public + fi + cd - > /dev/null + fi + done diff --git a/.husky/pre-commit b/.husky/pre-commit index 6ca46a7..2312dc5 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm run lint +npx lint-staged diff --git a/package.json b/package.json index 196d4e9..a0e8e43 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "", "scripts": { "nimpl:classnames-minifier": "pnpm --filter @nimpl/classnames-minifier", - "lint": "eslint \"package/\"", + "lint": "eslint \"./\"", "eslint": "eslint", - "prettier": "prettier", "prepare": "husky" }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": ["eslint"] + }, "repository": { "type": "git", "url": "git://github.com/alexdln/nimpl-classnames-minifier.git" @@ -27,5 +29,8 @@ "typescript-eslint": "8.51.0" }, "license": "MIT", + "resolutions": { + "@nimpl/classnames-minifier>classnames-minifier": "workspace:*" + }, "packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971" } diff --git a/packages/classnames-minifier/README.md b/packages/classnames-minifier/README.md new file mode 100644 index 0000000..5e5af9c --- /dev/null +++ b/packages/classnames-minifier/README.md @@ -0,0 +1,83 @@ +# classnames-minifier + +[![npm version](https://badge.fury.io/js/classnames-minifier.svg)](https://badge.fury.io/js/classnames-minifier) + +Library for configuring style _(css/scss/sass)_ modules to generate compressed classes (`.header` -> `.a`, `.nav` -> `.b`, ..., `.footer` -> `.aad`, etc.) with support for changes and rebuilding without clearing the built application. + +## Reasons + +_Compressing classes_ can reduce the size of the generated html and css by up to _20%_, which will have a positive effect on page rendering and metrics (primarily [FCP](https://web.dev/first-contentful-paint/)) + +## Installation + +**Using npm:** + +```bash +npm i classnames-minifier +``` + +**Using yarn:** + +```bash +yarn add classnames-minifier +``` + +## Configuration + +### Options + +- `prefix` - custom prefix that will be added to each updated class; +- `reservedNames` - array of reserved names that should not be used by this package (must include prefix); +- `cacheDir` - directory where this library will write the cache. Passing this parameter will enable caching. Use this option only if your framework really needs it; +- `distDir` - directory where the project is being assembled. Used only when caching is enabled to synchronize caches between this library and the project; +- `disableDistDeletion` - option that allows you to disable the automatic deletion of the dist folder if necessary (_f.e. differences in package setup in cache and now or first launch_); + +Configuration example: + +```js +// webpack.config.js +const classnamesMinifier = new ClassnamesMinifier({ + prefix: "_", + reservedNames: ["_en", "_de"], +}); +// ... +loaders.push({ + loader: require.resolve("css-loader"), + options: { + importLoaders: 3, + modules: { + mode: "local", + getLocalIdent: classnamesMinifier.getLocalIdent, + }, + }, +}); +``` + +If the framework you are using utilizes component caching between builds, you can configure caching in classnames-minifier as well. As a result, module classes between builds will use the same compressed classes. + +```js +// webpack.config.js +const classnamesMinifier = new ClassnamesMinifier({ + distDir: path.join(process.cwd(), "build"), + cacheDir: path.join(process.cwd(), "build/cache"), +}); +// ... +loaders.push( + classnamesMinifier.postLoader, + { + loader: require.resolve("css-loader"), + options: { + importLoaders: 3, + modules: { + mode: "local", + getLocalIdent: classnamesMinifier.getLocalIdent, + }, + }, + }, + classnamesMinifier.preLoader +); +``` + +## License + +[MIT](https://github.com/alexdln/classnames-minifier/blob/main/LICENSE) diff --git a/packages/classnames-minifier/package.json b/packages/classnames-minifier/package.json new file mode 100644 index 0000000..0101978 --- /dev/null +++ b/packages/classnames-minifier/package.json @@ -0,0 +1,61 @@ +{ + "name": "classnames-minifier", + "version": "1.0.0", + "description": "Library for configuring style modules to generate compressed classes", + "main": "dist/cjs/ClassnamesMinifier.js", + "module": "dist/esm/ClassnamesMinifier.mjs", + "types": "dist/cjs/ClassnamesMinifier.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run build:cjs && pnpm run build:esm", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:esm": "tsc -p tsconfig.esm.json", + "lint": "eslint .", + "eslint": "eslint", + "prepare": "husky" + }, + "exports": { + ".": { + "types": "./dist/cjs/ClassnamesMinifier.d.ts", + "import": "./dist/esm/ClassnamesMinifier.mjs", + "require": "./dist/cjs/ClassnamesMinifier.js", + "default": "./dist/esm/ClassnamesMinifier.mjs" + } + }, + "keywords": [ + "classname", + "class", + "minify", + "compress", + "cut", + "css", + "sass", + "scss", + "modules" + ], + "repository": { + "type": "git", + "url": "git://github.com/alexdln/nimpl-classnames-minifier.git" + }, + "author": { + "name": "Alex Savelyev", + "email": "dev@alexdln.com", + "url": "https://github.com/alexdln/" + }, + "license": "MIT", + "devDependencies": { + "@types/node": "25.0.3", + "@types/uuid": "11.0.0", + "@types/webpack": "5.28.5", + "css-loader": "7.1.2", + "typescript": "5.9.3" + }, + "peerDependencies": { + "css-loader": ">=4.0.0" + }, + "dependencies": { + "uuid": "13.0.0" + } +} diff --git a/packages/classnames-minifier/src/ClassnamesMinifier.ts b/packages/classnames-minifier/src/ClassnamesMinifier.ts new file mode 100644 index 0000000..4f1c457 --- /dev/null +++ b/packages/classnames-minifier/src/ClassnamesMinifier.ts @@ -0,0 +1,96 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { LoaderContext } from "webpack"; +import fs from "fs"; +import path from "path"; + +import type { Config } from "./lib/types/plugin"; +import { CODE_VERSION } from "./lib/constants/configuration"; +import validateConfig from "./lib/validateConfig"; +import ConverterMinified from "./lib/ConverterMinified"; +import validateDist from "./lib/validateDist"; +import removeDist from "./lib/removeDist"; + +export * from "./lib/types/plugin"; + +class ClassnamesMinifier { + converterMinified: ConverterMinified; + + constructor(config: Config) { + validateConfig(config); + + this.converterMinified = new ConverterMinified(config); + + if (!config.cacheDir || !config.distDir) { + console.log( + "classnames-minifier: Failed to check the dist folder because cacheDir or distDir is not specified", + ); + } else { + const manifestDir = path.join(config.cacheDir, "ncm-meta"); + const manifestPath = path.join(manifestDir, "manifest.json"); + const { distDeletionPolicy = "error" } = config; + + let distCleared = false; + if (config.cacheDir) { + const errors = validateDist(config, manifestPath); + + if (errors) { + console.log(`classnames-minifier: "distDeletionPolicy" option was set to "${distDeletionPolicy}"`); + if (distDeletionPolicy === "auto") { + removeDist(config.distDir, errors); + distCleared = true; + } else if (distDeletionPolicy === "error") { + throw new Error(`classnames-minifier: Please, remove dist dir manually. ${errors}`); + } else { + console.warn(`classnames-minifier: Please, remove dist dir manually. ${errors}`); + } + } + } + + const { syncFreedNames, freedNamesLimit = 100000 } = config.experimental || {}; + if (!syncFreedNames && this.converterMinified.freeClasses.length > freedNamesLimit && config.distDir) { + console.log(`classnames-minifier: "distDeletionPolicy" option was set to "${distDeletionPolicy}"`); + if (distDeletionPolicy === "auto") { + removeDist(config.distDir, `Freed names exceeds the limit (${freedNamesLimit})`); + distCleared = true; + } else if (distDeletionPolicy === "error") { + throw new Error( + `Please, remove dist dir manually. Freed names exceeds the limit (${freedNamesLimit})`, + ); + } else { + console.warn( + `Please, remove dist dir manually. Freed names exceeds the limit (${freedNamesLimit})`, + ); + } + } + if (distCleared) { + this.converterMinified.reset(); + } + if (!fs.existsSync(manifestDir)) fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify({ ...config, version: CODE_VERSION }), { encoding: "utf-8" }); + } + } + + get getLocalIdent() { + return this.converterMinified.getLocalIdent.bind(this.converterMinified); + } + + get preLoader() { + return { + loader: path.join(__dirname, "./lib/classnames-minifier-preloader.js"), + options: { + classnamesMinifier: this.converterMinified, + }, + }; + } + + get postLoader() { + return { + loader: path.join(__dirname, "./lib/classnames-minifier-postloader.js"), + options: { + classnamesMinifier: this.converterMinified, + }, + }; + } +} + +export default ClassnamesMinifier; diff --git a/packages/classnames-minifier/src/lib/ConverterMinified.ts b/packages/classnames-minifier/src/lib/ConverterMinified.ts new file mode 100644 index 0000000..ab408e8 --- /dev/null +++ b/packages/classnames-minifier/src/lib/ConverterMinified.ts @@ -0,0 +1,241 @@ +import type { LoaderContext } from "webpack"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "fs"; +import { v4 as uuidv4 } from "uuid"; +import path from "path"; + +import type { Config } from "./types/plugin"; + +type CacheType = { + [resourcePath: string]: { + cachePath: string; + matchings: { [origClass: string]: string }; + type: "new" | "updated" | "old"; + }; +}; + +class ConverterMinified { + private cacheDir?: string; + + private prefix: string; + + private reservedNames: string[]; + + private syncFreedNames: boolean; + + private symbols: string[] = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ]; + + dirtyСache: CacheType = {}; + + freeClasses: string[] = []; + + lastIndex = 0; + + private nextLoopEndsWith = 26; + + private currentLoopLength = 0; + + private nameMap = [0]; + + constructor({ cacheDir, prefix = "", reservedNames = [], experimental }: Config) { + this.prefix = prefix; + this.reservedNames = reservedNames; + this.syncFreedNames = Boolean(experimental?.syncFreedNames); + + if (cacheDir) this.invalidateCache(path.join(cacheDir, "ncm")); + } + + reset = () => { + this.dirtyСache = {}; + this.freeClasses = []; + this.lastIndex = 0; + this.nextLoopEndsWith = 26; + this.currentLoopLength = 0; + this.nameMap = [0]; + if (this.cacheDir) { + this.invalidateCache(this.cacheDir); + } + }; + + private invalidateCache = (cacheDir: string) => { + this.cacheDir = cacheDir; + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); + + const cachedFiles = readdirSync(cacheDir); + if (cachedFiles.length) { + console.log("classnames-minifier: Restoring pairs of classes..."); + } else { + return; + } + + const usedClassNames: string[] = []; + + const dirtyСache: CacheType = {}; + let prevLastIndex = 0; + cachedFiles.forEach((file) => { + const filePath = path.join(cacheDir, file); + const dirtyCacheFile = readFileSync(filePath, { encoding: "utf8" }); + const [resourcePath, lastIndex, ...classnames] = dirtyCacheFile.split(","); + if (lastIndex && +lastIndex > prevLastIndex) prevLastIndex = +lastIndex; + + if (existsSync(resourcePath)) { + const cachedMatchings = classnames.reduce<{ [orig: string]: string }>((acc, cur) => { + const [origClass, newClass] = cur.split("="); + acc[origClass] = newClass; + if (!usedClassNames.includes(newClass)) { + usedClassNames.push(newClass); + } + return acc; + }, {}); + dirtyСache[resourcePath] = { + cachePath: filePath, + matchings: cachedMatchings, + type: "old", + }; + } else { + rmSync(filePath); + } + }); + + for (let i = 0; i <= prevLastIndex; i++) { + const newClass = this.generateClassName(); + this.lastIndex += 1; + const usedClassNameIndex = usedClassNames.indexOf(newClass); + + if (usedClassNameIndex !== -1) { + usedClassNames.splice(usedClassNameIndex, 1); + } else if (!this.reservedNames.includes(newClass)) { + this.freeClasses.push(newClass); + } + } + + if (cachedFiles.length) { + console.log("classnames-minifier: Pairs restored"); + } + + this.dirtyСache = dirtyСache; + }; + + private generateClassName() { + const symbolsCount = 62; + if (this.lastIndex >= this.nextLoopEndsWith) { + if (this.nextLoopEndsWith === 26) this.nextLoopEndsWith = 62 * symbolsCount; + else this.nextLoopEndsWith = this.nextLoopEndsWith * symbolsCount; + this.nameMap.push(0); + this.currentLoopLength += 1; + } + + const currentClassname = this.prefix + this.nameMap.map((e) => this.symbols[e]).join(""); + + for (let i = this.currentLoopLength; i >= 0; i--) { + if (this.nameMap[i] === symbolsCount - 1 || (i === 0 && this.nameMap[i] === 25)) { + this.nameMap[i] = 0; + } else { + this.nameMap[i] += 1; + break; + } + } + + return currentClassname; + } + + private getTargetClassName(origName: string) { + let targetClassName: string; + if (this.freeClasses.length && this.syncFreedNames) { + targetClassName = this.freeClasses.shift() as string; + } else { + targetClassName = this.generateClassName(); + } + + if (this.reservedNames.includes(targetClassName)) { + targetClassName = this.getTargetClassName(origName); + this.lastIndex += 1; + } + + return targetClassName; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getLocalIdent({ resourcePath }: LoaderContext, _localIdent: string, origName: string) { + if (!this.dirtyСache[resourcePath]) { + this.dirtyСache[resourcePath] = { + cachePath: this.cacheDir ? path.join(this.cacheDir, uuidv4()) : "", + matchings: {}, + type: "new", + }; + } + const currentCache = this.dirtyСache[resourcePath]; + + if (currentCache.matchings[origName]) return currentCache.matchings[origName]; + + const targetClassName = this.getTargetClassName(origName); + currentCache.matchings[origName] = targetClassName; + currentCache.type = "updated"; + this.lastIndex += 1; + return targetClassName; + } +} + +export default ConverterMinified; diff --git a/packages/classnames-minifier/src/lib/classnames-minifier-postloader.ts b/packages/classnames-minifier/src/lib/classnames-minifier-postloader.ts new file mode 100644 index 0000000..8c84409 --- /dev/null +++ b/packages/classnames-minifier/src/lib/classnames-minifier-postloader.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { LoaderContext } from "webpack"; +import fs from "fs"; + +import type ConverterMinified from "./ConverterMinified"; + +export default function ( + this: LoaderContext<{ classnamesMinifier: ConverterMinified }>, + source: string, + map: any, + meta: any, +) { + const options = this.getOptions(); + const classnamesMinifier = options.classnamesMinifier; + Object.entries(classnamesMinifier.dirtyСache).forEach(([resourcePath, data]) => { + if (data.type !== "old") { + fs.writeFileSync( + data.cachePath, + `${resourcePath},${classnamesMinifier.lastIndex},${Object.entries(data.matchings) + .map(([key, value]) => `${key}=${value}`) + .join(",")}`, + ); + } + }); + + this.callback(null, source, map, meta); + return; +} diff --git a/packages/classnames-minifier/src/lib/classnames-minifier-preloader.ts b/packages/classnames-minifier/src/lib/classnames-minifier-preloader.ts new file mode 100644 index 0000000..ea64bcb --- /dev/null +++ b/packages/classnames-minifier/src/lib/classnames-minifier-preloader.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { LoaderContext } from "webpack"; + +import type ConverterMinified from "./ConverterMinified"; + +export default function ( + this: LoaderContext<{ classnamesMinifier: ConverterMinified }>, + source: string, + map: any, + meta: any, +) { + const options = this.getOptions(); + const classnamesMinifier = options.classnamesMinifier; + const maybeClassesList = source.match(/\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*/g); + const cache = classnamesMinifier.dirtyСache[this.resourcePath]; + + /** + * if some class has ceased to be used since the last time the file was loaded, we remove it from the cache + */ + if (cache && cache.matchings) { + cache.matchings = maybeClassesList + ? Object.fromEntries( + Object.entries(cache.matchings).filter(([key]) => maybeClassesList?.includes(`.${key}`)), + ) + : {}; + } + + this.callback(null, source, map, meta); + return; +} diff --git a/packages/classnames-minifier/src/lib/constants/configuration.ts b/packages/classnames-minifier/src/lib/constants/configuration.ts new file mode 100644 index 0000000..523b2d1 --- /dev/null +++ b/packages/classnames-minifier/src/lib/constants/configuration.ts @@ -0,0 +1,6 @@ +/** + * Just a version of the code whose change means that the logic has a critical update + * and minifying the previous version is not compatible with the current one, + * which would mean that we should clean dist folder + */ +export const CODE_VERSION = "parrot"; diff --git a/packages/classnames-minifier/src/lib/removeDist.ts b/packages/classnames-minifier/src/lib/removeDist.ts new file mode 100644 index 0000000..df742a7 --- /dev/null +++ b/packages/classnames-minifier/src/lib/removeDist.ts @@ -0,0 +1,9 @@ +import { rmSync } from "fs"; + +const removeDist = (distDir: string, message: string) => { + console.log(`classnames-minifier: ${message}Cleaning the dist folder...`); + rmSync(distDir, { recursive: true, force: true }); + console.log("classnames-minifier: Dist folder cleared"); +}; + +export default removeDist; diff --git a/packages/classnames-minifier/src/lib/types/plugin.ts b/packages/classnames-minifier/src/lib/types/plugin.ts new file mode 100644 index 0000000..079832c --- /dev/null +++ b/packages/classnames-minifier/src/lib/types/plugin.ts @@ -0,0 +1,60 @@ +export type Config = { + /** + * The directory where the package cache will be written + */ + cacheDir?: string; + /** + * Directory where the project is built + */ + distDir?: string; + /** + * Prefix which will be added to each generated name + */ + prefix?: string; + /** + * Reserved minified names. Use this option if you are adding short classes manually + */ + reservedNames?: string[]; + /** + * Package policy to resolve potential problems with minified classes + * + * This may happen due to the following reasons: + * + * 1. Launching the package for the first time. Package need clean next.js cache to put everything in the correct order + * 2. Changing the package configuration. Package need clean next.js cache to rebuild it with classes according to the new rules + * 3. Exceeding the limit on freed classes (these are classes that were used before, but are now *probably* no longer used) + * + * @param "warning" - a warning message will simply be displayed. + * With this option, there is a high risk of errors and duplicates of generated classes. + * + * @param "error" - an error will be thrown, and as a result the build will stop. + * If this option occurs, delete the next.js cache manually and restart the build. + * + * @param "auto" - the package will automatically delete the next.js cache directory. + * + * @default "error" + */ + distDeletionPolicy?: "warning" | "error" | "auto"; + /** + * Additional check of the dist directory for freshness + */ + checkDistFreshness?: () => boolean; + experimental?: { + /** + * Automatically synchronize freed classes (for example, if you deleted the original styles) + * Such classes will be reused in new locations. In this case, there may be situations that the package + * will mistakenly consider them freed and reuse the class again, thereby creating duplicates. + * + * Be careful using this option + * @default false + */ + syncFreedNames?: boolean; + /** + * Limit of unused minified classes. Such classes are not reused by default, + * since the package cannot be sure of this at this time. + * + * @default 100000 + */ + freedNamesLimit?: number; + }; +}; diff --git a/packages/classnames-minifier/src/lib/validateConfig.ts b/packages/classnames-minifier/src/lib/validateConfig.ts new file mode 100644 index 0000000..01415f3 --- /dev/null +++ b/packages/classnames-minifier/src/lib/validateConfig.ts @@ -0,0 +1,48 @@ +import type { Config } from "./types/plugin"; + +const validKeys = [ + "prefix", + "reservedNames", + "cacheDir", + "distDir", + "distDeletionPolicy", + "experimental", + "checkDistFreshness", +]; + +const validateIsObject = (config: unknown): config is Config => { + if (!config) return false; + + if (typeof config !== "object" || Array.isArray(config)) { + console.error( + `classnames-minifier: Invalid configuration. Expected object, received ${typeof config}. See https://github.com/alexdln/classnames-minifier#configuration`, + ); + process.exit(); + } + + const isValidKeys = Object.keys(config).every((key) => validKeys.includes(key)); + + if (!isValidKeys) { + console.error( + `classnames-minifier: Invalid configuration. Valid keys are: ${validKeys.join(", ")}. See https://github.com/alexdln/classnames-minifier#configuration`, + ); + process.exit(); + } + + return true; +}; + +const validateConfig = (config: unknown = {}): Config => { + if (!validateIsObject(config)) return {}; + + if (config.prefix && !config.prefix.match(/^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/)) { + console.error( + `classnames-minifier: Invalid prefix. It should match following rule: "^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$". See https://github.com/alexdln/classnames-minifier#configuration`, + ); + process.exit(); + } + + return config; +}; + +export default validateConfig; diff --git a/packages/classnames-minifier/src/lib/validateDist.ts b/packages/classnames-minifier/src/lib/validateDist.ts new file mode 100644 index 0000000..a4485f3 --- /dev/null +++ b/packages/classnames-minifier/src/lib/validateDist.ts @@ -0,0 +1,60 @@ +import fs from "fs"; +import type { Config } from "./types/plugin"; +import { CODE_VERSION } from "./constants/configuration"; + +const readManifest = (manifestPath: string) => { + try { + const prevData = fs.readFileSync(manifestPath, { encoding: "utf-8" }); + return JSON.parse(prevData) as Config & { version?: string }; + } catch { + return {} as Partial; + } +}; + +const validateDist = (pluginOptions: Config, manifestPath: string) => { + const { cacheDir, distDir, prefix, reservedNames, distDeletionPolicy, checkDistFreshness } = pluginOptions; + + if (!cacheDir || !distDir) { + console.log( + "classnames-minifier: Failed to check the dist folder because cacheDir or distDir is not specified", + ); + return; + } + let configurationError = ""; + + if (fs.existsSync(manifestPath)) { + const prevData = readManifest(manifestPath); + const configDiffMessages = []; + if (prevData.prefix !== prefix) { + configDiffMessages.push(`Different "prefix": "${prevData.prefix}" -> "${prefix}"`); + } + if (prevData.cacheDir !== cacheDir) { + configDiffMessages.push(`Different "cacheDir": "${prevData.cacheDir}" -> "${cacheDir}"`); + } + if (prevData.distDir !== distDir) { + configDiffMessages.push(`Different "distDir": "${prevData.distDir}" -> "${distDir}"`); + } + if ( + prevData.reservedNames?.length !== reservedNames?.length || + prevData.reservedNames?.some((name) => !reservedNames?.includes(name)) + ) { + configDiffMessages.push( + `Different "reservedNames": "${prevData.reservedNames?.join(", ")}" -> "${reservedNames?.join(", ")}"`, + ); + } + if (prevData.version !== CODE_VERSION) { + configDiffMessages.push(`Different package version: "${prevData.version}" -> "${CODE_VERSION}"`); + } + if (prevData.distDeletionPolicy && !distDeletionPolicy) { + configDiffMessages.push(`"distDeletionPolicy" set to "${distDeletionPolicy}"`); + } + if (configDiffMessages.length) { + configurationError = `Changes found in package configuration: \n${configDiffMessages.map((message) => `- ${message};\n`).join("")}`; + } + } else if (!checkDistFreshness?.()) { + configurationError = `Can not find the package cache manifest at ${manifestPath}\n`; + } + return configurationError; +}; + +export default validateDist; diff --git a/packages/classnames-minifier/tsconfig.cjs.json b/packages/classnames-minifier/tsconfig.cjs.json new file mode 100644 index 0000000..266aca5 --- /dev/null +++ b/packages/classnames-minifier/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/cjs", + "declarationDir": "dist/cjs" + }, + "include": ["src/**/*"] +} diff --git a/packages/classnames-minifier/tsconfig.esm.json b/packages/classnames-minifier/tsconfig.esm.json new file mode 100644 index 0000000..842d5f1 --- /dev/null +++ b/packages/classnames-minifier/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist/esm", + "declarationDir": "dist/esm" + }, + "include": ["src/**/*"] +} diff --git a/packages/classnames-minifier/tsconfig.json b/packages/classnames-minifier/tsconfig.json new file mode 100644 index 0000000..666c3e5 --- /dev/null +++ b/packages/classnames-minifier/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "commonjs", + "jsx": "react", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist/cjs", + "rootDir": "src", + "declaration": true, + "declarationDir": "dist/cjs" + }, + "include": ["src/**/*"] +} diff --git a/package/package.json b/packages/nimpl-classnames-minifier/package.json similarity index 62% rename from package/package.json rename to packages/nimpl-classnames-minifier/package.json index 4cbd579..22c2160 100644 --- a/package/package.json +++ b/packages/nimpl-classnames-minifier/package.json @@ -2,13 +2,24 @@ "name": "@nimpl/classnames-minifier", "version": "4.0.1", "description": "Library for configuring style modules to generate compressed classes", - "main": "dist/withClassnamesMinifier.js", - "types": "dist/withClassnamesMinifier.d.ts", + "main": "dist/cjs/withClassnamesMinifier.js", + "module": "dist/esm/withClassnamesMinifier.mjs", + "types": "dist/cjs/withClassnamesMinifier.d.ts", "files": [ "dist" ], "scripts": { - "build": "tsc" + "build": "pnpm run build:cjs && pnpm run build:esm", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:esm": "tsc -p tsconfig.esm.json" + }, + "exports": { + ".": { + "types": "./dist/cjs/withClassnamesMinifier.d.ts", + "import": "./dist/esm/withClassnamesMinifier.mjs", + "require": "./dist/cjs/withClassnamesMinifier.js", + "default": "./dist/esm/withClassnamesMinifier.mjs" + } }, "keywords": [ "next", diff --git a/package/src/lib/injectConfig.ts b/packages/nimpl-classnames-minifier/src/lib/injectConfig.ts similarity index 100% rename from package/src/lib/injectConfig.ts rename to packages/nimpl-classnames-minifier/src/lib/injectConfig.ts diff --git a/package/src/withClassnamesMinifier.ts b/packages/nimpl-classnames-minifier/src/withClassnamesMinifier.ts similarity index 90% rename from package/src/withClassnamesMinifier.ts rename to packages/nimpl-classnames-minifier/src/withClassnamesMinifier.ts index 114594e..8ac019d 100644 --- a/package/src/withClassnamesMinifier.ts +++ b/packages/nimpl-classnames-minifier/src/withClassnamesMinifier.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Configuration } from "webpack"; -import type { Config } from "classnames-minifier/dist/lib/types/plugin"; +import type { Config } from "classnames-minifier"; import ClassnamesMinifier from "classnames-minifier"; import path from "path"; import fs from "fs"; import injectConfig from "./lib/injectConfig"; -type PluginOptions = Omit & { disabled?: boolean }; +export type PluginOptions = Omit & { disabled?: boolean }; let classnamesMinifier: ClassnamesMinifier; diff --git a/packages/nimpl-classnames-minifier/tsconfig.cjs.json b/packages/nimpl-classnames-minifier/tsconfig.cjs.json new file mode 100644 index 0000000..266aca5 --- /dev/null +++ b/packages/nimpl-classnames-minifier/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/cjs", + "declarationDir": "dist/cjs" + }, + "include": ["src/**/*"] +} diff --git a/packages/nimpl-classnames-minifier/tsconfig.esm.json b/packages/nimpl-classnames-minifier/tsconfig.esm.json new file mode 100644 index 0000000..842d5f1 --- /dev/null +++ b/packages/nimpl-classnames-minifier/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist/esm", + "declarationDir": "dist/esm" + }, + "include": ["src/**/*"] +} diff --git a/package/tsconfig.json b/packages/nimpl-classnames-minifier/tsconfig.json similarity index 58% rename from package/tsconfig.json rename to packages/nimpl-classnames-minifier/tsconfig.json index 14c2909..517d550 100644 --- a/package/tsconfig.json +++ b/packages/nimpl-classnames-minifier/tsconfig.json @@ -1,13 +1,15 @@ { "compilerOptions": { - "target": "es2016", + "target": "es2019", "module": "commonjs", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, - "outDir": "dist", + "outDir": "dist/cjs", "rootDir": "src", - "declaration": true - } + "declaration": true, + "declarationDir": "dist/cjs" + }, + "include": ["src/**/*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6ccc79..056ef24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@nimpl/classnames-minifier>classnames-minifier': workspace:* + importers: .: @@ -55,11 +58,33 @@ importers: specifier: ^5.9.3 version: 5.9.3 - package: + packages/classnames-minifier: + dependencies: + uuid: + specifier: 13.0.0 + version: 13.0.0 + devDependencies: + '@types/node': + specifier: 25.0.3 + version: 25.0.3 + '@types/uuid': + specifier: 11.0.0 + version: 11.0.0 + '@types/webpack': + specifier: 5.28.5 + version: 5.28.5 + css-loader: + specifier: 7.1.2 + version: 7.1.2(webpack@5.104.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/nimpl-classnames-minifier: dependencies: classnames-minifier: - specifier: 1.0.0 - version: 1.0.0(css-loader@7.1.2(webpack@5.104.1)) + specifier: workspace:* + version: link:../classnames-minifier uuid: specifier: 13.0.0 version: 13.0.0 @@ -568,11 +593,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - classnames-minifier@1.0.0: - resolution: {integrity: sha512-f+oH54ka5SGsY2PB30/ztd74xhPC395anKTszSrKouZx3YQd+EEtfmFIOtOsdhmKkN9vCiuAvTEV78kdUUkVww==} - peerDependencies: - css-loader: '>=4.0.0' - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -1163,10 +1183,6 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - watchpack@2.5.0: resolution: {integrity: sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==} engines: {node: '>=10.13.0'} @@ -1695,11 +1711,6 @@ snapshots: chrome-trace-event@1.0.4: {} - classnames-minifier@1.0.0(css-loader@7.1.2(webpack@5.104.1)): - dependencies: - css-loader: 7.1.2(webpack@5.104.1) - uuid: 9.0.1 - client-only@0.0.1: {} color-convert@2.0.1: @@ -2239,8 +2250,6 @@ snapshots: uuid@13.0.0: {} - uuid@9.0.1: {} - watchpack@2.5.0: dependencies: glob-to-regexp: 0.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b2ef598..89723f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,3 @@ packages: - - "package" + - "packages/*" - "examples/*"