├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG.md ├── CONTRIBUTING ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── index.ts ├── minimal-renamer.ts ├── options.ts ├── skip.ts ├── strategy.ts └── variable.ts ├── test └── index.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | test/cases/ 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/", 3 | "rules": { 4 | "@typescript-eslint/explicit-function-return-type": [ 5 | "error", 6 | {"allowExpressions": true} 7 | ], 8 | "func-style": ["error", "declaration"], 9 | "prefer-const": ["error", {"destructuring": "all"}], 10 | // It would be nice to sort import declaration order as well, but that's not 11 | // autofixable and it's not worth the effort of handling manually. 12 | "sort-imports": ["error", {"ignoreDeclarationSort": true}] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: ['[0-9]+.[0-9]+.*'] 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm install 24 | - run: npm run check 25 | - run: npm run test 26 | 27 | release: 28 | needs: [test] 29 | if: github.ref_type == 'tag' && github.event.repository.fork == false 30 | permissions: 31 | contents: read 32 | uses: ./.github/workflows/release.yml 33 | secrets: inherit 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | release: 8 | permissions: 9 | contents: read 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '20.x' 16 | - run: npm install 17 | 18 | # Use node pointing to the Google releasing registry. 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: '20.x' 22 | registry-url: 'https://wombat-dressing-room.appspot.com/' 23 | - run: npm publish 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bazel-* 2 | build/ 3 | target/ 4 | .DS_STORE 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of Closure Stylesheets authors for copyright 2 | # purposes. This file is distinct from the CONTRIBUTORS file. 3 | # See the latter for an explanation. 4 | 5 | # Names should be added to this file as: 6 | # Name or Organization 7 | # The email address is not required for organizations. 8 | 9 | Google Inc. 10 | Voxtok Inc. 11 | Riku Ayanokoji 12 | Yves Brissaud 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.1 2 | 3 | * Fix a bad release. 4 | 5 | ## 0.7.0 6 | 7 | * Changed the plugin to run synchronously. This is a breaking change if you 8 | expect this plugin to run asynchronously. 9 | 10 | ## 0.6.1 11 | 12 | * Don't mangle `@keyframes` breakpoints with decimal points. 13 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows 28 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 29 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # People who have agreed to one of the CLAs and can contribute patches. 2 | # The AUTHORS file lists the copyright holders; this file 3 | # lists people. For example, Google employees are listed here 4 | # but not in AUTHORS, because Google holds the copyright. 5 | # 6 | # https://developers.google.com/open-source/cla/individual 7 | # https://developers.google.com/open-source/cla/corporate 8 | # 9 | # Names should be added to this file as: 10 | # Name 11 | 12 | Sébastien Mennetrier 13 | Stefan Liebenberg 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status](https://github.com/google/postcss-rename/actions/workflows/ci.yml/badge.svg)](https://github.com/google/postcss-rename/actions) 2 | 3 | A [PostCSS](https://github.com/postcss/postcss) plugin to replace class names 4 | based on a customizable renaming scheme. 5 | 6 | * [Usage](#usage) 7 | * [Options](#options) 8 | * [`strategy`](#strategy) 9 | * [`by`](#by) 10 | * [`prefix`](#prefix) 11 | * [`except`](#except) 12 | * [`ids`](#ids) 13 | * [`outputMapCallback`](#outputMapCallback) 14 | 15 | ## Usage 16 | 17 | `postcss-rename` makes it possible to rename CSS class names in the generated 18 | stylesheet, which helps reduce the size of the CSS that is sent down to your 19 | users. It's designed to be used along with a plugin for a build system like 20 | Webpack that can rewrite HTML templates and/or references in JS. If you write 21 | such a plugin, let us know and we'll link it here! 22 | 23 | ## Options 24 | 25 | ### `strategy` 26 | 27 | The renaming strategy to use: 28 | 29 | * `"none"`: Don't change names at all. This is the default strategy. 30 | 31 | * `"debug"`: Add an underscore at the end of each name. This is useful for 32 | keeping classes readable during debugging while still verifying that your 33 | templates and JavaScript aren't accidentally using non-renamed classes. 34 | 35 | * `"minimal"`: Use the shortest possible names, in order of appearance: the 36 | first class is renamed to `.a`, the second to `.b`, and so on. 37 | 38 | This can also be a function that takes a CSS name (the full name in by-whole 39 | mode and the part in by-part mode) and returns its renamed value. 40 | 41 | ### `by` 42 | 43 | Whether to rename in "by-whole mode" or "by-part mode". 44 | 45 | * `"whole"`: Rename the entire name at once, so for example `.tall-image` might 46 | become `.a`. This is the default mode. 47 | 48 | * `"part"`: Rename each hyphenated section of a name separately, so for example 49 | `.tall-image` might become `.a-b`. 50 | 51 | ### `prefix` 52 | 53 | A string prefix to add before every renamed class. This applies even if 54 | [`strategy`](#strategy) is set to `none`. 55 | 56 | In by-part mode, the prefix is applied to the entire class, but it isn't 57 | included in the [output map](#outputMapCallback). 58 | 59 | ### `except` 60 | 61 | An array (or other `Iterable`) of names that shouldn't be renamed. 62 | 63 | ### `ids` 64 | 65 | Whether to rename ID selectors as well as class selectors. Defaults to `false`. 66 | 67 | ### `outputMapCallback` 68 | 69 | A callback that's passed a map from original class names to their renamed 70 | equivalents, so that an HTML template or JS class references can also be 71 | renamed. 72 | 73 | In by-part mode, this contains separate entries for each part of a class name. 74 | It doesn't contain any names that weren't renamed because of 75 | [`except`](#except). 76 | 77 | Disclaimer: This is not an officially supported Google product. 78 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleNameMapper: { 5 | '^com_google_closure_stylesheets/(.*)$': '/$1', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-rename", 3 | "version": "0.7.1", 4 | "description": "A PostCSS plugin to replace class names based on a customizable renaming scheme.", 5 | "keywords": [ 6 | "postcss", 7 | "css", 8 | "postcss-plugin" 9 | ], 10 | "main": "build/src/index.js", 11 | "types": "build/src/index.d.ts", 12 | "files": [ 13 | "build" 14 | ], 15 | "scripts": { 16 | "test": "jest", 17 | "check": "gts check", 18 | "clean": "gts clean", 19 | "compile": "tsc -p .", 20 | "fix": "gts fix", 21 | "prepare": "npm run compile", 22 | "pretest": "npm run compile", 23 | "posttest": "npm run check" 24 | }, 25 | "license": "Apache-2.0", 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/google/postcss-rename.git" 29 | }, 30 | "dependencies": { 31 | "postcss-selector-parser": "^7.1.0" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^26.0.24", 35 | "@types/node": "^12.20.55", 36 | "gts": "^6.0.2", 37 | "jest": "^29.7.0", 38 | "jest-cli": "^29.7.0", 39 | "postcss": "^8.5.3", 40 | "ts-jest": "^29.3.4", 41 | "typescript": "^5.8.3" 42 | }, 43 | "engines": { 44 | "node": ">=20.0.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json'), 3 | }; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import selectorParser from 'postcss-selector-parser'; 19 | 20 | import postcss from 'postcss'; 21 | import {type ClassRenamingOptions} from './options'; 22 | import {type SkipPredicate, createSkipPredicate} from './skip'; 23 | import {type RenamingFunction, createStrategy} from './strategy'; 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-namespace 26 | namespace plugin { 27 | export type Options = ClassRenamingOptions; 28 | } 29 | 30 | function plugin({ 31 | strategy = 'none', 32 | by = 'whole', 33 | prefix = '', 34 | except = [], 35 | ids = false, 36 | outputMapCallback, 37 | }: plugin.Options = {}): postcss.Plugin { 38 | return { 39 | postcssPlugin: 'postcss-rename', 40 | prepare() { 41 | if (strategy === 'none' && !outputMapCallback && !prefix) return {}; 42 | 43 | const outputMap: {[key: string]: string} | null = outputMapCallback 44 | ? {} 45 | : null; 46 | 47 | const skip: SkipPredicate = createSkipPredicate(except); 48 | const rename: RenamingFunction = createStrategy(strategy, skip); 49 | 50 | if (by !== 'whole' && by !== 'part') { 51 | throw new Error(`Unknown mode "${by}".`); 52 | } 53 | 54 | function renameNode( 55 | node: selectorParser.ClassName | selectorParser.Identifier, 56 | ): void { 57 | if (skip(node.value)) return; 58 | 59 | if (by === 'part') { 60 | node.value = 61 | prefix + 62 | node.value 63 | .split('-') 64 | .map(part => { 65 | const newPart = skip(part) ? part : rename(part); 66 | if (outputMap) outputMap[part] = newPart; 67 | return newPart; 68 | }) 69 | .join('-'); 70 | } else { 71 | const newName = prefix + rename(node.value); 72 | if (outputMap) outputMap[node.value] = newName; 73 | node.value = newName; 74 | } 75 | } 76 | 77 | const selectorProcessor = selectorParser(selectors => { 78 | selectors.walkClasses(renameNode); 79 | if (ids) selectors.walkIds(renameNode); 80 | }); 81 | 82 | const alreadySeenNodes = new Set(); 83 | 84 | return { 85 | Rule(ruleNode: postcss.Rule) { 86 | // Cast `parent` to `postcss.AnyNode` for stricter `type` checking. 87 | // Otherwise, `parent` is typed as `postcss.ContainerWithChildren` 88 | // which declares `type` as a `string` rather than a sum type. 89 | const parent = ruleNode.parent as postcss.AnyNode; 90 | if (parent.type !== 'atrule' || !parent.name.endsWith('keyframes')) { 91 | if (alreadySeenNodes.has(ruleNode)) { 92 | return; 93 | } 94 | 95 | alreadySeenNodes.add(ruleNode); 96 | selectorProcessor.processSync(ruleNode, {updateSelector: true}); 97 | } 98 | }, 99 | OnceExit() { 100 | if (outputMapCallback) outputMapCallback(outputMap ?? {}); 101 | }, 102 | }; 103 | }, 104 | }; 105 | } 106 | 107 | plugin.postcss = true; 108 | 109 | export = plugin; 110 | -------------------------------------------------------------------------------- /src/minimal-renamer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /** 19 | * Possible first chars in a CSS name. This only includes ASCII characters to 20 | * avoid the risk of encoding mismatches, and it doesn't include `-` in case the 21 | * user is doing by-part renaming. 22 | */ 23 | const START_CHARS = 24 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'.split(''); 25 | 26 | /** 27 | * Possible non-initial chars in a CSS name. This only includes ASCII characters 28 | * to avoid the risk of encoding mismatches, and it doesn't include `-` in case 29 | * the user is doing by-part renaming. 30 | */ 31 | const CHARS = 32 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'.split(''); 33 | 34 | /** 35 | * Returns the next unique short string whose first character is in 36 | * `START_CHARS` and whose subsequent characters, if any, are in `CHARS`. 37 | * 38 | * This is public so it can be unit-tested. 39 | */ 40 | export function toShortName(index: number): string { 41 | const result = [START_CHARS[index % START_CHARS.length]]; 42 | if (index < START_CHARS.length) return result.join(''); 43 | index = Math.floor(index / START_CHARS.length) - 1; 44 | 45 | // eslint-disable-next-line no-constant-condition 46 | while (true) { 47 | result.push(CHARS[index % CHARS.length]); 48 | if (index < CHARS.length) break; 49 | index = Math.floor(index / CHARS.length) - 1; 50 | } 51 | 52 | return result.join(''); 53 | } 54 | 55 | /** Renames CSS names to the smallest valid identifiers. */ 56 | export class MinimalRenamer { 57 | /** The next index to pass to `toShortName()`. */ 58 | private nextIndex = 0; 59 | 60 | /** A map from original CSS names to their renamed equivalents. */ 61 | private readonly renames = new Map(); 62 | 63 | /** 64 | * Creates a new MinimalSubstitutionMap that generates CSS names from the 65 | * specified set of characters. 66 | * 67 | * @param skip A function which decides if given CSS names may not be 68 | * returned as the output from a substitution lookup. 69 | */ 70 | constructor(private readonly skip: (nodeValue: string) => boolean) {} 71 | 72 | rename(key: string): string { 73 | let value = this.renames.get(key); 74 | if (value) return value; 75 | 76 | do { 77 | value = toShortName(this.nextIndex++); 78 | } while (this.skip(value)); 79 | 80 | this.renames.set(key, value); 81 | return value; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import {type RenamingStrategy} from './strategy'; 19 | 20 | /** 21 | * Maps original names to their new names. 22 | */ 23 | export type RenamingMap = {[originalName: string]: string}; 24 | 25 | /** 26 | * Base options for renaming CSS entities. 27 | * 28 | * This interface is not meant to be used directly. It must be extended by more 29 | * specific renaming option interfaces. 30 | */ 31 | interface RenamingOptions { 32 | /** 33 | * The strategy to use when renaming CSS names. 34 | * @see strategy.ts 35 | */ 36 | strategy?: RenamingStrategy; 37 | 38 | /** 39 | * A prefix to prepend onto the renamed CSS names. 40 | */ 41 | prefix?: string; 42 | 43 | /** 44 | * Called with the final renaming map after the entire AST is processed. 45 | */ 46 | outputMapCallback?(map: RenamingMap): void; 47 | 48 | /** 49 | * A list of CSS names or patterns to exclude from renaming. 50 | */ 51 | except?: Iterable; 52 | } 53 | 54 | /** 55 | * Options for renaming CSS variables. 56 | */ 57 | export type VariableRenamingOptions = RenamingOptions; 58 | 59 | /** 60 | * Options for renaming CSS class selectors. 61 | */ 62 | export interface ClassRenamingOptions extends RenamingOptions { 63 | /** 64 | * Controls how class names are split when renaming. 65 | * - 'whole' takes the entire name as input into the renaming strategy. 66 | * - 'part' splits the class name by '-' and renames each part individually. 67 | */ 68 | by?: 'whole' | 'part'; 69 | 70 | /** 71 | * Whether to also rename id selectors. 72 | */ 73 | ids?: boolean; 74 | } 75 | -------------------------------------------------------------------------------- /src/skip.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /** 19 | * A predicate that determines if the given name should be skipped 20 | * in renaming or disallowed to be produced by a renaming function 21 | * @see strategy.ts 22 | */ 23 | export type SkipPredicate = (name: string) => boolean; 24 | 25 | /** 26 | * Creates a {@link SkipPredicate} that filters against the given strings or 27 | * regular expressions. 28 | * @param except - strings or regular expressions to filter against 29 | * @returns SkipPredicate 30 | */ 31 | export function createSkipPredicate( 32 | except?: Iterable, 33 | ): SkipPredicate { 34 | if (!except) { 35 | // If no `except` is given, then assume everything is allowed 36 | return () => false; 37 | } 38 | 39 | const disallowedNames = new Set(); 40 | const disallowedPatterns: RegExp[] = []; 41 | 42 | for (const disallowed of except) { 43 | if (typeof disallowed === 'string') { 44 | disallowedNames.add(disallowed); 45 | } else { 46 | disallowedPatterns.push(disallowed); 47 | } 48 | } 49 | 50 | return (name: string) => 51 | disallowedNames.has(name) || 52 | disallowedPatterns.some(disallowedPattern => disallowedPattern.test(name)); 53 | } 54 | -------------------------------------------------------------------------------- /src/strategy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import {MinimalRenamer} from './minimal-renamer'; 19 | import {type SkipPredicate} from './skip'; 20 | 21 | /** 22 | * Renaming function. 23 | * 24 | * A renaming function must satisfy the following: 25 | * - A renaming function always produces the same output for a specific input. 26 | */ 27 | export type RenamingFunction = (original: string) => string; 28 | 29 | /** 30 | * Defines a way to rename variables. 31 | * - 'none' performs no renaming. 32 | * - 'debug' appends an underscore ('_') at the end of the name. 33 | * - 'minimal' uses a minimal renamer (@see minimal-renamer.ts). 34 | * - {@link RenamingFunction} allows passing a custom renaming function. 35 | */ 36 | export type RenamingStrategy = 'none' | 'debug' | 'minimal' | RenamingFunction; 37 | 38 | /** 39 | * Produces a renaming function from the given `strategy` 40 | * @throws if `strategy` isn't a function or one of 'none', 'debug', 'minimal' 41 | */ 42 | export function createStrategy( 43 | strategy: RenamingStrategy, 44 | skip: SkipPredicate, 45 | ): RenamingFunction { 46 | if (typeof strategy === 'function') { 47 | return strategy; 48 | } 49 | 50 | switch (strategy) { 51 | case 'none': 52 | return name => name; 53 | case 'debug': 54 | return name => name + '_'; 55 | case 'minimal': { 56 | const renamer = new MinimalRenamer(skip); 57 | return name => renamer.rename(name); 58 | } 59 | default: 60 | throw new Error(`Unknown strategy "${strategy}".`); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/variable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import postcss from 'postcss'; 19 | import {type VariableRenamingOptions} from './options'; 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-namespace 22 | namespace plugin { 23 | export type Options = VariableRenamingOptions; 24 | } 25 | 26 | // eslint-disable-next-line no-redeclare 27 | function plugin({ 28 | strategy = 'none', 29 | prefix = '', 30 | outputMapCallback, 31 | except = [], 32 | }: plugin.Options = {}): postcss.Plugin { 33 | return { 34 | postcssPlugin: 'postcss-variable-rename', 35 | prepare() { 36 | // TODO(jiramide): add variable renaming logic 37 | return {}; 38 | }, 39 | }; 40 | } 41 | 42 | plugin.postcss = true; 43 | 44 | export = plugin; 45 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2020 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import plugin = require('../src'); 19 | import postcss, {LazyResult} from 'postcss'; 20 | import {toShortName} from '../src/minimal-renamer'; 21 | 22 | function run(input: string, options?: plugin.Options): LazyResult { 23 | return postcss([plugin(options)]).process(input, {from: undefined}); 24 | } 25 | 26 | function assertPostcss(result: LazyResult, output: string): void { 27 | expect(result.css).toEqual(output); 28 | expect(result.warnings()).toHaveLength(0); 29 | } 30 | 31 | /** 32 | * Compiles `input` with postcss and asserts that the output map equals 33 | * `expected`. 34 | */ 35 | function assertMapEquals( 36 | input: string, 37 | expected: {[key: string]: string}, 38 | options: plugin.Options = {}, 39 | ): void { 40 | run(input, { 41 | ...options, 42 | outputMapCallback: map => expect(map).toEqual(expected), 43 | }).sync(); 44 | } 45 | 46 | const INPUT = '.container, .full-height .image.full-width {}'; 47 | 48 | const KEYFRAMES = ` 49 | @-webkit-keyframes name { 50 | from {opacity: 0} 51 | 0.1% {opacity: 0.1} 52 | 90% {opacity: 0.9} 53 | to {opacity: 1} 54 | } 55 | 56 | @keyframes name { 57 | from {opacity: 0} 58 | 0.1% {opacity: 0.1} 59 | 90% {opacity: 0.9} 60 | to {opacity: 1} 61 | }`; 62 | 63 | describe('with strategy "none"', () => { 64 | it('does nothing with no options', () => { 65 | assertPostcss(run(INPUT), INPUT); 66 | }); 67 | 68 | it('does nothing with an explicit strategy', () => { 69 | assertPostcss(run(INPUT, {strategy: 'none'}), INPUT); 70 | }); 71 | 72 | describe('in by-whole mode', () => { 73 | it('adds a prefix', () => { 74 | assertPostcss( 75 | run(INPUT, {prefix: 'pf-'}), 76 | '.pf-container, .pf-full-height .pf-image.pf-full-width {}', 77 | ); 78 | }); 79 | 80 | it('emits an output map', () => { 81 | assertMapEquals(INPUT, { 82 | container: 'container', 83 | 'full-height': 'full-height', 84 | image: 'image', 85 | 'full-width': 'full-width', 86 | }); 87 | }); 88 | 89 | it('omits excluded names from the output map', () => { 90 | assertMapEquals( 91 | INPUT, 92 | { 93 | container: 'container', 94 | image: 'image', 95 | 'full-width': 'full-width', 96 | }, 97 | {except: ['full-height']}, 98 | ); 99 | }); 100 | 101 | it('omits excluded regexes from the output map', () => { 102 | assertMapEquals( 103 | INPUT, 104 | { 105 | container: 'container', 106 | image: 'image', 107 | }, 108 | {except: [/full/]}, 109 | ); 110 | }); 111 | 112 | it('includes the prefix in the output map', () => { 113 | assertMapEquals( 114 | INPUT, 115 | { 116 | container: 'pf-container', 117 | 'full-height': 'pf-full-height', 118 | image: 'pf-image', 119 | 'full-width': 'pf-full-width', 120 | }, 121 | {prefix: 'pf-'}, 122 | ); 123 | }); 124 | }); 125 | 126 | describe('in by-part mode', () => { 127 | it('emits each part in an output map in by-part mode', () => { 128 | assertMapEquals( 129 | INPUT, 130 | { 131 | container: 'container', 132 | full: 'full', 133 | height: 'height', 134 | image: 'image', 135 | width: 'width', 136 | }, 137 | {by: 'part'}, 138 | ); 139 | }); 140 | 141 | it('omits part that only appear in excluded names from the output map', () => { 142 | assertMapEquals( 143 | INPUT, 144 | { 145 | container: 'container', 146 | full: 'full', 147 | image: 'image', 148 | width: 'width', 149 | }, 150 | {except: ['full-height'], by: 'part'}, 151 | ); 152 | }); 153 | 154 | it('omits part that only appear in excluded regexes from the output map', () => { 155 | assertMapEquals( 156 | INPUT, 157 | { 158 | container: 'container', 159 | full: 'full', 160 | image: 'image', 161 | width: 'width', 162 | height: 'height', 163 | }, 164 | {except: ['full-.*'], by: 'part'}, 165 | ); 166 | }); 167 | 168 | it("doesn't include the prefix in the output map", () => { 169 | assertMapEquals( 170 | INPUT, 171 | { 172 | container: 'container', 173 | full: 'full', 174 | height: 'height', 175 | image: 'image', 176 | width: 'width', 177 | }, 178 | {prefix: 'pf-', by: 'part'}, 179 | ); 180 | }); 181 | }); 182 | 183 | it("doesn't modify keyframes", () => { 184 | assertPostcss(run(KEYFRAMES), KEYFRAMES); 185 | }); 186 | }); 187 | 188 | describe('with strategy "debug"', () => { 189 | describe('in by-whole mode', () => { 190 | it('adds underscores after every name', () => { 191 | assertPostcss( 192 | run(INPUT, {strategy: 'debug'}), 193 | '.container_, .full-height_ .image_.full-width_ {}', 194 | ); 195 | }); 196 | 197 | it('maps original names to underscored names', () => { 198 | assertMapEquals( 199 | INPUT, 200 | { 201 | container: 'container_', 202 | 'full-height': 'full-height_', 203 | image: 'image_', 204 | 'full-width': 'full-width_', 205 | }, 206 | {strategy: 'debug'}, 207 | ); 208 | }); 209 | 210 | it("doesn't map excluded names", () => { 211 | assertPostcss( 212 | run(INPUT, {strategy: 'debug', except: ['full-height']}), 213 | '.container_, .full-height .image_.full-width_ {}', 214 | ); 215 | }); 216 | 217 | it("doesn't map excluded regexes", () => { 218 | assertPostcss( 219 | run(INPUT, {strategy: 'debug', except: [/full/]}), 220 | '.container_, .full-height .image_.full-width {}', 221 | ); 222 | }); 223 | 224 | it("doesn't map ID selectors by default", () => { 225 | assertPostcss( 226 | run('#container, #full-height, #image.full-width {}', { 227 | strategy: 'debug', 228 | }), 229 | '#container, #full-height, #image.full-width_ {}', 230 | ); 231 | }); 232 | 233 | it('maps ID selectors with ids: true', () => { 234 | assertPostcss( 235 | run('#container, #full-height, #image.full-width {}', { 236 | strategy: 'debug', 237 | ids: true, 238 | }), 239 | '#container_, #full-height_, #image_.full-width_ {}', 240 | ); 241 | }); 242 | }); 243 | 244 | describe('in by-part mode', () => { 245 | it('adds underscores after every part', () => { 246 | assertPostcss( 247 | run(INPUT, {strategy: 'debug', by: 'part'}), 248 | '.container_, .full_-height_ .image_.full_-width_ {}', 249 | ); 250 | }); 251 | 252 | it('adds a prefix after underscoring', () => { 253 | assertPostcss( 254 | run(INPUT, {strategy: 'debug', prefix: 'pf-', by: 'part'}), 255 | '.pf-container_, .pf-full_-height_ .pf-image_.pf-full_-width_ {}', 256 | ); 257 | }); 258 | 259 | it('maps original names to underscored names', () => { 260 | assertMapEquals( 261 | INPUT, 262 | { 263 | container: 'container_', 264 | full: 'full_', 265 | height: 'height_', 266 | image: 'image_', 267 | width: 'width_', 268 | }, 269 | {strategy: 'debug', by: 'part'}, 270 | ); 271 | }); 272 | 273 | it("doesn't map excluded names", () => { 274 | assertPostcss( 275 | run(INPUT, { 276 | strategy: 'debug', 277 | except: ['full-height'], 278 | by: 'part', 279 | }), 280 | '.container_, .full-height .image_.full_-width_ {}', 281 | ); 282 | }); 283 | 284 | it("doesn't map excluded regexes", () => { 285 | assertPostcss( 286 | run(INPUT, { 287 | strategy: 'debug', 288 | except: [/full/], 289 | by: 'part', 290 | }), 291 | '.container_, .full-height .image_.full-width {}', 292 | ); 293 | }); 294 | 295 | it("doesn't map excluded parts", () => { 296 | assertPostcss( 297 | run(INPUT, { 298 | strategy: 'debug', 299 | except: ['full'], 300 | by: 'part', 301 | }), 302 | '.container_, .full-height_ .image_.full-width_ {}', 303 | ); 304 | }); 305 | }); 306 | 307 | it("doesn't modify keyframes", () => { 308 | assertPostcss(run(KEYFRAMES, {strategy: 'debug'}), KEYFRAMES); 309 | }); 310 | }); 311 | 312 | describe('with strategy "minimal"', () => { 313 | describe('in by-whole mode', () => { 314 | it('maps names to the shortest possible strings', () => { 315 | assertPostcss(run(INPUT, {strategy: 'minimal'}), '.a, .b .c.d {}'); 316 | }); 317 | 318 | it('adds a prefix after minimizing', () => { 319 | assertPostcss( 320 | run(INPUT, {strategy: 'minimal', prefix: 'pf-'}), 321 | '.pf-a, .pf-b .pf-c.pf-d {}', 322 | ); 323 | }); 324 | 325 | it('maps original names to minimized names', () => { 326 | assertMapEquals( 327 | INPUT, 328 | { 329 | container: 'a', 330 | 'full-height': 'b', 331 | image: 'c', 332 | 'full-width': 'd', 333 | }, 334 | {strategy: 'minimal'}, 335 | ); 336 | }); 337 | 338 | it("doesn't map excluded names", () => { 339 | assertPostcss( 340 | run(INPUT, {strategy: 'minimal', except: ['full-height']}), 341 | '.a, .full-height .b.c {}', 342 | ); 343 | }); 344 | 345 | it("doesn't map excluded regexes", () => { 346 | assertPostcss( 347 | run(INPUT, {strategy: 'minimal', except: [/full/]}), 348 | '.a, .full-height .b.full-width {}', 349 | ); 350 | }); 351 | 352 | it("doesn't produce a name that would be excluded", () => { 353 | assertPostcss( 354 | run(INPUT, {strategy: 'minimal', except: ['b']}), 355 | '.a, .c .d.e {}', 356 | ); 357 | }); 358 | 359 | it("doesn't produce a name that would be excluded with regexes", () => { 360 | assertPostcss( 361 | run(INPUT, {strategy: 'minimal', except: [/^a|b$/]}), 362 | '.c, .d .e.f {}', 363 | ); 364 | }); 365 | }); 366 | 367 | describe('in by-part mode', () => { 368 | it('maps parts to the shortest possible strings', () => { 369 | assertPostcss( 370 | run(INPUT, {strategy: 'minimal', by: 'part'}), 371 | '.a, .b-c .d.b-e {}', 372 | ); 373 | }); 374 | 375 | it('adds a prefix after minimizing', () => { 376 | assertPostcss( 377 | run(INPUT, {strategy: 'minimal', prefix: 'pf-', by: 'part'}), 378 | '.pf-a, .pf-b-c .pf-d.pf-b-e {}', 379 | ); 380 | }); 381 | 382 | it('maps original names to minimized names', () => { 383 | assertMapEquals( 384 | INPUT, 385 | { 386 | container: 'a', 387 | full: 'b', 388 | height: 'c', 389 | image: 'd', 390 | width: 'e', 391 | }, 392 | {strategy: 'minimal', by: 'part'}, 393 | ); 394 | }); 395 | 396 | it("doesn't map excluded names", () => { 397 | assertPostcss( 398 | run(INPUT, { 399 | strategy: 'minimal', 400 | except: ['full-height'], 401 | by: 'part', 402 | }), 403 | '.a, .full-height .b.c-d {}', 404 | ); 405 | }); 406 | 407 | it("doesn't map excluded regexes", () => { 408 | assertPostcss( 409 | run(INPUT, { 410 | strategy: 'minimal', 411 | except: [/full/], 412 | by: 'part', 413 | }), 414 | '.a, .full-height .b.full-width {}', 415 | ); 416 | }); 417 | 418 | it("doesn't produce a name that would be excluded", () => { 419 | assertPostcss( 420 | run(INPUT, {strategy: 'minimal', except: ['b'], by: 'part'}), 421 | '.a, .c-d .e.c-f {}', 422 | ); 423 | }); 424 | 425 | it("doesn't produce a name that would be with regexes", () => { 426 | assertPostcss( 427 | run(INPUT, {strategy: 'minimal', except: [/^a|b$/], by: 'part'}), 428 | '.c, .d-e .f.d-g {}', 429 | ); 430 | }); 431 | }); 432 | 433 | describe('toShortName()', () => { 434 | it('produces the right results around the two-character boundary', () => { 435 | expect(toShortName(52)).toEqual('_'); 436 | expect(toShortName(53)).toEqual('aa'); 437 | expect(toShortName(54)).toEqual('ba'); 438 | expect(toShortName(55)).toEqual('ca'); 439 | }); 440 | 441 | it('produces the right results around the three-character boundary', () => { 442 | expect(toShortName(3390)).toEqual('Z_'); 443 | expect(toShortName(3391)).toEqual('__'); 444 | expect(toShortName(3392)).toEqual('aaa'); 445 | expect(toShortName(3393)).toEqual('baa'); 446 | expect(toShortName(3394)).toEqual('caa'); 447 | }); 448 | }); 449 | 450 | it("doesn't modify keyframes", () => { 451 | assertPostcss(run(KEYFRAMES, {strategy: 'minimal'}), KEYFRAMES); 452 | }); 453 | }); 454 | 455 | describe('with a custom strategy', () => { 456 | function strategy(name: string): string { 457 | return name.substring(name.length - 2, name.length); 458 | } 459 | 460 | describe('in by-whole mode', () => { 461 | it('maps names to the shortest possible strings', () => { 462 | assertPostcss(run(INPUT, {strategy}), '.er, .ht .ge.th {}'); 463 | }); 464 | 465 | it('adds a prefix after renaming', () => { 466 | assertPostcss( 467 | run(INPUT, {strategy, prefix: 'pf-'}), 468 | '.pf-er, .pf-ht .pf-ge.pf-th {}', 469 | ); 470 | }); 471 | 472 | it('maps original names to renamed names', () => { 473 | assertMapEquals( 474 | INPUT, 475 | { 476 | container: 'er', 477 | 'full-height': 'ht', 478 | image: 'ge', 479 | 'full-width': 'th', 480 | }, 481 | {strategy}, 482 | ); 483 | }); 484 | 485 | it("doesn't map excluded names", () => { 486 | assertPostcss( 487 | run(INPUT, {strategy, except: ['full-height']}), 488 | '.er, .full-height .ge.th {}', 489 | ); 490 | }); 491 | 492 | it("doesn't map excluded regexes", () => { 493 | assertPostcss( 494 | run(INPUT, {strategy, except: [/full/]}), 495 | '.er, .full-height .ge.full-width {}', 496 | ); 497 | }); 498 | }); 499 | 500 | describe('in by-part mode', () => { 501 | it('maps parts to the shortest possible strings', () => { 502 | assertPostcss( 503 | run(INPUT, {strategy, by: 'part'}), 504 | '.er, .ll-ht .ge.ll-th {}', 505 | ); 506 | }); 507 | 508 | it('adds a prefix after renaming', () => { 509 | assertPostcss( 510 | run(INPUT, {strategy, prefix: 'pf-', by: 'part'}), 511 | '.pf-er, .pf-ll-ht .pf-ge.pf-ll-th {}', 512 | ); 513 | }); 514 | 515 | it('maps original names to renamed names', () => { 516 | assertMapEquals( 517 | INPUT, 518 | { 519 | container: 'er', 520 | full: 'll', 521 | height: 'ht', 522 | image: 'ge', 523 | width: 'th', 524 | }, 525 | {strategy, by: 'part'}, 526 | ); 527 | }); 528 | 529 | it("doesn't map excluded names", () => { 530 | assertPostcss( 531 | run(INPUT, { 532 | strategy, 533 | except: ['full-height'], 534 | by: 'part', 535 | }), 536 | '.er, .full-height .ge.ll-th {}', 537 | ); 538 | }); 539 | 540 | it("doesn't map excluded regexes", () => { 541 | assertPostcss( 542 | run(INPUT, { 543 | strategy, 544 | except: [/full/], 545 | by: 'part', 546 | }), 547 | '.er, .full-height .ge.full-width {}', 548 | ); 549 | }); 550 | }); 551 | 552 | it("doesn't modify keyframes", () => { 553 | assertPostcss(run(KEYFRAMES, {strategy}), KEYFRAMES); 554 | }); 555 | }); 556 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "rootDir": ".", 6 | "useUnknownInCatchVariables": false, 7 | "declaration": true, 8 | "outDir": "build", 9 | "module": "commonjs", 10 | "esModuleInterop": true, 11 | "lib": ["es2022"], 12 | "types": ["node", "jest"], 13 | "allowJs": true 14 | }, 15 | "include": ["src/**/*.ts", "test/**/*.ts"], 16 | "exclude": ["build/", "jest.config.js", "prettier.config.js"] 17 | } 18 | --------------------------------------------------------------------------------