├── .all-contributorsrc ├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── common.test.ts ├── legacy.test.ts ├── localize-identifier.test.ts ├── modern.test.ts ├── precedence.test.ts ├── test-component-themes-js │ └── theme.js ├── test-component-themes-ts │ └── theme.ts ├── test-modern-themes-ts │ └── theme.ts └── test-utils.ts ├── images ├── logo-animated.gif ├── logo-negative.png ├── logo-negative.svg ├── logo-primary.png └── logo-primary.svg ├── package.json ├── src ├── common │ └── index.ts ├── index.ts ├── legacy │ └── index.ts ├── localize-identifier.ts ├── modern │ └── index.ts └── types │ └── index.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "postcss-themed", 3 | "projectOwner": "intuit", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "tylerkrupicka", 15 | "name": "Tyler Krupicka", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/5761061?v=4", 17 | "profile": "http://tylerkrupicka.com", 18 | "contributions": [ 19 | "code", 20 | "test", 21 | "doc" 22 | ] 23 | }, 24 | { 25 | "login": "hipstersmoothie", 26 | "name": "Andrew Lisowski", 27 | "avatar_url": "https://avatars3.githubusercontent.com/u/1192452?v=4", 28 | "profile": "http://hipstersmoothie.com", 29 | "contributions": [ 30 | "code", 31 | "test", 32 | "doc" 33 | ] 34 | }, 35 | { 36 | "login": "adierkens", 37 | "name": "Adam Dierkens", 38 | "avatar_url": "https://avatars1.githubusercontent.com/u/13004162?v=4", 39 | "profile": "https://adamdierkens.com", 40 | "contributions": [ 41 | "code" 42 | ] 43 | }, 44 | { 45 | "login": "christyjacob4", 46 | "name": "Christy Jacob", 47 | "avatar_url": "https://avatars1.githubusercontent.com/u/20852629?v=4", 48 | "profile": "https://christyjacob4.github.io", 49 | "contributions": [ 50 | "code", 51 | "doc" 52 | ] 53 | }, 54 | { 55 | "login": "Sharps", 56 | "name": "Sharps", 57 | "avatar_url": "https://avatars2.githubusercontent.com/u/8174841?v=4", 58 | "profile": "https://github.com/Sharps", 59 | "contributions": [ 60 | "design" 61 | ] 62 | }, 63 | { 64 | "login": "mandyellow", 65 | "name": "Amanda Yoshiizumi", 66 | "avatar_url": "https://avatars0.githubusercontent.com/u/30158643?v=4", 67 | "profile": "https://www.behance.net/amandayoshiizumi", 68 | "contributions": [ 69 | "design" 70 | ] 71 | }, 72 | { 73 | "login": "ratnamal", 74 | "name": "Ratnamala Korlepara", 75 | "avatar_url": "https://avatars0.githubusercontent.com/u/36140652?v=4", 76 | "profile": "https://github.com/ratnamal", 77 | "contributions": [ 78 | "test", 79 | "doc" 80 | ] 81 | }, 82 | { 83 | "login": "EnzoZafra", 84 | "name": "Enzo Zafra", 85 | "avatar_url": "https://avatars1.githubusercontent.com/u/10554785?v=4", 86 | "profile": "http://enzozafra.com/", 87 | "contributions": [ 88 | "code", 89 | "doc", 90 | "infra", 91 | "test" 92 | ] 93 | }, 94 | { 95 | "login": "joshtym", 96 | "name": "Joshua Tymburski", 97 | "avatar_url": "https://avatars3.githubusercontent.com/u/6886456?v=4", 98 | "profile": "https://github.com/joshtym", 99 | "contributions": [ 100 | "test", 101 | "code" 102 | ] 103 | }, 104 | { 105 | "login": "kendallgassner", 106 | "name": "Kendall Gassner", 107 | "avatar_url": "https://avatars3.githubusercontent.com/u/15275462?v=4", 108 | "profile": "https://github.com/kendallgassner", 109 | "contributions": [ 110 | "test", 111 | "code" 112 | ] 113 | }, 114 | { 115 | "login": "kharrop", 116 | "name": "Kelly Harrop", 117 | "avatar_url": "https://avatars.githubusercontent.com/u/24794756?v=4", 118 | "profile": "http://kellyharrop.com", 119 | "contributions": [ 120 | "code", 121 | "doc", 122 | "test" 123 | ] 124 | }, 125 | { 126 | "login": "yoqwerty", 127 | "name": "yoqwerty", 128 | "avatar_url": "https://avatars.githubusercontent.com/u/26031967?v=4", 129 | "profile": "https://github.com/yoqwerty", 130 | "contributions": [ 131 | "test" 132 | ] 133 | } 134 | ], 135 | "contributorsPerLine": 7 136 | } 137 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript", "@babel/preset-env"], 3 | "plugins": ["@babel/plugin-proposal-nullish-coalescing-operator"] 4 | } 5 | 6 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | defaults: &defaults 4 | working_directory: ~/postcss-themed 5 | docker: 6 | - image: circleci/node:10-browsers 7 | environment: 8 | TZ: '/usr/share/zoneinfo/America/Los_Angeles' 9 | 10 | aliases: 11 | # Circle related commands 12 | - &restore-cache 13 | keys: 14 | # Find a cache corresponding to this specific package.json checksum 15 | # when this file is changed, this key will fail 16 | - auto-{{ checksum "yarn.lock" }}-{{ checksum ".circleci/config.yml" }} 17 | - auto-{{ checksum "yarn.lock" }} 18 | # Find the most recent cache used from any branch 19 | - auto- 20 | - &save-cache 21 | key: auto-{{ checksum "yarn.lock" }}-{{ checksum ".circleci/config.yml" }} 22 | paths: 23 | - ~/.cache/yarn 24 | - node_modules 25 | # Yarn commands 26 | - &yarn 27 | name: Install Dependencies 28 | command: yarn install --frozen-lockfile --non-interactive --cache-folder=~/.cache/yarn 29 | - &lint 30 | name: Lint 31 | command: yarn lint 32 | - &test 33 | name: Test 34 | command: yarn test 35 | - &build 36 | name: Build 37 | command: yarn build 38 | 39 | jobs: 40 | install: 41 | <<: *defaults 42 | steps: 43 | - checkout 44 | - restore_cache: *restore-cache 45 | - run: *yarn 46 | - save_cache: *save-cache 47 | - persist_to_workspace: 48 | root: . 49 | paths: 50 | - . 51 | 52 | build: 53 | <<: *defaults 54 | steps: 55 | - attach_workspace: 56 | at: ~/postcss-themed 57 | - run: *build 58 | - persist_to_workspace: 59 | root: . 60 | paths: 61 | - . 62 | 63 | lint: 64 | <<: *defaults 65 | steps: 66 | - attach_workspace: 67 | at: ~/postcss-themed 68 | - run: *lint 69 | 70 | test: 71 | <<: *defaults 72 | steps: 73 | - attach_workspace: 74 | at: ~/postcss-themed 75 | - run: *test 76 | - run: 77 | name: Send CodeCov Results 78 | command: bash <(curl -s https://codecov.io/bash) -t $CODECOV_KEY 79 | 80 | release: 81 | <<: *defaults 82 | steps: 83 | - attach_workspace: 84 | at: ~/postcss-themed 85 | - run: mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config 86 | - run: 87 | name: Release 88 | command: yarn run release 89 | 90 | workflows: 91 | version: 2 92 | build_and_test: 93 | jobs: 94 | - install 95 | 96 | - build: 97 | requires: 98 | - install 99 | 100 | - lint: 101 | requires: 102 | - build 103 | 104 | - test: 105 | requires: 106 | - build 107 | 108 | - release: 109 | requires: 110 | - test 111 | - lint 112 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Open source projects are “living.” Contributions in the form of issues and pull requests are welcomed and encouraged. When you contribute, you explicitly say you are part of the community and abide by its Code of Conduct. 2 | 3 | # The Code 4 | 5 | At Intuit, we foster a kind, respectful, harassment-free cooperative community. Our open source community works to: 6 | 7 | - Be kind and respectful; 8 | - Act as a global community; 9 | - Conduct ourselves professionally. 10 | 11 | As members of this community, we will not tolerate behaviors including, but not limited to: 12 | 13 | - Violent threats or language; 14 | - Discriminatory or derogatory jokes or language; 15 | - Public or private harassment of any kind; 16 | - Other conduct considered inappropriate in a professional setting. 17 | 18 | ## Reporting Concerns 19 | 20 | If you see someone violating the Code of Conduct please email TechOpenSource@intuit.com 21 | 22 | ## Scope 23 | 24 | This code of conduct applies to: 25 | 26 | All repos and communities for Intuit-managed projects, whether or not the text is included in a Intuit-managed project’s repository; 27 | 28 | Individuals or teams representing projects in official capacity, such as via official social media channels or at in-person meetups. 29 | 30 | ## Attribution 31 | 32 | This Code of Conduct is partly inspired by and based on those of Amazon, CocoaPods, GitHub, Microsoft, thoughtbot, and on the Contributor Covenant version 1.4.1. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | 17 | **Expected behavior** 18 | 19 | 20 | 21 | **Screenshots** 22 | 23 | 24 | 25 | **Desktop (please complete the following information):** 26 | 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | 17 | **Describe alternatives you've considered** 18 | 19 | 20 | 21 | **Additional context** 22 | 23 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 |
Tyler Krupicka
Tyler Krupicka

💻 ⚠️ 📖
Andrew Lisowski
Andrew Lisowski

💻 ⚠️ 📖
Adam Dierkens
Adam Dierkens

💻
Christy Jacob
Christy Jacob

💻 📖
Sharps
Sharps

🎨
Amanda Yoshiizumi
Amanda Yoshiizumi

🎨
Ratnamala Korlepara
Ratnamala Korlepara

⚠️ 📖
Enzo Zafra
Enzo Zafra

💻 📖 🚇 ⚠️
Joshua Tymburski
Joshua Tymburski

⚠️ 💻
Kendall Gassner
Kendall Gassner

⚠️ 💻
Kelly Harrop
Kelly Harrop

💻 📖 ⚠️
yoqwerty
yoqwerty

⚠️
543 | 544 | 545 | 546 | 547 | 548 | 549 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 550 | -------------------------------------------------------------------------------- /__tests__/common.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveThemeExtension, normalizeTheme } from '../src/common'; 2 | 3 | it('should be able to extend a simple theme', () => { 4 | expect( 5 | resolveThemeExtension( 6 | normalizeTheme({ 7 | default: { 8 | color: 'red', 9 | }, 10 | myTheme: { 11 | color: 'blue', 12 | }, 13 | myChildTheme: { 14 | extends: 'myTheme', 15 | }, 16 | }) 17 | ) 18 | ).toStrictEqual({ 19 | default: { 20 | light: { color: 'red' }, 21 | dark: {}, 22 | }, 23 | myTheme: { 24 | light: { color: 'blue' }, 25 | dark: {}, 26 | }, 27 | myChildTheme: { 28 | light: { color: 'blue' }, 29 | dark: {}, 30 | }, 31 | }); 32 | }); 33 | 34 | it('should be able to extend a simple theme', () => { 35 | expect( 36 | resolveThemeExtension( 37 | normalizeTheme({ 38 | default: { 39 | color: 'red', 40 | }, 41 | myTheme: { 42 | light: { color: 'blue' }, 43 | dark: { color: 'green' }, 44 | }, 45 | myChildTheme: { 46 | extends: 'myTheme', 47 | }, 48 | }) 49 | ) 50 | ).toStrictEqual({ 51 | default: { 52 | light: { color: 'red' }, 53 | dark: {}, 54 | }, 55 | myTheme: { 56 | light: { color: 'blue' }, 57 | dark: { color: 'green' }, 58 | }, 59 | myChildTheme: { 60 | light: { color: 'blue' }, 61 | dark: { color: 'green' }, 62 | }, 63 | }); 64 | }); 65 | 66 | it('should be able to extend a dark/light theme from root', () => { 67 | expect( 68 | resolveThemeExtension({ 69 | default: { 70 | light: { color: 'white' }, 71 | dark: { color: 'black' }, 72 | }, 73 | myTheme: { 74 | light: { color: 'blue' }, 75 | dark: { color: 'red' }, 76 | }, 77 | myChildTheme: { 78 | extends: 'myTheme', 79 | light: {}, 80 | dark: {}, 81 | }, 82 | }) 83 | ).toStrictEqual({ 84 | default: { 85 | light: { color: 'white' }, 86 | dark: { color: 'black' }, 87 | }, 88 | myTheme: { 89 | light: { color: 'blue' }, 90 | dark: { color: 'red' }, 91 | }, 92 | myChildTheme: { 93 | light: { color: 'blue' }, 94 | dark: { color: 'red' }, 95 | }, 96 | }); 97 | }); 98 | 99 | it('should be able to extend a theme that extends another theme', () => { 100 | expect( 101 | resolveThemeExtension( 102 | normalizeTheme({ 103 | default: { 104 | color: 'red', 105 | }, 106 | myTheme: { 107 | color: 'blue', 108 | }, 109 | myChildTheme: { 110 | extends: 'myTheme', 111 | }, 112 | myOtherChildTheme: { 113 | extends: 'myChildTheme', 114 | }, 115 | }) 116 | ) 117 | ).toStrictEqual({ 118 | default: { 119 | light: { color: 'red' }, 120 | dark: {}, 121 | }, 122 | myTheme: { 123 | light: { color: 'blue' }, 124 | dark: {}, 125 | }, 126 | myChildTheme: { 127 | light: { color: 'blue' }, 128 | dark: {}, 129 | }, 130 | myOtherChildTheme: { 131 | light: { color: 'blue' }, 132 | dark: {}, 133 | }, 134 | }); 135 | }); 136 | 137 | it('should add the light extras if there is an extension in the light theme', () => { 138 | expect( 139 | resolveThemeExtension({ 140 | default: { 141 | light: { color: 'white' }, 142 | dark: { color: 'black' }, 143 | }, 144 | myTheme: { 145 | light: { color: 'blue' }, 146 | dark: {}, 147 | }, 148 | myChildTheme: { 149 | light: { extends: 'myTheme' }, 150 | dark: {}, 151 | }, 152 | }) 153 | ).toStrictEqual({ 154 | default: { 155 | light: { color: 'white' }, 156 | dark: { color: 'black' }, 157 | }, 158 | myChildTheme: { 159 | dark: {}, 160 | light: { color: 'blue' }, 161 | }, 162 | myTheme: { 163 | dark: {}, 164 | light: { color: 'blue' }, 165 | } 166 | }); 167 | }); 168 | 169 | it('should add dark extras if there is an extension in the dark theme', () => { 170 | expect( 171 | resolveThemeExtension({ 172 | default: { 173 | light: { color: 'white' }, 174 | dark: { color: 'black' }, 175 | }, 176 | myTheme: { 177 | light: {}, 178 | dark: {color: 'blue'}, 179 | }, 180 | myChildTheme: { 181 | light: {}, 182 | dark: {extends: 'myTheme'} 183 | }, 184 | }) 185 | ).toStrictEqual({ 186 | default: { 187 | light: { color: 'white' }, 188 | dark: { color: 'black' }, 189 | }, 190 | myChildTheme: { 191 | light: {}, 192 | dark: { color: 'blue' }, 193 | }, 194 | myTheme: { 195 | light: {}, 196 | dark: { color: 'blue' }, 197 | } 198 | }); 199 | }); 200 | 201 | it('should be able to resolve color scheme theme correctly if there is a chain in the extension ', () => { 202 | expect( 203 | resolveThemeExtension({ 204 | default: { 205 | light: { color: 'white' }, 206 | dark: {}, 207 | }, 208 | myTheme: { 209 | light: {}, 210 | dark: {color: 'pink', extends: 'myOtherTheme'}, 211 | }, 212 | myOtherTheme: { 213 | light: { color: 'blue'}, 214 | dark: {color: 'red', extends: 'yetAnotherTheme'}, 215 | }, 216 | yetAnotherTheme: { 217 | light: { color: 'red'}, 218 | dark: { color: 'red'} 219 | } 220 | }) 221 | ).toStrictEqual({ 222 | default: { 223 | light: { color: 'white' }, 224 | dark: {}, 225 | }, 226 | myTheme: { 227 | light: {}, 228 | dark: { color: 'pink' } 229 | }, 230 | myOtherTheme: { 231 | light: { color: 'blue'}, 232 | dark: { color: 'red'} 233 | }, 234 | yetAnotherTheme: { 235 | light: { color: 'red' }, 236 | dark: { color: 'red' } 237 | } 238 | }); 239 | }); 240 | 241 | it('should be able to extend a theme that extends another theme - out of order', () => { 242 | expect( 243 | resolveThemeExtension( 244 | normalizeTheme({ 245 | default: { 246 | color: 'red', 247 | }, 248 | myTheme: { 249 | color: 'blue', 250 | }, 251 | myOtherChildTheme: { 252 | extends: 'myChildTheme', 253 | }, 254 | myChildTheme: { 255 | extends: 'myTheme', 256 | }, 257 | }) 258 | ) 259 | ).toStrictEqual({ 260 | default: { 261 | light: { color: 'red' }, 262 | dark: {}, 263 | }, 264 | myTheme: { 265 | light: { color: 'blue' }, 266 | dark: {}, 267 | }, 268 | myChildTheme: { 269 | light: { color: 'blue' }, 270 | dark: {}, 271 | }, 272 | myOtherChildTheme: { 273 | light: { color: 'blue' }, 274 | dark: {}, 275 | }, 276 | }); 277 | }); 278 | 279 | it('should error on unknown themes', () => { 280 | expect(() => 281 | resolveThemeExtension( 282 | normalizeTheme({ 283 | default: { 284 | color: 'red', 285 | }, 286 | myTheme: { 287 | color: 'blue', 288 | }, 289 | myChildTheme: { 290 | extends: 'myThemes', 291 | }, 292 | }) 293 | ) 294 | ).toThrow("Theme to extend from not found! 'myThemes'"); 295 | }); 296 | 297 | it('should error when extending itself', () => { 298 | expect(() => 299 | resolveThemeExtension( 300 | normalizeTheme({ 301 | default: { 302 | color: 'red', 303 | }, 304 | myTheme: { 305 | extends: 'myTheme', 306 | }, 307 | }) 308 | ) 309 | ).toThrow("A theme cannot extend itself! 'myTheme' extends 'myTheme'"); 310 | }); 311 | 312 | it('should error when cycles detected', () => { 313 | expect(() => 314 | resolveThemeExtension({ 315 | default: { 316 | light: { color: 'white' }, 317 | dark: { color: 'black' }, 318 | }, 319 | myTheme: { 320 | extends: 'myChildTheme', 321 | light: { color: 'blue' }, 322 | dark: {}, 323 | }, 324 | myChildTheme: { 325 | extends: 'myTheme', 326 | light: {}, 327 | dark: {}, 328 | }, 329 | }) 330 | ).toThrow( 331 | "Circular theme extension found! 'myTheme' => 'myChildTheme' => 'myTheme'" 332 | ); 333 | }); 334 | 335 | it('should error when cycles detected - subthemes', () => { 336 | expect(() => 337 | resolveThemeExtension( 338 | normalizeTheme({ 339 | default: { 340 | color: 'red', 341 | }, 342 | myTheme: { 343 | extends: 'myChildTheme', 344 | }, 345 | myChildTheme: { 346 | extends: 'myTheme', 347 | }, 348 | }) 349 | ) 350 | ).toThrow( 351 | "Circular theme extension found! 'myTheme' => 'myChildTheme' => 'myTheme'" 352 | ); 353 | }); 354 | 355 | it('should error when cycles detected - complicated', () => { 356 | expect(() => 357 | resolveThemeExtension( 358 | normalizeTheme({ 359 | default: { 360 | color: 'red', 361 | }, 362 | one: { 363 | extends: 'five', 364 | }, 365 | two: { 366 | extends: 'one', 367 | }, 368 | three: { 369 | extends: 'two', 370 | }, 371 | four: { 372 | extends: 'three', 373 | }, 374 | five: { 375 | extends: 'four', 376 | }, 377 | }) 378 | ) 379 | ).toThrow( 380 | "Circular theme extension found! 'one' => 'five' => 'four' => 'three' => 'two' => 'one'" 381 | ); 382 | }); 383 | -------------------------------------------------------------------------------- /__tests__/legacy.test.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | 3 | import plugin from '../src/index'; 4 | import { run } from './test-utils'; 5 | 6 | it('Creates theme override', () => { 7 | const config = { 8 | default: { 9 | color: 'purple', 10 | }, 11 | dark: { 12 | color: 'black', 13 | }, 14 | }; 15 | 16 | return run( 17 | ` 18 | .test { 19 | color: @theme color; 20 | background-image: linear-gradient(to right, @theme color, @theme color) 21 | } 22 | `, 23 | ` 24 | .test { 25 | color: purple; 26 | background-image: linear-gradient(to right, purple, purple) 27 | } 28 | .dark .test { 29 | color: black; 30 | background-image: linear-gradient(to right, black, black) 31 | } 32 | `, 33 | { 34 | config, 35 | } 36 | ); 37 | }); 38 | 39 | it('Creates multiple theme overrides', () => { 40 | const config = { 41 | default: { 42 | color: 'purple', 43 | }, 44 | light: { 45 | color: 'white', 46 | }, 47 | dark: { 48 | color: 'black', 49 | }, 50 | happy: { 51 | color: 'green', 52 | }, 53 | }; 54 | 55 | return run( 56 | ` 57 | .test { 58 | color: @theme color; 59 | } 60 | `, 61 | ` 62 | .test { 63 | color: purple; 64 | } 65 | .light .test { 66 | color: white; 67 | } 68 | .dark .test { 69 | color: black; 70 | } 71 | .happy .test { 72 | color: green; 73 | } 74 | `, 75 | { 76 | config, 77 | } 78 | ); 79 | }); 80 | 81 | it('Only overrides what it needs to', () => { 82 | const config = { 83 | default: { 84 | color: 'purple', 85 | }, 86 | light: { 87 | color: 'white', 88 | }, 89 | }; 90 | 91 | return run( 92 | ` 93 | .test { 94 | font-size: 20px; 95 | color: @theme color; 96 | display: flex; 97 | } 98 | `, 99 | ` 100 | .test { 101 | font-size: 20px; 102 | color: purple; 103 | display: flex; 104 | } 105 | .light .test { 106 | color: white; 107 | } 108 | `, 109 | { 110 | config, 111 | } 112 | ); 113 | }); 114 | 115 | it('replaces partial values', () => { 116 | const config = { 117 | default: { 118 | color: 'purple', 119 | }, 120 | light: { 121 | color: 'white', 122 | }, 123 | }; 124 | 125 | return run( 126 | ` 127 | .test { 128 | border: 1px solid @theme color; 129 | } 130 | `, 131 | ` 132 | .test { 133 | border: 1px solid purple; 134 | } 135 | .light .test { 136 | border: 1px solid white; 137 | } 138 | `, 139 | { 140 | config, 141 | } 142 | ); 143 | }); 144 | 145 | it('finds javascript themes', () => { 146 | const config = { 147 | default: {}, 148 | light: { 149 | background: 'red', 150 | }, 151 | }; 152 | 153 | return run( 154 | ` 155 | .test { 156 | background: @theme background; 157 | } 158 | `, 159 | ` 160 | .test { 161 | } 162 | .light .test { 163 | background: yellow; 164 | } 165 | `, 166 | { 167 | config, 168 | }, 169 | './__tests__/test-component-themes-js/test.css' 170 | ); 171 | }); 172 | 173 | it('finds typescript themes', () => { 174 | const config = { 175 | default: {}, 176 | light: { 177 | background: 'red', 178 | }, 179 | }; 180 | 181 | return run( 182 | ` 183 | .test { 184 | background: @theme background; 185 | } 186 | `, 187 | ` 188 | .test { 189 | } 190 | .light .test { 191 | background: yellow; 192 | } 193 | `, 194 | { 195 | config, 196 | }, 197 | './__tests__/test-component-themes-ts/test.css' 198 | ); 199 | }); 200 | 201 | it('custom theme resolver', () => { 202 | const config = { 203 | default: {}, 204 | light: { 205 | background: 'red', 206 | }, 207 | }; 208 | 209 | return run( 210 | ` 211 | .test { 212 | background: @theme background; 213 | } 214 | `, 215 | ` 216 | .test { 217 | } 218 | .light .test { 219 | background: yellow; 220 | } 221 | `, 222 | { 223 | config, 224 | resolveTheme: () => 225 | // eslint-disable-next-line global-require, node/no-missing-require 226 | require('./test-component-themes-ts/theme'), 227 | }, 228 | './test.css' 229 | ); 230 | }); 231 | 232 | it('works when no theme found', () => { 233 | const config = { 234 | default: {}, 235 | light: { 236 | background: 'red', 237 | }, 238 | }; 239 | 240 | return run( 241 | ` 242 | .test { 243 | background: @theme background; 244 | } 245 | `, 246 | ` 247 | .test { 248 | } 249 | .light .test { 250 | background: red; 251 | } 252 | `, 253 | { 254 | config, 255 | }, 256 | './__tests__/test.css' 257 | ); 258 | }); 259 | 260 | it('omits undefined values', () => { 261 | const config = { 262 | default: { 263 | background: 'blue', 264 | }, 265 | light: { 266 | color: 'white', 267 | }, 268 | }; 269 | 270 | return run( 271 | ` 272 | .test { 273 | background: @theme background; 274 | color: @theme color; 275 | } 276 | `, 277 | ` 278 | .test { 279 | background: blue; 280 | } 281 | .light .test { 282 | color: white; 283 | } 284 | `, 285 | { 286 | config, 287 | } 288 | ); 289 | }); 290 | 291 | it('process :theme-root', () => { 292 | const config = { 293 | default: { 294 | color: 'purple', 295 | }, 296 | light: { 297 | color: 'white', 298 | }, 299 | }; 300 | 301 | return run( 302 | ` 303 | :theme-root(*) { 304 | color: @theme color; 305 | } 306 | `, 307 | ` 308 | * { 309 | color: purple; 310 | } 311 | *.light { 312 | color: white; 313 | } 314 | `, 315 | { 316 | config, 317 | } 318 | ); 319 | }); 320 | 321 | it('process :theme-root - nested', () => { 322 | const config = { 323 | default: { 324 | color: 'purple', 325 | }, 326 | light: { 327 | color: 'white', 328 | }, 329 | }; 330 | 331 | return run( 332 | ` 333 | :theme-root { 334 | &.test { 335 | color: @theme color; 336 | } 337 | 338 | .another { 339 | color: @theme color; 340 | } 341 | } 342 | `, 343 | ` 344 | .test { 345 | color: purple; 346 | } 347 | .another { 348 | color: purple; 349 | } 350 | .light.test { 351 | color: white; 352 | } 353 | .light .another { 354 | color: white; 355 | } 356 | `, 357 | { 358 | config, 359 | } 360 | ); 361 | }); 362 | 363 | it('multiple values in one declaration', () => { 364 | const config = { 365 | default: { 366 | color: 'purple', 367 | width: '1px', 368 | }, 369 | light: { 370 | color: 'white', 371 | width: '10px', 372 | }, 373 | }; 374 | 375 | return run( 376 | ` 377 | .test { 378 | border: @theme width solid @theme color; 379 | } 380 | `, 381 | ` 382 | .test { 383 | border: 1px solid purple; 384 | } 385 | .light .test { 386 | border: 10px solid white; 387 | } 388 | `, 389 | { 390 | config, 391 | } 392 | ); 393 | }); 394 | 395 | it('Requires a config', () => { 396 | return postcss([plugin()]) 397 | .process('', { from: undefined }) 398 | .catch((e) => { 399 | expect(e).toEqual(new Error('No config provided to postcss-themed')); 400 | }); 401 | }); 402 | 403 | it('Finds missing keys', () => { 404 | const input = ` 405 | .test { 406 | color: @theme color; 407 | } 408 | `; 409 | const config = { 410 | default: { 411 | color: 'purple', 412 | }, 413 | dark: { 414 | 'background-color': 'black', 415 | }, 416 | }; 417 | 418 | return postcss([plugin({ config })]) 419 | .process(input, { from: undefined }) 420 | .catch((e) => { 421 | expect(e.message).toContain("Theme 'dark' does not contain key 'color'"); 422 | }); 423 | }); 424 | 425 | it('Finds missing default', () => { 426 | const input = ` 427 | .test { 428 | color: @theme color; 429 | } 430 | `; 431 | const config = { 432 | light: { 433 | color: 'purple', 434 | }, 435 | dark: { 436 | 'background-color': 'black', 437 | }, 438 | }; 439 | 440 | // @ts-ignore 441 | return postcss([plugin({ config })]) 442 | .process(input, { from: undefined }) 443 | .catch((e) => { 444 | expect(e.message).toContain( 445 | "Theme 'default' does not contain key 'color'" 446 | ); 447 | }); 448 | }); 449 | 450 | it('multiple themes + theme-root', () => { 451 | const config = { 452 | default: { 453 | color: 'purple', 454 | width: '1px', 455 | }, 456 | light: { 457 | color: 'white', 458 | width: '10px', 459 | }, 460 | dark: { 461 | color: 'black', 462 | width: '100px', 463 | }, 464 | }; 465 | 466 | return run( 467 | ` 468 | :theme-root { 469 | &.expanded { 470 | width: @theme width; 471 | } 472 | } 473 | `, 474 | ` 475 | .expanded { 476 | width: 1px; 477 | } 478 | .light.expanded { 479 | width: 10px; 480 | } 481 | .dark.expanded { 482 | width: 100px; 483 | } 484 | `, 485 | { 486 | config, 487 | } 488 | ); 489 | }); 490 | 491 | it('multiple themes + fallback', () => { 492 | const config = { 493 | default: { 494 | color: 'purple', 495 | width: '1px', 496 | }, 497 | light: { 498 | color: 'white', 499 | }, 500 | dark: { 501 | color: 'black', 502 | }, 503 | }; 504 | 505 | return run( 506 | ` 507 | :theme-root { 508 | &.expanded { 509 | border: @theme width solid @theme color; 510 | } 511 | } 512 | `, 513 | ` 514 | .expanded { 515 | border: 1px solid purple; 516 | } 517 | .light.expanded { 518 | border: 1px solid white; 519 | } 520 | .dark.expanded { 521 | border: 1px solid black; 522 | } 523 | `, 524 | { 525 | config, 526 | } 527 | ); 528 | }); 529 | 530 | it('non-default main theme', () => { 531 | const config = { 532 | newDefault: { 533 | color: 'black', 534 | }, 535 | shinyNewProduct: { 536 | color: 'red', 537 | width: '1rem', 538 | }, 539 | }; 540 | 541 | return run( 542 | ` 543 | .test { 544 | background-color: @theme color; 545 | width: @theme width; 546 | } 547 | `, 548 | ` 549 | .test { 550 | background-color: black; 551 | } 552 | 553 | .shinyNewProduct .test { 554 | background-color: red; 555 | width: 1rem; 556 | }`, 557 | { config, defaultTheme: 'newDefault' } 558 | ); 559 | }); 560 | 561 | it('non-existent default theme', () => { 562 | const config = { 563 | newDefault: { 564 | color: 'black', 565 | }, 566 | shinyNewProduct: { 567 | color: 'red', 568 | width: '1rem', 569 | }, 570 | }; 571 | 572 | const input = ` 573 | .test { 574 | background-color: @theme color; 575 | width: @theme width; 576 | } 577 | `; 578 | 579 | // @ts-ignore 580 | return postcss([plugin({ config, defaultTheme: 'otherDefaultTheme' })]) 581 | .process(input, { from: undefined }) 582 | .catch((e) => { 583 | expect(e.message).toContain( 584 | "Theme 'otherDefaultTheme' does not contain key 'color'" 585 | ); 586 | }); 587 | }); 588 | 589 | it('multiple selectors', () => { 590 | const config = { 591 | default: { 592 | color: 'purple', 593 | width: '1px', 594 | }, 595 | light: { 596 | color: 'white', 597 | width: '10px', 598 | }, 599 | }; 600 | 601 | return run( 602 | ` 603 | .expanded, .foo { 604 | width: @theme width; 605 | } 606 | `, 607 | ` 608 | .expanded, .foo { 609 | width: 1px; 610 | } 611 | .light .expanded,.light .foo { 612 | width: 10px; 613 | } 614 | `, 615 | { 616 | config, 617 | } 618 | ); 619 | }); 620 | 621 | it('multiple selectors - theme root', () => { 622 | const config = { 623 | default: { 624 | color: 'purple', 625 | width: '1px', 626 | }, 627 | light: { 628 | color: 'white', 629 | width: '10px', 630 | }, 631 | mint: {}, 632 | }; 633 | 634 | return run( 635 | ` 636 | .item { 637 | display: flex; 638 | 639 | &:focus, 640 | &:hover { 641 | background: @theme color; 642 | width: @theme width; 643 | } 644 | } 645 | `, 646 | ` 647 | .item { 648 | display: flex; 649 | } 650 | .item:focus,.item:hover { 651 | background: purple; 652 | width: 1px; 653 | } 654 | .light .item:focus,.light .item:hover { 655 | background: white; 656 | width: 10px; 657 | } 658 | `, 659 | { 660 | config, 661 | } 662 | ); 663 | }); 664 | 665 | it('dark themes', () => { 666 | const config = { 667 | default: { 668 | light: { 669 | color: 'white', 670 | }, 671 | dark: { 672 | color: 'black', 673 | }, 674 | }, 675 | mint: { 676 | light: { 677 | color: 'lightblue', 678 | }, 679 | dark: { 680 | color: 'darkblue', 681 | }, 682 | }, 683 | tto: { 684 | color: 'red', 685 | }, 686 | }; 687 | 688 | return run( 689 | ` 690 | .item { 691 | color: @theme color; 692 | } 693 | `, 694 | ` 695 | .item { 696 | color: white; 697 | } 698 | .dark .item { 699 | color: black; 700 | } 701 | .mint .item { 702 | color: lightblue; 703 | } 704 | .mint.dark .item { 705 | color: darkblue; 706 | } 707 | .tto .item { 708 | color: red; 709 | } 710 | `, 711 | { 712 | config, 713 | } 714 | ); 715 | }); 716 | 717 | it('overrides themes to single theme', () => { 718 | const config = { 719 | newDefault: { 720 | color: 'black', 721 | }, 722 | shinyNewProduct: { 723 | color: 'red', 724 | width: '1rem', 725 | }, 726 | }; 727 | 728 | const input = ` 729 | .test { 730 | background-color: @theme color; 731 | width: @theme width; 732 | } 733 | `; 734 | 735 | return postcss([ 736 | plugin({ config, defaultTheme: 'quickBooks', forceSingleTheme: 'true' }), 737 | ]) 738 | .process(input, { from: undefined }) 739 | .catch((e) => { 740 | expect(e.message).toContain( 741 | "Theme 'quickBooks' does not contain key 'color'" 742 | ); 743 | }); 744 | }); 745 | 746 | it('when theme = light , forceSingleTheme = true, single selector is generated', () => { 747 | const config = { 748 | default: { 749 | color: 'purple', 750 | width: '1px', 751 | }, 752 | light: { 753 | color: 'white', 754 | width: '10px', 755 | }, 756 | dark: { 757 | color: 'black', 758 | width: '20px', 759 | }, 760 | }; 761 | 762 | return run( 763 | ` 764 | .expanded, .foo { 765 | color: @theme color; 766 | width: @theme width; 767 | } 768 | `, 769 | ` 770 | .expanded, .foo { 771 | color: white; 772 | width: 10px; 773 | } 774 | `, 775 | { 776 | config, 777 | defaultTheme: 'light', 778 | forceSingleTheme: 'true', 779 | } 780 | ); 781 | }); 782 | 783 | it('when theme = light , forceSingleTheme = false, multiple selectors are generated', () => { 784 | const config = { 785 | default: { 786 | color: 'purple', 787 | width: '1px', 788 | }, 789 | light: { 790 | color: 'white', 791 | width: '10px', 792 | }, 793 | dark: { 794 | color: 'black', 795 | width: '20px', 796 | }, 797 | }; 798 | 799 | return run( 800 | ` 801 | .expanded, .foo { 802 | width: @theme width; 803 | color: @theme color; 804 | } 805 | `, 806 | ` 807 | .expanded, .foo { 808 | width: 10px; 809 | color: white; 810 | } 811 | `, 812 | { 813 | config, 814 | defaultTheme: 'light', 815 | forceSingleTheme: 'false', 816 | } 817 | ); 818 | }); 819 | 820 | it('Adding empty selectors to final output. Part of legacy code', () => { 821 | const config = { 822 | default: { 823 | color: 'purple', 824 | }, 825 | light: { 826 | color: 'white', 827 | }, 828 | }; 829 | 830 | return run( 831 | ` 832 | .test { 833 | color: @theme color; 834 | } 835 | `, 836 | ` 837 | .test { 838 | color: purple; 839 | } 840 | .light .test { 841 | color: white; 842 | } 843 | .default {} 844 | .light {} 845 | .dark {} 846 | `, 847 | { 848 | config, 849 | forceEmptyThemeSelectors: true, 850 | } 851 | ); 852 | }); 853 | -------------------------------------------------------------------------------- /__tests__/localize-identifier.test.ts: -------------------------------------------------------------------------------- 1 | import localizeIdentifier from '../src/localize-identifier'; 2 | 3 | it('should not do anything to identifier', () => { 4 | expect( 5 | localizeIdentifier( 6 | { resourcePath: '/app/foo.css' }, 7 | '[local]', 8 | 'background' 9 | ) 10 | ).toBe('background'); 11 | }); 12 | 13 | it('should add file name', () => { 14 | expect( 15 | localizeIdentifier( 16 | { resourcePath: '/app/foo.css' }, 17 | '[name]-[local]', 18 | 'background' 19 | ) 20 | ).toBe('foo-background'); 21 | }); 22 | 23 | it('should hash', () => { 24 | expect( 25 | localizeIdentifier( 26 | { resourcePath: '/app/foo.css' }, 27 | '[hash:base64:7]', 28 | 'background' 29 | ) 30 | ).toBe('JAUIJsV'); 31 | }); 32 | 33 | it('should use folder', () => { 34 | expect( 35 | localizeIdentifier( 36 | { resourcePath: '/app/foo.css' }, 37 | '[folder]-[name]-[local]', 38 | 'background' 39 | ) 40 | ).toBe('app-foo-background'); 41 | }); 42 | -------------------------------------------------------------------------------- /__tests__/modern.test.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | import { run } from './test-utils'; 4 | 5 | jest.mock('browserslist', () => () => ['chrome 76']); 6 | 7 | it('Creates a simple css variable based theme', () => { 8 | const config = { 9 | default: { 10 | color: 'purple', 11 | extras: 'black', 12 | }, 13 | mint: { 14 | color: 'teal', 15 | }, 16 | }; 17 | 18 | return run( 19 | ` 20 | .test { 21 | color: @theme color; 22 | background-image: linear-gradient(to right, @theme color, @theme color) 23 | } 24 | `, 25 | ` 26 | .test { 27 | color: var(--color); 28 | background-image: linear-gradient(to right, var(--color), var(--color)) 29 | } 30 | 31 | :root { 32 | --color: purple 33 | } 34 | 35 | .mint { 36 | --color: teal 37 | } 38 | `, 39 | { 40 | config, 41 | } 42 | ); 43 | }); 44 | 45 | it('Can use alternative theme syntax', () => { 46 | const config = { 47 | default: { 48 | color: 'purple', 49 | }, 50 | mint: { 51 | color: 'teal', 52 | }, 53 | }; 54 | 55 | return run( 56 | ` 57 | .test { 58 | color: theme('color'); 59 | } 60 | `, 61 | ` 62 | .test { 63 | color: var(--color, purple); 64 | } 65 | 66 | .mint { 67 | --color: teal; 68 | } 69 | `, 70 | { 71 | config, 72 | } 73 | ); 74 | }); 75 | 76 | it('Can use alternative theme syntax - multiline', () => { 77 | const config = { 78 | default: { 79 | color: 'purple', 80 | }, 81 | mint: { 82 | color: 'teal', 83 | }, 84 | }; 85 | 86 | return run( 87 | ` 88 | .test { 89 | color: theme( 90 | 'color' 91 | ); 92 | } 93 | `, 94 | ` 95 | .test { 96 | color: var(--color, purple); 97 | } 98 | 99 | .mint { 100 | --color: teal; 101 | } 102 | `, 103 | { 104 | config, 105 | } 106 | ); 107 | }); 108 | 109 | it('inlineRootThemeVariables false', () => { 110 | const config = { 111 | default: { 112 | color: 'purple', 113 | extras: 'black', 114 | }, 115 | mint: { 116 | color: 'teal', 117 | }, 118 | }; 119 | 120 | return run( 121 | ` 122 | .test { 123 | color: @theme color; 124 | background-image: linear-gradient(to right, @theme color, @theme color) 125 | } 126 | `, 127 | ` 128 | .test { 129 | color: var(--color); 130 | background-image: linear-gradient(to right, var(--color), var(--color)) 131 | } 132 | 133 | :root { 134 | --color: purple 135 | } 136 | 137 | .mint { 138 | --color: teal 139 | } 140 | `, 141 | { 142 | config, 143 | inlineRootThemeVariables: false, 144 | } 145 | ); 146 | }); 147 | 148 | it('Creates a simple css variable based theme with light and dark', () => { 149 | const config = { 150 | default: { 151 | light: { 152 | color: 'purple', 153 | }, 154 | dark: { 155 | color: 'black', 156 | }, 157 | }, 158 | 159 | mint: { 160 | color: 'teal', 161 | }, 162 | chair: { 163 | light: { 164 | color: 'beige', 165 | }, 166 | dark: { 167 | color: 'darkpurple', 168 | }, 169 | }, 170 | }; 171 | 172 | return run( 173 | ` 174 | .test { 175 | color: @theme color; 176 | background-image: linear-gradient(to right, @theme color, @theme color) 177 | } 178 | `, 179 | ` 180 | .test { 181 | color: var(--color); 182 | background-image: linear-gradient(to right, var(--color), var(--color)) 183 | } 184 | :root { 185 | --color: purple 186 | } 187 | 188 | .dark { 189 | --color: black 190 | } 191 | 192 | .mint.light { 193 | --color: teal 194 | } 195 | 196 | .chair.light { 197 | --color: beige 198 | } 199 | 200 | .chair.dark { 201 | --color: darkpurple 202 | } 203 | `, 204 | { 205 | config, 206 | } 207 | ); 208 | }); 209 | 210 | it('Can override dark and light class', () => { 211 | const config = { 212 | default: { 213 | light: { 214 | color: 'purple', 215 | }, 216 | dark: { 217 | color: 'black', 218 | }, 219 | }, 220 | 221 | mint: { 222 | color: 'teal', 223 | }, 224 | chair: { 225 | light: { 226 | color: 'beige', 227 | }, 228 | dark: { 229 | color: 'darkpurple', 230 | }, 231 | }, 232 | }; 233 | 234 | return run( 235 | ` 236 | .test { 237 | color: @theme color; 238 | background-image: linear-gradient(to right, @theme color, @theme color) 239 | } 240 | `, 241 | ` 242 | .test { 243 | color: var(--color); 244 | background-image: linear-gradient(to right, var(--color), var(--color)) 245 | } 246 | :root { 247 | --color: purple 248 | } 249 | 250 | .dark-theme { 251 | --color: black 252 | } 253 | 254 | .mint.light-theme { 255 | --color: teal 256 | } 257 | 258 | .chair.light-theme { 259 | --color: beige 260 | } 261 | 262 | .chair.dark-theme { 263 | --color: darkpurple 264 | } 265 | `, 266 | { 267 | config, 268 | lightClass: '.light-theme', 269 | darkClass: '.dark-theme', 270 | } 271 | ); 272 | }); 273 | 274 | it('Produces a single theme', () => { 275 | const config = { 276 | default: { 277 | light: { 278 | color: 'purple', 279 | }, 280 | dark: { 281 | color: 'black', 282 | }, 283 | }, 284 | mint: { 285 | color: 'teal', 286 | }, 287 | chair: { 288 | light: { 289 | color: 'beige', 290 | }, 291 | dark: { 292 | color: 'darkpurple', 293 | }, 294 | }, 295 | }; 296 | 297 | return run( 298 | ` 299 | .test { 300 | color: @theme color; 301 | } 302 | `, 303 | ` 304 | .test { 305 | color: var(--color, beige); 306 | } 307 | 308 | .dark { 309 | --color: darkpurple; 310 | } 311 | `, 312 | { 313 | config, 314 | forceSingleTheme: 'chair', 315 | } 316 | ); 317 | }); 318 | 319 | it('Produces a single theme with dark mode if default has it', () => { 320 | const config = { 321 | default: { 322 | light: { 323 | color: 'purple', 324 | }, 325 | dark: { 326 | color: 'black', 327 | }, 328 | }, 329 | mint: { 330 | color: 'teal', 331 | }, 332 | }; 333 | 334 | return run( 335 | ` 336 | .test { 337 | color: @theme color; 338 | } 339 | `, 340 | ` 341 | .test { 342 | color: var(--color, teal); 343 | } 344 | 345 | .dark { 346 | --color: black; 347 | } 348 | `, 349 | { 350 | config, 351 | forceSingleTheme: 'mint', 352 | } 353 | ); 354 | }); 355 | 356 | it("Don't produce extra variables for matching values in the default theme", () => { 357 | const config = { 358 | default: { 359 | light: { 360 | color: 'black', 361 | }, 362 | dark: { 363 | color: 'black', 364 | }, 365 | }, 366 | }; 367 | 368 | return run( 369 | ` 370 | .test { 371 | color: @theme color; 372 | } 373 | `, 374 | ` 375 | .test { 376 | color: var(--color, black); 377 | } 378 | `, 379 | { 380 | config, 381 | } 382 | ); 383 | }); 384 | 385 | it("Don't produce extra variables for matching values in theme", () => { 386 | const config = { 387 | default: { 388 | light: { 389 | color: 'black', 390 | }, 391 | dark: { 392 | color: 'black', 393 | }, 394 | }, 395 | someTheme: { 396 | light: { 397 | color: 'black', 398 | }, 399 | dark: { 400 | color2: 'black', 401 | }, 402 | }, 403 | }; 404 | 405 | return run( 406 | ` 407 | .test { 408 | color: @theme color; 409 | } 410 | `, 411 | ` 412 | .test { 413 | color: var(--color, black); 414 | } 415 | `, 416 | { 417 | config, 418 | } 419 | ); 420 | }); 421 | 422 | it("Don't produce extra variables for matching values in theme", () => { 423 | const config = { 424 | default: { 425 | light: { 426 | color: 'red', 427 | }, 428 | dark: { 429 | color: 'black', 430 | }, 431 | }, 432 | someTheme: { 433 | light: { 434 | color: 'blue', 435 | }, 436 | dark: { 437 | color: 'black', 438 | }, 439 | }, 440 | }; 441 | 442 | return run( 443 | ` 444 | .test { 445 | color: @theme color; 446 | } 447 | `, 448 | ` 449 | .test { 450 | color: var(--color, red); 451 | } 452 | 453 | .dark { 454 | --color: black; 455 | } 456 | 457 | .someTheme.light { 458 | --color: blue; 459 | } 460 | `, 461 | { 462 | config, 463 | } 464 | ); 465 | }); 466 | 467 | it("Don't included deep values in theme", () => { 468 | const config = { 469 | default: { 470 | // Component theme defines a "color" variable that clashes 471 | // with "color" object on themes 472 | color: 'red', 473 | }, 474 | someTheme: { 475 | // Theme doesn't set a "color" but get the "color" tokens 476 | color: { 477 | red: 'red2', 478 | green: 'green2', 479 | }, 480 | }, 481 | }; 482 | 483 | return run( 484 | ` 485 | .test { 486 | color: @theme color; 487 | } 488 | `, 489 | ` 490 | .test { 491 | color: var(--color, red); 492 | } 493 | `, 494 | { 495 | config, 496 | } 497 | ); 498 | }); 499 | 500 | it('Produces a single theme with variables by default', () => { 501 | const config = { 502 | default: { 503 | color: 'purple', 504 | }, 505 | mint: { 506 | color: 'teal', 507 | }, 508 | }; 509 | 510 | return run( 511 | ` 512 | .test { 513 | color: @theme color; 514 | } 515 | `, 516 | ` 517 | .test { 518 | color: var(--color, teal); 519 | } 520 | `, 521 | { 522 | config, 523 | forceSingleTheme: 'mint', 524 | } 525 | ); 526 | }); 527 | 528 | it('Gets deep paths', () => { 529 | const config = { 530 | default: { 531 | colors: { 532 | purple: 'purple', 533 | }, 534 | }, 535 | mint: { 536 | colors: { 537 | purple: 'purple2', 538 | }, 539 | }, 540 | }; 541 | 542 | return run( 543 | ` 544 | .test { 545 | color: @theme colors.purple; 546 | } 547 | `, 548 | ` 549 | .test { 550 | color: var(--colors-purple, purple); 551 | } 552 | 553 | .mint { 554 | --colors-purple: purple2; 555 | } 556 | `, 557 | { 558 | config, 559 | } 560 | ); 561 | }); 562 | 563 | it('Errors on unknown deep paths', () => { 564 | const config = { 565 | default: { 566 | colors: { 567 | purple: 'purple', 568 | }, 569 | }, 570 | mint: { 571 | colors: { 572 | purple: 'purple2', 573 | }, 574 | }, 575 | }; 576 | 577 | return run( 578 | ` 579 | .test { 580 | color: @theme colors.black; 581 | } 582 | `, 583 | ` 584 | .test { 585 | color: var(--colors-purple, purple); 586 | } 587 | 588 | .mint { 589 | --colors-purple: purple2; 590 | } 591 | `, 592 | { 593 | config, 594 | } 595 | ).catch((e) => { 596 | expect(e.message).toEqual( 597 | 'postcss-themed: :3:16: Could not find key colors.black in theme configuration.' 598 | ); 599 | }); 600 | }); 601 | 602 | it("doesn't hang on $Variable", () => { 603 | const config = { 604 | default: { 605 | color: 'purple', 606 | }, 607 | mint: { 608 | color: 'teal', 609 | }, 610 | }; 611 | 612 | return run( 613 | ` 614 | .test { 615 | color: @theme $color; 616 | } 617 | `, 618 | ` 619 | .test { 620 | color: var(--color, teal); 621 | } 622 | `, 623 | { 624 | config, 625 | forceSingleTheme: 'mint', 626 | } 627 | ); 628 | }); 629 | 630 | it("doesn't error on multi-line declaration", () => { 631 | const config = { 632 | default: { 633 | color: 'purple', 634 | otherColor: 'red', 635 | }, 636 | mint: { 637 | color: 'teal', 638 | otherColor: 'green', 639 | }, 640 | }; 641 | 642 | return run( 643 | ` 644 | .test { 645 | background: @theme 646 | $color, @theme otherColor; 647 | } 648 | `, 649 | ` 650 | .test { 651 | background: var(--color, teal), var(--otherColor, green); 652 | } 653 | `, 654 | { 655 | config, 656 | forceSingleTheme: 'mint', 657 | } 658 | ); 659 | }); 660 | 661 | it('should error on missing space', () => { 662 | const config = { 663 | default: { 664 | color: 'purple', 665 | }, 666 | mint: { 667 | color: 'teal', 668 | }, 669 | }; 670 | 671 | return run( 672 | ` 673 | .test { 674 | color: @themecolor; 675 | } 676 | `, 677 | ` 678 | .test { 679 | color: var(--color, teal); 680 | } 681 | `, 682 | { 683 | config, 684 | forceSingleTheme: 'mint', 685 | } 686 | ).catch((e) => { 687 | expect(e.message).toEqual( 688 | 'postcss-themed: :3:16: Invalid theme usage: @themecolor' 689 | ); 690 | }); 691 | }); 692 | 693 | it('should error while trying to read invalid/ not available input file provided', () => { 694 | const config = { 695 | default: { 696 | color: 'purple', 697 | }, 698 | light: { 699 | color: 'white', 700 | }, 701 | }; 702 | 703 | return run( 704 | ` 705 | .test { 706 | color: @theme color; 707 | background-image: linear-gradient(to right, @theme color, @theme color) 708 | } 709 | `, 710 | '', 711 | { 712 | config, 713 | modules: 'default', 714 | }, 715 | '/qwerty.css' 716 | ).catch((e) => { 717 | expect(e.message).toEqual( 718 | "ENOENT: no such file or directory, open '/qwerty.css'" 719 | ); 720 | }); 721 | }); 722 | 723 | it('should error on invalid alt usage space', () => { 724 | const config = { 725 | default: { 726 | color: 'purple', 727 | }, 728 | mint: { 729 | color: 'teal', 730 | }, 731 | }; 732 | 733 | return run( 734 | ` 735 | .test { 736 | color: theme ('color'); 737 | } 738 | `, 739 | '', 740 | { 741 | config, 742 | forceSingleTheme: 'mint', 743 | } 744 | ).catch((e) => { 745 | expect(e.message).toEqual( 746 | "postcss-themed: :3:16: Invalid theme usage: theme ('color')" 747 | ); 748 | }); 749 | }); 750 | 751 | it('Produces a single theme with variables by default with inlineRootThemeVariables off', () => { 752 | const config = { 753 | default: { 754 | color: 'purple', 755 | }, 756 | mint: { 757 | color: 'teal', 758 | }, 759 | }; 760 | 761 | return run( 762 | ` 763 | .test { 764 | color: @theme color; 765 | } 766 | `, 767 | ` 768 | .test { 769 | color: var(--color); 770 | } 771 | 772 | :root { 773 | --color: teal; 774 | } 775 | `, 776 | { 777 | config, 778 | forceSingleTheme: 'mint', 779 | inlineRootThemeVariables: false, 780 | } 781 | ); 782 | }); 783 | 784 | it('Optimizes single theme by removing variables', () => { 785 | const config = { 786 | default: { 787 | color: 'purple', 788 | }, 789 | mint: { 790 | color: 'teal', 791 | }, 792 | }; 793 | 794 | return run( 795 | ` 796 | .test { 797 | color: @theme color; 798 | } 799 | `, 800 | ` 801 | .test { 802 | color: teal; 803 | } 804 | `, 805 | { 806 | config, 807 | forceSingleTheme: 'mint', 808 | optimizeSingleTheme: true, 809 | } 810 | ); 811 | }); 812 | 813 | it('works with nested', () => { 814 | const config = { 815 | default: { 816 | color: 'purple', 817 | }, 818 | light: { 819 | color: 'white', 820 | }, 821 | }; 822 | 823 | return run( 824 | ` 825 | .foo { 826 | &.test { 827 | color: @theme color; 828 | } 829 | 830 | .another { 831 | color: @theme color; 832 | } 833 | } 834 | `, 835 | ` 836 | .foo.test { 837 | color: var(--color); 838 | } 839 | 840 | .foo .another { 841 | color: var(--color); 842 | } 843 | 844 | :root { 845 | --color: purple; 846 | } 847 | 848 | .light { 849 | --color: white; 850 | } 851 | `, 852 | { 853 | config, 854 | } 855 | ); 856 | }); 857 | 858 | it('scoped variable names', () => { 859 | const config = { 860 | default: { 861 | color: 'purple', 862 | }, 863 | light: { 864 | color: 'white', 865 | }, 866 | }; 867 | 868 | return run( 869 | ` 870 | .test { 871 | color: @theme color; 872 | background-image: linear-gradient(to right, @theme color, @theme color) 873 | } 874 | `, 875 | ` 876 | .test { 877 | color: var(--app-foo-color); 878 | background-image: linear-gradient(to right, var(--app-foo-color), var(--app-foo-color)) 879 | } 880 | 881 | :root { 882 | --app-foo-color: purple 883 | } 884 | 885 | .light { 886 | --app-foo-color: white 887 | } 888 | `, 889 | { 890 | config, 891 | modules: '[folder]-[name]-[local]', 892 | }, 893 | '/app/foo.css' 894 | ); 895 | }); 896 | 897 | it('scoped variable names with custom function', () => { 898 | const config = { 899 | default: { 900 | color: 'purple', 901 | }, 902 | light: { 903 | color: 'white', 904 | }, 905 | }; 906 | 907 | return run( 908 | ` 909 | .test { 910 | color: @theme color; 911 | background-image: linear-gradient(to right, @theme color, @theme color) 912 | } 913 | `, 914 | ` 915 | .test { 916 | color: var(--test-color-da3); 917 | background-image: linear-gradient(to right, var(--test-color-da3), var(--test-color-da3)) 918 | } 919 | 920 | :root { 921 | --test-color-da3: purple 922 | } 923 | 924 | .light { 925 | --test-color-da3: white 926 | } 927 | `, 928 | { 929 | config, 930 | modules: (name: string, filename: string, css: string) => { 931 | const hash = crypto 932 | .createHash('sha1') 933 | .update(css) 934 | .digest('hex') 935 | .slice(0, 3); 936 | return `${filename || 'test'}-${name}-${hash}`; 937 | }, 938 | } 939 | ); 940 | }); 941 | 942 | it('scoped variable names with default function', () => { 943 | const config = { 944 | default: { 945 | color: 'purple', 946 | }, 947 | light: { 948 | color: 'white', 949 | }, 950 | }; 951 | 952 | return run( 953 | ` 954 | .test { 955 | color: @theme color; 956 | background-image: linear-gradient(to right, @theme color, @theme color) 957 | } 958 | `, 959 | ` 960 | .test { 961 | color: var(--default-color-d41d8c); 962 | background-image: linear-gradient(to right, var(--default-color-d41d8c), var(--default-color-d41d8c)) 963 | } 964 | 965 | :root { 966 | --default-color-d41d8c: purple 967 | } 968 | 969 | .light { 970 | --default-color-d41d8c: white 971 | } 972 | `, 973 | { 974 | config, 975 | modules: 'default', 976 | } 977 | ); 978 | }); 979 | 980 | it('With component Config', () => { 981 | const config = { 982 | default: { 983 | light: { 984 | background: 'purple', 985 | extras: 'black', 986 | }, 987 | dark: { 988 | background: 'black', 989 | }, 990 | }, 991 | mint: { 992 | background: 'teal', 993 | }, 994 | }; 995 | 996 | return run( 997 | ` 998 | .test { 999 | color: @theme background; 1000 | background-image: linear-gradient(to right, @theme background, @theme background) 1001 | } 1002 | `, 1003 | ` 1004 | .test { 1005 | color: var(--background); 1006 | background-image: linear-gradient(to right, var(--background), var(--background)) 1007 | } 1008 | 1009 | :root { 1010 | --background: yellow 1011 | } 1012 | 1013 | .dark { 1014 | --background: pink 1015 | } 1016 | .mint.light { 1017 | --background: teal 1018 | } 1019 | `, 1020 | { 1021 | config, 1022 | }, 1023 | './__tests__/test-modern-themes-ts/test.css' 1024 | ); 1025 | }); 1026 | 1027 | it('Some variables show inline and some show in root', () => { 1028 | const config = { 1029 | default: { 1030 | color: 'purple', 1031 | extras: 'black', 1032 | }, 1033 | mint: { 1034 | color: 'teal', 1035 | }, 1036 | }; 1037 | 1038 | return run( 1039 | ` 1040 | .test { 1041 | color: @theme color; 1042 | background-image: linear-gradient(to right, @theme extras, @theme extras) 1043 | } 1044 | `, 1045 | ` 1046 | .test { 1047 | color: var(--color, purple); 1048 | background-image: linear-gradient(to right, var(--extras), var(--extras)) 1049 | } 1050 | 1051 | :root { 1052 | --extras: black 1053 | } 1054 | 1055 | .mint { 1056 | --color: teal 1057 | } 1058 | `, 1059 | { 1060 | config, 1061 | } 1062 | ); 1063 | }); 1064 | 1065 | it('can extend another theme', () => { 1066 | const config = { 1067 | default: { 1068 | color: 'purple', 1069 | }, 1070 | turbotax: { 1071 | color: 'teal', 1072 | }, 1073 | mytt: { 1074 | extends: 'turbotax', 1075 | }, 1076 | }; 1077 | 1078 | return run( 1079 | ` 1080 | .test { 1081 | color: @theme color; 1082 | } 1083 | `, 1084 | ` 1085 | .test { 1086 | color: var(--color, purple); 1087 | } 1088 | 1089 | .turbotax, 1090 | .mytt { 1091 | --color: teal; 1092 | } 1093 | `, 1094 | { 1095 | config, 1096 | } 1097 | ); 1098 | }); 1099 | 1100 | it('can extend another theme that extends a theme', () => { 1101 | const config = { 1102 | default: { 1103 | color: 'purple', 1104 | }, 1105 | turbotax: { 1106 | color: 'teal', 1107 | }, 1108 | mytt: { 1109 | extends: 'turbotax', 1110 | }, 1111 | ttlive: { 1112 | extends: 'mytt', 1113 | }, 1114 | }; 1115 | 1116 | return run( 1117 | ` 1118 | .test { 1119 | color: @theme color; 1120 | } 1121 | `, 1122 | ` 1123 | .test { 1124 | color: var(--color, purple); 1125 | } 1126 | 1127 | .turbotax, 1128 | .mytt, 1129 | .ttlive { 1130 | --color: teal; 1131 | } 1132 | `, 1133 | { 1134 | config, 1135 | } 1136 | ); 1137 | }); 1138 | -------------------------------------------------------------------------------- /__tests__/precedence.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './test-utils'; 2 | 3 | jest.mock('browserslist', () => () => ['chrome 76']); 4 | 5 | it('Overrides all themes from default', () => { 6 | const config = { 7 | default: { 8 | light: { 9 | color: 'red', 10 | }, 11 | dark: { 12 | color: 'blue', 13 | }, 14 | }, 15 | mint: { 16 | color: 'teal', 17 | }, 18 | }; 19 | 20 | return run( 21 | ` 22 | .test { 23 | color: @theme color; 24 | } 25 | `, 26 | ` 27 | .test { 28 | color: var(--color, red); 29 | } 30 | 31 | 32 | .dark { 33 | --color: blue; 34 | } 35 | 36 | .mint.light { 37 | --color: teal; 38 | } 39 | `, 40 | { 41 | config, 42 | } 43 | ); 44 | }); 45 | 46 | it('Overrides dark themes from default', () => { 47 | const config = { 48 | default: { 49 | light: { 50 | color: 'red', 51 | }, 52 | dark: { 53 | color: 'blue', 54 | }, 55 | }, 56 | mint: { 57 | light: { 58 | color: 'purple', 59 | }, 60 | dark: { 61 | color: 'teal', 62 | }, 63 | }, 64 | }; 65 | 66 | return run( 67 | ` 68 | .test { 69 | color: @theme color; 70 | } 71 | `, 72 | ` 73 | .test { 74 | color: var(--color, red); 75 | } 76 | 77 | 78 | .dark { 79 | --color: blue; 80 | } 81 | 82 | .mint.light { 83 | --color: purple; 84 | } 85 | 86 | .mint.dark { 87 | --color: teal; 88 | } 89 | `, 90 | { 91 | config, 92 | } 93 | ); 94 | }); 95 | 96 | it('Merges missing variables from single theme', () => { 97 | const config = { 98 | default: { 99 | light: { 100 | color: 'red', 101 | bgColor: 'orange', 102 | }, 103 | dark: { 104 | color: 'blue', 105 | bgColor: 'magenta', 106 | }, 107 | }, 108 | mint: { 109 | color: 'teal', 110 | }, 111 | }; 112 | 113 | return run( 114 | ` 115 | .test { 116 | color: @theme color; 117 | background-color: @theme bgColor; 118 | } 119 | `, 120 | ` 121 | .test { 122 | color: var(--color, teal); 123 | background-color: var(--bgColor, orange); 124 | } 125 | 126 | .dark { 127 | --color: blue; 128 | --bgColor: magenta; 129 | } 130 | `, 131 | { 132 | config, 133 | forceSingleTheme: 'mint', 134 | } 135 | ); 136 | }); 137 | 138 | it('Merges single theme but leaves variables by default', () => { 139 | const config = { 140 | default: { 141 | color: 'red', 142 | bgColor: 'orange', 143 | }, 144 | mint: { 145 | color: 'teal', 146 | }, 147 | }; 148 | 149 | return run( 150 | ` 151 | .test { 152 | color: @theme color; 153 | background-color: @theme bgColor; 154 | } 155 | `, 156 | ` 157 | .test { 158 | color: var(--color, teal); 159 | background-color: var(--bgColor, orange); 160 | } 161 | 162 | `, 163 | { 164 | config, 165 | forceSingleTheme: 'mint', 166 | } 167 | ); 168 | }); 169 | 170 | it('Merges single theme but omits variables when optimized', () => { 171 | const config = { 172 | default: { 173 | color: 'red', 174 | bgColor: 'orange', 175 | }, 176 | mint: { 177 | color: 'teal', 178 | }, 179 | }; 180 | 181 | return run( 182 | ` 183 | .test { 184 | color: @theme color; 185 | background-color: @theme bgColor; 186 | } 187 | `, 188 | ` 189 | .test { 190 | color: teal; 191 | background-color: orange; 192 | } 193 | `, 194 | { 195 | config, 196 | forceSingleTheme: 'mint', 197 | optimizeSingleTheme: true, 198 | } 199 | ); 200 | }); 201 | -------------------------------------------------------------------------------- /__tests__/test-component-themes-js/theme.js: -------------------------------------------------------------------------------- 1 | module.exports = () => ({ 2 | light: { 3 | background: 'yellow', 4 | }, 5 | }); 6 | -------------------------------------------------------------------------------- /__tests__/test-component-themes-ts/theme.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | light: { 3 | background: 'yellow', 4 | }, 5 | }); 6 | -------------------------------------------------------------------------------- /__tests__/test-modern-themes-ts/theme.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | default: { 3 | light: { 4 | background: 'yellow', 5 | color: 'green', 6 | }, 7 | dark: { 8 | background: 'pink', 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /__tests__/test-utils.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import nested from 'postcss-nested'; 3 | 4 | import plugin from '../src/index'; 5 | import { PostcssThemeOptions } from '../src/types'; 6 | 7 | export function normalizeResult(input: string) { 8 | return input 9 | .split('\n') 10 | .map((tok) => tok.trim()) 11 | .join(''); 12 | } 13 | 14 | export function run( 15 | input: string, 16 | output: string, 17 | opts: PostcssThemeOptions, 18 | inputPath?: string 19 | ) { 20 | return postcss([nested, plugin(opts)]) 21 | .process(input, { from: inputPath }) 22 | .then((result) => { 23 | expect(normalizeResult(result.css)).toEqual(normalizeResult(output)); 24 | expect(result.warnings()).toHaveLength(0); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /images/logo-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/postcss-themed/99b4c802740b684faea9975a5e990c31b3d627ba/images/logo-animated.gif -------------------------------------------------------------------------------- /images/logo-negative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/postcss-themed/99b4c802740b684faea9975a5e990c31b3d627ba/images/logo-negative.png -------------------------------------------------------------------------------- /images/logo-negative.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/logo-primary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/postcss-themed/99b4c802740b684faea9975a5e990c31b3d627ba/images/logo-primary.png -------------------------------------------------------------------------------- /images/logo-primary.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-themed", 3 | "version": "2.8.1", 4 | "main": "dist/index.js", 5 | "description": "PostCSS plugin for adding multiple themes to CSS files", 6 | "keywords": [ 7 | "postcss", 8 | "css", 9 | "postcss-plugin", 10 | "theme" 11 | ], 12 | "scripts": { 13 | "build": "tsc -P tsconfig.build.json", 14 | "test": "jest", 15 | "lint": "eslint src/*.ts --fix", 16 | "release": "auto shipit" 17 | }, 18 | "author": "Tyler Krupicka <5761061+tylerkrupicka@users.noreply.github.com>", 19 | "license": "MIT", 20 | "repository": "https://github.com/intuit/postcss-themed", 21 | "bugs": { 22 | "url": "https://github.com/intuit/postcss-themed/issues" 23 | }, 24 | "homepage": "https://github.com/intuit/postcss-themed", 25 | "dependencies": { 26 | "browserslist": "^4.7.0", 27 | "caniuse-api": "^3.0.0", 28 | "cssesc": "^3.0.0", 29 | "debug": "^4.1.1", 30 | "deepmerge": "^3.2.0", 31 | "dlv": "^1.1.3", 32 | "dset": "^3.1.0", 33 | "flat": "^5.0.2", 34 | "loader-utils": "^1.2.3", 35 | "postcss": "^7.0.14", 36 | "ts-node": "^8.0.3" 37 | }, 38 | "devDependencies": { 39 | "@auto-it/all-contributors": "^7.2.2", 40 | "@babel/core": "^7.6.2", 41 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", 42 | "@babel/preset-env": "^7.4.3", 43 | "@babel/preset-typescript": "^7.7.7", 44 | "@logux/eslint-config": "^34.0.0", 45 | "@types/browserslist": "^4.4.0", 46 | "@types/caniuse-api": "^3.0.0", 47 | "@types/cssesc": "^3.0.0", 48 | "@types/debug": "^4.1.5", 49 | "@types/dlv": "^1.1.2", 50 | "@types/flat": "^5.0.1", 51 | "@types/jest": "^24.0.11", 52 | "@types/loader-utils": "^1.1.3", 53 | "@types/node": "^11.13.1", 54 | "@types/postcss-nested": "^4.1.0", 55 | "@types/webpack": "^4.39.2", 56 | "@typescript-eslint/eslint-plugin": "^2.15.0", 57 | "@typescript-eslint/parser": "^2.15.0", 58 | "all-contributors-cli": "^6.9.1", 59 | "auto": "^7.2.2", 60 | "babel-eslint": "^10.0.1", 61 | "eslint": "^6.6.0", 62 | "eslint-config-postcss": "^3.0.7", 63 | "eslint-config-prettier": "^6.5.0", 64 | "eslint-config-standard": "^14.1.0", 65 | "eslint-config-xo": "^0.27.2", 66 | "eslint-plugin-import": "^2.18.2", 67 | "eslint-plugin-import-helpers": "^1.0.2", 68 | "eslint-plugin-jest": "^23.0.2", 69 | "eslint-plugin-node": "^10.0.0", 70 | "eslint-plugin-prefer-let": "^1.0.1", 71 | "eslint-plugin-promise": "^4.2.1", 72 | "eslint-plugin-security": "^1.4.0", 73 | "eslint-plugin-standard": "^4.0.1", 74 | "eslint-plugin-unicorn": "^12.1.0", 75 | "husky": "^1.3.1", 76 | "jest": "^24.7.1", 77 | "lint-staged": "^8.1.5", 78 | "postcss-nested": "^4.1.2", 79 | "prettier": "^2.2.1", 80 | "tapable": "^1.1.3", 81 | "typescript": "^4.2.2" 82 | }, 83 | "eslintConfig": { 84 | "extends": [ 85 | "plugin:@typescript-eslint/recommended", 86 | "eslint-config-postcss", 87 | "xo", 88 | "prettier", 89 | "prettier/@typescript-eslint" 90 | ], 91 | "plugins": [ 92 | "jest", 93 | "@typescript-eslint" 94 | ], 95 | "env": { 96 | "jest/globals": true 97 | }, 98 | "parser": "@typescript-eslint/parser", 99 | "parserOptions": { 100 | "project": "./tsconfig.json", 101 | "sourceType": "module" 102 | }, 103 | "rules": { 104 | "max-len": 0, 105 | "valid-jsdoc": 0, 106 | "max-params": 0, 107 | "prefer-const": 1, 108 | "func-style": 0, 109 | "prefer-let/prefer-let": 0, 110 | "node/no-unsupported-features/es-syntax": 0, 111 | "guard-for-in": 0, 112 | "@typescript-eslint/explicit-function-return-type": 0, 113 | "@typescript-eslint/ban-ts-ignore": 0, 114 | "consistent-return": 0 115 | } 116 | }, 117 | "jest": { 118 | "testEnvironment": "node", 119 | "collectCoverage": true, 120 | "coverageDirectory": "coverage", 121 | "coverageReporters": [ 122 | "text", 123 | "lcov" 124 | ], 125 | "testPathIgnorePatterns": [ 126 | "/node_modules/", 127 | "test-component-themes", 128 | "test-modern-themes", 129 | "test-utils" 130 | ], 131 | "collectCoverageFrom": [ 132 | "src/**/*.ts" 133 | ] 134 | }, 135 | "engines": { 136 | "node": ">=7.5.0" 137 | }, 138 | "auto": { 139 | "plugins": [ 140 | "npm", 141 | "released", 142 | "all-contributors" 143 | ] 144 | }, 145 | "prettier": { 146 | "singleQuote": true 147 | }, 148 | "husky": { 149 | "hooks": { 150 | "pre-commit": "lint-staged" 151 | } 152 | }, 153 | "lint-staged": { 154 | "*.{js,json,css,md}": [ 155 | "prettier --write", 156 | "git add" 157 | ] 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import merge from 'deepmerge'; 4 | 5 | import { 6 | PostcssThemeConfig, 7 | PostcssStrictThemeConfig, 8 | Theme, 9 | LightDarkTheme, 10 | ColorScheme, 11 | } from '../types'; 12 | 13 | const THEME_USAGE_REGEX = /@theme\s+\$?([a-zA-Z-_0-9.]+)/; 14 | const ALT_THEME_USAGE_REGEX = /theme\(\s*['"]([a-zA-Z-_0-9.]+)['"]\s*\)/; 15 | 16 | /** Get the theme variable name from a string */ 17 | export const parseThemeKey = (value: string) => { 18 | let key = value.match(THEME_USAGE_REGEX); 19 | 20 | if (key) { 21 | return key[1]; 22 | } 23 | 24 | key = value.match(ALT_THEME_USAGE_REGEX); 25 | 26 | if (key) { 27 | return key[1]; 28 | } 29 | 30 | return ''; 31 | }; 32 | 33 | /** Replace a theme variable reference with a value */ 34 | export const replaceTheme = (value: string, replace: string) => { 35 | if (value.match(THEME_USAGE_REGEX)) { 36 | return value.replace(THEME_USAGE_REGEX, replace); 37 | } 38 | 39 | return value.replace(ALT_THEME_USAGE_REGEX, replace); 40 | }; 41 | 42 | /** Get the location of the theme file */ 43 | export function getThemeFilename(cssFile: string) { 44 | let themePath = path.join(path.dirname(cssFile), 'theme.ts'); 45 | 46 | if (!fs.existsSync(themePath)) { 47 | themePath = path.join(path.dirname(cssFile), 'theme.js'); 48 | } 49 | 50 | return themePath; 51 | } 52 | 53 | /** Remove :theme-root usage from a selector */ 54 | export const replaceThemeRoot = (selector: string) => 55 | selector.replace(/:theme-root\((\S+)\)/g, '$1').replace(/:theme-root/g, ''); 56 | 57 | /** Make a SimpleTheme into a LightDarkTheme */ 58 | export const normalizeTheme = ( 59 | config: PostcssThemeConfig | {} 60 | ): PostcssStrictThemeConfig => { 61 | return Object.assign( 62 | {}, 63 | ...Object.entries(config).map(([theme, themeConfig]) => { 64 | if ('light' in themeConfig && 'dark' in themeConfig) { 65 | return { [theme]: themeConfig }; 66 | } 67 | 68 | if (themeConfig.extends) { 69 | const configWithoutExtends = { ...themeConfig }; 70 | 71 | delete configWithoutExtends.extends; 72 | 73 | return { 74 | [theme]: { 75 | extends: themeConfig.extends, 76 | light: configWithoutExtends, 77 | dark: {}, 78 | }, 79 | }; 80 | } 81 | 82 | return { [theme]: { light: themeConfig, dark: {} } }; 83 | }) 84 | ); 85 | }; 86 | 87 | /** Resolve any "extends" fields for a theme */ 88 | export const resolveThemeExtension = ( 89 | config: PostcssStrictThemeConfig 90 | ): PostcssStrictThemeConfig => { 91 | const checkExtendSelf = (theme: string, extendsTheme: string) => { 92 | if (extendsTheme === theme) { 93 | throw new Error( 94 | `A theme cannot extend itself! '${theme}' extends '${extendsTheme}'` 95 | ); 96 | } 97 | }; 98 | 99 | const checkThemeExists = (extendsTheme: string) => { 100 | if (!config[extendsTheme]) { 101 | throw new Error(`Theme to extend from not found! '${extendsTheme}'`); 102 | } 103 | }; 104 | 105 | const checkCycles = (theme: string, colorScheme?: ColorScheme) => { 106 | const chain = [theme]; 107 | let currentTheme = colorScheme 108 | ? config[theme][colorScheme].extends 109 | : config[theme].extends; 110 | 111 | while (currentTheme) { 112 | if (chain.includes(currentTheme)) { 113 | chain.push(currentTheme); 114 | throw new Error( 115 | `Circular theme extension found! ${chain 116 | .map((i) => `'${i}'`) 117 | .join(' => ')}` 118 | ); 119 | } 120 | 121 | chain.push(currentTheme); 122 | currentTheme = colorScheme 123 | ? config[currentTheme][colorScheme].extends 124 | : config[currentTheme].extends; 125 | } 126 | }; 127 | 128 | const resolveSubTheme = (theme: string) => { 129 | const subConfig = { ...config }; 130 | delete subConfig[theme]; 131 | 132 | Object.keys(subConfig).forEach((t) => { 133 | if ( 134 | subConfig[t].extends === theme || 135 | subConfig[t].light.extends === theme || 136 | subConfig[t].dark.extends === theme 137 | ) { 138 | delete subConfig[t]; 139 | } 140 | }); 141 | 142 | resolveThemeExtension(subConfig); 143 | }; 144 | 145 | const resolveColorSchemeTheme = ( 146 | themeConfig: LightDarkTheme, 147 | theme: string, 148 | colorScheme: ColorScheme 149 | ) => { 150 | const extendsTheme = themeConfig[colorScheme].extends; 151 | 152 | let extras = {}; 153 | 154 | if (extendsTheme) { 155 | checkThemeExists(extendsTheme); 156 | checkExtendSelf(theme, extendsTheme); 157 | checkCycles(theme, colorScheme); 158 | 159 | if (config[extendsTheme][colorScheme].extends) { 160 | resolveSubTheme(theme); 161 | } 162 | 163 | extras = config[extendsTheme][colorScheme]; 164 | delete themeConfig[colorScheme].extends; 165 | } 166 | 167 | return extras; 168 | }; 169 | 170 | Object.entries(config).forEach(([theme, themeConfig]) => { 171 | let lightExtras = {}; 172 | let darkExtras = {}; 173 | 174 | if (themeConfig.extends) { 175 | checkThemeExists(themeConfig.extends); 176 | checkExtendSelf(theme, themeConfig.extends); 177 | checkCycles(theme); 178 | 179 | if (config[themeConfig.extends]) { 180 | resolveSubTheme(theme); 181 | } 182 | 183 | const newConfig = merge(config[themeConfig.extends], themeConfig); 184 | delete themeConfig.extends; 185 | themeConfig.light = newConfig.light; 186 | themeConfig.dark = newConfig.dark; 187 | } 188 | 189 | if (themeConfig.light.extends) { 190 | lightExtras = resolveColorSchemeTheme(themeConfig, theme, 'light'); 191 | } 192 | 193 | if (themeConfig.dark.extends) { 194 | darkExtras = resolveColorSchemeTheme(themeConfig, theme, 'dark'); 195 | } 196 | 197 | themeConfig.light = { ...lightExtras, ...themeConfig.light }; 198 | themeConfig.dark = { ...darkExtras, ...themeConfig.dark }; 199 | }); 200 | 201 | return config; 202 | }; 203 | 204 | /** Determine if a theme has dark mode enabled */ 205 | export const hasDarkMode = (theme: Theme) => 206 | Boolean( 207 | Object.keys(theme.dark).length > 0 && Object.keys(theme.light).length > 0 208 | ); 209 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import fs from 'fs'; 3 | import debug from 'debug'; 4 | import merge from 'deepmerge'; 5 | import * as caniuse from 'caniuse-api'; 6 | import browserslist from 'browserslist'; 7 | import * as tsNode from 'ts-node'; 8 | 9 | import { 10 | getThemeFilename, 11 | normalizeTheme, 12 | resolveThemeExtension, 13 | } from './common'; 14 | import { modernTheme } from './modern'; 15 | import { legacyTheme } from './legacy'; 16 | import { 17 | ComponentTheme, 18 | PostcssThemeConfig, 19 | PostcssThemeOptions, 20 | ThemeResolver, 21 | } from './types'; 22 | 23 | const log = debug('postcss-themed'); 24 | 25 | tsNode.register({ 26 | compilerOptions: { module: 'commonjs' }, 27 | transpileOnly: true, 28 | }); 29 | 30 | /** Try to load component theme from same directory as css file */ 31 | export const configForComponent = ( 32 | cssFile: string | undefined, 33 | rootTheme: PostcssThemeConfig, 34 | resolveTheme?: ThemeResolver 35 | ): PostcssThemeConfig | {} => { 36 | if (!cssFile) { 37 | return {}; 38 | } 39 | 40 | try { 41 | let componentConfig: ComponentTheme | { default: ComponentTheme }; 42 | 43 | if (resolveTheme) { 44 | componentConfig = resolveTheme(cssFile); 45 | } else { 46 | const theme = getThemeFilename(cssFile); 47 | delete require.cache[require.resolve(theme)]; 48 | // eslint-disable-next-line security/detect-non-literal-require, global-require 49 | componentConfig = require(theme); 50 | } 51 | 52 | const fn = 53 | 'default' in componentConfig ? componentConfig.default : componentConfig; 54 | return fn(rootTheme); 55 | } catch (error) { 56 | if (error instanceof SyntaxError || error instanceof TypeError) { 57 | throw error; 58 | } else { 59 | log(error); 60 | } 61 | 62 | return {}; 63 | } 64 | }; 65 | 66 | /** Generate a theme */ 67 | const themeFile = (options: PostcssThemeOptions = {}) => ( 68 | root: postcss.Root, 69 | result: postcss.Result 70 | ) => { 71 | // Postcss-modules runs twice and we only ever want to process the CSS once 72 | // @ts-ignore 73 | if (root.source.processed) { 74 | return; 75 | } 76 | 77 | const { config, resolveTheme } = options; 78 | 79 | if (!config) { 80 | throw Error('No config provided to postcss-themed'); 81 | } 82 | 83 | if (!root.source) { 84 | throw Error('No source found'); 85 | } 86 | 87 | const globalConfig = normalizeTheme(config); 88 | const componentConfig = normalizeTheme( 89 | configForComponent(root.source.input.file, config, resolveTheme) 90 | ); 91 | const mergedConfig = merge(globalConfig, componentConfig); 92 | 93 | resolveThemeExtension(mergedConfig); 94 | 95 | if (caniuse.isSupported('css-variables', browserslist())) { 96 | modernTheme(root, mergedConfig, options); 97 | } else { 98 | legacyTheme(root, mergedConfig, options); 99 | } 100 | 101 | // @ts-ignore 102 | root.source.processed = true; 103 | 104 | if (!resolveTheme && root.source.input.file) { 105 | const themeFilename = getThemeFilename(root.source.input.file); 106 | 107 | if (fs.existsSync(themeFilename)) { 108 | result.messages.push({ 109 | plugin: 'postcss-themed', 110 | type: 'dependency', 111 | file: themeFilename, 112 | }); 113 | } 114 | } 115 | }; 116 | 117 | export * from './types'; 118 | export default postcss.plugin('postcss-themed', themeFile); 119 | -------------------------------------------------------------------------------- /src/legacy/index.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import debug from 'debug'; 3 | import get from 'dlv'; 4 | 5 | import { parseThemeKey, replaceTheme, replaceThemeRoot } from '../common'; 6 | import { 7 | ColorScheme, 8 | PostcssStrictThemeConfig, 9 | PostcssThemeOptions, 10 | } from '../types'; 11 | 12 | const log = debug('postcss-themed'); 13 | 14 | /** Find all the theme variables in a CSS value and replace them with the configured theme values */ 15 | const replaceThemeVariables = ( 16 | config: PostcssStrictThemeConfig, 17 | theme: string, 18 | decl: postcss.Declaration, 19 | colorScheme: 'light' | 'dark' = 'light', 20 | defaultTheme = 'default' 21 | ) => { 22 | const hasMultiple = 23 | (decl.value.match(/@theme/g) || decl.value.match(/theme\(['"]/g) || []) 24 | .length > 1; 25 | 26 | let themeKey = parseThemeKey(decl.value); 27 | 28 | // Found a theme reference 29 | while (themeKey) { 30 | // Check for issues with theme 31 | try { 32 | const themeDefault: string = get( 33 | config[defaultTheme][colorScheme], 34 | themeKey 35 | ); 36 | const newValue: string = get(config[theme][colorScheme], themeKey); 37 | 38 | decl.value = replaceTheme( 39 | decl.value, 40 | hasMultiple ? newValue || themeDefault : newValue 41 | ); 42 | 43 | if (decl.value === 'undefined') { 44 | decl.remove(); 45 | } 46 | } catch (error) { 47 | log(error); 48 | throw decl.error(`Theme '${theme}' does not contain key '${themeKey}'`, { 49 | plugin: 'postcss-themed', 50 | }); 51 | } 52 | 53 | themeKey = parseThemeKey(decl.value); 54 | } 55 | }; 56 | 57 | /** Apply a transformation to a selector */ 58 | const applyToSelectors = ( 59 | selector: string, 60 | fn: (selector: string) => string 61 | ) => { 62 | return selector.replace(/\n/gm, '').split(',').map(fn).join(','); 63 | }; 64 | 65 | /** Create a new rule by inject injecting theme vars into a class with theme usage */ 66 | const createNewRule = ( 67 | componentConfig: PostcssStrictThemeConfig, 68 | rule: postcss.Rule, 69 | themedDeclarations: postcss.Declaration[], 70 | originalSelector: string, 71 | defaultTheme: string 72 | ) => (theme: string, colorScheme: ColorScheme) => { 73 | if (theme === defaultTheme && colorScheme === 'light') { 74 | return; 75 | } 76 | 77 | if (Object.keys(componentConfig[theme][colorScheme]).length === 0) { 78 | return; 79 | } 80 | 81 | const themeClass = 82 | (colorScheme !== 'dark' && `.${theme}`) || 83 | (theme === defaultTheme && `.${colorScheme}`) || 84 | `.${theme}.${colorScheme}`; 85 | 86 | let newSelector = applyToSelectors( 87 | originalSelector, 88 | (s) => `${themeClass} ${s}` 89 | ); 90 | 91 | if (originalSelector.includes(':theme-root')) { 92 | rule.selector = replaceThemeRoot(rule.selector); 93 | 94 | if (rule.selector === '*') { 95 | newSelector = applyToSelectors(rule.selector, (s) => `${s}${themeClass}`); 96 | } else { 97 | newSelector = applyToSelectors(rule.selector, (s) => `${themeClass}${s}`); 98 | } 99 | } 100 | 101 | if (themedDeclarations.length > 0) { 102 | // Add theme to selector, clone to retain source maps 103 | const newRule = rule.clone({ 104 | selector: newSelector, 105 | }); 106 | 107 | newRule.removeAll(); 108 | 109 | // Only add themed declarations to override 110 | for (const property of themedDeclarations) { 111 | const declaration = postcss.decl(property); 112 | replaceThemeVariables( 113 | componentConfig, 114 | theme, 115 | declaration, 116 | colorScheme, 117 | defaultTheme 118 | ); 119 | 120 | if (declaration.value !== 'undefined') { 121 | newRule.append(declaration); 122 | } 123 | } 124 | 125 | return newRule; 126 | } 127 | }; 128 | 129 | /** Create theme override rule for every theme */ 130 | const createNewRules = ( 131 | componentConfig: PostcssStrictThemeConfig, 132 | rule: postcss.Rule, 133 | themedDeclarations: postcss.Declaration[], 134 | defaultTheme: string 135 | ) => { 136 | // Need to remember original selector because we overwrite rule.selector 137 | // once :theme-root is found. If we don't remember the original value then 138 | // multiple themes break 139 | const originalSelector = rule.selector; 140 | const themes = Object.keys(componentConfig); 141 | const rules: postcss.Rule[] = []; 142 | 143 | // Create new rules for theme overrides 144 | for (const themeKey of themes) { 145 | const theme = componentConfig[themeKey]; 146 | const themeRule = createNewRule( 147 | componentConfig, 148 | rule, 149 | themedDeclarations, 150 | originalSelector, 151 | defaultTheme 152 | ); 153 | 154 | for (const colorScheme in theme) { 155 | const newRule = themeRule(themeKey, colorScheme as ColorScheme); 156 | 157 | if (newRule) { 158 | rules.push(newRule); 159 | } 160 | } 161 | } 162 | 163 | return rules; 164 | }; 165 | 166 | /** Accomplish theming by creating new classes to override theme values */ 167 | export const legacyTheme = ( 168 | root: postcss.Root, 169 | componentConfig: PostcssStrictThemeConfig, 170 | options: PostcssThemeOptions 171 | ) => { 172 | const { 173 | defaultTheme = 'default', 174 | forceSingleTheme = undefined, 175 | forceEmptyThemeSelectors, 176 | } = options; 177 | let newRules: postcss.Rule[] = []; 178 | 179 | root.walkRules((rule) => { 180 | const themedDeclarations: postcss.Declaration[] = []; 181 | 182 | // Walk each declaration and find themed values 183 | rule.walkDecls((decl) => { 184 | const { value } = decl; 185 | 186 | if (parseThemeKey(value)) { 187 | themedDeclarations.push(decl.clone()); 188 | // Replace defaults in original CSS rule 189 | replaceThemeVariables( 190 | componentConfig, 191 | defaultTheme, 192 | decl, 193 | 'light', 194 | defaultTheme 195 | ); 196 | } 197 | }); 198 | 199 | let createNewThemeRules: postcss.Rule[]; 200 | if (forceSingleTheme) { 201 | createNewThemeRules = []; 202 | } else { 203 | createNewThemeRules = createNewRules( 204 | componentConfig, 205 | rule, 206 | themedDeclarations, 207 | defaultTheme 208 | ); 209 | } 210 | 211 | newRules = [...newRules, ...createNewThemeRules]; 212 | }); 213 | 214 | if (forceEmptyThemeSelectors) { 215 | const themes = Object.keys(componentConfig); 216 | const extra = new Set(); 217 | 218 | for (const themeKey of themes) { 219 | const theme = componentConfig[themeKey]; 220 | 221 | extra.add(themeKey); 222 | 223 | for (const colorScheme in theme) { 224 | extra.add(colorScheme); 225 | } 226 | } 227 | 228 | extra.forEach((selector) => 229 | newRules.push(postcss.rule({ selector: `.${selector}` })) 230 | ); 231 | } 232 | 233 | newRules.forEach((r) => { 234 | if (forceEmptyThemeSelectors || (r.nodes && r.nodes.length > 0)) { 235 | root.append(r); 236 | } 237 | }); 238 | }; 239 | -------------------------------------------------------------------------------- /src/localize-identifier.ts: -------------------------------------------------------------------------------- 1 | import cssesc from 'cssesc'; 2 | import loaderUtils from 'loader-utils'; 3 | import { loader } from 'webpack'; 4 | 5 | // eslint-disable-next-line no-control-regex 6 | const filenameReservedRegex = /[<>:"/\\|?*\x00-\x1F]/g; 7 | // eslint-disable-next-line no-control-regex 8 | const reControlChars = /[\u0000-\u001f\u0080-\u009f]/g; 9 | const reRelativePath = /^\.+/; 10 | 11 | export default function localizeIdentifier( 12 | loaderContext: Partial, 13 | localIdentName: string, 14 | name: string 15 | ) { 16 | return cssesc( 17 | loaderUtils 18 | .interpolateName( 19 | loaderContext as Required, 20 | localIdentName, 21 | { content: name } 22 | ) // For `[hash]` placeholder 23 | .replace(/^((-?\d)|--)/, '_$1') 24 | .replace(filenameReservedRegex, '-') 25 | .replace(reControlChars, '-') 26 | .replace(reRelativePath, '-') 27 | .replace(/\./g, '-') 28 | ).replace(/\[local\]/gi, name); 29 | } 30 | -------------------------------------------------------------------------------- /src/modern/index.ts: -------------------------------------------------------------------------------- 1 | import postcss from 'postcss'; 2 | import crypto from 'crypto'; 3 | import fs from 'fs'; 4 | import get from 'dlv'; 5 | import flat from 'flat'; 6 | import { dset as set } from 'dset'; 7 | 8 | import localizeIdentifier from '../localize-identifier'; 9 | import { 10 | ColorScheme, 11 | LightDarkTheme, 12 | PostcssStrictThemeConfig, 13 | PostcssThemeOptions, 14 | ScopedNameFunction, 15 | SimpleTheme, 16 | } from '../types'; 17 | import { 18 | hasDarkMode, 19 | parseThemeKey, 20 | replaceTheme, 21 | replaceThemeRoot, 22 | } from '../common'; 23 | 24 | /** Create a CSS variable override block for a given selector */ 25 | const createModernTheme = ( 26 | selector: string, 27 | theme: SimpleTheme, 28 | transform: (value: string) => string 29 | ) => { 30 | const rule = postcss.rule({ selector }); 31 | const decls = Object.entries(flat(theme)).map(([prop, value]) => 32 | postcss.decl({ 33 | prop: `--${transform(prop)}`, 34 | value: `${value}`, 35 | }) 36 | ); 37 | 38 | if (decls.length === 0) { 39 | return; 40 | } 41 | 42 | rule.append(decls); 43 | 44 | return rule; 45 | }; 46 | 47 | /** Merge a given theme with a base theme */ 48 | const mergeConfigs = (theme: LightDarkTheme, defaultTheme: LightDarkTheme) => { 49 | const merged = defaultTheme; 50 | 51 | for (const [colorScheme, values] of Object.entries(theme)) { 52 | if (!values) { 53 | continue; 54 | } 55 | 56 | for (const [key, value] of Object.entries(values)) { 57 | merged[colorScheme as ColorScheme][key] = value; 58 | } 59 | } 60 | 61 | return merged; 62 | }; 63 | 64 | const defaultLocalizeFunction = ( 65 | name: string, 66 | filePath: string, 67 | css: string 68 | ) => { 69 | const hash = crypto.createHash('md5').update(css).digest('hex').slice(0, 6); 70 | return `${filePath || 'default'}-${name}-${hash}`; 71 | }; 72 | 73 | const getLocalizeFunction = ( 74 | modules: string | ScopedNameFunction | undefined, 75 | resourcePath: string | undefined 76 | ) => { 77 | if (typeof modules === 'function' || modules === 'default') { 78 | let fileContents = ''; 79 | if (resourcePath) { 80 | fileContents = fs.readFileSync(resourcePath, 'utf8'); 81 | } 82 | 83 | const localize = 84 | typeof modules === 'function' ? modules : defaultLocalizeFunction; 85 | return (name: string) => { 86 | return localize(name, resourcePath || '', fileContents); 87 | }; 88 | } 89 | 90 | return (name: string) => 91 | localizeIdentifier({ resourcePath }, modules || '[local]', name); 92 | }; 93 | 94 | const declarationsAsString = ({ nodes }: postcss.Rule) => { 95 | if (!nodes) { 96 | return ''; 97 | } 98 | 99 | return nodes 100 | .filter((node): node is postcss.Declaration => node.type === 'decl') 101 | .map((declaration) => `${declaration.prop}: ${declaration.value};`) 102 | .join(''); 103 | }; 104 | 105 | /** Accomplish theming by creating CSS variable overrides */ 106 | export const modernTheme = ( 107 | root: postcss.Root, 108 | componentConfig: PostcssStrictThemeConfig, 109 | options: PostcssThemeOptions 110 | ) => { 111 | const usage = new Map(); 112 | const defaultTheme = options.defaultTheme || 'default'; 113 | const singleTheme = options.forceSingleTheme || undefined; 114 | const optimizeSingleTheme = options.optimizeSingleTheme; 115 | const inlineRootThemeVariables = options.inlineRootThemeVariables ?? true; 116 | const lightClass = options.lightClass || '.light'; 117 | const darkClass = options.darkClass || '.dark'; 118 | const resourcePath = root.source ? root.source.input.file : ''; 119 | const localize = (name: string) => 120 | getLocalizeFunction( 121 | options.modules, 122 | resourcePath 123 | )(name.replace(/\./g, '-')); 124 | 125 | const defaultThemeConfig = Object.entries(componentConfig).find( 126 | ([theme]) => theme === defaultTheme 127 | ); 128 | const hasRootDarkMode = 129 | defaultThemeConfig && hasDarkMode(defaultThemeConfig[1]); 130 | 131 | // For single theme mode, we need to handle themes that may be incomplete 132 | // In that case, we merge the theme with default so all variables are present 133 | const singleThemeConfig = Object.entries(componentConfig).find( 134 | ([theme]) => theme === singleTheme 135 | ); 136 | 137 | let mergedSingleThemeConfig = defaultThemeConfig 138 | ? defaultThemeConfig[1] 139 | : { light: {}, dark: {} }; 140 | 141 | if (defaultThemeConfig && singleThemeConfig && defaultTheme !== singleTheme) { 142 | mergedSingleThemeConfig = mergeConfigs( 143 | singleThemeConfig[1], 144 | defaultThemeConfig[1] 145 | ); 146 | } 147 | 148 | const hasMergedDarkMode = 149 | mergedSingleThemeConfig && hasDarkMode(mergedSingleThemeConfig); 150 | 151 | // 1a. walk again to optimize inline default values 152 | root.walkRules((rule) => { 153 | rule.selector = replaceThemeRoot(rule.selector); 154 | 155 | rule.walkDecls((decl) => { 156 | decl.value.split(/(?=@theme)/g).forEach((chunk) => { 157 | const key = parseThemeKey(chunk); 158 | 159 | if (key) { 160 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 161 | const count = usage.has(key) ? usage.get(key)! + 1 : 1; 162 | usage.set(key, count); 163 | } 164 | }); 165 | }); 166 | }); 167 | 168 | // 1b. Walk each declaration and replace theme vars with CSS vars 169 | root.walkRules((rule) => { 170 | rule.selector = replaceThemeRoot(rule.selector); 171 | 172 | rule.walkDecls((decl) => { 173 | let key = parseThemeKey(decl.value); 174 | 175 | while (key) { 176 | const themeValue = get(mergedSingleThemeConfig.light, key); 177 | 178 | if (singleTheme && !hasMergedDarkMode && optimizeSingleTheme) { 179 | // If we are only building a single theme with light mode, we can optionally insert the value 180 | if (themeValue) { 181 | decl.value = replaceTheme(decl.value, themeValue); 182 | } else { 183 | root.warn( 184 | root.toResult(), 185 | `Could not find key ${key} in theme configuration. Removing declaration.`, 186 | { node: decl } 187 | ); 188 | decl.remove(); 189 | break; 190 | } 191 | } else if (key && !themeValue) { 192 | throw decl.error( 193 | `Could not find key ${key} in theme configuration.`, 194 | { word: decl.value } 195 | ); 196 | } else if ( 197 | inlineRootThemeVariables && 198 | usage.has(key) && 199 | usage.get(key) === 1 200 | ) { 201 | decl.value = replaceTheme( 202 | decl.value, 203 | `var(--${localize(key)}, ${themeValue})` 204 | ); 205 | } else if (key) { 206 | decl.value = replaceTheme(decl.value, `var(--${localize(key)})`); 207 | } else { 208 | throw decl.error(`Invalid theme usage: ${decl.value}`, { 209 | word: decl.value, 210 | }); 211 | } 212 | 213 | key = parseThemeKey(decl.value); 214 | } 215 | 216 | if (decl.value.match(/@theme/g) || decl.value.match(/theme\s+\(['"]/g)) { 217 | throw decl.error(`Invalid theme usage: ${decl.value}`, { 218 | word: decl.value, 219 | }); 220 | } 221 | }); 222 | }); 223 | 224 | // 2. Create variable declaration blocks 225 | const filterUsed = ( 226 | colorScheme: ColorScheme, 227 | theme: string | LightDarkTheme, 228 | filterFunction = (name: string) => usage.has(name) 229 | ): SimpleTheme => { 230 | const themeConfig = 231 | typeof theme === 'string' ? componentConfig[theme] : theme; 232 | const currentThemeConfig = themeConfig[colorScheme]; 233 | const usedVariables: SimpleTheme = {}; 234 | 235 | Array.from(usage.keys()).forEach((key) => { 236 | const value = get(currentThemeConfig, key); 237 | 238 | if (value && filterFunction(key) && typeof value !== 'object') { 239 | // If the dark and light theme have the same value don't include 240 | if (theme === defaultTheme) { 241 | if (colorScheme === 'dark' && get(themeConfig.light, key) === value) { 242 | return; 243 | } 244 | } 245 | 246 | // If the theme value matches the base theme don't include 247 | if ( 248 | defaultThemeConfig && 249 | typeof theme === 'string' && 250 | theme !== defaultTheme && 251 | get(defaultThemeConfig[1][colorScheme], key) === value 252 | ) { 253 | return; 254 | } 255 | 256 | set(usedVariables, key, value); 257 | } 258 | }); 259 | 260 | return usedVariables; 261 | }; 262 | 263 | const addRootTheme = (themeConfig: LightDarkTheme) => { 264 | // If inlineRootThemeVariables then only add vars to root that are used more than once 265 | const func = inlineRootThemeVariables 266 | ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 267 | (name: string) => usage.has(name) && usage.get(name)! > 1 268 | : undefined; 269 | 270 | return createModernTheme( 271 | ':root', 272 | filterUsed('light', themeConfig, func), 273 | localize 274 | ); 275 | }; 276 | 277 | // 2a. If generating a single theme, simply generate the default 278 | if (singleTheme) { 279 | const rules: (postcss.Rule | undefined)[] = []; 280 | const rootRules = addRootTheme(mergedSingleThemeConfig); 281 | 282 | if (hasMergedDarkMode) { 283 | rules.push( 284 | createModernTheme( 285 | darkClass, 286 | filterUsed('dark', mergedSingleThemeConfig), 287 | localize 288 | ) 289 | ); 290 | rules.push(rootRules); 291 | } 292 | 293 | if (!optimizeSingleTheme) { 294 | rules.push(rootRules); 295 | } 296 | 297 | root.append(...rules.filter((x): x is postcss.Rule => Boolean(x))); 298 | return; 299 | } 300 | 301 | const rules: (postcss.Rule | undefined)[] = []; 302 | 303 | // 2b. Under normal operation, generate CSS variable blocks for each theme 304 | Object.entries(componentConfig).forEach(([theme, themeConfig]) => { 305 | if (theme === defaultTheme) { 306 | rules.push(addRootTheme(themeConfig)); 307 | rules.push( 308 | createModernTheme(darkClass, filterUsed('dark', defaultTheme), localize) 309 | ); 310 | } else if (hasDarkMode(themeConfig)) { 311 | rules.push( 312 | createModernTheme( 313 | `.${theme}${lightClass}`, 314 | filterUsed('light', theme), 315 | localize 316 | ), 317 | createModernTheme( 318 | `.${theme}${darkClass}`, 319 | filterUsed('dark', theme), 320 | localize 321 | ) 322 | ); 323 | } else { 324 | rules.push( 325 | createModernTheme( 326 | hasRootDarkMode ? `.${theme}${lightClass}` : `.${theme}`, 327 | filterUsed('light', theme), 328 | localize 329 | ) 330 | ); 331 | } 332 | }); 333 | 334 | const definedRules: postcss.Rule[] = []; 335 | 336 | rules.forEach((rule) => { 337 | if (!rule) { 338 | return; 339 | } 340 | 341 | const defined = definedRules.find( 342 | (definedRule) => 343 | declarationsAsString(definedRule) === declarationsAsString(rule) 344 | ); 345 | 346 | if (defined) { 347 | defined.selector += `,${rule.selector}`; 348 | } else { 349 | definedRules.push(rule); 350 | } 351 | }); 352 | 353 | root.append(...definedRules); 354 | }; 355 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface ThemeObject { 2 | [key: string]: string | ThemeObject; 3 | } 4 | export type SimpleTheme = Omit & { 5 | extends?: string; 6 | }; 7 | export type ColorScheme = 'light' | 'dark'; 8 | export type LightDarkTheme = Record & { 9 | extends?: string; 10 | }; 11 | export type Theme = SimpleTheme | LightDarkTheme; 12 | 13 | export interface Config { 14 | [theme: string]: T; 15 | } 16 | 17 | export type PostcssThemeConfig = Config; 18 | export type PostcssStrictThemeConfig = Config; 19 | 20 | export type ComponentTheme = (theme: PostcssThemeConfig) => PostcssThemeConfig; 21 | export type ThemeResolver = (path: string) => ComponentTheme; 22 | 23 | export type ScopedNameFunction = ( 24 | name: string, 25 | filename: string, 26 | css: string 27 | ) => string; 28 | 29 | export interface PostcssThemeOptions { 30 | /** Configuration given to the postcss plugin */ 31 | config?: PostcssThemeConfig; 32 | /** Class to apply to light theme overrides */ 33 | lightClass?: string; 34 | /** Class to apply to dark theme overrides */ 35 | darkClass?: string; 36 | /** A function to resolve the theme file */ 37 | resolveTheme?: ThemeResolver; 38 | /** LEGACY - Put empty selectors in final output */ 39 | forceEmptyThemeSelectors?: boolean; 40 | /** The name of the default theme */ 41 | defaultTheme?: string; 42 | /** Attempt to substitute only a single theme */ 43 | forceSingleTheme?: string; 44 | /** Remove CSS Variables when possible */ 45 | optimizeSingleTheme?: boolean; 46 | /** Whether to include custom variable default values. Defaults to true. */ 47 | inlineRootThemeVariables?: boolean; 48 | /** Transform CSS variable names similar to CSS-Modules */ 49 | modules?: string | ScopedNameFunction; 50 | } 51 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/index.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "lib": ["esnext"], 5 | "esModuleInterop": true, 6 | "outDir": "dist", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "declaration": true, 11 | "noUnusedLocals": true, 12 | "preserveConstEnums": true, 13 | "removeComments": false, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "es2017" 17 | } 18 | } 19 | --------------------------------------------------------------------------------