├── .github ├── CONTRIBUTING.md └── workflows │ ├── benchmarks.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── benchmark ├── compare.js ├── performance.js └── size.js ├── configs ├── .babelrc ├── .eslintrc ├── .flowconfig └── .prettierignore ├── package-lock.json ├── package.json ├── src └── styleq.js ├── styleq.d.ts ├── styleq.js ├── styleq.js.flow └── test ├── styleq-transform.test.js └── styleq.test.js /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Reporting Issues and Asking Questions 4 | 5 | Before opening an issue, please search the issue tracker to make sure your issue hasn't already been reported. Please note that your issue may be closed if it doesn't include the information requested in the issue template. 6 | 7 | ## Getting started 8 | 9 | Visit the issue tracker to find a list of open issues that need attention. 10 | 11 | Fork, then clone the repo: 12 | 13 | ``` 14 | git clone https://github.com/your-username/styleq.git 15 | ``` 16 | 17 | Make sure you have npm@>=7 and node@>=0.12.15 installed. Then install the package dependencies: 18 | 19 | ``` 20 | npm install 21 | ``` 22 | 23 | ## Automated tests 24 | 25 | To run the linter: 26 | 27 | ``` 28 | npm run lint 29 | ``` 30 | 31 | To run flow: 32 | 33 | ``` 34 | npm run flow 35 | ``` 36 | 37 | To run all the unit tests: 38 | 39 | ``` 40 | npm run tests 41 | ``` 42 | 43 | …in watch mode: 44 | 45 | ``` 46 | npm run tests -- --watch 47 | ``` 48 | 49 | ## Compile and build 50 | 51 | To compile the source code: 52 | 53 | ``` 54 | npm run build 55 | ``` 56 | 57 | ### New Features 58 | 59 | Please open an issue with a proposal for a new feature or refactoring before starting on the work. We don't want you to waste your efforts on a pull request that we won't want to accept. 60 | 61 | ## Pull requests 62 | 63 | **Before submitting a pull request**, please make sure the following is done: 64 | 65 | 1. Fork the repository and create your branch from `main`. 66 | 2. If you've added code that should be tested, add tests! 67 | 3. If you've changed APIs, update the documentation. 68 | 4. Ensure the tests pass (`npm run test`). 69 | 70 | You can now submit a pull request, referencing any issues it addresses. 71 | 72 | Please try to keep your pull request focused in scope and avoid including unrelated commits. 73 | 74 | After you have submitted your pull request, we'll try to get back to you as soon as possible. We may suggest some changes or improvements. 75 | 76 | Thank you for contributing! 77 | -------------------------------------------------------------------------------- /.github/workflows/benchmarks.yml: -------------------------------------------------------------------------------- 1 | name: benchmarks 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | size: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | with: 11 | fetch-depth: 50 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: '20.x' 15 | - name: 'Setup temporary files' 16 | run: | 17 | echo "BASE_JSON=$(mktemp)" >> $GITHUB_ENV 18 | echo "PATCH_JSON=$(mktemp)" >> $GITHUB_ENV 19 | - name: 'Benchmark base' 20 | run: | 21 | git checkout -f ${{ github.event.pull_request.base.sha }} 22 | npm install --ignore-scripts --loglevel error 23 | npm run build 24 | if npm run benchmark:size -- -o ${{ env.BASE_JSON }}; then 25 | echo "Ran successfully on base branch" 26 | else 27 | echo "{}" > ${{ env.BASE_JSON }} # Empty JSON as default 28 | echo "Benchmark script not found on base branch, using default values" 29 | fi 30 | - name: 'Benchmark patch' 31 | run: | 32 | git checkout -f ${{ github.event.pull_request.head.sha }} 33 | npm install --ignore-scripts --loglevel error 34 | npm run benchmark:size -- -o ${{ env.PATCH_JSON }} 35 | echo "Ran successfully on patch branch" 36 | - name: 'Collect results' 37 | id: collect 38 | run: | 39 | echo "table<> $GITHUB_OUTPUT 40 | npm run benchmark:compare -- ${{ env.BASE_JSON }} ${{ env.PATCH_JSON }} >> markdown 41 | cat markdown >> $GITHUB_OUTPUT 42 | echo "EOF" >> $GITHUB_OUTPUT 43 | - name: 'Post comment' 44 | uses: edumserrano/find-create-or-update-comment@v3 45 | with: 46 | issue-number: ${{ github.event.pull_request.number }} 47 | body-includes: '' 48 | comment-author: 'github-actions[bot]' 49 | body: | 50 | 51 | ### workflow: benchmarks/size 52 | Comparison of minified (terser) and compressed (brotli) size results, measured in bytes. Smaller is better. 53 | ${{ steps.collect.outputs.table }} 54 | edit-mode: replace 55 | 56 | perf: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | fetch-depth: 50 62 | - uses: actions/setup-node@v4 63 | with: 64 | node-version: '20.x' 65 | - name: 'Setup temporary files' 66 | run: | 67 | echo "BASE_JSON=$(mktemp)" >> $GITHUB_ENV 68 | echo "PATCH_JSON=$(mktemp)" >> $GITHUB_ENV 69 | - name: 'Benchmark base' 70 | run: | 71 | git checkout -f ${{ github.event.pull_request.base.sha }} 72 | npm install --ignore-scripts --loglevel error 73 | npm run build 74 | if npm run benchmark:perf -- -o ${{ env.BASE_JSON }}; then 75 | echo "Ran successfully on base branch" 76 | else 77 | echo "{}" > ${{ env.BASE_JSON }} # Empty JSON as default 78 | echo "Benchmark script not found on base branch, using default values" 79 | fi 80 | - name: 'Benchmark patch' 81 | run: | 82 | git checkout -f ${{ github.event.pull_request.head.sha }} 83 | npm install --ignore-scripts --loglevel error 84 | npm run benchmark:perf -- -o ${{ env.PATCH_JSON }} 85 | echo "Ran successfully on patch branch" 86 | - name: 'Collect results' 87 | id: collect 88 | run: | 89 | echo "table<> $GITHUB_OUTPUT 90 | npm run benchmark:compare -- ${{ env.BASE_JSON }} ${{ env.PATCH_JSON }} >> markdown 91 | cat markdown >> $GITHUB_OUTPUT 92 | echo "EOF" >> $GITHUB_OUTPUT 93 | - name: 'Post comment' 94 | uses: edumserrano/find-create-or-update-comment@v3 95 | with: 96 | issue-number: ${{ github.event.pull_request.number }} 97 | body-includes: '' 98 | comment-author: 'github-actions[bot]' 99 | body: | 100 | 101 | ### workflow: benchmarks/perf 102 | Comparison of performance test results, measured in operations per second. Larger is better. 103 | ${{ steps.collect.outputs.table }} 104 | edit-mode: replace 105 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | # Type checks 12 | 13 | flow: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '20.x' 20 | - run: npm install 21 | - run: npm run flow 22 | 23 | # Code format 24 | 25 | format: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version: '20.x' 32 | - run: npm install 33 | - run: npm run prettier:report 34 | 35 | lint: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-node@v4 40 | with: 41 | node-version: '20.x' 42 | - run: npm install 43 | - run: npm run lint:report 44 | 45 | # Unit tests 46 | 47 | test: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: actions/setup-node@v4 52 | with: 53 | node-version: '20.x' 54 | - run: npm install 55 | - run: npm run jest 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | logs 4 | node_modules 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Nicolas Gallagher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # styleQ · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/necolas/styleq/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/styleq.svg?style=flat)](https://www.npmjs.com/package/styleq) [![Build Status](https://github.com/necolas/styleq/workflows/tests/badge.svg)](https://github.com/necolas/styleq/actions) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/necolas/styleq/blob/master/.github/CONTRIBUTING.md) 2 | 3 | **styleQ** is a quick, small JavaScript runtime for merging the HTML class names produced by CSS compilers. 4 | 5 | * High performance merging for initial render. 6 | * Built-in memoization for updates. 7 | * Merges static and dynamic styles. 8 | * Supports various CSS compiler designs. 9 | * 0.7 KB gzipped runtime. 10 | 11 | ## Use 12 | 13 | Install: 14 | 15 | ``` 16 | npm install styleq 17 | ``` 18 | 19 | Import: 20 | 21 | ```js 22 | import { styleq } from 'styleq'; 23 | ``` 24 | 25 | ## API 26 | 27 | ### styleq(...styles) 28 | 29 | Merges style objects and produces a DOM `className` string and inline `style` object (camelCase property names). 30 | 31 | ```js 32 | const [ className, inlineStyle ] = styleq(styles.root, { opacity }); 33 | ``` 34 | 35 | The `styleq` function efficiently merges deeply nested arrays of both extracted and inline style objects. 36 | 37 | * Compiled styles must set the `$$css` property to `true` for production. And may be a string for development. 38 | * Any style object without the `$$css` property is treated as an **inline style**. 39 | * Compiled styles must be static for best performance. 40 | * Compiled style object keys do not need to match CSS property names; any string is allowed. 41 | * Compiled style object values must be an **HTML class string**. 42 | 43 | ```js 44 | /* Generated output */ 45 | 46 | const styles = { 47 | root: { 48 | // Needed by the runtime 49 | $$css: true, 50 | // Atomic CSS classes 51 | display: 'display-flex-class', 52 | alignItems: 'alignItems-center-class' 53 | }, 54 | other: { 55 | // String values for $$css are concatenated and can be used for dev debugging. 56 | // Compilers are encouraged to provide file and line info. 57 | $$css: 'path/to/file:10', 58 | // Atomic CSS classes 59 | display: 'display-flex-class', 60 | alignItems: 'alignItems-center-class' 61 | }, 62 | }; 63 | 64 | const [ className, inlineStyle, dataStyleSrc ] = styleq(styles.root, props.style); 65 | ``` 66 | 67 | ### styleq.factory(options) => styleq 68 | 69 | A factory for creating custom merging functions, tailored to the design of specific compilers. 70 | 71 | ```js 72 | const compilerStyleq = styleq.factory(options); 73 | ``` 74 | 75 | Options are used to configure the merging function. 76 | 77 | ```js 78 | type Options = { 79 | // control memoization 80 | disableCache: boolean = false; 81 | // control className/style merge strategy 82 | disableMix: boolean = false; 83 | // transform individual styles at runtime before merging 84 | transform: ?(style) => compiledStyle; 85 | } 86 | ``` 87 | 88 | #### `disableCache` 89 | 90 | **Memoization is enabled by default**. This option can be used to disable it. Memoization relies on a tree of WeakMaps keyed on static compiled styles. This allows the runtime to efficiently store chunks of merged styles, making re-computation very cheap. However, checking the WeakMap for memoized data when there is none significantly adds to the cost of initially computing the result. Therefore, if initial computations need to be as fast as possible (e.g., your use case involves few repeat merges), memoization should be disabled. 91 | 92 | ```js 93 | const styleqNoCache = styleq.factory({ disableCache: true }); 94 | ``` 95 | 96 | #### `disableMix` 97 | 98 | **Inline styles are merged together with static styles by default**, but can be merged independently if preferred. Both static and inline styles can be passed to `styleq` for merging. By default, the properties defined by static and inline styles are merged together. The performance of this option is still excellent, but merging with inline styles often means memoization cannot be used as effectively. In certain circumstances, this merging strategy can result in better performance, as the deduplication of styles can reduce the number of CSS rules applied to the element (which improves browser layout times). 99 | 100 | If mixing is diabled, the static and inline styles will be treated as values for different attributes: either `className` OR `style` respectively. If an inline style sets a property that is later set by a static style, *both* the static class name and dynamic style property will be set. In practice this means that inline style declarations override those of static styles, whatever their position in the styles array passed to `styleq`. Therefore, memoization of class name merges is not changed by inline styles, and so provides the best general performance. 101 | 102 | ```js 103 | const styleqNoMix = styleq.factory({ disableMix: true }); 104 | ``` 105 | 106 | #### `transform` 107 | 108 | **Styles can be transformed before merging** by using the `transform` function. The runtime loop is extremely performance sensitive as class name merges can happen 1000s of times during a screen render, whether on the server or client. The `transform` function is used to change style objects before styleQ merges them. For example, if a compiler needs runtime information before selecting a compiled style. 109 | 110 | ```js 111 | // compiler/useStyleq 112 | import { styleq } from 'styleq'; 113 | import { localizeExtractedStyle } from './localizeExtractedStyle'; 114 | import { useLocaleContext } from './useLocaleContext' 115 | import { useMemo } from 'react'; 116 | 117 | export function useStyleq(styles) { 118 | // Runtime context provides subtree writing direction 119 | const { direction } = useLocaleContext(); 120 | const isRTL = direction === 'rtl'; 121 | // Create a custom styleq for localization transform 122 | const styleqWithPolyfills = useMemo( 123 | () => styleq.factory({ 124 | transform(style) { 125 | // Memoize results in the transform 126 | return localizeExtractedStyle(style, isRTL); 127 | } 128 | }), 129 | [ isRTL ] 130 | ); 131 | const styleProps = styleqWithPolyfills(styles); 132 | // Add vendor prefixes to inline styles 133 | if (styleProps[1]) { 134 | styleProps[1] = prefixAll(styleProps[1]); 135 | } 136 | return styleProps; 137 | } 138 | ``` 139 | 140 | WARNING: Transforming compiled styles to support runtime dynamism is possible without negatively effecting performance, however, transforms must be done carefully to avoid creating merge operations that cannot be efficiently memoized. `WeakMap` is recommended for memoizing the result of transforms, so that static objects are passed to styleq. 141 | 142 | ## Notes for compiler authors 143 | 144 | CSS compilers implementing different styling models can all target styleQ to deliver excellent runtime performance. styleQ can be used at build time and runtime (for server and client) to generate `className` and `style` values. 145 | 146 | Examples of how various compiler features and designs can supported with styleQ are discussed below. 147 | 148 | * [Supporting zero-conflict styles](#supporting-zero-conflict-styles) 149 | * [Supporting arbitrary selectors](#supporting-arbitrary-selectors) 150 | * [Supporting high-performance layouts](#supporting-high-performance-layouts) 151 | * [Supporting high-performance inline styles](#supporting-high-performance-inline-styles) 152 | * [Supporting themes](#supporting-themes) 153 | * [Polyfilling logical properties and values](#polyfilling-logical-properties-and-values) 154 | * [Implementing utility styles](#implementing-utility-styles) 155 | 156 | ### Supporting zero-conflict styles 157 | 158 | Zero-conflict styles provide developers with guarantees that component style is encapsulated and not implicitly altered by styles defined by other components. A compiler designed around zero-conflict styles will generally output "atomic CSS" and produce smaller CSS style sheets that avoid all specificity and source order conflicts. 159 | 160 | Typically, a zero-conflict design involves excluding support for descendant selectors (i.e., any selector that targets an element other than the element receiving the class name). And shortform properties are either disallowed, restricted, or automatically expanded to longform properties. If pseudo-classes (e.g., `:focus`) are supported, the compiler must guarantee the order of precedence between pseudo-classes in the CSS style sheet (e.g., `:focus` rules appear before `:active` rules). If Media Queries are supported, they too must be carefully ordered. 161 | 162 | Input: 163 | 164 | ```js 165 | import * as compiler from 'compiler'; 166 | 167 | const styles = compiler.create({ 168 | root: { 169 | margin: 10, 170 | opacity: 0.7, 171 | ':focus': { 172 | opacity: 0.8 173 | }, 174 | ':active': { 175 | opacity: 1.0 176 | } 177 | } 178 | }); 179 | ``` 180 | 181 | Output: 182 | 183 | ```js 184 | insertOrExtract('.margin-left-10 { margin-left:10px; }', 0); 185 | insertOrExtract('.margin-top-10 { margin-top:10px; }', 0); 186 | insertOrExtract('.margin-right-10 { margin-right:10px; }', 0); 187 | insertOrExtract('.margin-bottom-10 { margin-bottom:10px; }', 0); 188 | insertOrExtract('.opacity-07 { opacity:0.7; }', 0); 189 | // Pseudo-class insertion order is after class selector rules 190 | insertOrExtract('.focus-opacity-08:focus { opacity:0.8; }', 1.0); 191 | insertOrExtract('.active-opacity-1:active { opacity:1; }', 1.1); 192 | 193 | const styles = { 194 | root: { 195 | $$css: true, 196 | marginLeft: 'margin-left-10', 197 | marginTop: 'margin-top-10', 198 | marginRight: 'margin-right-10', 199 | marginBottom: 'margin-bottom-10', 200 | opacity: 'opacity-07', 201 | __focus$opacity: 'focus-opacity-08', 202 | __active$opacity: 'active-opacity-1' 203 | } 204 | }; 205 | ``` 206 | 207 | ### Supporting arbitrary selectors 208 | 209 | The runtime can be used by compilers that support arbitrary selectors, e.g., by concatenating (hashing, etc.) the selector string and property to create a unique key for that selector-property combination. (Note that supporting arbitrary CSS selectors trades flexibility for zero-conflict styles.) 210 | 211 | Input: 212 | 213 | ```js 214 | import * as compiler from 'compiler'; 215 | 216 | const styles = compiler.create({ 217 | root: { 218 | ':focus a[data-prop]': { 219 | opacity: 1 220 | } 221 | } 222 | }); 223 | ``` 224 | 225 | Output: 226 | 227 | ```js 228 | insertOrExtract('.xjrodmsp-opacity-1:focus a[data-prop] { opacity:1.0; }'); 229 | 230 | const styles = { 231 | root: { 232 | $$css: true, 233 | '__xjrodmsp-opacity-1': 'xjrodmsp-opacity-1' 234 | } 235 | }; 236 | ``` 237 | 238 | ### Supporting high-performance layouts 239 | 240 | Atomic CSS has tradeoffs. Once an element has many HTML class names each pointing to different CSS rules, browser layout times slow down. In some cases, compilers may choose to flatten multiple declarations into "traditional" CSS. For example, a component library may optimize the "reset" styles for its core components by flattening those styles, and then inserting those rules into the CSS style sheet before all the atomic CSS. That way atomic CSS will always override the reset rules, and the layout performance of the core components will be significantly improved. 241 | 242 | Input: 243 | 244 | ```jsx 245 | import { createResetStyle } from 'compiler'; 246 | 247 | function View(props) { 248 | return ( 249 |
250 | ); 251 | } 252 | 253 | const reset = createResetStyle({ 254 | display: 'flex', 255 | alignItems: 'stretch', 256 | flexDirection: 'row', 257 | ... 258 | }); 259 | ``` 260 | 261 | Output: 262 | 263 | ```jsx 264 | import { styleq } from 'compiler/styleq'; 265 | 266 | // Compiler inserts Reset CSS rules before Atomic CSS rules. 267 | insertOrExtract('.reset- { display:flex; align-items:stretch; flex-direction:row', 0); 268 | 269 | function View(props) { 270 | const [ className, inlineStyle ] = styleq(reset, props.css); 271 | return ( 272 |
273 | ); 274 | } 275 | 276 | const reset = { 277 | $$css: true, 278 | // Compiler decides that only one reset is allowed per element. 279 | // Each reset rule created is set to the '__reset' key. 280 | __reset: 'reset-', 281 | }; 282 | ``` 283 | 284 | ### Supporting high-performance inline styles 285 | 286 | A compiler may provide a single API for defining static and dynamic values, and maximize the number of compiled styles by replacing dynamic values with unique CSS custom properties that are then set by inline styles. This compiler design decouples static and inline property merges, and makes the best use of runtime memoization. 287 | 288 | Input: 289 | 290 | ```jsx 291 | // @jsx createElement 292 | import { createElement } from 'compiler'; 293 | 294 | function Fade(props) { 295 | return ( 296 |
306 | ); 307 | } 308 | ``` 309 | 310 | Output: 311 | 312 | ```jsx 313 | // Custom styleq with mixing disabled 314 | import { customStyleq } from 'compiler/customStyleq'; 315 | 316 | // The opacity value is a unique CSS custom property 317 | insertOrExtract('.backgroundColor-blue { background-color:blue; }'); 318 | insertOrExtract('.opacity-var-xyz { opacity:var(--opacity-xyz); }'); 319 | 320 | // A compiled style is generated, including the 'opacity' property 321 | const compiledStyle = { 322 | $$css: true, 323 | backgroundColor: 'backgroundColor-blue', 324 | opacity: 'opacity-var-xyz' 325 | }; 326 | 327 | function Fade(props) { 328 | const [ className, style ] = customStyleq( 329 | // The dynamic value is set to the custom property. 330 | // With static/dynamic mixing disabled, the position of the inline style 331 | // is irrelevant. However, with mixing enabled, the best performance is 332 | // achieved by placing inline styles earlier in the queue. 333 | { '--opacity-xyz': props.opacity }, 334 | compiledStyle, 335 | props.css 336 | ); 337 | 338 | return ( 339 |
344 | ); 345 | } 346 | ``` 347 | 348 | 349 | ### Supporting themes 350 | 351 | Compilers implementing themes via CSS custom properties should avoid creating atomic CSS rules for each theme property. As mentioned above, this can slow down browser layout and flattening theme styles into a single rule is preferred. Theme classes can be deduplicated by using the same key for all themes in the generated style object. 352 | 353 | Input: 354 | 355 | ```js 356 | import * as compiler from 'compiler'; 357 | 358 | const [themeVars, themeStyle] = compiler.createDefaultTheme({ 359 | color: { 360 | primary: '#fff', 361 | secondary: '#f5d90a' 362 | ... 363 | }, 364 | space: {}, 365 | size: {} 366 | }); 367 | 368 | const className = compiler.merge(themeStyle, props.style); 369 | ``` 370 | 371 | Output: 372 | 373 | ```js 374 | import { styleq } from 'compiler/styleq'; 375 | 376 | insertOrExtract( 377 | ':root, .theme-default { --theme-default-color-primary:#fff; --theme-default-color-secondary:#f5d90a; }' 378 | ); 379 | 380 | const themeVars = { 381 | color: { 382 | primary: 'var(--theme-default-color-primary)', 383 | secondary: 'var(--theme-default-color-secondary)' 384 | ... 385 | } 386 | }; 387 | 388 | const themeStyle = { 389 | $$css: true, 390 | __theme: 'theme-default' 391 | }; 392 | 393 | const [ className ] = styleq(themeStyle, props.style); 394 | ``` 395 | 396 | ### Polyfilling features at runtime 397 | 398 | A compiler might want to provide polyfills or other runtime transforms, e.g., [CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties). Using the `transform` option is one way to implement this kind of functionality. 399 | 400 | Input: 401 | 402 | ```js 403 | import { StyleSheet } from 'compiler'; 404 | 405 | function Box() { 406 | return
407 | } 408 | 409 | const styles = StyleSheet.create({ 410 | root: { 411 | float: 'inline-start', 412 | } 413 | }); 414 | ``` 415 | 416 | Output: 417 | 418 | ```js 419 | // See the 'useStyleq' example in the API docs above 420 | import { useStyleq } from 'compiler/useStyleq'; 421 | 422 | insertOrExtract('.float-left { float:left; }'); 423 | insertOrExtract('.float-right { float:right; }'); 424 | 425 | function Box() { 426 | const [ className ] = useStyleq(styles.view, styles.root); 427 | return
428 | } 429 | 430 | const styles = { 431 | root: { 432 | $$css: true, 433 | // Compiler defines a custom key to mark this style object 434 | // for processessing by the localized transform. 435 | $$css$localize: true, 436 | // [ LTR, RTL ] 437 | float: [ 'float-left', 'float-right' ] 438 | } 439 | } 440 | ``` 441 | 442 | In this case, `useStyleq` is a function defined by the compiler which transforms `$$css` styles into the correct class name for a given writing direction. Take care to memoize the transform so that the same result is always used in merges. For example: 443 | 444 | ```js 445 | const cache = new WeakMap(); 446 | const markerProp = '$$css$localize'; 447 | 448 | /** 449 | * The compiler polyfills logical properties and values, generating a class 450 | * name for both writing directions. The style objects are annotated by 451 | * the compiler as needing this runtime transform. The results are memoized. 452 | * 453 | * { '$$css$localize': true, float: [ 'float-left', 'float-right' ] } 454 | * => { float: 'float-left' } 455 | */ 456 | 457 | function compileStyle(style, isRTL) { 458 | // Create a new compiled style for styleq 459 | const compiledStyle = {}; 460 | for (const prop in style) { 461 | if (prop !== markerProp) { 462 | const value = style[prop]; 463 | if (Array.isArray(value)) { 464 | compiledStyle[prop] = isRTL ? value[1] : value[0]; 465 | } else { 466 | compiledStyle[prop] = value; 467 | } 468 | } 469 | } 470 | return compiledStyle; 471 | } 472 | export function localizeStyle(style, isRTL) { 473 | if (style[markerProp] != null) { 474 | const compiledStyleIndex = isRTL ? 1 : 0; 475 | // Check the cache in case we've already seen this object 476 | if (cache.has(style)) { 477 | const cachedStyles = cache.get(style); 478 | let compiledStyle = cachedStyles[compiledStyleIndex]; 479 | if (compiledStyle == null) { 480 | // Update the missing cache entry 481 | compiledStyle = compileStyle(style, isRTL); 482 | cachedStyles[compiledStyleIndex] = compiledStyle; 483 | cache.set(style, cachedStyles); 484 | } 485 | return compiledStyle; 486 | } 487 | 488 | // Create a new compiled style for styleq 489 | const compiledStyle = compileStyle(style, isRTL); 490 | const cachedStyles = new Array(2); 491 | cachedStyles[compiledStyleIndex] = compiledStyle; 492 | cache.set(style, cachedStyles); 493 | return compiledStyle; 494 | } 495 | return style; 496 | } 497 | ``` 498 | 499 | ### Implementing utility styles 500 | 501 | Compilers that produce "utility" CSS rules can use styleQ to dedupe utilities across categories, i.e., higher-level styling abstractions such as "size", "spacing", "color scheme", etc. The keys of extracted styles can match the utility categories. 502 | 503 | Input: 504 | 505 | ```js 506 | import { oocss } from 'compiler'; 507 | 508 | const View = (props) => ( 509 | // This compiler targets strings for named "utilities" 510 |
511 | ); 512 | 513 | const StyledView = (props) => ( 514 | 515 | ); 516 | ``` 517 | 518 | Output: 519 | 520 | ```js 521 | import { styleq } from 'styleq'; 522 | 523 | insertOrExtract('.cs-1 { --primary-color:#000; --secondary-color:#eee }', 2); 524 | insertOrExtract('.cs-2 { --primary-color:#fff; --secondary-color:#333 }', 2); 525 | insertOrExtract('.p-1 { padding:10px }', 0); 526 | insertOrExtract('.p-2 { padding:20px }', 0); 527 | insertOrExtract('.s-1 { height:100px; width:100px }', 1); 528 | 529 | // Each utility class is categorized. For example, only a single 'colorScheme' 530 | // rule will be applied to each element. 531 | const oocss1 = { 532 | $$css: true, 533 | __cs: 'cs-1', 534 | __p: 'p-1', 535 | __s: 's-1' 536 | }; 537 | 538 | const View = (props) => ( 539 |
540 | ); 541 | 542 | const oocss2 = { 543 | $$css: true, 544 | __cs: 'cs-2', 545 | __p: 'p-2' 546 | } 547 | 548 | const StyledView = (props) => ( 549 | 550 | ); 551 | ``` 552 | 553 | ## License 554 | 555 | styleq is [MIT licensed](./LICENSE). 556 | -------------------------------------------------------------------------------- /benchmark/compare.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require('fs'); 11 | 12 | function readJsonFile(filePath) { 13 | try { 14 | const fileContents = fs.readFileSync(filePath, 'utf8'); 15 | const data = JSON.parse(fileContents); 16 | return data; 17 | } catch (error) { 18 | console.error(`Error reading file ${filePath}:`, error); 19 | return null; 20 | } 21 | } 22 | 23 | function mergeData(base, patch) { 24 | const merged = {}; 25 | function addToMerged(data, fileIndex) { 26 | Object.keys(data).forEach((key) => { 27 | if (merged[key] == null) { 28 | merged[key] = {}; 29 | } 30 | Object.keys(data[key]).forEach((subKey) => { 31 | if (merged[key][subKey] == null) { 32 | merged[key][subKey] = {}; 33 | } 34 | merged[key][subKey][fileIndex] = data[key][subKey]; 35 | }); 36 | }); 37 | } 38 | if (base != null) { 39 | addToMerged(base, 1); 40 | } 41 | if (patch != null) { 42 | addToMerged(patch, 2); 43 | } 44 | return merged; 45 | } 46 | 47 | function generateComparisonData(results) { 48 | const baseResult = parseInt(results[1], 10); 49 | const patchResult = parseInt(results[2], 10); 50 | const isValidBase = !isNaN(baseResult); 51 | const isValidPatch = !isNaN(patchResult); 52 | let icon = '', 53 | ratioFixed = ''; 54 | 55 | if (isValidBase && isValidPatch) { 56 | const ratio = patchResult / baseResult; 57 | ratioFixed = ratio.toFixed(2); 58 | if (ratio < 0.95 || ratio > 1.05) { 59 | icon = '**!!**'; 60 | } else if (ratio < 1) { 61 | icon = '-'; 62 | } else if (ratio > 1) { 63 | icon = '+'; 64 | } 65 | } 66 | 67 | return { 68 | baseResult: isValidBase ? baseResult.toLocaleString() : '', 69 | patchResult: isValidPatch ? patchResult.toLocaleString() : '', 70 | ratio: ratioFixed, 71 | icon 72 | }; 73 | } 74 | 75 | function generateMarkdownTable(mergedData) { 76 | const rows = []; 77 | rows.push('| **Results** | **Base** | **Patch** | **Ratio** | |'); 78 | rows.push('| :--- | ---: | ---: | ---: | ---: |'); 79 | Object.keys(mergedData).forEach((suiteName) => { 80 | rows.push('| | | | |'); 81 | rows.push(`| **${suiteName}** | | | | |`); 82 | Object.keys(mergedData[suiteName]).forEach((test) => { 83 | const results = mergedData[suiteName][test]; 84 | const { baseResult, patchResult, ratio, icon } = 85 | generateComparisonData(results); 86 | rows.push( 87 | `| · ${test} | ${baseResult} | ${patchResult} | ${ratio} | ${icon} |` 88 | ); 89 | }); 90 | }); 91 | return rows.join('\n'); 92 | } 93 | 94 | /** 95 | * Compare up to 2 different benchmark runs 96 | */ 97 | const args = process.argv.slice(2); 98 | const baseResults = args[0] ? readJsonFile(args[0]) : null; 99 | const patchResults = args[1] ? readJsonFile(args[1]) : null; 100 | const mergedData = mergeData(baseResults, patchResults); 101 | const markdownTable = generateMarkdownTable(mergedData); 102 | 103 | console.log(markdownTable); 104 | -------------------------------------------------------------------------------- /benchmark/performance.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const fs = require('fs'); 11 | const Benchmark = require('benchmark'); 12 | const yargs = require('yargs/yargs'); 13 | const { hideBin } = require('yargs/helpers'); 14 | const { styleq } = require('../dist/styleq'); 15 | 16 | /** 17 | * CLI 18 | */ 19 | 20 | // run.js --outfile filename.js 21 | const argv = yargs(hideBin(process.argv)).option('outfile', { 22 | alias: 'o', 23 | type: 'string', 24 | description: 'Output file', 25 | demandOption: false 26 | }).argv; 27 | const outfile = argv.outfile; 28 | 29 | /** 30 | * Test helpers 31 | */ 32 | 33 | function createSuite(name, options) { 34 | const suite = new Benchmark.Suite(name); 35 | const test = (...args) => suite.add(...args); 36 | 37 | function jsonReporter(suite) { 38 | const benchmarks = []; 39 | 40 | suite.on('cycle', (event) => { 41 | benchmarks.push(event.target); 42 | }); 43 | 44 | suite.on('error', (event) => { 45 | throw new Error(String(event.target.error)); 46 | }); 47 | 48 | suite.on('complete', () => { 49 | const timestamp = Date.now(); 50 | const result = benchmarks.map((bench) => { 51 | if (bench.error) { 52 | return { 53 | name: bench.name, 54 | id: bench.id, 55 | error: bench.error 56 | }; 57 | } 58 | 59 | return { 60 | name: bench.name, 61 | id: bench.id, 62 | samples: bench.stats.sample.length, 63 | deviation: bench.stats.rme.toFixed(2), 64 | ops: bench.hz.toFixed(bench.hz < 100 ? 2 : 0), 65 | timestamp 66 | }; 67 | }); 68 | options.callback(result, suite.name); 69 | }); 70 | } 71 | 72 | jsonReporter(suite); 73 | return { suite, test }; 74 | } 75 | 76 | /** 77 | * Test setup 78 | */ 79 | 80 | const aggregatedResults = {}; 81 | const options = { 82 | callback(data, suiteName) { 83 | const testResults = data.reduce((acc, test) => { 84 | const { name, ops } = test; 85 | acc[name] = ops; 86 | return acc; 87 | }, {}); 88 | 89 | aggregatedResults[suiteName] = testResults; 90 | } 91 | }; 92 | 93 | console.log('Running performance benchmark, please wait...'); 94 | 95 | const { suite, test } = createSuite('styleq', options); 96 | 97 | /** 98 | * Additional test subjects 99 | */ 100 | const transformCache = new WeakMap(); 101 | const transform = (style) => { 102 | // Check the cache in case we've already seen this object 103 | if (transformCache.has(style)) { 104 | const flexStyle = transformCache.get(style); 105 | return flexStyle; 106 | } 107 | // Create a new compiled style for styleq 108 | const flexStyle = { ...style, display: 'display-flex' }; 109 | transformCache.set(style, flexStyle); 110 | return flexStyle; 111 | }; 112 | 113 | const styleqNoCache = styleq.factory({ disableCache: true }); 114 | const styleqNoMix = styleq.factory({ disableMix: true }); 115 | const styleqTransform = styleq.factory({ transform }); 116 | 117 | /** 118 | * Fixtures 119 | */ 120 | 121 | const basicStyleFixture1 = { 122 | $$css: true, 123 | backgroundColor: 'backgroundColor-1', 124 | color: 'color-1' 125 | }; 126 | 127 | const basicStyleFixture2 = { 128 | $$css: true, 129 | backgroundColor: 'backgroundColor-2', 130 | color: 'color-2' 131 | }; 132 | 133 | const bigStyleFixture = { 134 | $$css: true, 135 | backgroundColor: 'backgroundColor-3', 136 | borderColor: 'borderColor-3', 137 | borderStyle: 'borderStyle-3', 138 | borderWidth: 'borderWidth-3', 139 | boxSizing: 'boxSizing-3', 140 | display: 'display-3', 141 | listStyle: 'listStyle-3', 142 | marginBottom: 'marginBottom-3', 143 | marginInlineEnd: 'marginInlineEnd-3', 144 | marginInlineStart: 'marginInlineStart-3', 145 | marginLeft: 'marginLeft-3', 146 | marginRight: 'marginRight-3', 147 | marginTop: 'marginTop-3', 148 | paddingBottom: 'paddingBottom-3', 149 | paddingInlineEnd: 'paddingInlineEnd-3', 150 | paddingInlineStart: 'paddingInlineStart-3', 151 | paddingLeft: 'paddingLeft-3', 152 | paddingRight: 'paddingRight-3', 153 | paddingTop: 'paddingTop-3', 154 | textAlign: 'textAlign-3', 155 | textDecoration: 'textDecoration-3', 156 | whiteSpace: 'whiteSpace-3', 157 | wordWrap: 'wordWrap-3', 158 | zIndex: 'zIndex-3' 159 | }; 160 | 161 | const bigStyleWithPseudosFixture = { 162 | $$css: true, 163 | backgroundColor: 'backgroundColor-4', 164 | border: 'border-4', 165 | color: 'color-4', 166 | cursor: 'cursor-4', 167 | display: 'display-4', 168 | fontFamily: 'fontFamily-4', 169 | fontSize: 'fontSize-4', 170 | lineHeight: 'lineHeight-4', 171 | marginEnd: 'marginEnd-4', 172 | marginStart: 'marginStart-4', 173 | paddingEnd: 'paddingEnd-4', 174 | paddingStart: 'paddingStart-4', 175 | textAlign: 'textAlign-4', 176 | textDecoration: 'textDecoration-4', 177 | ':focus$color': 'focus$color-4', 178 | ':focus$textDecoration': 'focus$textDecoration-4', 179 | ':active$transform': 'active$transform-4', 180 | ':active$transition': 'active$transition-4' 181 | }; 182 | 183 | const complexNestedStyleFixture = [ 184 | bigStyleFixture, 185 | false, 186 | false, 187 | false, 188 | false, 189 | [ 190 | { 191 | $$css: true, 192 | cursor: 'cursor-a', 193 | touchAction: 'touchAction-a' 194 | }, 195 | false, 196 | { 197 | $$css: true, 198 | outline: 'outline-b' 199 | }, 200 | [ 201 | { 202 | $$css: true, 203 | cursor: 'cursor-c', 204 | touchAction: 'touchAction-c' 205 | }, 206 | false, 207 | false, 208 | { 209 | $$css: true, 210 | textDecoration: 'textDecoration-d', 211 | ':focus$textDecoration': 'focus$textDecoration-d' 212 | }, 213 | false, 214 | [ 215 | bigStyleWithPseudosFixture, 216 | { 217 | $$css: true, 218 | display: 'display-e', 219 | width: 'width-e' 220 | }, 221 | [ 222 | { 223 | $$css: true, 224 | ':active$transform': 'active$transform-f' 225 | } 226 | ] 227 | ] 228 | ] 229 | ] 230 | ]; 231 | 232 | /** 233 | * Performance tests 234 | */ 235 | 236 | // SMALL OBJECT 237 | 238 | test('small object', () => { 239 | styleq(basicStyleFixture1); 240 | }); 241 | 242 | test('small object (cache miss)', () => { 243 | styleq({ ...basicStyleFixture1 }); 244 | }); 245 | 246 | test('small object (cache disabled)', () => { 247 | styleqNoCache({ ...basicStyleFixture1 }); 248 | }); 249 | 250 | // LARGE OBJECT 251 | 252 | test('large object', () => { 253 | styleq(bigStyleFixture); 254 | }); 255 | 256 | test('large object (cache miss)', () => { 257 | styleq({ ...bigStyleFixture }); 258 | }); 259 | 260 | test('large object (cache disabled)', () => { 261 | styleqNoCache({ ...bigStyleFixture }); 262 | }); 263 | 264 | // SMALL MERGE 265 | 266 | test('small merge', () => { 267 | styleq(basicStyleFixture1, basicStyleFixture2); 268 | }); 269 | 270 | test('small merge (cache miss)', () => { 271 | styleq({ ...basicStyleFixture1 }, { ...basicStyleFixture2 }); 272 | }); 273 | 274 | test('small merge (cache disabled)', () => { 275 | styleqNoCache(basicStyleFixture1, basicStyleFixture2); 276 | }); 277 | 278 | // LARGE MERGE 279 | 280 | test('large merge', () => { 281 | styleq([complexNestedStyleFixture]); 282 | }); 283 | 284 | test('large merge (cache disabled)', () => { 285 | styleqNoCache([complexNestedStyleFixture]); 286 | }); 287 | 288 | test('large merge (transform)', () => { 289 | styleqTransform([complexNestedStyleFixture]); 290 | }); 291 | 292 | // INLINE STYLES 293 | 294 | test('small inline style', () => { 295 | styleq({ backgroundColor: 'red' }); 296 | }); 297 | 298 | test('large inline style', () => { 299 | styleq({ 300 | backgroundColor: 'red', 301 | borderColor: 'red', 302 | borderStyle: 'solid', 303 | borderWidth: '1px', 304 | boxSizing: 'border-bx', 305 | display: 'flex', 306 | listStyle: 'none', 307 | marginTop: '0', 308 | marginEnd: '0', 309 | marginBottom: '0', 310 | marginStart: '0', 311 | paddingTop: '0', 312 | paddingEnd: '0', 313 | paddingBottom: '0', 314 | paddingStart: '0', 315 | textAlign: 'start', 316 | textDecoration: 'none', 317 | whiteSpace: 'pre', 318 | zIndex: '0' 319 | }); 320 | }); 321 | 322 | test('merged inline style', () => { 323 | styleq( 324 | { 325 | backgroundColor: 'blue', 326 | borderColor: 'blue', 327 | display: 'block' 328 | }, 329 | { 330 | backgroundColor: 'red', 331 | borderColor: 'red', 332 | borderStyle: 'solid', 333 | borderWidth: '1px', 334 | boxSizing: 'border-bx', 335 | display: 'flex', 336 | listStyle: 'none', 337 | marginTop: '0', 338 | marginEnd: '0', 339 | marginBottom: '0', 340 | marginStart: '0', 341 | paddingTop: '0', 342 | paddingEnd: '0', 343 | paddingBottom: '0', 344 | paddingStart: '0', 345 | textAlign: 'start', 346 | textDecoration: 'none', 347 | whiteSpace: 'pre', 348 | zIndex: '0' 349 | } 350 | ); 351 | }); 352 | 353 | test('merged inline style (mix disabled)', () => { 354 | styleqNoMix( 355 | { 356 | backgroundColor: 'blue', 357 | borderColor: 'blue', 358 | display: 'block' 359 | }, 360 | { 361 | backgroundColor: 'red', 362 | borderColor: 'red', 363 | borderStyle: 'solid', 364 | borderWidth: '1px', 365 | boxSizing: 'border-bx', 366 | display: 'flex', 367 | listStyle: 'none', 368 | marginTop: '0', 369 | marginEnd: '0', 370 | marginBottom: '0', 371 | marginStart: '0', 372 | paddingTop: '0', 373 | paddingEnd: '0', 374 | paddingBottom: '0', 375 | paddingStart: '0', 376 | textAlign: 'start', 377 | textDecoration: 'none', 378 | whiteSpace: 'pre', 379 | zIndex: '0' 380 | } 381 | ); 382 | }); 383 | 384 | suite.run(); 385 | 386 | /** 387 | * Print results 388 | */ 389 | 390 | const aggregatedResultsString = JSON.stringify(aggregatedResults, null, 2); 391 | 392 | // Print / Write results 393 | const now = new Date(); 394 | const year = now.getFullYear(); 395 | const month = String(now.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed 396 | const day = String(now.getDate()).padStart(2, '0'); 397 | const hours = String(now.getHours()).padStart(2, '0'); 398 | const minutes = String(now.getMinutes()).padStart(2, '0'); 399 | const timestamp = `${year}${month}${day}-${hours}${minutes}`; 400 | 401 | const dirpath = `${process.cwd()}/logs`; 402 | const filepath = `${dirpath}/perf-${timestamp}.json`; 403 | if (!fs.existsSync(dirpath)) { 404 | fs.mkdirSync(dirpath); 405 | } 406 | const outpath = outfile || filepath; 407 | fs.writeFileSync(outpath, `${aggregatedResultsString}\n`); 408 | 409 | console.log(aggregatedResultsString); 410 | console.log('Results written to', outpath); 411 | -------------------------------------------------------------------------------- /benchmark/size.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | const brotliSizePkg = require('brotli-size'); 11 | const fs = require('fs'); 12 | const path = require('path'); 13 | const yargs = require('yargs/yargs'); 14 | const { hideBin } = require('yargs/helpers'); 15 | const { minify_sync } = require('terser'); 16 | 17 | // run.js --outfile filename.js 18 | const argv = yargs(hideBin(process.argv)).option('outfile', { 19 | alias: 'o', 20 | type: 'string', 21 | description: 'Output file', 22 | demandOption: false 23 | }).argv; 24 | const outfile = argv.outfile; 25 | 26 | const files = [path.join(__dirname, '../dist/styleq.js')]; 27 | 28 | console.log('Running benchmark-size, please wait...'); 29 | 30 | const sizes = files.map((file) => { 31 | const code = fs.readFileSync(file, 'utf8'); 32 | const result = minify_sync(code).code; 33 | const minified = Buffer.byteLength(result, 'utf8'); 34 | const compressed = brotliSizePkg.sync(result); 35 | return { file, compressed, minified }; 36 | }); 37 | 38 | const aggregatedResults = {}; 39 | sizes.forEach((entry) => { 40 | const { file, minified, compressed } = entry; 41 | const filename = file.split('dist/')[1]; 42 | aggregatedResults[filename] = { 43 | compressed, 44 | minified 45 | }; 46 | }); 47 | 48 | const aggregatedResultsString = JSON.stringify(aggregatedResults, null, 2); 49 | 50 | // Print / Write results 51 | const now = new Date(); 52 | const year = now.getFullYear(); 53 | const month = String(now.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed 54 | const day = String(now.getDate()).padStart(2, '0'); 55 | const hours = String(now.getHours()).padStart(2, '0'); 56 | const minutes = String(now.getMinutes()).padStart(2, '0'); 57 | const timestamp = `${year}${month}${day}-${hours}${minutes}`; 58 | 59 | const dirpath = `${process.cwd()}/logs`; 60 | const filepath = `${dirpath}/size-${timestamp}.json`; 61 | if (!fs.existsSync(dirpath)) { 62 | fs.mkdirSync(dirpath); 63 | } 64 | const outpath = outfile || filepath; 65 | fs.writeFileSync(outpath, `${aggregatedResultsString}\n`); 66 | 67 | console.log(aggregatedResultsString); 68 | console.log('Results written to', outpath); 69 | -------------------------------------------------------------------------------- /configs/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "assumptions": { 3 | "iterableIsArray": true 4 | }, 5 | "presets": [ 6 | ["@babel/preset-env", { 7 | "exclude": [ 8 | "@babel/plugin-transform-typeof-symbol" 9 | ], 10 | "targets": { 11 | "browsers": "> 0%", 12 | "esmodules": false, 13 | "ie": "11" 14 | }, 15 | "modules": "commonjs" 16 | }], 17 | "@babel/preset-flow" 18 | ], 19 | "plugins": [ 20 | "babel-plugin-syntax-hermes-parser" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /configs/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // babel parser to support ES6/7 features 3 | "parser": "hermes-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 7, 6 | "ecmaFeatures": { 7 | "experimentalObjectRestSpread": true, 8 | "jsx": true 9 | }, 10 | "requireConfigFile": false, 11 | "sourceType": "module" 12 | }, 13 | "extends": [ 14 | "plugin:ft-flow/recommended", 15 | "prettier" 16 | ], 17 | "env": { 18 | "browser": true, 19 | "es6": true, 20 | "jest": true, 21 | "node": true 22 | }, 23 | "ignorePatterns": [ 24 | "coverage/", 25 | "dist/", 26 | "logs/", 27 | "node_modules/" 28 | ], 29 | "globals": {}, 30 | "rules": { 31 | "camelcase": 0, 32 | "constructor-super": 2, 33 | "default-case": [2, { "commentPattern": "^no default$" }], 34 | "eqeqeq": [2, "allow-null"], 35 | "handle-callback-err": [2, "^(err|error)$" ], 36 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }], 37 | "no-alert": 1, 38 | "no-array-constructor": 2, 39 | "no-caller": 2, 40 | "no-case-declarations": 2, 41 | "no-class-assign": 2, 42 | "no-cond-assign": 2, 43 | "no-const-assign": 2, 44 | "no-control-regex": 2, 45 | "no-debugger": 2, 46 | "no-delete-var": 2, 47 | "no-dupe-args": 2, 48 | "no-dupe-class-members": 2, 49 | "no-dupe-keys": 2, 50 | "no-duplicate-case": 2, 51 | "no-empty-character-class": 2, 52 | "no-empty-pattern": 2, 53 | "no-eval": 2, 54 | "no-ex-assign": 2, 55 | "no-extend-native": 2, 56 | "no-extra-bind": 2, 57 | "no-extra-boolean-cast": 2, 58 | "no-fallthrough": 2, 59 | "no-floating-decimal": 2, 60 | "no-func-assign": 2, 61 | "no-implied-eval": 2, 62 | "no-inner-declarations": [2, "functions"], 63 | "no-invalid-regexp": 2, 64 | "no-irregular-whitespace": 2, 65 | "no-iterator": 2, 66 | "no-label-var": 2, 67 | "no-labels": [2, { "allowLoop": false, "allowSwitch": false }], 68 | "no-lone-blocks": 2, 69 | "no-loop-func": 2, 70 | "no-multi-str": 2, 71 | "no-native-reassign": 2, 72 | "no-negated-in-lhs": 2, 73 | "no-new": 2, 74 | "no-new-func": 2, 75 | "no-new-object": 2, 76 | "no-new-require": 2, 77 | "no-new-symbol": 2, 78 | "no-new-wrappers": 2, 79 | "no-obj-calls": 2, 80 | "no-octal": 2, 81 | "no-octal-escape": 2, 82 | "no-path-concat": 2, 83 | "no-proto": 2, 84 | "no-redeclare": 2, 85 | "no-regex-spaces": 2, 86 | "no-return-assign": [2, "except-parens"], 87 | "no-script-url": 2, 88 | "no-self-assign": 2, 89 | "no-self-compare": 2, 90 | "no-sequences": 2, 91 | "no-shadow-restricted-names": 2, 92 | "no-sparse-arrays": 2, 93 | "no-this-before-super": 2, 94 | "no-throw-literal": 2, 95 | "no-undef": 0, 96 | "no-undef-init": 2, 97 | "no-unexpected-multiline": 2, 98 | "no-unmodified-loop-condition": 2, 99 | "no-unneeded-ternary": [2, { "defaultAssignment": false }], 100 | "no-unreachable": 2, 101 | "no-unsafe-finally": 2, 102 | "no-unused-vars": [2, { "vars": "all", "args": "none" }], 103 | "no-useless-call": 2, 104 | "no-useless-computed-key": 2, 105 | "no-useless-concat": 2, 106 | "no-useless-constructor": 2, 107 | "no-useless-escape": 2, 108 | "no-var": 2, 109 | "no-with": 2, 110 | "prefer-const": 2, 111 | "prefer-rest-params": 0, 112 | "quotes": [2, "single", "avoid-escape"], 113 | "radix": 2, 114 | "use-isnan": 2, 115 | "valid-typeof": 2, 116 | "yoda": [2, "never"], 117 | 118 | // flow 119 | "ft-flow/space-after-type-colon": 0, 120 | "ft-flow/generic-spacing": 0 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /configs/.flowconfig: -------------------------------------------------------------------------------- 1 | [version] 2 | 0.252.0 3 | 4 | [ignore] 5 | .*/coverage/.* 6 | .*/dist/.* 7 | .*/logs/.* 8 | 9 | [options] 10 | casting_syntax=as 11 | suppress_type=$FlowFixMe 12 | 13 | [strict] 14 | nonstrict-import 15 | sketchy-null 16 | unclear-type 17 | untyped-import 18 | untyped-type-import 19 | -------------------------------------------------------------------------------- /configs/.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | logs 4 | node_modules 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.1", 3 | "name": "styleq", 4 | "main": "styleq.js", 5 | "module": "dist/styleq.js", 6 | "sideEffects": false, 7 | "license": "MIT", 8 | "description": "A quick JavaScript runtime for Atomic CSS compilers.", 9 | "repository": "https://github.com/necolas/styleq", 10 | "author": "Nicolas Gallagher", 11 | "files": [ 12 | "dist", 13 | "*.js", 14 | "*.ts" 15 | ], 16 | "scripts": { 17 | "benchmark:compare": "node benchmark/compare.js", 18 | "benchmark:perf": "npm run build && node benchmark/performance.js", 19 | "benchmark:size": "npm run build && node benchmark/size.js", 20 | "build": "babel src --out-dir dist --config-file ./configs/.babelrc", 21 | "flow": "flow --flowconfig-name ./configs/.flowconfig", 22 | "jest": "jest ./test", 23 | "lint": "npm run lint:report -- --fix", 24 | "lint:report": "eslint src/**/*.js --config ./configs/.eslintrc", 25 | "prepare": "npm run test && npm run build", 26 | "prettier": "prettier --write \"**/*.js\" --ignore-path ./configs/.prettierignore", 27 | "prettier:report": "prettier --check \"**/*.js\" --ignore-path ./configs/.prettierignore", 28 | "test": "npm run flow && npm run prettier:report && npm run lint:report && npm run jest" 29 | }, 30 | "devDependencies": { 31 | "@babel/cli": "^7.25.9", 32 | "@babel/core": "^7.26.0", 33 | "@babel/eslint-parser": "^7.25.9", 34 | "@babel/preset-env": "^7.25.4", 35 | "@babel/preset-flow": "^7.24.7", 36 | "@babel/types": "^7.26.0", 37 | "babel-plugin-syntax-hermes-parser": "^0.25.0", 38 | "benchmark": "^2.1.4", 39 | "brotli-size": "^4.0.0", 40 | "eslint": "^8.57.0", 41 | "eslint-config-prettier": "^8.9.0", 42 | "eslint-plugin-ft-flow": "^3.0.7", 43 | "flow-bin": "^0.252.0", 44 | "hermes-eslint": "^0.25.0", 45 | "jest": "^29.7.0", 46 | "prettier": "^3.3.3", 47 | "prettier-plugin-hermes-parser": "0.25.0", 48 | "terser": "^5.3.0", 49 | "yargs": "17.7.2" 50 | }, 51 | "jest": { 52 | "snapshotFormat": { 53 | "printBasicPrototype": false 54 | }, 55 | "transform": { 56 | "\\.js$": [ 57 | "babel-jest", 58 | { 59 | "configFile": "./configs/.babelrc" 60 | } 61 | ] 62 | } 63 | }, 64 | "prettier": { 65 | "plugins": [ 66 | "prettier-plugin-hermes-parser" 67 | ], 68 | "singleQuote": true, 69 | "trailingComma": "none", 70 | "overrides": [ 71 | { 72 | "files": [ 73 | "*.js", 74 | "*.jsx", 75 | "*.flow" 76 | ], 77 | "options": { 78 | "parser": "hermes" 79 | } 80 | } 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/styleq.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow strict 8 | */ 9 | 10 | 'use strict'; 11 | 12 | import type { 13 | IStyleq, 14 | Styleq, 15 | StyleqOptions, 16 | CompiledStyle, 17 | InlineStyle, 18 | Styles 19 | } from '../styleq.js.flow'; 20 | 21 | type Cache = WeakMap< 22 | CompiledStyle, 23 | [ 24 | // className 25 | string, 26 | // style 27 | $ReadOnlyArray, 28 | // debug string 29 | string, 30 | Cache 31 | ] 32 | >; 33 | 34 | const cache: Cache = new WeakMap(); 35 | const compiledKey: '$$css' = '$$css'; 36 | 37 | function createStyleq(options?: StyleqOptions): Styleq { 38 | let disableCache; 39 | let disableMix; 40 | let transform; 41 | 42 | if (options != null) { 43 | disableCache = options.disableCache === true; 44 | disableMix = options.disableMix === true; 45 | transform = options.transform; 46 | } 47 | 48 | return function styleq() { 49 | // Keep track of property commits to the className 50 | const definedProperties: Array = []; 51 | // The className and inline style to build up 52 | let className = ''; 53 | let inlineStyle: null | InlineStyle = null; 54 | // The debug string to build up 55 | let debugString = ''; 56 | // The current position in the cache graph 57 | let nextCache = disableCache ? null : cache; 58 | 59 | // This way of creating an array from arguments is fastest 60 | const styles: Array = new Array(arguments.length); 61 | for (let i = 0; i < arguments.length; i++) { 62 | styles[i] = arguments[i]; 63 | } 64 | 65 | // Iterate over styles from last to first 66 | while (styles.length > 0) { 67 | const possibleStyle = styles.pop(); 68 | // Skip empty items 69 | if (possibleStyle == null || possibleStyle === false) { 70 | continue; 71 | } 72 | // Push nested styles back onto the stack to be processed 73 | if (Array.isArray(possibleStyle)) { 74 | for (let i = 0; i < possibleStyle.length; i++) { 75 | styles.push(possibleStyle[i]); 76 | } 77 | continue; 78 | } 79 | 80 | // Process an individual style object 81 | const style = 82 | transform != null ? transform(possibleStyle) : possibleStyle; 83 | 84 | if (style.$$css != null) { 85 | // Build up the class names defined by this object 86 | let classNameChunk = ''; 87 | 88 | // Check the cache to see if we've already done this work 89 | if (nextCache != null && nextCache.has(style)) { 90 | // Cache: read 91 | const cacheEntry = nextCache.get(style); 92 | if (cacheEntry != null) { 93 | classNameChunk = cacheEntry[0]; 94 | debugString = cacheEntry[2]; 95 | // $FlowIgnore 96 | definedProperties.push.apply(definedProperties, cacheEntry[1]); 97 | nextCache = cacheEntry[3]; 98 | } 99 | } 100 | // Update the chunks with data from this object 101 | else { 102 | // The properties defined by this object 103 | const definedPropertiesChunk = []; 104 | for (const prop in style) { 105 | const value = style[prop]; 106 | if (prop === compiledKey) { 107 | // Updating the debug string only happens once for each style in 108 | // the stack. 109 | const compiledKeyValue = style[prop]; 110 | if (compiledKeyValue !== true) { 111 | debugString = debugString 112 | ? compiledKeyValue + '; ' + debugString 113 | : compiledKeyValue; 114 | } 115 | continue; 116 | } 117 | // Each property value is used as an HTML class name 118 | // { 'debug.string': 'debug.string', opacity: 's-jskmnoqp' } 119 | if (typeof value === 'string' || value === null) { 120 | // Only add to chunks if this property hasn't already been seen 121 | if (!definedProperties.includes(prop)) { 122 | definedProperties.push(prop); 123 | if (nextCache != null) { 124 | definedPropertiesChunk.push(prop); 125 | } 126 | if (typeof value === 'string') { 127 | classNameChunk += classNameChunk ? ' ' + value : value; 128 | } 129 | } 130 | } 131 | // If we encounter a value that isn't a string or `null` 132 | else { 133 | console.error( 134 | `styleq: ${prop} typeof ${String( 135 | value 136 | )} is not "string" or "null".` 137 | ); 138 | } 139 | } 140 | // Cache: write 141 | if (nextCache != null) { 142 | // Create the next WeakMap for this sequence of styles 143 | const weakMap: Cache = new WeakMap(); 144 | nextCache.set(style, [ 145 | classNameChunk, 146 | definedPropertiesChunk, 147 | debugString, 148 | weakMap 149 | ]); 150 | nextCache = weakMap; 151 | } 152 | } 153 | 154 | // Order of classes in chunks matches property-iteration order of style 155 | // object. Order of chunks matches passed order of styles from first to 156 | // last (which we iterate over in reverse). 157 | if (classNameChunk) { 158 | className = className 159 | ? classNameChunk + ' ' + className 160 | : classNameChunk; 161 | } 162 | } 163 | 164 | // ----- DYNAMIC: Process inline style object ----- 165 | else { 166 | if (disableMix) { 167 | if (inlineStyle == null) { 168 | inlineStyle = {}; 169 | } 170 | inlineStyle = Object.assign( 171 | {} as { ...InlineStyle }, 172 | style, 173 | inlineStyle 174 | ); 175 | } else { 176 | let subStyle: null | { ...InlineStyle } = null; 177 | for (const prop in style) { 178 | const value = style[prop]; 179 | if (value !== undefined) { 180 | if (!definedProperties.includes(prop)) { 181 | if (value != null) { 182 | if (inlineStyle == null) { 183 | inlineStyle = {}; 184 | } 185 | if (subStyle == null) { 186 | subStyle = {}; 187 | } 188 | (subStyle as { ...InlineStyle })[prop] = value; 189 | } 190 | definedProperties.push(prop); 191 | // Cache is unnecessary overhead if results can't be reused. 192 | nextCache = null; 193 | } 194 | } 195 | } 196 | if (subStyle != null) { 197 | inlineStyle = Object.assign(subStyle, inlineStyle); 198 | } 199 | } 200 | } 201 | } 202 | 203 | const styleProps = [className, inlineStyle, debugString]; 204 | return styleProps; 205 | }; 206 | } 207 | 208 | const styleq: IStyleq = createStyleq() as $FlowFixMe; 209 | styleq.factory = createStyleq; 210 | 211 | export { styleq }; 212 | -------------------------------------------------------------------------------- /styleq.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | -------------------------------------------------------------------------------- /styleq.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | module.exports = require('./dist/styleq'); 9 | -------------------------------------------------------------------------------- /styleq.js.flow: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow strict 8 | */ 9 | 10 | export type CompiledStyle = $ReadOnly<{ 11 | $$css: true | string, 12 | [key: string]: string, 13 | }>; 14 | 15 | export type InlineStyle = $ReadOnly<{ 16 | $$css?: empty, 17 | [key: string]: number | string, 18 | }>; 19 | 20 | export type EitherStyle = CompiledStyle | InlineStyle; 21 | 22 | export type StylesArray<+T> = T | $ReadOnlyArray>; 23 | export type Styles = StylesArray; 24 | export type Style<+T = EitherStyle> = StylesArray; 25 | 26 | export type StyleqOptions = { 27 | disableCache?: boolean, 28 | disableMix?: boolean, 29 | transform?: (EitherStyle) => EitherStyle, 30 | }; 31 | 32 | export type StyleqResult = [string, InlineStyle | null, string]; 33 | export type Styleq = (styles: Styles) => StyleqResult; 34 | 35 | export type IStyleq = { 36 | (...styles: $ReadOnlyArray): StyleqResult, 37 | factory: (options?: StyleqOptions) => Styleq, 38 | }; 39 | -------------------------------------------------------------------------------- /test/styleq-transform.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import { styleq } from '../src/styleq'; 11 | 12 | /** 13 | * This compiler example polyfills logical properties and values, generating a 14 | * class name for both writing directions. The style objects are annotated by 15 | * the compiler as needing this runtime transform. The results are memoized. 16 | * 17 | * { '$$css$localize': true, float: [ 'float-left', 'float-right' ] } 18 | * => { float: 'float-left' } 19 | */ 20 | 21 | const cache = new WeakMap(); 22 | 23 | const markerProp = '$$css$localize'; 24 | 25 | function compileStyle(style, isRTL) { 26 | // Create a new compiled style for styleq 27 | const compiledStyle = {}; 28 | for (const prop in style) { 29 | if (prop !== markerProp) { 30 | const value = style[prop]; 31 | if (Array.isArray(value)) { 32 | compiledStyle[prop] = isRTL ? value[1] : value[0]; 33 | } else { 34 | compiledStyle[prop] = value; 35 | } 36 | } 37 | } 38 | return compiledStyle; 39 | } 40 | 41 | function localizeStyle(style, isRTL) { 42 | if (style[markerProp] != null) { 43 | const compiledStyleIndex = isRTL ? 1 : 0; 44 | // Check the cache in case we've already seen this object 45 | if (cache.has(style)) { 46 | const cachedStyles = cache.get(style); 47 | let compiledStyle = cachedStyles[compiledStyleIndex]; 48 | if (compiledStyle == null) { 49 | // Update the missing cache entry 50 | compiledStyle = compileStyle(style, isRTL); 51 | cachedStyles[compiledStyleIndex] = compiledStyle; 52 | cache.set(style, cachedStyles); 53 | } 54 | return compiledStyle; 55 | } 56 | 57 | // Create a new compiled style for styleq 58 | const compiledStyle = compileStyle(style, isRTL); 59 | const cachedStyles = new Array(2); 60 | cachedStyles[compiledStyleIndex] = compiledStyle; 61 | cache.set(style, cachedStyles); 62 | return compiledStyle; 63 | } 64 | return style; 65 | } 66 | 67 | describe('transform: styles', () => { 68 | let isRTL = false; 69 | const styleqWithLocalization = styleq.factory({ 70 | transform(style) { 71 | return localizeStyle(style, isRTL); 72 | } 73 | }); 74 | 75 | const fixture = { 76 | $$css: true, 77 | $$css$localize: true, 78 | marginStart: ['margin-left-0px', 'margin-right-0px'], 79 | marginEnd: ['margin-right-10px', 'margin-left-10px'] 80 | }; 81 | 82 | test('supports style transforms', () => { 83 | isRTL = false; 84 | const [classNameLtr, styleLtr] = styleqWithLocalization(fixture, { 85 | opacity: 1 86 | }); 87 | expect(classNameLtr).toEqual('margin-left-0px margin-right-10px'); 88 | expect(styleLtr).toEqual({ opacity: 1 }); 89 | 90 | isRTL = true; 91 | const [classNameRtl, styleRtl] = styleqWithLocalization(fixture, { 92 | opacity: 1 93 | }); 94 | expect(classNameRtl).toEqual('margin-right-0px margin-left-10px'); 95 | expect(styleRtl).toEqual({ opacity: 1 }); 96 | }); 97 | 98 | test('memoizes results', () => { 99 | const firstStyle = localizeStyle(fixture, false); 100 | const secondStyle = localizeStyle(fixture, false); 101 | expect(firstStyle).toBe(secondStyle); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/styleq.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Nicolas Gallagher 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | 'use strict'; 9 | 10 | import { styleq } from '../src/styleq'; 11 | 12 | const styleqNoCache = styleq.factory({ disableCache: true }); 13 | const styleqNoMix = styleq.factory({ disableMix: true }); 14 | 15 | function stringifyInlineStyle(inlineStyle) { 16 | let str = ''; 17 | Object.keys(inlineStyle).forEach((prop) => { 18 | const value = inlineStyle[prop]; 19 | str += `${prop}:${value};`; 20 | }); 21 | return str; 22 | } 23 | 24 | describe('styleq()', () => { 25 | describe('invalid values', () => { 26 | beforeAll(() => { 27 | jest.spyOn(global.console, 'error').mockImplementation((msg) => { 28 | throw new Error(msg); 29 | }); 30 | }); 31 | afterAll(() => { 32 | global.console.error.mockRestore(); 33 | }); 34 | 35 | test('warns if extracted property values are not strings or null', () => { 36 | expect(() => styleq({ $$css: true, a: 1 })).toThrow(); 37 | expect(() => styleq({ $$css: true, a: undefined })).toThrow(); 38 | expect(() => styleq({ $$css: true, a: false })).toThrow(); 39 | expect(() => styleq({ $$css: true, a: true })).toThrow(); 40 | expect(() => styleq({ $$css: true, a: {} })).toThrow(); 41 | expect(() => styleq({ $$css: true, a: [] })).toThrow(); 42 | expect(() => styleq({ $$css: true, a: new Date() })).toThrow(); 43 | }); 44 | }); 45 | 46 | test('combines different class names', () => { 47 | const style = { $$css: true, a: 'aaa', b: 'bbb' }; 48 | expect(styleqNoCache(style)[0]).toBe('aaa bbb'); 49 | expect(styleq(style)[0]).toBe('aaa bbb'); 50 | }); 51 | 52 | test('combines different class names in order', () => { 53 | const a = { $$css: true, a: 'a', ':focus$aa': 'focus$aa' }; 54 | const b = { $$css: true, b: 'b' }; 55 | const c = { $$css: true, c: 'c', ':focus$cc': 'focus$cc' }; 56 | expect(styleqNoCache([a, b, c])[0]).toBe('a focus$aa b c focus$cc'); 57 | expect(styleq([a, b, c])[0]).toBe('a focus$aa b c focus$cc'); 58 | }); 59 | 60 | test('dedupes class names for the same key', () => { 61 | const a = { $$css: true, backgroundColor: 'backgroundColor-a' }; 62 | const b = { $$css: true, backgroundColor: 'backgoundColor-b' }; 63 | const c = { $$css: true, backgroundColor: 'backgoundColor-c' }; 64 | expect(styleqNoCache([a, b])[0]).toEqual('backgoundColor-b'); 65 | expect(styleq([a, b])[0]).toEqual('backgoundColor-b'); 66 | // Tests memoized result of [a,b] is correct 67 | expect(styleq([c, a, b])[0]).toEqual('backgoundColor-b'); 68 | }); 69 | 70 | test('dedupes class names with "null" value', () => { 71 | const a = { $$css: true, backgroundColor: 'backgroundColor-a' }; 72 | const b = { $$css: true, backgroundColor: null }; 73 | expect(styleqNoCache([a, b])[0]).toEqual(''); 74 | expect(styleq([a, b])[0]).toEqual(''); 75 | }); 76 | 77 | test('dedupes class names in complex merges', () => { 78 | const styles = { 79 | a: { 80 | $$css: true, 81 | backgroundColor: 'backgroundColor-a', 82 | borderColor: 'borderColor-a', 83 | borderStyle: 'borderStyle-a', 84 | borderWidth: 'borderWidth-a', 85 | boxSizing: 'boxSizing-a', 86 | display: 'display-a', 87 | listStyle: 'listStyle-a', 88 | marginTop: 'marginTop-a', 89 | marginEnd: 'marginEnd-a', 90 | marginBottom: 'marginBottom-a', 91 | marginStart: 'marginStart-a', 92 | paddingTop: 'paddingTop-a', 93 | paddingEnd: 'paddingEnd-a', 94 | paddingBottom: 'paddingBottom-a', 95 | paddingStart: 'paddingStart-a', 96 | textAlign: 'textAlign-a', 97 | textDecoration: 'textDecoration-a', 98 | whiteSpace: 'whiteSpace-a', 99 | wordWrap: 'wordWrap-a', 100 | zIndex: 'zIndex-a' 101 | }, 102 | b: { 103 | $$css: true, 104 | cursor: 'cursor-b', 105 | touchAction: 'touchAction-b' 106 | }, 107 | c: { 108 | $$css: true, 109 | outline: 'outline-c' 110 | }, 111 | d: { 112 | $$css: true, 113 | cursor: 'cursor-d', 114 | touchAction: 'touchAction-d' 115 | }, 116 | e: { 117 | $$css: true, 118 | textDecoration: 'textDecoration-e', 119 | ':focus$textDecoration': 'focus$textDecoration-e' 120 | }, 121 | f: { 122 | $$css: true, 123 | backgroundColor: 'backgroundColor-f', 124 | color: 'color-f', 125 | cursor: 'cursor-f', 126 | display: 'display-f', 127 | marginEnd: 'marginEnd-f', 128 | marginStart: 'marginStart-f', 129 | textAlign: 'textAlign-f', 130 | textDecoration: 'textDecoration-f', 131 | ':focus$color': 'focus$color-f', 132 | ':focus$textDecoration': 'focus$textDecoration-f', 133 | ':active$transform': 'active$transform-f', 134 | ':active$transition': 'active$transition-f' 135 | }, 136 | g: { 137 | $$css: true, 138 | display: 'display-g', 139 | width: 'width-g' 140 | }, 141 | h: { 142 | $$css: true, 143 | ':active$transform': 'active$transform-h' 144 | } 145 | }; 146 | 147 | // This tests that repeat results are the same, and that memoized chunks 148 | // are correctly recorded. The second test reuses chunks from the first. 149 | 150 | // ONE 151 | const one = [ 152 | styles.a, 153 | false, 154 | [ 155 | styles.b, 156 | false, 157 | styles.c, 158 | [styles.d, false, styles.e, false, [styles.f, styles.g], [styles.h]] 159 | ] 160 | ]; 161 | const oneValue = styleq(one)[0]; 162 | const oneRepeat = styleq(one)[0]; 163 | // Check the memoized result is correct 164 | expect(oneValue).toEqual(oneRepeat); 165 | expect(oneValue).toMatchInlineSnapshot( 166 | `"borderColor-a borderStyle-a borderWidth-a boxSizing-a listStyle-a marginTop-a marginBottom-a paddingTop-a paddingEnd-a paddingBottom-a paddingStart-a whiteSpace-a wordWrap-a zIndex-a outline-c touchAction-d backgroundColor-f color-f cursor-f marginEnd-f marginStart-f textAlign-f textDecoration-f focus$color-f focus$textDecoration-f active$transition-f display-g width-g active$transform-h"` 167 | ); 168 | 169 | // TWO 170 | const two = [ 171 | styles.d, 172 | false, 173 | [ 174 | styles.c, 175 | false, 176 | styles.b, 177 | [styles.a, false, styles.e, false, [styles.f, styles.g], [styles.h]] 178 | ] 179 | ]; 180 | const twoValue = styleq(two)[0]; 181 | const twoRepeat = styleq(two)[0]; 182 | // Check the memoized result is correct 183 | expect(twoValue).toEqual(twoRepeat); 184 | expect(twoValue).toMatchInlineSnapshot( 185 | `"outline-c touchAction-b borderColor-a borderStyle-a borderWidth-a boxSizing-a listStyle-a marginTop-a marginBottom-a paddingTop-a paddingEnd-a paddingBottom-a paddingStart-a whiteSpace-a wordWrap-a zIndex-a backgroundColor-f color-f cursor-f marginEnd-f marginStart-f textAlign-f textDecoration-f focus$color-f focus$textDecoration-f active$transition-f display-g width-g active$transform-h"` 186 | ); 187 | }); 188 | 189 | test('dedupes inline styles', () => { 190 | const [, inlineStyle] = styleq([{ a: 'a' }, { a: 'aa' }]); 191 | expect(inlineStyle).toEqual({ a: 'aa' }); 192 | const [, inlineStyle2] = styleq([{ a: 'a' }, { a: null }]); 193 | expect(inlineStyle2).toEqual(null); 194 | }); 195 | 196 | test('preserves order of stringified inline style', () => { 197 | const [, inlineStyle] = styleq([{ font: 'inherit', fontSize: 12 }]); 198 | const str = stringifyInlineStyle(inlineStyle); 199 | expect(str).toMatchInlineSnapshot(`"font:inherit;fontSize:12;"`); 200 | 201 | const [, inlineStyle2] = styleq([{ font: 'inherit' }, { fontSize: 12 }]); 202 | const str2 = stringifyInlineStyle(inlineStyle2); 203 | expect(str2).toMatchInlineSnapshot(`"font:inherit;fontSize:12;"`); 204 | }); 205 | 206 | test('dedupes class names and inline styles', () => { 207 | const a = { $$css: true, a: 'a', ':focus$a': 'focus$a' }; 208 | const b = { $$css: true, b: 'b' }; 209 | const binline = { b: 'b', bb: null }; 210 | const binlinealt = { b: null }; 211 | 212 | const [className1, inlineStyle1] = styleq([a, b, binline]); 213 | expect(className1).toBe('a focus$a'); 214 | expect(inlineStyle1).toEqual({ b: 'b' }); 215 | 216 | const [className2, inlineStyle2] = styleq([a, binline, b]); 217 | expect(className2).toBe('a focus$a b'); 218 | expect(inlineStyle2).toEqual(null); 219 | 220 | const [className3, inlineStyle3] = styleq([a, b, binlinealt]); 221 | expect(className3).toBe('a focus$a'); 222 | expect(inlineStyle3).toEqual(null); 223 | }); 224 | 225 | test('disableMix dedupes inline styles', () => { 226 | const [, inlineStyle] = styleqNoMix([{ a: 'a' }, { a: 'aa' }]); 227 | expect(inlineStyle).toEqual({ a: 'aa' }); 228 | const [, inlineStyle2] = styleqNoMix([{ a: 'a' }, { a: null }]); 229 | expect(inlineStyle2).toEqual({ a: null }); 230 | }); 231 | 232 | test('disableMix preserves order of stringified inline style', () => { 233 | const [, inlineStyle] = styleqNoMix([{ font: 'inherit', fontSize: 12 }]); 234 | const str = stringifyInlineStyle(inlineStyle); 235 | expect(str).toMatchInlineSnapshot(`"font:inherit;fontSize:12;"`); 236 | 237 | const [, inlineStyle2] = styleqNoMix([ 238 | { font: 'inherit' }, 239 | { fontSize: 12 } 240 | ]); 241 | const str2 = stringifyInlineStyle(inlineStyle2); 242 | expect(str2).toMatchInlineSnapshot(`"font:inherit;fontSize:12;"`); 243 | }); 244 | 245 | test('disableMix does not dedupe class names and inline styles', () => { 246 | const a = { $$css: true, a: 'a', ':focus$a': 'focus$a' }; 247 | const b = { $$css: true, b: 'b' }; 248 | const binline = { b: 'b', bb: null }; 249 | 250 | // Both should produce: [ 'a hover$a b', { b: 'b' } ] 251 | expect(styleqNoMix([a, b, binline])).toEqual(styleqNoMix([a, binline, b])); 252 | }); 253 | 254 | test('supports generating debug strings', () => { 255 | const a = { $$css: 'path/to/a:1', a: 'aaa' }; 256 | const b = { $$css: 'path/to/b:2', b: 'bbb' }; 257 | const c = { $$css: 'path/to/c:3', b: 'ccc' }; 258 | const [, , debugString] = styleq([a]); 259 | expect(debugString).toBe('path/to/a:1'); 260 | const [, , dataStyleSrc] = styleq([a, [b, c]]); 261 | expect(dataStyleSrc).toBe('path/to/a:1; path/to/b:2; path/to/c:3'); 262 | const [, , dataStyleSrcNoCache] = styleqNoCache([a, [b, c]]); 263 | expect(dataStyleSrcNoCache).toBe('path/to/a:1; path/to/b:2; path/to/c:3'); 264 | }); 265 | }); 266 | --------------------------------------------------------------------------------