├── .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 |
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 |
--------------------------------------------------------------------------------