├── .eslintrc ├── .github └── workflows │ ├── build.yml │ ├── check-dependencies.yml │ ├── compressed-size.yml │ └── run-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── __mocks__ └── @flair │ └── common │ └── index.js ├── architecture.md ├── babel.config.json ├── example-sites ├── create-react-app │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── App.css │ │ ├── App.js │ │ ├── App.test.js │ │ ├── index.css │ │ ├── index.js │ │ ├── logo.svg │ │ ├── serviceWorker.js │ │ └── setupTests.js └── gatsby │ ├── .babelrc │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── LICENSE │ ├── README.md │ ├── gatsby-browser.js │ ├── gatsby-config.js │ ├── gatsby-node.js │ ├── gatsby-ssr.js │ ├── noop.js │ ├── package-lock.json │ ├── package.json │ └── src │ ├── components │ ├── header.js │ ├── image.js │ ├── layout.css │ ├── layout.js │ └── seo.js │ ├── images │ ├── gatsby-astronaut.png │ └── gatsby-icon.png │ ├── pages │ ├── 404.js │ ├── index.js │ └── page-2.js │ └── styles │ └── theme.js ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── packages ├── babel-plugin-plugin │ ├── package.json │ ├── src │ │ ├── Example.js │ │ ├── __cacheDir__ │ │ │ └── Example--00000.css │ │ ├── exampleTheme.ts │ │ ├── index.ts │ │ ├── plugin.test.js │ │ ├── plugin.ts │ │ └── submodule.ts │ └── tsconfig.json ├── collect │ ├── package.json │ ├── src │ │ ├── Example.js │ │ ├── Example2.js │ │ ├── collect.test.js │ │ ├── collect.ts │ │ ├── collectionPlugin.test.js │ │ ├── collectionPlugin.ts │ │ ├── exampleTheme.ts │ │ ├── index.ts │ │ ├── submodule.ts │ │ ├── transformCssTemplateLiteral.test.js │ │ └── transformCssTemplateLiteral.ts │ └── tsconfig.json ├── common │ ├── package.json │ ├── src │ │ ├── createFilenameHash.test.js │ │ ├── createFilenameHash.ts │ │ ├── index.ts │ │ ├── seek.test.js │ │ └── seek.ts │ └── tsconfig.json ├── core │ ├── package.json │ ├── src │ │ ├── ColorContext.test.js │ │ ├── ColorContext.ts │ │ ├── ColorContextProvider.test.js │ │ ├── ColorContextProvider.tsx │ │ ├── ThemeContext.test.js │ │ ├── ThemeContext.ts │ │ ├── ThemeProvider.test.js │ │ ├── ThemeProvider.tsx │ │ ├── createReadablePalette.test.js │ │ ├── createReadablePalette.ts │ │ ├── css.test.js │ │ ├── css.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── useColorContext.test.js │ │ ├── useColorContext.ts │ │ ├── useTheme.test.js │ │ └── useTheme.ts │ └── tsconfig.json ├── flair │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── loader │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── load.ts │ └── tsconfig.json ├── ssr │ ├── package.json │ ├── src │ │ ├── createStyles.test.js │ │ ├── createStyles.tsx │ │ └── index.ts │ └── tsconfig.json └── standalone │ ├── package.json │ ├── src │ ├── createStyles.test.js │ ├── createStyles.tsx │ ├── index.ts │ ├── tryGetCurrentFilename.test.js │ └── tryGetCurrentFilename.ts │ └── tsconfig.json ├── prettier.config.js ├── renovate.json ├── rollup.config.js ├── scripts ├── build.js ├── check-dependencies.js ├── publish.js └── resolve-dependencies.js ├── tsconfig.json └── why-another-css-in-js-lib.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"], 3 | "rules": { 4 | "import/order": ["error"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | - run: npm i 14 | - run: npm run build 15 | -------------------------------------------------------------------------------- /.github/workflows/check-dependencies.yml: -------------------------------------------------------------------------------- 1 | name: Check Dependencies 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | check-dependencies: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | - run: npm i 14 | - run: npm run resolve-dependencies 15 | -------------------------------------------------------------------------------- /.github/workflows/compressed-size.yml: -------------------------------------------------------------------------------- 1 | name: Compressed Size 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | fetch-depth: 1 12 | - uses: preactjs/compressed-size-action@v2 13 | with: 14 | repo-token: '${{ secrets.GITHUB_TOKEN }}' 15 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '12.x' 13 | - run: npm i 14 | - run: npm run lint 15 | - run: npm t 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | examples/.flair-cache 3 | coverage 4 | .DS_Store 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rico Kahler 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 | # Flair 2 | 3 | > a lean, component-centric style system for React components 4 | 5 | ## ⚠️ This library is still in heavy development with the best features coming soon (development on hiatus) 6 | 7 | Watch releases to be notified for new features. 8 | 9 | ## Features 10 | 11 | - 🎣 hooks API 12 | - 👩‍🎨 theming 13 | - 🎨 advanced color context features including **dark mode** 14 | - 🧩 composable styles by default 15 | - 📦 small size, [7.3kB](https://bundlephobia.com/result?p=flair) 16 | - 👩‍🎨 full color manipulation library included ([colork2k](https://github.com/ricokahler/color2k)) (no need for chroma-js or polished) 17 | - ⛓ full TypeScript support and enhanced DX 18 | 19 | **Experimental features** 20 | 21 | The best features of this library are still in development: 22 | 23 | - static and extracted CSS similar to [Linaria](https://github.com/callstack/linaria) via a [Babel Plugin](https://github.com/ricokahler/flair/tree/master/packages/babel-plugin-plugin) (this will become the preferred way to use the library when stable) 24 | - SSR support 25 | - much smaller bundle [2.5kB](https://bundlephobia.com/result?p=@flair/ssr) 26 | - performance improvements 27 | 28 | **Requirements** 29 | 30 | - React `>16.8.0` (requires hooks) 31 | - No IE 11 support 32 | 33 | **Table of contents** 34 | 35 | - [Flair](#flair) 36 | - [Features](#features) 37 | - [Why another CSS-in-JS lib?](#why-another-css-in-js-lib) 38 | - [Installation](#installation) 39 | - [Install](#install) 40 | - [Create your theme](#create-your-theme) 41 | - [Provider installation](#provider-installation) 42 | - [Add type augments](#add-type-augments) 43 | - [VS Code extension](#vs-code-extension) 44 | - [Usage](#usage) 45 | - [Basic usage](#basic-usage) 46 | - [Composability](#composability) 47 | - [Dynamic coloring](#dynamic-coloring) 48 | - [Color system usage](#color-system-usage) 49 | - [Theming usage](#theming-usage) 50 | - [Implementations](#implementations) 51 | - [How does this all work?](#how-does-this-all-work) 52 | - [Enabling the experiemental SSR mode (`@flair/ssr`)](#enabling-the-experiemental-ssr-mode-flairssr) 53 | - [Configure babel](#configure-babel) 54 | - [Configure Webpack](#configure-webpack) 55 | 56 | ## Why another CSS-in-JS lib? 57 | 58 | [Glad you asked! See here for more info.](./why-another-css-in-js-lib.md) 59 | 60 | ## Installation 61 | 62 | ### Install 63 | 64 | ``` 65 | npm i --save flair 66 | ``` 67 | 68 | ### Create your theme 69 | 70 | `flair`'s theming works by providing an object to all your components. This theme object should contain values to keep your app's styles consistent. 71 | 72 | [See theming usage for more info](#theming-usage) 73 | 74 | ```ts 75 | // /src/theme.ts (or /src/theme.js) 76 | 77 | const theme = { 78 | // see theming usage for more info 79 | colors: { 80 | brand: 'palevioletred', 81 | accent: 'peachpuff', 82 | surface: 'white', 83 | }, 84 | }; 85 | 86 | export default theme; 87 | ``` 88 | 89 | ### Provider installation 90 | 91 | ```tsx 92 | // index.ts (or index.js) 93 | import React from 'react'; 94 | import { ThemeProvider, ColorContextProvider } from 'flair'; 95 | import { render } from 'react-dom'; 96 | import theme from './theme'; 97 | import App from './App'; 98 | 99 | const container = document.querySelector('#root'); 100 | 101 | render( 102 | 103 | 107 | 108 | 109 | , 110 | container, 111 | ); 112 | ``` 113 | 114 | ### Add type augments 115 | 116 | If you're using typescript or an editor that supports the typescript language service (VS Code), you'll need to add one more file to configure the types and intellisense. 117 | 118 | Place this file at the root of your project. 119 | 120 | ```tsx 121 | // /arguments.d.ts 122 | import { 123 | StyleFnArgs, 124 | ReactComponent, 125 | StyleProps, 126 | GetComponentProps, 127 | } from 'flair'; 128 | 129 | declare module 'flair' { 130 | // this should import your theme 131 | type Theme = typeof import('./src/theme').default; 132 | 133 | // provides an override type that includes the type for your theme 134 | export function useTheme(): Theme; 135 | 136 | // provides an override type that includes the type for your theme 137 | export function createStyles( 138 | stylesFn: (args: StyleFnArgs) => Styles, 139 | ): >( 140 | props: Props, 141 | component?: ComponentType, 142 | ) => { 143 | Root: React.ComponentType>; 144 | styles: { [P in keyof Styles]: string } & { 145 | cssVariableObject: { [key: string]: string }; 146 | }; 147 | } & Omit>; 148 | } 149 | ``` 150 | 151 | ### VS Code extension 152 | 153 | If you're using VSCode, we recommend installing the `vscode-styled-components` by [the styled-components team](https://github.com/styled-components/vscode-styled-components). This will add syntax highlighting for our style of CSS-in-JS. 154 | 155 | ## Usage 156 | 157 | ### Basic usage 158 | 159 | ```tsx 160 | // Card.tsx 161 | import React from 'react'; 162 | import { createStyles, PropsFromStyles } from 'flair'; 163 | 164 | // `flair` works by creating a hook that intercepts your props 165 | const useStyles = createStyles(({ css, theme }) => ({ 166 | // here you return an object of styles 167 | root: css` 168 | padding: 1rem; 169 | background-color: peachpuff; 170 | /* you can pull in your theme like so */ 171 | border-right: 5px solid ${theme.colors.brand}; 172 | `, 173 | title: css` 174 | font-weight: bold; 175 | font-weight: 3rem; 176 | margin-bottom: 1rem; 177 | `, 178 | description: css` 179 | line-height: 1.5; 180 | `, 181 | })); 182 | 183 | // write your props like normal, just add the `extends…` like so: 184 | interface Props extends PropsFromStyles { 185 | title: React.ReactNode; 186 | description: React.ReactNode; 187 | } 188 | 189 | function Card(props: Props) { 190 | // `useStyles` intercepts your props 191 | const { 192 | // `Root` and `styles` are props added via `useStyles` 193 | Root, 194 | styles, 195 | // `title` and `description` are the props you defined 196 | title, 197 | description, 198 | } = useStyles(props, 'div' /* 👈 `div` is the default if you omit this */); 199 | 200 | return ( 201 | // the `root` class is automatically applied to the `Root` component 202 | { 204 | // you can supply any props you would send to the root component 205 | // (which is a `div` in this case) 206 | }} 207 | > 208 | {/* the styles that come back are class names */} 209 |

{title}

210 |

{description}

211 |
212 | ); 213 | } 214 | 215 | export default Card; 216 | ``` 217 | 218 | ### Composability 219 | 220 | `flair`'s styles are composable by default. This means that every style you write can be augmented because the style props `className`, `style`, and `styles` are automatically propagated to the subject `Root` component. 221 | 222 | Building from the example above: 223 | 224 | ```tsx 225 | // Grid.tsx 226 | import React from 'react'; 227 | import { createStyles, PropsFromStyles } from 'flair'; 228 | import Cart from './Card'; 229 | 230 | const useStyles = createStyles(({ css }) => ({ 231 | root: css` 232 | display: grid; 233 | gap: 1rem; 234 | grid-template-columns: repeat(3, 1fr); 235 | `, 236 | card: css` 237 | box-shadow: 0 0 45px 0 rgba(0, 0, 0, 0.2); 238 | `, 239 | titleUnderlined: css` 240 | text-decoration: underlined; 241 | `, 242 | })); 243 | 244 | interface Props extends PropsFromStyles {} 245 | 246 | function Grid(props: Props) { 247 | const { Root, styles } = useStyles(props); 248 | 249 | return ( 250 | 251 | a lean, component-centric style system for React components 261 | } 262 | /> 263 | 264 | CSS-in-JS library designed for high performance style composition 269 | } 270 | /> 271 | 272 | 277 | Visual primitives for the component age. Use the best bits of ES6 278 | and CSS to style your apps without stress 279 | 280 | } 281 | /> 282 | 283 | ); 284 | } 285 | ``` 286 | 287 | ### Dynamic coloring 288 | 289 | Every component styled with `flair` supports dynamic coloring. This means you can pass the prop `color` to it and use that color when defining styles. 290 | 291 | ```tsx 292 | // passing the color prop 293 | 294 | ``` 295 | 296 | ```tsx 297 | // using the color prop to define styles 298 | import React from 'react'; 299 | import { createStyles, PropsFromStyles } from 'flair'; 300 | 301 | // the `color` prop comes through here 👇 302 | const useStyles = createStyles(({ css, color, surface }) => ({ 303 | // 👆 304 | // additionally, there is another prop `surface` that hold the color of the 305 | // surface this component is on currently. this is usually black for dark mode 306 | // and white for non-dark modes 307 | root: css` 308 | border: 1px solid ${color.decorative}; 309 | background-color: ${surface}; 310 | color: ${color.readable}; 311 | `, 312 | })); 313 | 314 | interface Props extends PropsFromStyles { 315 | children: React.ReactNode; 316 | onClick: () => void; 317 | } 318 | 319 | function Button(props: Props) { 320 | const { Root, children, onClick } = useStyles(props, 'children'); 321 | return {children}; 322 | } 323 | 324 | export default Button; 325 | ``` 326 | 327 | [See this demo in CodeSandbox](https://codesandbox.io/s/dynamic-coloring-7dr3n) 328 | 329 | ### Color system usage 330 | 331 | `flair` ships with a simple yet robust color system. You can wrap your components in `ColorContextProvider`s to give your components context for what color they should expect to be on top of. This works well when supporting dark mode. 332 | 333 | [See here for a full demo of color context.](https://codesandbox.io/s/nested-color-system-demo-qphro) 334 | 335 | ### Theming usage 336 | 337 | Theming in `flair` is implemented as one object that will be available to all your components in the app. You can use this object to store values to make your app's styles consistent. We recommend referring to [`material-ui`'s theme object](https://material-ui.com/customization/default-theme/#default-theme) for idea on how to define your own theme's shape. 338 | 339 | Wrap your App in a `ThemeProvider` and give that `ThemeProvider` a theme object. 340 | 341 | After your wrap in a theme provider, you can access the theme via the args in `createStyles`: 342 | 343 | ```tsx 344 | // 👇👇👇 345 | const useStyles = createStyles(({ css, theme }) => ({ 346 | root: css` 347 | color: ${theme.colors.brand}; 348 | `, 349 | })); 350 | ``` 351 | 352 | And inside your component. You can access the theme via `useTheme()` 353 | 354 | ```tsx 355 | function Component(props: Props) { 356 | const theme = useTheme(); 357 | 358 | // ... 359 | } 360 | ``` 361 | 362 | ## Implementations 363 | 364 | This repo has two implementations that are better suited for different environments/setups. 365 | 366 | Both implementations share the exact same API and even use the same import (the SSR version rewrites the imports via the babel plugin). 367 | 368 | In general, the standalone implementation is easier to get started with, works in more environments, and is currently much more stable than the SSR counterpart. 369 | 370 | With the existence of both versions, you can get started using the standalone version and optimize later with the SSR version. 371 | 372 | 373 | | Feature | `@flair/standalone` | `@flair/ssr` | 374 | |--|--|--| 375 | | Works standalone without any babel plugins or webpack loaders (for `create-react-app` support) | ✅ | 🔴 | 376 | | Zero config | ✅ | 🔴 | 377 | | Faster, static CSS 🚀 | 🔴 | ✅ | 378 | | Extracts CSS from JS bundle | 🔴 | ✅ | 379 | | Stability | 👍 beta | 🤔 experimental | 380 | | Bundle size | [7.3kB](https://bundlephobia.com/result?p=@flair/standalone) 🤷‍♀️ | [2.5kB](https://bundlephobia.com/result?p=@flair/ssr) 😎 | 381 | | [Theming](#theming-usage) | ✅ | ✅ | 382 | | [Dynamic coloring](#dynamic-coloring) | ✅ | ✅ | 383 | | Same lean API | 😎 | 😎 | 384 | 385 | 386 | ### How does this all work? 387 | 388 | [See the architecture docs for more info.](./architecture.md) 389 | 390 | ### Enabling the experimental SSR mode (`@flair/ssr`) 391 | 392 | > ⚠️ In order to get this to work, you need to be able to freely configure babel and webpack. This is currently _not_ possible with `create-react-app`. 393 | 394 | ### Configure babel 395 | 396 | Create or modify your `.babelrc` configuration file at the root of your folder. 397 | 398 | ```js 399 | { 400 | "presets": [ 401 | // ...rest of your presets 402 | ], 403 | "plugins": [ 404 | // ...rest of your plugins 405 | [ 406 | "@flair/plugin", 407 | { 408 | // this is the theme file. refer to here: 409 | // https://github.com/ricokahler/flair#create-your-theme 410 | "themePath": "./src/styles/theme.js" 411 | } 412 | ] 413 | ] 414 | } 415 | ``` 416 | 417 | > **Note:** You do _not_ need to change your imports. The babel plugin `@flair/plugin` will re-write your imports to use the `@flair/ssr` package 418 | 419 | ### Configure Webpack 420 | 421 | In your webpack config, create a new rule for `.rss-css` files and include the `@flair/loader` in the chain. 422 | 423 | ```js 424 | module.exports = { 425 | // ... 426 | module: { 427 | // ... 428 | rules: [ 429 | // ... 430 | { 431 | test: /\.rss-css$/, 432 | use: [ 433 | 'style-loader', // you can use the mini-css-extract-plugin instead too 434 | { 435 | loader: 'css-loader', 436 | options: { importLoaders: 2 }, 437 | }, 438 | // flair loader must be last 439 | '@flair/loader', 440 | ], 441 | include: [ 442 | require.resolve('@flair/loader/load.rss-css'), 443 | // ... 444 | ], 445 | }, 446 | ], 447 | }, 448 | }; 449 | ``` 450 | 451 | ### Credits 452 | 453 | Big thanks to [`@lepture`](https://twitter.com/lepture) for the name `flair` ❤️ 454 | -------------------------------------------------------------------------------- /__mocks__/@flair/common/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import * as Flair from '@flair/common'; 3 | 4 | const { createFilenameHash, ...rest } = Flair; 5 | 6 | module.exports = { 7 | createFilenameHash: filename => { 8 | const extension = path.extname(filename); 9 | const basename = path.basename(filename); 10 | 11 | return `${path 12 | .basename(filename) 13 | .substring(0, basename.length - extension.length)}--00000`; 14 | }, 15 | ...rest, 16 | }; 17 | -------------------------------------------------------------------------------- /architecture.md: -------------------------------------------------------------------------------- 1 | ### Architecture 2 | 3 | This repo is structured as a mono-repo solely to split up dependencies. This ensures that when a package is installed, only the necessary dependencies are brought in. 4 | 5 | There are a total of 8 packages in this repo: 6 | 7 | 8 | | Package | Description | 9 | |-|-| 10 | | `@flair/babel-plugin-plugin` | The babel plugin that enables SSR mode | 11 | | `@flair/collect` | A function that extracts/collects the CSS from the JS | 12 | | `@flair/common` | A common set of zero-dependency helpers (mostly collection related) | 13 | | `@flair/core` | The common set of functions needed in both the SSR and standalone versions used in the browser | 14 | | `@flair/loader` | A simple webpack loader that feeds CSS to other CSS loaders in your webpack loader chain | 15 | | `@flair/ssr` | The browser implementation of flair that requires the babel plugin | 16 | | `@flair/standalone` | The browser implementation of flair that works with no plugins or loaders required | 17 | | `flair` | The user-facing top-level package. It simply re-exports the standalone implementation but also serves as a placeholder package for the SSR version | 18 | 19 | > **Note:** Even though there are many packages in this repo, you should only ever need to install the top-level `flair` package. 20 | 21 | 22 | 23 | ### How does the standalone version work? 24 | 25 | The unscoped, top-level package `flair` simply re-exports the entire `@flair/standalone` package. 26 | 27 | The standalone version includes [stylis](https://github.com/thysultan/stylis.js) (which powers both `styled-components` and `emotion`) as a browser dependency. 28 | 29 | During render, the standalone version: 30 | 31 | 1. pulls your styles from the CSS template literals, 32 | 2. processes via stylis it in the browser, and 33 | 3. creates and mounts a stylesheet 34 | 35 | This all occurs in a React layout effect on first render. 36 | 37 | The standalone version is nice because it doesn't require any compilers to work so it drops in most environments no problem. 38 | 39 | [See the implementation of the standalone version here.](https://github.com/ricokahler/flair/blob/master/packages/standalone/src/createStyles.tsx) 40 | 41 | ### How does the SSR version work? 42 | 43 | The SSR version is bit more involved with a bit more moving parts. 44 | 45 | 1. The babel plugin (`@flair/babel-plugin-plugin`) does two things: 46 | 1. it transforms any imports of `flair` to `@flair/ssr` to use the SSR entry point instead of the top-level package. 47 | 2. it calls collect from `@flair/collect` to extract the CSS from the JS. When the CSS is extracted, it creates an import that can be picked up by the loader. 48 | 3. it removes any left over CSS that would be extracted but leaves any JavaScript dynamic expressions (to populate CSS variables) 49 | 2. In order for the collection above to work (`@flair/collect`), it: 50 | 1. Transform the component code (again) so that the CSS can be extracted and executed in node 51 | 2. Javascript expressions in the template literals are converted to CSS variables 52 | 3. The transformed code is executed in node and the static CSS is returned 53 | 3. After the babel plugin makes a pass, it can then be picked up by the loader (`@flair/loader`). The plugin puts the CSS in the query string of a resource (e.g. `import '@flair/loader/load.rss-css?css=`) so the job of the loader is to grab the CSS from the query string and forward it to the next CSS loader. 54 | 55 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": ["@babel/plugin-proposal-class-properties"] 8 | } 9 | -------------------------------------------------------------------------------- /example-sites/create-react-app/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /example-sites/create-react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example-sites/create-react-app/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /example-sites/create-react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "5.11.0", 7 | "@testing-library/react": "10.4.3", 8 | "@testing-library/user-event": "12.0.11", 9 | "react": "16.13.1", 10 | "react-dom": "16.13.1", 11 | "react-scripts": "3.4.1", 12 | "flair": "0.0.0-af376dbc1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example-sites/create-react-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/flair/54701d2de653d08d42614649fd015693f81da188/example-sites/create-react-app/public/favicon.ico -------------------------------------------------------------------------------- /example-sites/create-react-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example-sites/create-react-app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/flair/54701d2de653d08d42614649fd015693f81da188/example-sites/create-react-app/public/logo192.png -------------------------------------------------------------------------------- /example-sites/create-react-app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/flair/54701d2de653d08d42614649fd015693f81da188/example-sites/create-react-app/public/logo512.png -------------------------------------------------------------------------------- /example-sites/create-react-app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example-sites/create-react-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | min-height: 100vh; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: calc(10px + 2vmin); 23 | color: white; 24 | } 25 | 26 | .App-link { 27 | color: #61dafb; 28 | } 29 | 30 | @keyframes App-logo-spin { 31 | from { 32 | transform: rotate(0deg); 33 | } 34 | to { 35 | transform: rotate(360deg); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.svg'; 3 | import './App.css'; 4 | 5 | import { createStyles } from 'flair'; 6 | 7 | const useStyles = createStyles(({ css }) => ({ 8 | root: css` 9 | background-color: midnightblue; 10 | `, 11 | })); 12 | 13 | function App(props) { 14 | const { Root } = useStyles(props); 15 | return ( 16 | 17 |
18 | logo 19 |

20 | Edit src/App.js and save to reload. 21 |

22 | 28 | Learn React 29 | 30 |
31 |
32 | ); 33 | } 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import { ThemeProvider, ColorContextProvider } from 'flair'; 7 | 8 | const theme = {}; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById('root'), 19 | ); 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister(); 25 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /example-sites/create-react-app/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /example-sites/gatsby/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "babel-preset-gatsby", 5 | { 6 | "targets": { 7 | "browsers": [">0.25%", "not dead", "not IE 11"] 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | [ 14 | "@flair/plugin", 15 | { 16 | "themePath": "./src/styles/theme.js" 17 | } 18 | ], 19 | "@babel/plugin-proposal-optional-chaining" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /example-sites/gatsby/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variable files 55 | .env* 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | .rss-cache 72 | -------------------------------------------------------------------------------- /example-sites/gatsby/.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | package-lock.json 4 | public 5 | -------------------------------------------------------------------------------- /example-sites/gatsby/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /example-sites/gatsby/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 gatsbyjs 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 | 23 | -------------------------------------------------------------------------------- /example-sites/gatsby/README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | Gatsby 5 | 6 |

7 |

8 | Gatsby's default starter 9 |

10 | 11 | Kick off your project with this default boilerplate. This starter ships with the main Gatsby configuration files you might need to get up and running blazing fast with the blazing fast app generator for React. 12 | 13 | _Have another more specific idea? You may want to check out our vibrant collection of [official and community-created starters](https://www.gatsbyjs.org/docs/gatsby-starters/)._ 14 | 15 | ## 🚀 Quick start 16 | 17 | 1. **Create a Gatsby site.** 18 | 19 | Use the Gatsby CLI to create a new site, specifying the default starter. 20 | 21 | ```shell 22 | # create a new Gatsby site using the default starter 23 | gatsby new my-default-starter https://github.com/gatsbyjs/gatsby-starter-default 24 | ``` 25 | 26 | 1. **Start developing.** 27 | 28 | Navigate into your new site’s directory and start it up. 29 | 30 | ```shell 31 | cd my-default-starter/ 32 | gatsby develop 33 | ``` 34 | 35 | 1. **Open the source code and start editing!** 36 | 37 | Your site is now running at `http://localhost:8000`! 38 | 39 | _Note: You'll also see a second link: _`http://localhost:8000/___graphql`_. This is a tool you can use to experiment with querying your data. Learn more about using this tool in the [Gatsby tutorial](https://www.gatsbyjs.org/tutorial/part-five/#introducing-graphiql)._ 40 | 41 | Open the `my-default-starter` directory in your code editor of choice and edit `src/pages/index.js`. Save your changes and the browser will update in real time! 42 | 43 | ## 🧐 What's inside? 44 | 45 | A quick look at the top-level files and directories you'll see in a Gatsby project. 46 | 47 | . 48 | ├── node_modules 49 | ├── src 50 | ├── .gitignore 51 | ├── .prettierrc 52 | ├── gatsby-browser.js 53 | ├── gatsby-config.js 54 | ├── gatsby-node.js 55 | ├── gatsby-ssr.js 56 | ├── LICENSE 57 | ├── package-lock.json 58 | ├── package.json 59 | └── README.md 60 | 61 | 1. **`/node_modules`**: This directory contains all of the modules of code that your project depends on (npm packages) are automatically installed. 62 | 63 | 2. **`/src`**: This directory will contain all of the code related to what you will see on the front-end of your site (what you see in the browser) such as your site header or a page template. `src` is a convention for “source code”. 64 | 65 | 3. **`.gitignore`**: This file tells git which files it should not track / not maintain a version history for. 66 | 67 | 4. **`.prettierrc`**: This is a configuration file for [Prettier](https://prettier.io/). Prettier is a tool to help keep the formatting of your code consistent. 68 | 69 | 5. **`gatsby-browser.js`**: This file is where Gatsby expects to find any usage of the [Gatsby browser APIs](https://www.gatsbyjs.org/docs/browser-apis/) (if any). These allow customization/extension of default Gatsby settings affecting the browser. 70 | 71 | 6. **`gatsby-config.js`**: This is the main configuration file for a Gatsby site. This is where you can specify information about your site (metadata) like the site title and description, which Gatsby plugins you’d like to include, etc. (Check out the [config docs](https://www.gatsbyjs.org/docs/gatsby-config/) for more detail). 72 | 73 | 7. **`gatsby-node.js`**: This file is where Gatsby expects to find any usage of the [Gatsby Node APIs](https://www.gatsbyjs.org/docs/node-apis/) (if any). These allow customization/extension of default Gatsby settings affecting pieces of the site build process. 74 | 75 | 8. **`gatsby-ssr.js`**: This file is where Gatsby expects to find any usage of the [Gatsby server-side rendering APIs](https://www.gatsbyjs.org/docs/ssr-apis/) (if any). These allow customization of default Gatsby settings affecting server-side rendering. 76 | 77 | 9. **`LICENSE`**: Gatsby is licensed under the MIT license. 78 | 79 | 10. **`package-lock.json`** (See `package.json` below, first). This is an automatically generated file based on the exact versions of your npm dependencies that were installed for your project. **(You won’t change this file directly).** 80 | 81 | 11. **`package.json`**: A manifest file for Node.js projects, which includes things like metadata (the project’s name, author, etc). This manifest is how npm knows which packages to install for your project. 82 | 83 | 12. **`README.md`**: A text file containing useful reference information about your project. 84 | 85 | ## 🎓 Learning Gatsby 86 | 87 | Looking for more guidance? Full documentation for Gatsby lives [on the website](https://www.gatsbyjs.org/). Here are some places to start: 88 | 89 | - **For most developers, we recommend starting with our [in-depth tutorial for creating a site with Gatsby](https://www.gatsbyjs.org/tutorial/).** It starts with zero assumptions about your level of ability and walks through every step of the process. 90 | 91 | - **To dive straight into code samples, head [to our documentation](https://www.gatsbyjs.org/docs/).** In particular, check out the _Guides_, _API Reference_, and _Advanced Tutorials_ sections in the sidebar. 92 | 93 | ## 💫 Deploy 94 | 95 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/gatsbyjs/gatsby-starter-default) 96 | 97 | [![Deploy with ZEIT Now](https://zeit.co/button)](https://zeit.co/import/project?template=https://github.com/gatsbyjs/gatsby-starter-default) 98 | 99 | 100 | -------------------------------------------------------------------------------- /example-sites/gatsby/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ThemeProvider, ColorContextProvider } from "flair" 3 | import theme from "./src/styles/theme" 4 | 5 | export const wrapRootElement = ({ element }) => ( 6 | 7 | 11 | {element} 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /example-sites/gatsby/gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteMetadata: { 3 | title: `Gatsby Default Starter`, 4 | description: `Kick off your next, great Gatsby project with this default starter. This barebones starter ships with the main Gatsby configuration files you might need.`, 5 | author: `@gatsbyjs`, 6 | }, 7 | plugins: [ 8 | `gatsby-plugin-react-helmet`, 9 | { 10 | resolve: `gatsby-source-filesystem`, 11 | options: { 12 | name: `images`, 13 | path: `${__dirname}/src/images`, 14 | }, 15 | }, 16 | `gatsby-transformer-sharp`, 17 | `gatsby-plugin-sharp`, 18 | { 19 | resolve: `gatsby-plugin-manifest`, 20 | options: { 21 | name: `gatsby-starter-default`, 22 | short_name: `starter`, 23 | start_url: `/`, 24 | background_color: `#663399`, 25 | theme_color: `#663399`, 26 | display: `minimal-ui`, 27 | icon: `src/images/gatsby-icon.png`, // This path is relative to the root of the site. 28 | }, 29 | }, 30 | // this (optional) plugin enables Progressive Web App + Offline functionality 31 | // To learn more, visit: https://gatsby.dev/offline 32 | // `gatsby-plugin-offline`, 33 | ], 34 | } 35 | -------------------------------------------------------------------------------- /example-sites/gatsby/gatsby-node.js: -------------------------------------------------------------------------------- 1 | exports.onCreateWebpackConfig = ( 2 | { actions, stage, loaders }, 3 | { 4 | cssLoaderOptions = {}, 5 | postCssPlugins, 6 | useResolveUrlLoader, 7 | } 8 | ) => { 9 | const { setWebpackConfig } = actions 10 | const isSSR = stage.includes(`html`) 11 | 12 | const rule = { 13 | test: /\.rss-css$/, 14 | use: isSSR 15 | ? [loaders.null()] 16 | : [ 17 | loaders.miniCssExtract(), 18 | loaders.css({ ...cssLoaderOptions, importLoaders: 2 }), 19 | loaders.postcss({ plugins: postCssPlugins }), 20 | "@flair/loader", 21 | ], 22 | } 23 | 24 | if (useResolveUrlLoader && !isSSR) { 25 | rule.use.splice(-1, 0, { 26 | loader: `resolve-url-loader`, 27 | options: useResolveUrlLoader.options ? useResolveUrlLoader.options : {}, 28 | }) 29 | } 30 | 31 | let configRules = [] 32 | 33 | if ( 34 | ["develop", "build-javascript", "build-html", "develop-html"].includes( 35 | stage 36 | ) 37 | ) { 38 | configRules = configRules.concat([rule]) 39 | } 40 | 41 | setWebpackConfig({ 42 | module: { 43 | rules: configRules, 44 | }, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /example-sites/gatsby/gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ThemeProvider, ColorContextProvider } from "flair" 3 | import theme from "./src/styles/theme" 4 | 5 | export const wrapRootElement = ({ element }) => ( 6 | 7 | 11 | {element} 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /example-sites/gatsby/noop.js: -------------------------------------------------------------------------------- 1 | // no-op 2 | -------------------------------------------------------------------------------- /example-sites/gatsby/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-starter-default", 3 | "private": true, 4 | "description": "A simple starter to get up and developing quickly with Gatsby", 5 | "version": "0.1.0", 6 | "author": "Kyle Mathews ", 7 | "dependencies": { 8 | "gatsby": "2.23.11", 9 | "gatsby-image": "2.4.9", 10 | "gatsby-plugin-manifest": "2.4.14", 11 | "gatsby-plugin-offline": "3.2.13", 12 | "gatsby-plugin-react-helmet": "3.3.6", 13 | "gatsby-plugin-sharp": "2.6.14", 14 | "gatsby-source-filesystem": "2.3.14", 15 | "gatsby-transformer-sharp": "2.5.7", 16 | "polished": "3.6.5", 17 | "prop-types": "15.7.2", 18 | "react": "16.13.1", 19 | "react-dom": "16.13.1", 20 | "react-helmet": "6.1.0", 21 | "flair": "0.0.0-fee2b107b" 22 | }, 23 | "devDependencies": { 24 | "canvas": "2.6.1", 25 | "prettier": "2.0.5" 26 | }, 27 | "keywords": [ 28 | "gatsby" 29 | ], 30 | "license": "MIT", 31 | "scripts": { 32 | "build": "gatsby build", 33 | "develop": "gatsby develop", 34 | "format": "prettier --write \"**/*.{js,jsx,json,md}\"", 35 | "start": "npm run develop", 36 | "serve": "gatsby serve", 37 | "clean": "gatsby clean && rm -rf ./.rss-cache", 38 | "test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\" && exit 1" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/gatsbyjs/gatsby-starter-default" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/gatsbyjs/gatsby/issues" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/components/header.js: -------------------------------------------------------------------------------- 1 | import { Link } from "gatsby" 2 | import PropTypes from "prop-types" 3 | import React from "react" 4 | 5 | const Header = ({ siteTitle }) => ( 6 |
12 |
19 |

20 | 27 | {siteTitle} 28 | 29 |

30 |
31 |
32 | ) 33 | 34 | Header.propTypes = { 35 | siteTitle: PropTypes.string, 36 | } 37 | 38 | Header.defaultProps = { 39 | siteTitle: ``, 40 | } 41 | 42 | export default Header 43 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/components/image.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useStaticQuery, graphql } from "gatsby" 3 | import Img from "gatsby-image" 4 | 5 | /* 6 | * This component is built using `gatsby-image` to automatically serve optimized 7 | * images with lazy loading and reduced file sizes. The image is loaded using a 8 | * `useStaticQuery`, which allows us to load the image from directly within this 9 | * component, rather than having to pass the image data down from pages. 10 | * 11 | * For more information, see the docs: 12 | * - `gatsby-image`: https://gatsby.dev/gatsby-image 13 | * - `useStaticQuery`: https://www.gatsbyjs.org/docs/use-static-query/ 14 | */ 15 | 16 | const Image = () => { 17 | const data = useStaticQuery(graphql` 18 | query { 19 | placeholderImage: file(relativePath: { eq: "gatsby-astronaut.png" }) { 20 | childImageSharp { 21 | fluid(maxWidth: 300) { 22 | ...GatsbyImageSharpFluid 23 | } 24 | } 25 | } 26 | } 27 | `) 28 | 29 | return 30 | } 31 | 32 | export default Image 33 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/components/layout.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | -ms-text-size-adjust: 100%; 4 | -webkit-text-size-adjust: 100%; 5 | } 6 | body { 7 | margin: 0; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | article, 12 | aside, 13 | details, 14 | figcaption, 15 | figure, 16 | footer, 17 | header, 18 | main, 19 | menu, 20 | nav, 21 | section, 22 | summary { 23 | display: block; 24 | } 25 | audio, 26 | canvas, 27 | progress, 28 | video { 29 | display: inline-block; 30 | } 31 | audio:not([controls]) { 32 | display: none; 33 | height: 0; 34 | } 35 | progress { 36 | vertical-align: baseline; 37 | } 38 | [hidden], 39 | template { 40 | display: none; 41 | } 42 | a { 43 | background-color: transparent; 44 | -webkit-text-decoration-skip: objects; 45 | } 46 | a:active, 47 | a:hover { 48 | outline-width: 0; 49 | } 50 | abbr[title] { 51 | border-bottom: none; 52 | text-decoration: underline; 53 | text-decoration: underline dotted; 54 | } 55 | b, 56 | strong { 57 | font-weight: inherit; 58 | font-weight: bolder; 59 | } 60 | dfn { 61 | font-style: italic; 62 | } 63 | h1 { 64 | font-size: 2em; 65 | margin: 0.67em 0; 66 | } 67 | mark { 68 | background-color: #ff0; 69 | color: #000; 70 | } 71 | small { 72 | font-size: 80%; 73 | } 74 | sub, 75 | sup { 76 | font-size: 75%; 77 | line-height: 0; 78 | position: relative; 79 | vertical-align: baseline; 80 | } 81 | sub { 82 | bottom: -0.25em; 83 | } 84 | sup { 85 | top: -0.5em; 86 | } 87 | img { 88 | border-style: none; 89 | } 90 | svg:not(:root) { 91 | overflow: hidden; 92 | } 93 | code, 94 | kbd, 95 | pre, 96 | samp { 97 | font-family: monospace, monospace; 98 | font-size: 1em; 99 | } 100 | figure { 101 | margin: 1em 40px; 102 | } 103 | hr { 104 | box-sizing: content-box; 105 | height: 0; 106 | overflow: visible; 107 | } 108 | button, 109 | input, 110 | optgroup, 111 | select, 112 | textarea { 113 | font: inherit; 114 | margin: 0; 115 | } 116 | optgroup { 117 | font-weight: 700; 118 | } 119 | button, 120 | input { 121 | overflow: visible; 122 | } 123 | button, 124 | select { 125 | text-transform: none; 126 | } 127 | [type="reset"], 128 | [type="submit"], 129 | button, 130 | html [type="button"] { 131 | -webkit-appearance: button; 132 | } 133 | [type="button"]::-moz-focus-inner, 134 | [type="reset"]::-moz-focus-inner, 135 | [type="submit"]::-moz-focus-inner, 136 | button::-moz-focus-inner { 137 | border-style: none; 138 | padding: 0; 139 | } 140 | [type="button"]:-moz-focusring, 141 | [type="reset"]:-moz-focusring, 142 | [type="submit"]:-moz-focusring, 143 | button:-moz-focusring { 144 | outline: 1px dotted ButtonText; 145 | } 146 | fieldset { 147 | border: 1px solid silver; 148 | margin: 0 2px; 149 | padding: 0.35em 0.625em 0.75em; 150 | } 151 | legend { 152 | box-sizing: border-box; 153 | color: inherit; 154 | display: table; 155 | max-width: 100%; 156 | padding: 0; 157 | white-space: normal; 158 | } 159 | textarea { 160 | overflow: auto; 161 | } 162 | [type="checkbox"], 163 | [type="radio"] { 164 | box-sizing: border-box; 165 | padding: 0; 166 | } 167 | [type="number"]::-webkit-inner-spin-button, 168 | [type="number"]::-webkit-outer-spin-button { 169 | height: auto; 170 | } 171 | [type="search"] { 172 | -webkit-appearance: textfield; 173 | outline-offset: -2px; 174 | } 175 | [type="search"]::-webkit-search-cancel-button, 176 | [type="search"]::-webkit-search-decoration { 177 | -webkit-appearance: none; 178 | } 179 | ::-webkit-input-placeholder { 180 | color: inherit; 181 | opacity: 0.54; 182 | } 183 | ::-webkit-file-upload-button { 184 | -webkit-appearance: button; 185 | font: inherit; 186 | } 187 | html { 188 | font: 112.5%/1.45em georgia, serif; 189 | box-sizing: border-box; 190 | overflow-y: scroll; 191 | } 192 | * { 193 | box-sizing: inherit; 194 | } 195 | *:before { 196 | box-sizing: inherit; 197 | } 198 | *:after { 199 | box-sizing: inherit; 200 | } 201 | body { 202 | color: hsla(0, 0%, 0%, 0.8); 203 | font-family: georgia, serif; 204 | font-weight: normal; 205 | word-wrap: break-word; 206 | font-kerning: normal; 207 | -moz-font-feature-settings: "kern", "liga", "clig", "calt"; 208 | -ms-font-feature-settings: "kern", "liga", "clig", "calt"; 209 | -webkit-font-feature-settings: "kern", "liga", "clig", "calt"; 210 | font-feature-settings: "kern", "liga", "clig", "calt"; 211 | } 212 | img { 213 | max-width: 100%; 214 | margin-left: 0; 215 | margin-right: 0; 216 | margin-top: 0; 217 | padding-bottom: 0; 218 | padding-left: 0; 219 | padding-right: 0; 220 | padding-top: 0; 221 | margin-bottom: 1.45rem; 222 | } 223 | h1 { 224 | margin-left: 0; 225 | margin-right: 0; 226 | margin-top: 0; 227 | padding-bottom: 0; 228 | padding-left: 0; 229 | padding-right: 0; 230 | padding-top: 0; 231 | margin-bottom: 1.45rem; 232 | color: inherit; 233 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 234 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 235 | font-weight: bold; 236 | text-rendering: optimizeLegibility; 237 | font-size: 2.25rem; 238 | line-height: 1.1; 239 | } 240 | h2 { 241 | margin-left: 0; 242 | margin-right: 0; 243 | margin-top: 0; 244 | padding-bottom: 0; 245 | padding-left: 0; 246 | padding-right: 0; 247 | padding-top: 0; 248 | margin-bottom: 1.45rem; 249 | color: inherit; 250 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 251 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 252 | font-weight: bold; 253 | text-rendering: optimizeLegibility; 254 | font-size: 1.62671rem; 255 | line-height: 1.1; 256 | } 257 | h3 { 258 | margin-left: 0; 259 | margin-right: 0; 260 | margin-top: 0; 261 | padding-bottom: 0; 262 | padding-left: 0; 263 | padding-right: 0; 264 | padding-top: 0; 265 | margin-bottom: 1.45rem; 266 | color: inherit; 267 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 268 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 269 | font-weight: bold; 270 | text-rendering: optimizeLegibility; 271 | font-size: 1.38316rem; 272 | line-height: 1.1; 273 | } 274 | h4 { 275 | margin-left: 0; 276 | margin-right: 0; 277 | margin-top: 0; 278 | padding-bottom: 0; 279 | padding-left: 0; 280 | padding-right: 0; 281 | padding-top: 0; 282 | margin-bottom: 1.45rem; 283 | color: inherit; 284 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 285 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 286 | font-weight: bold; 287 | text-rendering: optimizeLegibility; 288 | font-size: 1rem; 289 | line-height: 1.1; 290 | } 291 | h5 { 292 | margin-left: 0; 293 | margin-right: 0; 294 | margin-top: 0; 295 | padding-bottom: 0; 296 | padding-left: 0; 297 | padding-right: 0; 298 | padding-top: 0; 299 | margin-bottom: 1.45rem; 300 | color: inherit; 301 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 302 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 303 | font-weight: bold; 304 | text-rendering: optimizeLegibility; 305 | font-size: 0.85028rem; 306 | line-height: 1.1; 307 | } 308 | h6 { 309 | margin-left: 0; 310 | margin-right: 0; 311 | margin-top: 0; 312 | padding-bottom: 0; 313 | padding-left: 0; 314 | padding-right: 0; 315 | padding-top: 0; 316 | margin-bottom: 1.45rem; 317 | color: inherit; 318 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 319 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 320 | font-weight: bold; 321 | text-rendering: optimizeLegibility; 322 | font-size: 0.78405rem; 323 | line-height: 1.1; 324 | } 325 | hgroup { 326 | margin-left: 0; 327 | margin-right: 0; 328 | margin-top: 0; 329 | padding-bottom: 0; 330 | padding-left: 0; 331 | padding-right: 0; 332 | padding-top: 0; 333 | margin-bottom: 1.45rem; 334 | } 335 | ul { 336 | margin-left: 1.45rem; 337 | margin-right: 0; 338 | margin-top: 0; 339 | padding-bottom: 0; 340 | padding-left: 0; 341 | padding-right: 0; 342 | padding-top: 0; 343 | margin-bottom: 1.45rem; 344 | list-style-position: outside; 345 | list-style-image: none; 346 | } 347 | ol { 348 | margin-left: 1.45rem; 349 | margin-right: 0; 350 | margin-top: 0; 351 | padding-bottom: 0; 352 | padding-left: 0; 353 | padding-right: 0; 354 | padding-top: 0; 355 | margin-bottom: 1.45rem; 356 | list-style-position: outside; 357 | list-style-image: none; 358 | } 359 | dl { 360 | margin-left: 0; 361 | margin-right: 0; 362 | margin-top: 0; 363 | padding-bottom: 0; 364 | padding-left: 0; 365 | padding-right: 0; 366 | padding-top: 0; 367 | margin-bottom: 1.45rem; 368 | } 369 | dd { 370 | margin-left: 0; 371 | margin-right: 0; 372 | margin-top: 0; 373 | padding-bottom: 0; 374 | padding-left: 0; 375 | padding-right: 0; 376 | padding-top: 0; 377 | margin-bottom: 1.45rem; 378 | } 379 | p { 380 | margin-left: 0; 381 | margin-right: 0; 382 | margin-top: 0; 383 | padding-bottom: 0; 384 | padding-left: 0; 385 | padding-right: 0; 386 | padding-top: 0; 387 | margin-bottom: 1.45rem; 388 | } 389 | figure { 390 | margin-left: 0; 391 | margin-right: 0; 392 | margin-top: 0; 393 | padding-bottom: 0; 394 | padding-left: 0; 395 | padding-right: 0; 396 | padding-top: 0; 397 | margin-bottom: 1.45rem; 398 | } 399 | pre { 400 | margin-left: 0; 401 | margin-right: 0; 402 | margin-top: 0; 403 | margin-bottom: 1.45rem; 404 | font-size: 0.85rem; 405 | line-height: 1.42; 406 | background: hsla(0, 0%, 0%, 0.04); 407 | border-radius: 3px; 408 | overflow: auto; 409 | word-wrap: normal; 410 | padding: 1.45rem; 411 | } 412 | table { 413 | margin-left: 0; 414 | margin-right: 0; 415 | margin-top: 0; 416 | padding-bottom: 0; 417 | padding-left: 0; 418 | padding-right: 0; 419 | padding-top: 0; 420 | margin-bottom: 1.45rem; 421 | font-size: 1rem; 422 | line-height: 1.45rem; 423 | border-collapse: collapse; 424 | width: 100%; 425 | } 426 | fieldset { 427 | margin-left: 0; 428 | margin-right: 0; 429 | margin-top: 0; 430 | padding-bottom: 0; 431 | padding-left: 0; 432 | padding-right: 0; 433 | padding-top: 0; 434 | margin-bottom: 1.45rem; 435 | } 436 | blockquote { 437 | margin-left: 1.45rem; 438 | margin-right: 1.45rem; 439 | margin-top: 0; 440 | padding-bottom: 0; 441 | padding-left: 0; 442 | padding-right: 0; 443 | padding-top: 0; 444 | margin-bottom: 1.45rem; 445 | } 446 | form { 447 | margin-left: 0; 448 | margin-right: 0; 449 | margin-top: 0; 450 | padding-bottom: 0; 451 | padding-left: 0; 452 | padding-right: 0; 453 | padding-top: 0; 454 | margin-bottom: 1.45rem; 455 | } 456 | noscript { 457 | margin-left: 0; 458 | margin-right: 0; 459 | margin-top: 0; 460 | padding-bottom: 0; 461 | padding-left: 0; 462 | padding-right: 0; 463 | padding-top: 0; 464 | margin-bottom: 1.45rem; 465 | } 466 | iframe { 467 | margin-left: 0; 468 | margin-right: 0; 469 | margin-top: 0; 470 | padding-bottom: 0; 471 | padding-left: 0; 472 | padding-right: 0; 473 | padding-top: 0; 474 | margin-bottom: 1.45rem; 475 | } 476 | hr { 477 | margin-left: 0; 478 | margin-right: 0; 479 | margin-top: 0; 480 | padding-bottom: 0; 481 | padding-left: 0; 482 | padding-right: 0; 483 | padding-top: 0; 484 | margin-bottom: calc(1.45rem - 1px); 485 | background: hsla(0, 0%, 0%, 0.2); 486 | border: none; 487 | height: 1px; 488 | } 489 | address { 490 | margin-left: 0; 491 | margin-right: 0; 492 | margin-top: 0; 493 | padding-bottom: 0; 494 | padding-left: 0; 495 | padding-right: 0; 496 | padding-top: 0; 497 | margin-bottom: 1.45rem; 498 | } 499 | b { 500 | font-weight: bold; 501 | } 502 | strong { 503 | font-weight: bold; 504 | } 505 | dt { 506 | font-weight: bold; 507 | } 508 | th { 509 | font-weight: bold; 510 | } 511 | li { 512 | margin-bottom: calc(1.45rem / 2); 513 | } 514 | ol li { 515 | padding-left: 0; 516 | } 517 | ul li { 518 | padding-left: 0; 519 | } 520 | li > ol { 521 | margin-left: 1.45rem; 522 | margin-bottom: calc(1.45rem / 2); 523 | margin-top: calc(1.45rem / 2); 524 | } 525 | li > ul { 526 | margin-left: 1.45rem; 527 | margin-bottom: calc(1.45rem / 2); 528 | margin-top: calc(1.45rem / 2); 529 | } 530 | blockquote *:last-child { 531 | margin-bottom: 0; 532 | } 533 | li *:last-child { 534 | margin-bottom: 0; 535 | } 536 | p *:last-child { 537 | margin-bottom: 0; 538 | } 539 | li > p { 540 | margin-bottom: calc(1.45rem / 2); 541 | } 542 | code { 543 | font-size: 0.85rem; 544 | line-height: 1.45rem; 545 | } 546 | kbd { 547 | font-size: 0.85rem; 548 | line-height: 1.45rem; 549 | } 550 | samp { 551 | font-size: 0.85rem; 552 | line-height: 1.45rem; 553 | } 554 | abbr { 555 | border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); 556 | cursor: help; 557 | } 558 | acronym { 559 | border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); 560 | cursor: help; 561 | } 562 | abbr[title] { 563 | border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); 564 | cursor: help; 565 | text-decoration: none; 566 | } 567 | thead { 568 | text-align: left; 569 | } 570 | td, 571 | th { 572 | text-align: left; 573 | border-bottom: 1px solid hsla(0, 0%, 0%, 0.12); 574 | font-feature-settings: "tnum"; 575 | -moz-font-feature-settings: "tnum"; 576 | -ms-font-feature-settings: "tnum"; 577 | -webkit-font-feature-settings: "tnum"; 578 | padding-left: 0.96667rem; 579 | padding-right: 0.96667rem; 580 | padding-top: 0.725rem; 581 | padding-bottom: calc(0.725rem - 1px); 582 | } 583 | th:first-child, 584 | td:first-child { 585 | padding-left: 0; 586 | } 587 | th:last-child, 588 | td:last-child { 589 | padding-right: 0; 590 | } 591 | tt, 592 | code { 593 | background-color: hsla(0, 0%, 0%, 0.04); 594 | border-radius: 3px; 595 | font-family: "SFMono-Regular", Consolas, "Roboto Mono", "Droid Sans Mono", 596 | "Liberation Mono", Menlo, Courier, monospace; 597 | padding: 0; 598 | padding-top: 0.2em; 599 | padding-bottom: 0.2em; 600 | } 601 | pre code { 602 | background: none; 603 | line-height: 1.42; 604 | } 605 | code:before, 606 | code:after, 607 | tt:before, 608 | tt:after { 609 | letter-spacing: -0.2em; 610 | content: " "; 611 | } 612 | pre code:before, 613 | pre code:after, 614 | pre tt:before, 615 | pre tt:after { 616 | content: ""; 617 | } 618 | @media only screen and (max-width: 480px) { 619 | html { 620 | font-size: 100%; 621 | } 622 | } 623 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/components/layout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout component that queries for data 3 | * with Gatsby's useStaticQuery component 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from "react" 9 | import PropTypes from "prop-types" 10 | import { useStaticQuery, graphql } from "gatsby" 11 | 12 | import Header from "./header" 13 | import "./layout.css" 14 | 15 | const Layout = ({ children }) => { 16 | const data = useStaticQuery(graphql` 17 | query SiteTitleQuery { 18 | site { 19 | siteMetadata { 20 | title 21 | } 22 | } 23 | } 24 | `) 25 | 26 | return ( 27 | <> 28 |
29 |
36 |
{children}
37 |
38 | © {new Date().getFullYear()}, Built with 39 | {` `} 40 | Gatsby 41 |
42 |
43 | 44 | ) 45 | } 46 | 47 | Layout.propTypes = { 48 | children: PropTypes.node.isRequired, 49 | } 50 | 51 | export default Layout 52 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/components/seo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SEO component that queries for data with 3 | * Gatsby's useStaticQuery React hook 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from "react" 9 | import PropTypes from "prop-types" 10 | import { Helmet } from "react-helmet" 11 | import { useStaticQuery, graphql } from "gatsby" 12 | 13 | function SEO({ description, lang, meta, title }) { 14 | const { site } = useStaticQuery( 15 | graphql` 16 | query { 17 | site { 18 | siteMetadata { 19 | title 20 | description 21 | author 22 | } 23 | } 24 | } 25 | ` 26 | ) 27 | 28 | const metaDescription = description || site.siteMetadata.description 29 | 30 | return ( 31 | 72 | ) 73 | } 74 | 75 | SEO.defaultProps = { 76 | lang: `en`, 77 | meta: [], 78 | description: ``, 79 | } 80 | 81 | SEO.propTypes = { 82 | description: PropTypes.string, 83 | lang: PropTypes.string, 84 | meta: PropTypes.arrayOf(PropTypes.object), 85 | title: PropTypes.string.isRequired, 86 | } 87 | 88 | export default SEO 89 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/images/gatsby-astronaut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/flair/54701d2de653d08d42614649fd015693f81da188/example-sites/gatsby/src/images/gatsby-astronaut.png -------------------------------------------------------------------------------- /example-sites/gatsby/src/images/gatsby-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/flair/54701d2de653d08d42614649fd015693f81da188/example-sites/gatsby/src/images/gatsby-icon.png -------------------------------------------------------------------------------- /example-sites/gatsby/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import Layout from "../components/layout" 4 | import SEO from "../components/seo" 5 | 6 | const NotFoundPage = () => ( 7 | 8 | 9 |

NOT FOUND

10 |

You just hit a route that doesn't exist... the sadness.

11 |
12 | ) 13 | 14 | export default NotFoundPage 15 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link } from "gatsby" 3 | import { createStyles } from "flair" 4 | 5 | import Layout from "../components/layout" 6 | // import Image from "../components/image" 7 | import SEO from "../components/seo" 8 | 9 | const useStyles = createStyles(({ css, color, theme }) => ({ 10 | root: css` 11 | background-color: ${color.original}; 12 | color: ${theme.colors.primary}; 13 | `, 14 | })) 15 | 16 | const IndexPage = props => { 17 | const { Root } = useStyles(props) 18 | 19 | return ( 20 | 21 | 22 | 23 |

Hi people

24 |

Welcome to your new Gatsby site.

25 |

Now go build something great.

26 |
27 | {/* */} 28 |
29 | Go to page 2 30 |
31 |
32 | ) 33 | } 34 | 35 | export default IndexPage 36 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/pages/page-2.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link } from "gatsby" 3 | 4 | import Layout from "../components/layout" 5 | import SEO from "../components/seo" 6 | 7 | const SecondPage = () => ( 8 | 9 | 10 |

Hi from the second page

11 |

Welcome to page 2

12 | Go back to the homepage 13 |
14 | ) 15 | 16 | export default SecondPage 17 | -------------------------------------------------------------------------------- /example-sites/gatsby/src/styles/theme.js: -------------------------------------------------------------------------------- 1 | export default { 2 | colors: { 3 | primary: "red", 4 | surface: "white", 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // stub 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: __dirname, 3 | moduleNameMapper: { 4 | '^@flair/(.+)': '/packages/$1/src', 5 | '^flair$': '/packages/flair/src', 6 | '^prettier$': '/node_modules/prettier', 7 | }, 8 | modulePathIgnorePatterns: ['/dist/', '/example-sites/'], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.0", 3 | "author": { 4 | "email": "ricokahler@me.com", 5 | "name": "Rico Kahler", 6 | "url": "https://github.com/ricokahler" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/ricokahler/flair.git" 11 | }, 12 | "description": "a lean, component-centric style system for React components", 13 | "keywords": [ 14 | "css", 15 | "css-in-js", 16 | "jss", 17 | "linaria" 18 | ], 19 | "license": "MIT", 20 | "private": true, 21 | "scripts": { 22 | "test": "jest", 23 | "lint": "eslint packages --ext .ts,.tsx,.js,.jsx", 24 | "check-dependencies": "node ./scripts/check-dependencies.js", 25 | "resolve-dependencies": "node ./scripts/resolve-dependencies.js", 26 | "publish": "node ./scripts/publish.js", 27 | "build": "node ./scripts/build.js" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "7.9.6", 31 | "@babel/generator": "7.9.6", 32 | "@babel/plugin-proposal-class-properties": "7.8.3", 33 | "@babel/plugin-transform-runtime": "7.9.6", 34 | "@babel/polyfill": "7.8.7", 35 | "@babel/preset-env": "7.9.6", 36 | "@babel/preset-react": "7.9.4", 37 | "@babel/preset-typescript": "7.9.0", 38 | "@babel/runtime": "7.9.6", 39 | "@babel/template": "7.8.6", 40 | "@babel/traverse": "7.9.6", 41 | "@babel/types": "7.9.6", 42 | "@color2k/compat": "1.0.0-rc.5", 43 | "@rollup/plugin-babel": "5.0.4", 44 | "@rollup/plugin-node-resolve": "8.1.0", 45 | "@types/babel__core": "7.1.9", 46 | "@types/babel__traverse": "7.0.12", 47 | "@types/classnames": "2.2.10", 48 | "@types/common-tags": "1.8.0", 49 | "@types/node": "13.13.12", 50 | "@types/react": "16.9.41", 51 | "@types/require-from-string": "1.2.0", 52 | "@types/webpack": "4.41.18", 53 | "@typescript-eslint/eslint-plugin": "3.4.0", 54 | "@typescript-eslint/parser": "3.4.0", 55 | "babel-eslint": "10.1.0", 56 | "babel-plugin-module-resolver": "4.0.0", 57 | "canvas": "2.6.1", 58 | "classnames": "2.2.6", 59 | "color2k": "1.0.0-rc.5", 60 | "common-tags": "1.8.0", 61 | "eslint": "7.3.1", 62 | "eslint-config-react-app": "5.2.1", 63 | "eslint-plugin-flowtype": "5.1.3", 64 | "eslint-plugin-import": "2.21.2", 65 | "eslint-plugin-jsx-a11y": "6.3.1", 66 | "eslint-plugin-react": "7.20.0", 67 | "eslint-plugin-react-hooks": "4.0.4", 68 | "folder-hash": "3.3.2", 69 | "jest": "26.1.0", 70 | "jsdom": "16.2.2", 71 | "pirates": "4.0.1", 72 | "prettier": "2.0.5", 73 | "react": "16.13.1", 74 | "react-dom": "16.13.1", 75 | "react-test-renderer": "16.13.1", 76 | "require-from-string": "2.0.2", 77 | "rollup": "2.18.0", 78 | "semver": "7.3.2", 79 | "stylis": "3.5.4", 80 | "typescript": "3.9.5", 81 | "uid": "1.0.0", 82 | "webpack": "4.43.0" 83 | }, 84 | "peerDependencies": { 85 | "@babel/preset-typescript": "^7.0.0", 86 | "@babel/preset-env": "^7.0.0", 87 | "@babel/preset-react": "^7.0.0", 88 | "@babel/plugin-proposal-class-properties": "^7.0.0", 89 | "@types/react": "^16.8.0", 90 | "react": "^16.8.0" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/babel-plugin-plugin", 3 | "dependencies": { 4 | "@babel/traverse": "7.9.6", 5 | "@babel/types": "7.9.6", 6 | "@types/node": "13.13.12" 7 | }, 8 | "private": true 9 | } 10 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/Example.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStyles } from 'flair'; 3 | import getRed from './submodule'; 4 | 5 | const useStyles = createStyles(({ css, theme }) => ({ 6 | root: css` 7 | margin: ${theme.space(1)} ${theme.space(2)}; 8 | height: ${theme.block(5)}; 9 | display: flex; 10 | flex-direction: column; 11 | transition: background-color ${theme.durations.standard}, 12 | border ${theme.durations.standard}; 13 | overflow: hidden; 14 | color: ${getRed()}; 15 | `, 16 | title: css` 17 | ${theme.fonts.h4}; 18 | flex: 0 0 auto; 19 | /* margin-bottom: calc(50vh - ${theme.space(2)}); */ 20 | color: ${theme.colors.brand}; 21 | 22 | ${theme.down(theme.tablet)} { 23 | ${theme.fonts.h5}; 24 | } 25 | `, 26 | body: css` 27 | border-bottom: 1px solid ${theme.colors.danger}; 28 | flex: 1 1 auto; 29 | `, 30 | })); 31 | 32 | function Card(props) { 33 | const { Root, styles, title, description } = useStyles(props); 34 | 35 | return ( 36 | 37 |

{title}

38 |

{description}

39 |
40 | ); 41 | } 42 | 43 | export default Card; 44 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/__cacheDir__/Example--00000.css: -------------------------------------------------------------------------------- 1 | .Example--00000-0-root{margin:var(--Example--00000-0-root-0);height:var(--Example--00000-0-root-1);display:flex;flex-direction:column;transition:var(--Example--00000-0-root-2);overflow:hidden;color:var(--Example--00000-0-root-3)} 2 | .Example--00000-0-title{font-size:32px;font-weight:bold;margin:0;flex:0 0 auto;color:var(--Example--00000-0-title-1)}@media (max-width:768px){.Example--00000-0-title{font-size:24px;font-weight:bold;margin:0}} 3 | .Example--00000-0-body{border-bottom:var(--Example--00000-0-body-0);flex:1 1 auto} -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/exampleTheme.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | block: (n: number) => `${n * 96}px`, 3 | space: (n: number) => `${n * 16}px`, 4 | fonts: { 5 | h4: ` 6 | font-size: 32px; 7 | font-weight: bold; 8 | margin: 0; 9 | `, 10 | h5: ` 11 | font-size: 24px; 12 | font-weight: bold; 13 | margin: 0; 14 | `, 15 | body1: ` 16 | font-size: 16px; 17 | margin: 0; 18 | line-height: 1.5; 19 | `, 20 | }, 21 | down: (value: string) => `@media (max-width: ${value})`, 22 | tablet: '768px', 23 | 24 | colors: { 25 | brand: '#00f', 26 | danger: '#f00', 27 | }, 28 | 29 | durations: { 30 | standard: '250ms', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './plugin'; 2 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/plugin.test.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { transform, traverse, parse } from '@babel/core'; 4 | import generate from '@babel/generator'; 5 | import { seek } from '@flair/common'; 6 | import { collect } from '@flair/collect'; 7 | import prettier from 'prettier'; 8 | import plugin from './plugin'; 9 | 10 | it('removes the tagged template literals and replaces it with array expressions', async () => { 11 | const filename = require.resolve('./Example'); 12 | const code = (await fs.promises.readFile(filename)).toString(); 13 | 14 | expect(code).toMatchInlineSnapshot(` 15 | "import React from 'react'; 16 | import { createStyles } from 'flair'; 17 | import getRed from './submodule'; 18 | 19 | const useStyles = createStyles(({ css, theme }) => ({ 20 | root: css\` 21 | margin: \${theme.space(1)} \${theme.space(2)}; 22 | height: \${theme.block(5)}; 23 | display: flex; 24 | flex-direction: column; 25 | transition: background-color \${theme.durations.standard}, 26 | border \${theme.durations.standard}; 27 | overflow: hidden; 28 | color: \${getRed()}; 29 | \`, 30 | title: css\` 31 | \${theme.fonts.h4}; 32 | flex: 0 0 auto; 33 | /* margin-bottom: calc(50vh - \${theme.space(2)}); */ 34 | color: \${theme.colors.brand}; 35 | 36 | \${theme.down(theme.tablet)} { 37 | \${theme.fonts.h5}; 38 | } 39 | \`, 40 | body: css\` 41 | border-bottom: 1px solid \${theme.colors.danger}; 42 | flex: 1 1 auto; 43 | \`, 44 | })); 45 | 46 | function Card(props) { 47 | const { Root, styles, title, description } = useStyles(props); 48 | 49 | return ( 50 | 51 |

{title}

52 |

{description}

53 |
54 | ); 55 | } 56 | 57 | export default Card; 58 | " 59 | `); 60 | 61 | const result = transform(code, { 62 | babelrc: false, 63 | filename, 64 | presets: [ 65 | [ 66 | '@babel/preset-env', 67 | { 68 | targets: ['> 5% and last 2 years'], 69 | }, 70 | ], 71 | ], 72 | plugins: [ 73 | [ 74 | plugin, 75 | { 76 | themePath: require.resolve('./exampleTheme'), 77 | cacheDir: path.resolve(__dirname, './__cacheDir__'), 78 | }, 79 | ], 80 | ], 81 | }); 82 | 83 | expect(result.code.includes('@flair/loader')).toBe(true); 84 | 85 | const useStylesAst = seek((report) => { 86 | traverse(parse(result.code, { filename }), { 87 | VariableDeclaration(path) { 88 | const [variableDeclarator] = path.node.declarations; 89 | if (variableDeclarator.id.name !== 'useStyles') return; 90 | report(path.node); 91 | }, 92 | }); 93 | }); 94 | 95 | const useStylesCode = generate(useStylesAst).code; 96 | 97 | // TODO: remove empty arrays (e.g. `body` is an empty array) 98 | expect(useStylesCode).toMatchInlineSnapshot(` 99 | "const useStyles = (0, _ssr.createStyles)(({ 100 | css, 101 | theme 102 | }) => ({ 103 | root: [\`\${theme.space(1)} \${theme.space(2)}\`, \`\${theme.block(5)}\`, \`background-color \${theme.durations.standard}, 104 | border \${theme.durations.standard}\`, \`\${(0, _submodule.default)()}\`], 105 | title: [\`calc(50vh - \${theme.space(2)})\`, \`\${theme.colors.brand}\`], 106 | body: [\`1px solid \${theme.colors.danger}\`], 107 | classNamePrefix: \\"Example--00000-0\\" 108 | }));" 109 | `); 110 | 111 | const css = collect(filename, { 112 | themePath: require.resolve('./exampleTheme'), 113 | cacheDir: path.resolve(__dirname, './__cacheDir__'), 114 | }); 115 | 116 | const prettyCss = prettier.format(css, { 117 | parser: 'css', 118 | }); 119 | 120 | expect(prettyCss).toMatchInlineSnapshot(` 121 | ".Example--00000-0-root { 122 | margin: var(--Example--00000-0-root-0); 123 | height: var(--Example--00000-0-root-1); 124 | display: flex; 125 | flex-direction: column; 126 | transition: var(--Example--00000-0-root-2); 127 | overflow: hidden; 128 | color: var(--Example--00000-0-root-3); 129 | } 130 | .Example--00000-0-title { 131 | font-size: 32px; 132 | font-weight: bold; 133 | margin: 0; 134 | flex: 0 0 auto; 135 | color: var(--Example--00000-0-title-1); 136 | } 137 | @media (max-width: 768px) { 138 | .Example--00000-0-title { 139 | font-size: 24px; 140 | font-weight: bold; 141 | margin: 0; 142 | } 143 | } 144 | .Example--00000-0-body { 145 | border-bottom: var(--Example--00000-0-body-0); 146 | flex: 1 1 auto; 147 | } 148 | " 149 | `); 150 | }); 151 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Visitor } from '@babel/traverse'; 2 | import * as t from '@babel/types'; 3 | import { 4 | collect, 5 | transformCssTemplateLiteral, 6 | CollectionPluginOptions, 7 | } from '@flair/collect'; 8 | import { seek, createFilenameHash } from '@flair/common'; 9 | 10 | interface Options extends CollectionPluginOptions { 11 | themePath: string; 12 | cacheDir: string; 13 | } 14 | 15 | const importSourceValue = 'flair'; 16 | const replacementImportSourceValue = '@flair/ssr'; 17 | const importedName = 'createStyles'; 18 | 19 | function plugin( 20 | _: any, 21 | opts: Options, 22 | ): { 23 | visitor: Visitor<{ opts: Options; file: { opts: { filename: string } } }>; 24 | } { 25 | return { 26 | visitor: { 27 | Program(path, state) { 28 | /** 29 | * this is used to match the `useStyle` index. because we allow more than one 30 | * `createStyles` call, we need to keep track of what number this one is. 31 | */ 32 | let useStyleIndex = 0; 33 | 34 | const { filename } = state.file.opts; 35 | 36 | // Find create styles before continuing 37 | const foundCreateStyles = seek((report) => { 38 | path.traverse({ 39 | ImportDeclaration(path) { 40 | const { specifiers, source } = path.node; 41 | 42 | const hasPackageName = source.value === importSourceValue; 43 | if (!hasPackageName) return; 44 | 45 | const hasCreateStyles = specifiers.some((node) => { 46 | if (!t.isImportSpecifier(node)) return false; 47 | return node.imported.name === importedName; 48 | }); 49 | if (!hasCreateStyles) return; 50 | 51 | report(true); 52 | }, 53 | }); 54 | 55 | report(false); 56 | }); 57 | if (!foundCreateStyles) return; 58 | 59 | // Generate the CSS via `collect` 60 | // This generation is done up here so that this plugin can early quit 61 | // if `collect` throws 62 | const css = collect(filename, opts); 63 | 64 | // Add the import for the CSS filename 65 | path.node.body.unshift( 66 | t.importDeclaration( 67 | [], 68 | t.stringLiteral( 69 | `@flair/loader/load.rss-css?css=${encodeURIComponent( 70 | Buffer.from(css).toString('base64'), 71 | )}`, 72 | ), 73 | ), 74 | ); 75 | 76 | path.traverse({ 77 | // Rewrite imports 78 | ImportDeclaration(path) { 79 | const { specifiers, source } = path.node; 80 | const hasCreateStyles = specifiers.some((node) => { 81 | if (!t.isImportSpecifier(node)) return false; 82 | return node.imported.name === importedName; 83 | }); 84 | if (!hasCreateStyles) return; 85 | 86 | const hasPackageName = source.value === importSourceValue; 87 | if (!hasPackageName) return; 88 | 89 | path.node.source = t.stringLiteral(replacementImportSourceValue); 90 | }, 91 | 92 | // Rewrite `createStyles` return object expression 93 | CallExpression(path) { 94 | const { callee, arguments: expressionArguments } = path.node; 95 | if (!t.isIdentifier(callee)) return; 96 | if (callee.name !== importedName) return; 97 | 98 | const [firstArgument] = expressionArguments; 99 | 100 | if ( 101 | !t.isFunctionExpression(firstArgument) && 102 | !t.isArrowFunctionExpression(firstArgument) 103 | ) { 104 | return; 105 | } 106 | 107 | const { body } = firstArgument; 108 | 109 | const stylesObjectExpression = seek( 110 | (report) => { 111 | if (t.isObjectExpression(body)) { 112 | report(body); 113 | } 114 | 115 | path.traverse({ 116 | ReturnStatement(path) { 117 | const { argument } = path.node; 118 | 119 | if (!t.isObjectExpression(argument)) return; 120 | report(argument); 121 | }, 122 | }); 123 | }, 124 | ); 125 | 126 | // Go through each property and replace it with strings that can be 127 | // replaced with CSS variables 128 | stylesObjectExpression.properties = stylesObjectExpression.properties.map( 129 | (property) => { 130 | if (!t.isObjectProperty(property)) return property; 131 | 132 | const { value, key } = property; 133 | if (!t.isTaggedTemplateExpression(value)) return property; 134 | 135 | const { tag, quasi } = value; 136 | if (!t.isIdentifier(tag)) return property; 137 | if (tag.name !== 'css') return property; 138 | 139 | const transformedTemplateLiteral = transformCssTemplateLiteral( 140 | quasi, 141 | ); 142 | 143 | const cssPropertyExpressions = transformedTemplateLiteral.expressions.filter( 144 | (expression) => { 145 | if ( 146 | t.isCallExpression(expression) && 147 | t.isIdentifier(expression.callee) && 148 | expression.callee.name === 'staticVar' 149 | ) { 150 | return false; 151 | } 152 | return true; 153 | }, 154 | ); 155 | 156 | return t.objectProperty( 157 | key, 158 | t.arrayExpression(cssPropertyExpressions), 159 | ); 160 | }, 161 | ); 162 | 163 | stylesObjectExpression.properties.push( 164 | t.objectProperty( 165 | t.identifier('classNamePrefix'), 166 | t.stringLiteral( 167 | `${createFilenameHash(filename)}-${useStyleIndex}`, 168 | ), 169 | ), 170 | ); 171 | 172 | useStyleIndex += 1; 173 | }, 174 | }); 175 | }, 176 | }, 177 | }; 178 | } 179 | 180 | export default plugin; 181 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/src/submodule.ts: -------------------------------------------------------------------------------- 1 | export default () => '#f00'; 2 | -------------------------------------------------------------------------------- /packages/babel-plugin-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "baseUrl": "../", 6 | "paths": { 7 | "@flair/collect": ["collect/src/index.ts"], 8 | "@flair/common": ["common/src/index.ts"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/collect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/collect", 3 | "dependencies": { 4 | "@babel/core": "7.9.6", 5 | "@babel/polyfill": "7.8.7", 6 | "@babel/generator": "7.9.6", 7 | "@babel/plugin-proposal-class-properties": "7.8.3", 8 | "@babel/template": "7.8.6", 9 | "@babel/traverse": "7.9.6", 10 | "@babel/types": "7.9.6", 11 | "babel-plugin-module-resolver": "4.0.0", 12 | "@types/common-tags": "1.8.0", 13 | "@types/node": "13.13.12", 14 | "common-tags": "1.8.0", 15 | "pirates": "4.0.1", 16 | "stylis": "3.5.4", 17 | "require-from-string": "2.0.2", 18 | "@types/require-from-string": "1.2.0" 19 | }, 20 | "peerDependencies": { 21 | "@babel/preset-typescript": "^7.0.0", 22 | "@babel/preset-env": "^7.0.0", 23 | "@babel/preset-react": "^7.0.0", 24 | "@babel/plugin-proposal-class-properties": "^7.0.0" 25 | }, 26 | "private": true 27 | } 28 | -------------------------------------------------------------------------------- /packages/collect/src/Example.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStyles } from 'flair'; 3 | import getRed from './submodule'; 4 | import Example2 from './Example2'; 5 | 6 | import 'thing.css'; 7 | // eslint-disable-next-line no-unused-vars 8 | import * as styles from './thing.module.scss'; 9 | 10 | const useStyles = createStyles(({ css, theme }) => ({ 11 | root: css` 12 | height: ${theme.block(5)}; 13 | display: flex; 14 | flex-direction: column; 15 | overflow: hidden; 16 | color: ${getRed()}; 17 | `, 18 | title: css` 19 | ${theme.fonts.h4}; 20 | flex: 0 0 auto; 21 | margin-bottom: -${theme.space(1)}; 22 | color: ${theme.colors.brand}; 23 | border-bottom: 1px solid ${theme.colors.brand}; 24 | 25 | ${theme.down(theme.tablet)} { 26 | ${theme.fonts.h5}; 27 | } 28 | `, 29 | body: css` 30 | ${theme.fonts.body1}; 31 | flex: 1 1 auto; 32 | `, 33 | })); 34 | 35 | // eslint-disable-next-line no-unused-vars 36 | const useAnother = createStyles(({ css }) => ({ 37 | root: css` 38 | color: black; 39 | `, 40 | })); 41 | 42 | function Card(props) { 43 | const { Root, styles, title, description } = useStyles(props); 44 | 45 | return ( 46 | 47 |

{title}

48 |

{description}

49 | 50 |
51 | ); 52 | } 53 | 54 | export default Card; 55 | -------------------------------------------------------------------------------- /packages/collect/src/Example2.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStyles } from 'flair'; 3 | 4 | const useStyles = createStyles(({ css }) => ({ 5 | root: css``, 6 | })); 7 | 8 | function Example2(props) { 9 | const { Root } = useStyles(props); 10 | 11 | return Text; 12 | } 13 | 14 | export default Example2; 15 | -------------------------------------------------------------------------------- /packages/collect/src/collect.test.js: -------------------------------------------------------------------------------- 1 | import collect from './collect'; 2 | 3 | it('collects the static css from a file', () => { 4 | const exampleFilename = require.resolve('./Example'); 5 | const exampleStaticThemeFilename = require.resolve('./exampleTheme.ts'); 6 | 7 | const css = collect(exampleFilename, { 8 | themePath: exampleStaticThemeFilename, 9 | }); 10 | 11 | expect(css).toMatchInlineSnapshot(` 12 | ".Example--00000-0-root{height:var(--Example--00000-0-root-0);display:flex;flex-direction:column;overflow:hidden;color:var(--Example--00000-0-root-1)} 13 | .Example--00000-0-title{font-size:32px;font-weight:bold;margin:0;flex:0 0 auto;margin-bottom:var(--Example--00000-0-title-0);color:var(--Example--00000-0-title-1);border-bottom:var(--Example--00000-0-title-2)}@media (max-width:768px){.Example--00000-0-title{font-size:24px;font-weight:bold;margin:0}} 14 | .Example--00000-0-body{font-size:16px;margin:0;line-height:1.5;flex:1 1 auto} 15 | .Example--00000-1-root{color:black}" 16 | `); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/collect/src/collect.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | // the types for stylis seems to have been deleted at this time of writing 3 | // @ts-ignore 4 | import stylis from 'stylis'; 5 | import * as babel from '@babel/core'; 6 | import { addHook } from 'pirates'; 7 | import { createFilenameHash } from '@flair/common'; 8 | import requireFromString from 'require-from-string'; 9 | import collectionPlugin, { Options } from './collectionPlugin'; 10 | 11 | stylis.set({ 12 | compress: true, 13 | prefix: false, 14 | }); 15 | 16 | function collect(filename: string, opts: Options) { 17 | const { themePath } = opts; 18 | const filenameHash = createFilenameHash(filename); 19 | 20 | if (!themePath) { 21 | throw new Error('theme path is required'); 22 | } 23 | 24 | const code = fs.readFileSync(filename).toString(); 25 | 26 | function attempt(fn: () => T, errorMessage: string) { 27 | try { 28 | return fn(); 29 | } catch (e) { 30 | throw new Error(`[${filename}] ${errorMessage}: ${e?.message}`); 31 | } 32 | } 33 | 34 | const babelConfig = (filename: string) => ({ 35 | filename, 36 | presets: [ 37 | ['@babel/preset-env', { targets: { node: 'current' } }], 38 | ['@babel/preset-typescript'], 39 | ['@babel/preset-react'], 40 | ], 41 | plugins: [ 42 | '@babel/plugin-proposal-class-properties', 43 | [collectionPlugin, opts], 44 | ...(opts.moduleResolver 45 | ? [['module-resolver', opts.moduleResolver]] 46 | : []), 47 | ], 48 | babelrc: false, 49 | }); 50 | 51 | const revert = addHook( 52 | (code: string, filename: string) => { 53 | const result = babel.transform(code, babelConfig(filename)); 54 | 55 | if (!result?.code) { 56 | throw new Error('no transform'); 57 | } 58 | 59 | return result.code; 60 | }, 61 | { exts: ['.js', '.ts', '.tsx'] }, 62 | ); 63 | 64 | try { 65 | const transformedCode = attempt(() => { 66 | const result = babel.transform( 67 | `require('@babel/polyfill');\n${code}`, 68 | babelConfig(filename), 69 | ); 70 | 71 | if (!result?.code) { 72 | throw new Error('no transform'); 73 | } 74 | 75 | return result.code; 76 | }, 'Failed to transform'); 77 | 78 | const stylesToPull = attempt( 79 | () => 80 | (() => { 81 | const result = requireFromString(transformedCode); 82 | return Object.values(result).filter( 83 | (maybeFn: any) => maybeFn.__cssExtractable, 84 | ) as Array<() => { [key: string]: string }>; 85 | })(), 86 | 'Failed to execute file', 87 | ); 88 | 89 | const unprocessedCss = attempt(() => { 90 | return stylesToPull.map((fn) => fn()); 91 | }, 'Failed to evaluate CSS strings') as Array<{ [key: string]: string }>; 92 | 93 | const finalCss = attempt( 94 | () => 95 | unprocessedCss 96 | .map((styleObj, index) => 97 | Object.entries(styleObj) 98 | .filter(([_key, value]) => typeof value === 'string') 99 | .map(([key, value]) => { 100 | const className = `.${filenameHash}-${index}-${key}`; 101 | return stylis(className, value as string); 102 | }) 103 | .join('\n'), 104 | ) 105 | .join('\n'), 106 | 'Failed to process styles', 107 | ); 108 | 109 | return finalCss; 110 | } catch (e) { 111 | throw e; 112 | } finally { 113 | revert(); 114 | } 115 | } 116 | 117 | export default collect; 118 | -------------------------------------------------------------------------------- /packages/collect/src/collectionPlugin.test.js: -------------------------------------------------------------------------------- 1 | import { transform } from '@babel/core'; 2 | import { stripIndent } from 'common-tags'; 3 | import plugin from './collectionPlugin'; 4 | 5 | it('transforms the given code so that useStyles is exported', () => { 6 | const code = stripIndent` 7 | import { createStyles, createReadablePalette } from 'flair'; 8 | import { readableColor } from 'polished'; 9 | import { doThing } from './localModule'; 10 | 11 | const useStyles = createStyles(({ css, theme }) => { 12 | const danger = createReadablePalette(theme.colors.danger); 13 | 14 | return { 15 | root: css\` 16 | padding: \${theme.space(0.75)} \${theme.space(1)}; 17 | color: \${danger.readable}; 18 | background-color: \${readableColor(danger.readable)}; 19 | width: 50%; 20 | 21 | \${doThing(theme.colors.brand)}; 22 | 23 | \${theme.down(theme.tablet)} { 24 | width: 100%; 25 | } 26 | 27 | margin: \${theme.space(0)}; 28 | \`, 29 | }; 30 | }); 31 | `; 32 | 33 | const result = transform(code, { 34 | filename: '/usr/example/blah/Example.js', 35 | babelrc: false, 36 | plugins: [ 37 | [ 38 | plugin, 39 | { 40 | themePath: '/usr/theme/exampleTheme.js', 41 | }, 42 | ], 43 | ], 44 | }); 45 | 46 | expect(result.code).toMatchInlineSnapshot(` 47 | "\\"use strict\\"; 48 | 49 | Object.defineProperty(exports, \\"__esModule\\", { 50 | value: true 51 | }); 52 | exports.useStyles = void 0; 53 | 54 | var _flair = require(\\"flair\\"); 55 | 56 | var _polished = require(\\"polished\\"); 57 | 58 | var _localModule = require(\\"/usr/example/blah/localModule\\"); 59 | 60 | const staticVar = t => t; 61 | 62 | const createStyles = styleFn => { 63 | function css(strings, ...values) { 64 | let combined = ''; 65 | 66 | for (let i = 0; i < strings.length; i += 1) { 67 | const currentString = strings[i]; 68 | const currentValue = values[i] || ''; 69 | combined += currentString + currentValue; 70 | } 71 | 72 | return combined; 73 | } 74 | 75 | const themePath = \\"/usr/theme/exampleTheme.js\\"; 76 | 77 | const theme = require(themePath).default || require(themePath); 78 | 79 | const color = { 80 | original: '#000', 81 | decorative: '#000', 82 | readable: '#000', 83 | aa: '#000', 84 | aaa: '#000' 85 | }; 86 | const surface = '#fff'; 87 | return () => styleFn({ 88 | css, 89 | theme, 90 | color, 91 | surface, 92 | staticVar 93 | }); 94 | }; 95 | 96 | const useStyles = createStyles(({ 97 | css, 98 | theme 99 | }) => { 100 | const danger = (0, _flair.createReadablePalette)(theme.colors.danger); 101 | return { 102 | root: css\` 103 | padding: \${\\"var(--Example--00000-0-root-0)\\"}; 104 | color: \${\\"var(--Example--00000-0-root-1)\\"}; 105 | background-color: \${\\"var(--Example--00000-0-root-2)\\"}; 106 | width: 50%; 107 | 108 | \${staticVar((0, _localModule.doThing)(theme.colors.brand))}; 109 | 110 | \${staticVar(theme.down(theme.tablet))} { 111 | width: 100%; 112 | } 113 | 114 | margin: \${\\"var(--Example--00000-0-root-3)\\"}; 115 | \` 116 | }; 117 | }); 118 | exports.useStyles = useStyles; 119 | useStyles.__cssExtractable = true;" 120 | `); 121 | }); 122 | -------------------------------------------------------------------------------- /packages/collect/src/collectionPlugin.ts: -------------------------------------------------------------------------------- 1 | import _path from 'path'; 2 | import * as t from '@babel/types'; 3 | import template from '@babel/template'; 4 | import { Visitor } from '@babel/traverse'; 5 | import { seek, createFilenameHash } from '@flair/common'; 6 | import transformCssTemplateLiteral from './transformCssTemplateLiteral'; 7 | 8 | export interface Options { 9 | themePath: string; 10 | moduleResolver?: any; 11 | ignoreImportPattern?: string; 12 | } 13 | 14 | const importSourceValue = 'flair'; 15 | const importedName = 'createStyles'; 16 | 17 | function collectionPlugin(): { 18 | visitor: Visitor<{ 19 | opts: Options; 20 | file: { opts: { filename: string } }; 21 | }>; 22 | } { 23 | return { 24 | visitor: { 25 | Program(path, state) { 26 | /** 27 | * this is used to match the `useStyle` index. because we allow more than one 28 | * `createStyles` call, we need to keep track of what number this one is. 29 | */ 30 | let useStyleIndex = 0; 31 | 32 | const { filename } = state.file.opts; 33 | const filenameHash = createFilenameHash(filename); 34 | const { 35 | themePath, 36 | ignoreImportPattern = '\\.((css)|(scss))$', 37 | } = state.opts; 38 | 39 | if (!themePath) throw new Error('themePath required'); 40 | 41 | // remove ignored import statement 42 | path.node.body = path.node.body.filter((statement) => { 43 | if (!t.isImportDeclaration(statement)) return true; 44 | 45 | const { source } = statement; 46 | const match = new RegExp(ignoreImportPattern).exec(source.value); 47 | if (!match) return true; 48 | 49 | return false; 50 | }); 51 | 52 | // Check if this file should be transformed 53 | const foundCreateStyles = seek((report) => { 54 | path.traverse({ 55 | ImportDeclaration(path) { 56 | const { specifiers, source } = path.node; 57 | 58 | const hasPackageName = source.value === importSourceValue; 59 | if (!hasPackageName) return; 60 | 61 | const hasCreateStyles = specifiers.some((node) => { 62 | if (!t.isImportSpecifier(node)) return false; 63 | return node.imported.name === importedName; 64 | }); 65 | if (!hasCreateStyles) return; 66 | 67 | report(true); 68 | }, 69 | }); 70 | 71 | report(false); 72 | }); 73 | if (!foundCreateStyles) return; 74 | 75 | // Remove the `createStyles` import 76 | path.traverse({ 77 | ImportDeclaration(path) { 78 | const { specifiers, source } = path.node; 79 | 80 | const hasPackageName = source.value === importSourceValue; 81 | if (!hasPackageName) return; 82 | 83 | path.node.specifiers = specifiers.filter((node) => { 84 | if (!t.isImportSpecifier(node)) return true; 85 | 86 | // return false in this case bc we're removing it 87 | if (node.imported.name === importedName) return false; 88 | 89 | return true; 90 | }); 91 | 92 | if (path.node.specifiers.length <= 0) { 93 | path.remove(); 94 | } 95 | }, 96 | }); 97 | 98 | // Add a `createStyles` statement to the top of the body 99 | path.node.body.unshift(template.statement.ast` 100 | /** 101 | * This is a mocked version of \`createStyles\` made to extract the 102 | * CSS written in it. 103 | */ 104 | const createStyles = styleFn => { 105 | function css(strings, ...values) { 106 | let combined = ''; 107 | for (let i = 0; i < strings.length; i += 1) { 108 | const currentString = strings[i]; 109 | const currentValue = values[i] || ''; 110 | combined += currentString + currentValue; 111 | } 112 | return combined; 113 | } 114 | 115 | const themePath = ${JSON.stringify(themePath)}; 116 | const theme = require(themePath).default || require(themePath); 117 | 118 | // TODO: warn against executing these variables. 119 | // There should never really need to be a reason to execute these 120 | // and have their static versions show up in the static CSS 121 | const color = { 122 | original: '#000', 123 | decorative: '#000', 124 | readable: '#000', 125 | aa: '#000', 126 | aaa: '#000', 127 | }; 128 | const surface = '#fff'; 129 | 130 | return () => styleFn({ css, theme, color, surface, staticVar }); 131 | } 132 | `); 133 | 134 | path.node.body.unshift( 135 | template.statement.ast`const staticVar = t => t;`, 136 | ); 137 | 138 | // Find the `useStyles` declaration and export it 139 | path.node.body = path.node.body 140 | .map((statement) => { 141 | if (!t.isVariableDeclaration(statement)) return statement; 142 | if (!statement.declarations.length) return statement; 143 | 144 | const createStylesDeclarations = statement.declarations.filter( 145 | (declaration) => { 146 | const { init, id } = declaration; 147 | if (!t.isIdentifier(id)) return false; 148 | if (!t.isCallExpression(init)) return false; 149 | const { callee } = init; 150 | if (!t.isIdentifier(callee)) return false; 151 | 152 | if (callee.name !== importedName) return false; 153 | return true; 154 | }, 155 | ); 156 | if (createStylesDeclarations.length <= 0) return statement; 157 | const variableNames = createStylesDeclarations.map( 158 | (declaration) => { 159 | const { id } = declaration; 160 | if (!t.isIdentifier(id)) { 161 | throw new Error( 162 | `Expected to find identifier but found "${id.type}"`, 163 | ); 164 | } 165 | 166 | return id.name; 167 | }, 168 | ); 169 | 170 | // if we get this far, then this is the createStyles declaration 171 | // and we'll export it 172 | return [ 173 | t.exportNamedDeclaration(statement), 174 | ...variableNames.map( 175 | (name) => 176 | template.statement.ast`${name}.__cssExtractable = true;`, 177 | ), 178 | ]; 179 | }) 180 | .flat(); 181 | 182 | // Take all the relative file imports and make them absolute using the 183 | // filename path 184 | path.node.body = path.node.body.map((statement) => { 185 | if (!t.isImportDeclaration(statement)) return statement; 186 | if (!statement.source.value.startsWith('.')) return statement; 187 | 188 | const { source, specifiers } = statement; 189 | const dirname = _path.dirname(filename); 190 | const resolved = _path.resolve(dirname, source.value); 191 | return t.importDeclaration(specifiers, t.stringLiteral(resolved)); 192 | }); 193 | 194 | // Transform the body of the createStyles function 195 | path.traverse({ 196 | CallExpression(path) { 197 | const { callee, arguments: expressionArguments } = path.node; 198 | 199 | // Find the createStyles invocation 200 | if (!t.isIdentifier(callee)) return; 201 | if (callee.name !== importedName) return; 202 | 203 | // ensure that the argument is a function 204 | const [firstArgument] = expressionArguments; 205 | if ( 206 | !t.isFunctionExpression(firstArgument) && 207 | !t.isArrowFunctionExpression(firstArgument) 208 | ) { 209 | return; 210 | } 211 | 212 | const stylesObjectExpression = seek( 213 | (report) => { 214 | const { body } = firstArgument; 215 | 216 | if (t.isObjectExpression(body)) { 217 | report(body); 218 | } 219 | 220 | path.traverse({ 221 | ReturnStatement(path) { 222 | const { argument } = path.node; 223 | 224 | if (!t.isObjectExpression(argument)) return; 225 | report(argument); 226 | }, 227 | }); 228 | }, 229 | ); 230 | 231 | // Go through each property and replace it with strings that can be 232 | // replaced with CSS variables 233 | stylesObjectExpression.properties = stylesObjectExpression.properties.map( 234 | (property) => { 235 | if (!t.isObjectProperty(property)) return property; 236 | 237 | const { value, key } = property; 238 | if (!t.isTaggedTemplateExpression(value)) return property; 239 | 240 | const { tag, quasi } = value; 241 | if (!t.isIdentifier(tag)) return property; 242 | if (tag.name !== 'css') return property; 243 | 244 | const transformedQuasi = transformCssTemplateLiteral(quasi); 245 | 246 | let index = 0; 247 | const transformedExpressions = transformedQuasi.expressions.map( 248 | (expression) => { 249 | if ( 250 | t.isCallExpression(expression) && 251 | t.isIdentifier(expression.callee) && 252 | expression.callee.name === 'staticVar' 253 | ) { 254 | return expression; 255 | } 256 | 257 | const ret = t.stringLiteral( 258 | `var(--${filenameHash}-${useStyleIndex}-${key.name}-${index})`, 259 | ); 260 | 261 | index += 1; 262 | 263 | return ret; 264 | }, 265 | ); 266 | 267 | const final = t.taggedTemplateExpression( 268 | tag, 269 | t.templateLiteral( 270 | transformedQuasi.quasis, 271 | transformedExpressions, 272 | ), 273 | ); 274 | 275 | return t.objectProperty(key, final); 276 | }, 277 | ); 278 | 279 | useStyleIndex += 1; 280 | }, 281 | }); 282 | }, 283 | }, 284 | }; 285 | } 286 | 287 | export default collectionPlugin; 288 | -------------------------------------------------------------------------------- /packages/collect/src/exampleTheme.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | block: (n: number) => `${n * 96}px`, 3 | space: (n: number) => `${n * 16}px`, 4 | fonts: { 5 | h4: ` 6 | font-size: 32px; 7 | font-weight: bold; 8 | margin: 0; 9 | `, 10 | h5: ` 11 | font-size: 24px; 12 | font-weight: bold; 13 | margin: 0; 14 | `, 15 | body1: ` 16 | font-size: 16px; 17 | margin: 0; 18 | line-height: 1.5; 19 | `, 20 | }, 21 | down: (value: string) => `@media (max-width: ${value})`, 22 | tablet: '768px', 23 | 24 | durations: { 25 | standard: '250ms', 26 | }, 27 | 28 | colors: { 29 | brand: '#00f', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/collect/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as collect } from './collect'; 2 | export { default as transformCssTemplateLiteral } from './transformCssTemplateLiteral'; 3 | export type { Options as CollectionPluginOptions } from './collectionPlugin'; 4 | -------------------------------------------------------------------------------- /packages/collect/src/submodule.ts: -------------------------------------------------------------------------------- 1 | export default () => '#f00'; 2 | -------------------------------------------------------------------------------- /packages/collect/src/transformCssTemplateLiteral.test.js: -------------------------------------------------------------------------------- 1 | import { parse } from '@babel/core'; 2 | import { stripIndent } from 'common-tags'; 3 | import * as t from '@babel/types'; 4 | import generate from '@babel/generator'; 5 | import transformCssTemplateLiteral from './transformCssTemplateLiteral'; 6 | 7 | it('takes in a CSS template literal and wraps non-CSS property expressions in `staticVar`', () => { 8 | const code = stripIndent` 9 | \` 10 | padding: \${theme.space(0.75)} \${theme.space(1)}; 11 | \${theme.fonts.h4}; 12 | flex: 0 0 auto; 13 | margin-bottom: -\${theme.space(1)}; 14 | color: \${theme.colors.brand}; 15 | transition: background-color \${theme.durations.standard}, 16 | border \${theme.durations.standard}; 17 | border-bottom: -\${theme.space(1)} solid \${theme.colors.brand}; 18 | background: \${staticVar(transparentize(0.5, 'black'))}; 19 | 20 | \${theme.down(theme.tablet)} { 21 | \${theme.fonts.h5}; 22 | } 23 | \``.trim(); 24 | 25 | const file = parse(code, { filename: 'example.js' }); 26 | 27 | const [firstStatement] = file.program.body; 28 | const templateLiteral = firstStatement.expression; 29 | expect(t.isTemplateLiteral(templateLiteral)).toBe(true); 30 | 31 | const result = transformCssTemplateLiteral(templateLiteral); 32 | 33 | const { code: outputCode } = generate(result); 34 | expect(outputCode).toMatchInlineSnapshot(` 35 | "\` 36 | padding: \${\`\${theme.space(0.75)} \${theme.space(1)}\`}; 37 | \${staticVar(theme.fonts.h4)}; 38 | flex: 0 0 auto; 39 | margin-bottom: \${\`-\${theme.space(1)}\`}; 40 | color: \${\`\${theme.colors.brand}\`}; 41 | transition: \${\`background-color \${theme.durations.standard}, 42 | border \${theme.durations.standard}\`}; 43 | border-bottom: \${\`-\${theme.space(1)} solid \${theme.colors.brand}\`}; 44 | background: \${\`\${staticVar(transparentize(0.5, 'black'))}\`}; 45 | 46 | \${staticVar(theme.down(theme.tablet))} { 47 | \${staticVar(theme.fonts.h5)}; 48 | } 49 | \`" 50 | `); 51 | }); 52 | 53 | test('non-CSS property expression first', () => { 54 | const code = stripIndent`\` 55 | \${theme.fonts.body1}; 56 | flex: 1 1 auto; 57 | \``; 58 | 59 | const file = parse(code, { filename: 'example.js' }); 60 | 61 | const [firstStatement] = file.program.body; 62 | const templateLiteral = firstStatement.expression; 63 | expect(t.isTemplateLiteral(templateLiteral)).toBe(true); 64 | 65 | const result = transformCssTemplateLiteral(templateLiteral); 66 | 67 | const { code: outputCode } = generate(result); 68 | expect(outputCode).toMatchInlineSnapshot(` 69 | "\` 70 | \${staticVar(theme.fonts.body1)}; 71 | flex: 1 1 auto; 72 | \`" 73 | `); 74 | }); 75 | 76 | test('multiline CSS property', () => { 77 | const code = stripIndent`\` 78 | transition: background-color \${theme.durations.standard}, 79 | border \${theme.durations.standard}; 80 | \``; 81 | 82 | const file = parse(code, { filename: 'example.js' }); 83 | 84 | const [firstStatement] = file.program.body; 85 | const templateLiteral = firstStatement.expression; 86 | expect(t.isTemplateLiteral(templateLiteral)).toBe(true); 87 | 88 | const result = transformCssTemplateLiteral(templateLiteral); 89 | 90 | const { code: outputCode } = generate(result); 91 | expect(outputCode).toMatchInlineSnapshot(` 92 | "\` 93 | transition: \${\`background-color \${theme.durations.standard}, 94 | border \${theme.durations.standard}\`}; 95 | \`" 96 | `); 97 | }); 98 | 99 | test('`!important`s should be kept in the string', () => { 100 | const code = stripIndent`\` 101 | background-color: \${theme.colors.brand} !important; 102 | \``; 103 | 104 | const file = parse(code, { filename: 'example.js' }); 105 | 106 | const [firstStatement] = file.program.body; 107 | const templateLiteral = firstStatement.expression; 108 | expect(t.isTemplateLiteral(templateLiteral)).toBe(true); 109 | 110 | const result = transformCssTemplateLiteral(templateLiteral); 111 | 112 | const { code: outputCode } = generate(result); 113 | expect(outputCode).toMatchInlineSnapshot(` 114 | "\` 115 | background-color: \${\`\${theme.colors.brand} \`}!important; 116 | \`" 117 | `); 118 | }); 119 | -------------------------------------------------------------------------------- /packages/collect/src/transformCssTemplateLiteral.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import generate from '@babel/generator'; 3 | import requireFromString from 'require-from-string'; 4 | 5 | const errorMessage = 6 | 'This is a bug in flair. Please open an issue.'; 7 | 8 | function parsePre(pre: string, expressions: t.Expression[]) { 9 | const preExpressionMatches = Array.from(pre.matchAll(/xX__\d+__Xx/g)); 10 | const preLocations = preExpressionMatches.map((expressionMatch) => { 11 | const start = expressionMatch.index!; 12 | const end = expressionMatch.index! + expressionMatch[0].length; 13 | 14 | return { start, end }; 15 | }); 16 | 17 | const preTuples = preLocations.map(({ start, end }, i) => { 18 | const previous = preLocations[i - 1]; 19 | const p = pre.substring(previous ? previous.end : 0, start); 20 | 21 | const templateElement = t.templateElement({ raw: p }); 22 | const s = pre.substring(start, end); 23 | const m = /xX__(\d+)__Xx/.exec(s); 24 | if (!m) { 25 | throw new Error(`${errorMessage} code-2`); 26 | } 27 | const d = parseInt(m[1], 10); 28 | 29 | // wrap in `staticVar` 30 | const expression = t.callExpression(t.identifier('staticVar'), [ 31 | expressions[d], 32 | ]) as t.Expression; 33 | 34 | return { templateElement, expression }; 35 | }); 36 | 37 | const lastPreLocation = preLocations[preLocations.length - 1]; 38 | const lastPreStr = lastPreLocation ? pre.substring(lastPreLocation.end) : pre; 39 | const lastPreTemplateElement = t.templateElement({ raw: lastPreStr }); 40 | 41 | return { tuples: preTuples, lastTemplateElement: lastPreTemplateElement }; 42 | } 43 | 44 | /** 45 | * This function takes in CSS template literals and rewrites them so that CSS 46 | * properties are re-written in nested template strings. 47 | * 48 | * That way the plugin downstream and replace template string expressions with 49 | * CSS variables. 50 | * 51 | * in: 52 | * 53 | * ```js 54 | * css` 55 | * background-color: red; 56 | * border: 1px solid ${theme.colors.brand}; 57 | * ` 58 | * ``` 59 | * 60 | * out: 61 | * 62 | * ```js 63 | * css` 64 | * background-color: red; 65 | * border: ${`1px solid ${theme.colors.brand}`}; 66 | * ` 67 | * ``` 68 | */ 69 | function transformCssTemplateLiteral(templateLiteral: t.TemplateLiteral) { 70 | const { quasis, expressions } = templateLiteral; 71 | 72 | const quasiQuotesCssResult = generate( 73 | t.templateLiteral( 74 | quasis, 75 | Array.from(Array(expressions.length)).map((_, i) => 76 | t.stringLiteral(`xX__${i}__Xx`), 77 | ), 78 | ), 79 | ); 80 | 81 | const quasiQuotesCss: string = requireFromString( 82 | `module.exports = ${quasiQuotesCssResult.code}`, 83 | ); 84 | 85 | const propertyMatches = Array.from(quasiQuotesCss.matchAll(/:[^;:]*;/g)); 86 | 87 | const quasiQuoteLocations = propertyMatches 88 | // only grab the properties that are quasi quoted 89 | .filter((match) => /xX__\d+__Xx/.test(match[0])) 90 | // calculate the start and end for each property match 91 | .map((match) => { 92 | const property = match[0]; 93 | const propertyMatch = /(:\s*)(.*xX__[^;:]*__Xx[^!]*)(!important)?\s*;/.exec( 94 | property, 95 | ); 96 | if (!propertyMatch) { 97 | throw new Error(`${errorMessage} code-1`); 98 | } 99 | const matchIndex = propertyMatch[1].length; 100 | const start = match.index! + matchIndex; 101 | const end = start + propertyMatch[2].length; // minus 1 to remove semi 102 | 103 | return { start, end }; 104 | }); 105 | 106 | const quasiQuoteParts = quasiQuoteLocations.map(({ start, end }, i) => { 107 | const previous = quasiQuoteLocations[i - 1]; 108 | 109 | /** 110 | * `pre` is the non-quasi-quoted string before a quasi-quoted string 111 | */ 112 | const pre = quasiQuotesCss.substring(previous ? previous.end : 0, start); 113 | const str = quasiQuotesCss.substring(start, end); 114 | 115 | return { pre, str }; 116 | }); 117 | 118 | const lastLocation = quasiQuoteLocations[quasiQuoteLocations.length - 1]; 119 | if (!lastLocation) { 120 | const parsed = parsePre(quasiQuotesCss, expressions); 121 | return t.templateLiteral( 122 | [ 123 | ...parsed.tuples.map((t) => t.templateElement), 124 | parsed.lastTemplateElement, 125 | ], 126 | parsed.tuples.map((t) => t.expression), 127 | ); 128 | } 129 | const lastPart = quasiQuotesCss.substring(lastLocation.end); 130 | 131 | const tuples = quasiQuoteParts 132 | .map(({ pre, str }) => { 133 | const { tuples, lastTemplateElement } = parsePre(pre, expressions); 134 | 135 | // str processing 136 | const matches = Array.from(str.matchAll(/xX__(\d+)__Xx/g)); 137 | 138 | const strParts = matches.map((match, i) => { 139 | const previous = matches[i - 1]; 140 | const previousEnd = previous ? previous.index! + previous[0].length : 0; 141 | const d = parseInt(match[1], 10); 142 | const pre = str.substring(previousEnd, match.index!); 143 | const expression = expressions[d]; 144 | 145 | return { pre, expression }; 146 | }); 147 | const lastMatch = matches[matches.length - 1]; 148 | if (!lastMatch) { 149 | throw new Error(`${errorMessage} code-3`); 150 | } 151 | const last = str.substring(lastMatch.index! + lastMatch[0].length); 152 | 153 | const pres = [...strParts.map((t) => t.pre), last]; 154 | const exps = strParts.map((t) => t.expression); 155 | 156 | const revisedExpression = t.templateLiteral( 157 | pres.map((pre) => t.templateElement({ raw: pre })), 158 | exps, 159 | ); 160 | 161 | return [ 162 | ...tuples, 163 | { 164 | templateElement: lastTemplateElement, 165 | expression: revisedExpression as t.Expression, 166 | }, 167 | ]; 168 | }) 169 | .flat(); 170 | 171 | const last = parsePre(lastPart, expressions); 172 | 173 | const templateElements = [ 174 | ...tuples.map((tuple) => tuple.templateElement), 175 | ...last.tuples.map((tuple) => tuple.templateElement), 176 | last.lastTemplateElement, 177 | ]; 178 | 179 | const finalExpressions = [ 180 | ...tuples.map((tuple) => tuple.expression), 181 | ...last.tuples.map((tuple) => tuple.expression), 182 | ]; 183 | 184 | return t.templateLiteral(templateElements, finalExpressions); 185 | } 186 | 187 | export default transformCssTemplateLiteral; 188 | -------------------------------------------------------------------------------- /packages/collect/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "baseUrl": "../", 6 | "paths": { 7 | "@flair/common": ["common/src/index.ts"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/common", 3 | "dependencies": {} 4 | } 5 | -------------------------------------------------------------------------------- /packages/common/src/createFilenameHash.test.js: -------------------------------------------------------------------------------- 1 | import createFilenameHash from './createFilenameHash'; 2 | 3 | it('creates a hash for a file name keeping the base', () => { 4 | expect(createFilenameHash('/some/path/Component.js')).toMatchInlineSnapshot( 5 | `"Component-509eac38"`, 6 | ); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/common/src/createFilenameHash.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | /** 4 | * from here: https://stackoverflow.com/a/7616484/5776910 5 | */ 6 | function hashString(str: string) { 7 | let hash = 0, 8 | i, 9 | chr; 10 | 11 | if (str.length === 0) return hash; 12 | 13 | for (i = 0; i < str.length; i++) { 14 | chr = str.charCodeAt(i); 15 | hash = (hash << 5) - hash + chr; 16 | hash |= 0; // Convert to 32bit integer 17 | } 18 | return hash; 19 | } 20 | 21 | function createFilenameHash(filename: string) { 22 | const basename = path.basename(filename); 23 | const extension = path.extname(filename); 24 | 25 | const name = basename 26 | .substring(0, basename.length - extension.length) 27 | .replace(/\W/g, ''); 28 | 29 | return `${name}-${hashString(filename).toString(16)}`; 30 | } 31 | 32 | export default createFilenameHash; 33 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as seek } from './seek'; 2 | export { default as createFilenameHash } from './createFilenameHash'; 3 | -------------------------------------------------------------------------------- /packages/common/src/seek.test.js: -------------------------------------------------------------------------------- 1 | import seek from './seek'; 2 | 3 | it('early returns with the reported value when found', () => { 4 | let i = 0; 5 | const reportedValue = seek((report) => { 6 | for (i = 0; i < 10; i += 1) { 7 | if (i === 5) { 8 | report(i); 9 | } 10 | } 11 | }); 12 | 13 | expect(reportedValue).toBe(5); 14 | expect(i).toBe(5); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/common/src/seek.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * abusing throw for better control flow 🤷‍♀️ 3 | */ 4 | function seek(traverse: (report: (t: T) => never) => void): T { 5 | const found = Symbol(); 6 | let result: T; 7 | 8 | const report = (t: T) => { 9 | result = t; 10 | throw found; 11 | }; 12 | 13 | try { 14 | traverse(report); 15 | } catch (e) { 16 | if (e !== found) throw e; 17 | return result!; 18 | } 19 | 20 | throw new Error( 21 | 'seek report was never called. This is probably a bug in flair', 22 | ); 23 | } 24 | 25 | export default seek; 26 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/core", 3 | "peerDependencies": { 4 | "@types/react": "^16.8.0", 5 | "react": "^16.8.0" 6 | }, 7 | "dependencies": { 8 | "color2k": "1.0.0-rc.5", 9 | "@babel/runtime": "7.9.6" 10 | }, 11 | "private": true 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/src/ColorContext.test.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import ColorContext from './ColorContext'; 4 | 5 | it('is a react context value with an initial value of black and white', () => { 6 | const effectHandler = jest.fn(); 7 | 8 | function ExampleComponent() { 9 | const colorContext = useContext(ColorContext); 10 | useEffect(() => { 11 | effectHandler(colorContext); 12 | }, [colorContext]); 13 | return null; 14 | } 15 | 16 | act(() => { 17 | create(); 18 | }); 19 | 20 | expect(effectHandler).toHaveBeenCalled(); 21 | const result = effectHandler.mock.calls[0][0]; 22 | 23 | expect(result).toMatchInlineSnapshot(` 24 | Object { 25 | "color": "black", 26 | "surface": "white", 27 | } 28 | `); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/core/src/ColorContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { ColorContextValue } from './types'; 3 | 4 | export default createContext({ 5 | color: 'black', 6 | surface: 'white', 7 | }); 8 | -------------------------------------------------------------------------------- /packages/core/src/ColorContextProvider.test.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import ColorContextProvider from './ColorContextProvider'; 4 | import ColorContext from './ColorContext'; 5 | 6 | it('wraps the components with a populated color context', () => { 7 | const effectHandler = jest.fn(); 8 | 9 | function ExampleComponent() { 10 | const colorContext = useContext(ColorContext); 11 | 12 | useEffect(() => { 13 | effectHandler(colorContext); 14 | }, [colorContext]); 15 | 16 | return null; 17 | } 18 | 19 | act(() => { 20 | create( 21 | 22 | 23 | , 24 | ); 25 | }); 26 | 27 | expect(effectHandler).toHaveBeenCalled(); 28 | expect(effectHandler).toMatchInlineSnapshot(` 29 | [MockFunction] { 30 | "calls": Array [ 31 | Array [ 32 | Object { 33 | "color": "red", 34 | "surface": "black", 35 | }, 36 | ], 37 | ], 38 | "results": Array [ 39 | Object { 40 | "type": "return", 41 | "value": undefined, 42 | }, 43 | ], 44 | } 45 | `); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/core/src/ColorContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import ColorContext from './ColorContext'; 3 | 4 | interface Props { 5 | color: string; 6 | surface: string; 7 | children: React.ReactNode; 8 | } 9 | 10 | function ColorContextProvider({ color, surface, children }: Props) { 11 | const contextValue = useMemo(() => ({ color, surface }), [color, surface]); 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | 19 | export default ColorContextProvider; 20 | -------------------------------------------------------------------------------- /packages/core/src/ThemeContext.test.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import ThemeContext from './ThemeContext'; 4 | 5 | it('is a react context value with an initial value of empty obj', () => { 6 | const effectHandler = jest.fn(); 7 | 8 | function ExampleComponent() { 9 | const theme = useContext(ThemeContext); 10 | useEffect(() => { 11 | effectHandler(theme); 12 | }, [theme]); 13 | return null; 14 | } 15 | 16 | act(() => { 17 | create(); 18 | }); 19 | 20 | expect(effectHandler).toHaveBeenCalled(); 21 | const result = effectHandler.mock.calls[0][0]; 22 | 23 | expect(result).toEqual({}); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/core/src/ThemeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | export default createContext({}); 3 | -------------------------------------------------------------------------------- /packages/core/src/ThemeProvider.test.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import ThemeProvider from './ThemeProvider'; 4 | import ThemeContext from './ThemeContext'; 5 | 6 | it('wraps the components with a populated color context', () => { 7 | const effectHandler = jest.fn(); 8 | const exampleTheme = {}; 9 | 10 | function ExampleComponent() { 11 | const theme = useContext(ThemeContext); 12 | 13 | useEffect(() => { 14 | effectHandler(theme); 15 | }, [theme]); 16 | 17 | return null; 18 | } 19 | 20 | act(() => { 21 | create( 22 | 23 | 24 | , 25 | ); 26 | }); 27 | 28 | expect(effectHandler).toHaveBeenCalled(); 29 | const firstCall = effectHandler.mock.calls[0][0]; 30 | 31 | expect(firstCall).toBe(exampleTheme); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/core/src/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ThemeContext from './ThemeContext'; 3 | 4 | interface Props { 5 | theme: unknown; 6 | children: React.ReactNode; 7 | } 8 | 9 | function ThemeProvider({ theme, children }: Props) { 10 | return ( 11 | {children} 12 | ); 13 | } 14 | 15 | export default ThemeProvider; 16 | -------------------------------------------------------------------------------- /packages/core/src/createReadablePalette.test.js: -------------------------------------------------------------------------------- 1 | import createReadablePalette from './createReadablePalette'; 2 | 3 | test('light blue', () => { 4 | expect(createReadablePalette('#ccf')).toMatchInlineSnapshot(` 5 | Object { 6 | "aa": "#000", 7 | "aaa": "#000", 8 | "decorative": "#ccf", 9 | "original": "#ccf", 10 | "readable": "#000", 11 | } 12 | `); 13 | }); 14 | 15 | test('dark blue', () => { 16 | expect(createReadablePalette('#00a')).toMatchInlineSnapshot(` 17 | Object { 18 | "aa": "#00a", 19 | "aaa": "#00a", 20 | "decorative": "#00a", 21 | "original": "#00a", 22 | "readable": "#00a", 23 | } 24 | `); 25 | }); 26 | 27 | test('middle blue', () => { 28 | expect(createReadablePalette('#7575FF')).toMatchInlineSnapshot(` 29 | Object { 30 | "aa": "#000", 31 | "aaa": "#000", 32 | "decorative": "#7575FF", 33 | "original": "#7575FF", 34 | "readable": "#7575FF", 35 | } 36 | `); 37 | }); 38 | 39 | test('dark mode', () => { 40 | expect(createReadablePalette('#eee', '#000')).toMatchInlineSnapshot(` 41 | Object { 42 | "aa": "#eee", 43 | "aaa": "#eee", 44 | "decorative": "#eee", 45 | "original": "#eee", 46 | "readable": "#eee", 47 | } 48 | `); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/core/src/createReadablePalette.ts: -------------------------------------------------------------------------------- 1 | import { readableColor, getContrast } from 'color2k'; 2 | import { ReadableColorPalette } from './types'; 3 | 4 | const hasBadDecorativeContrast = (a: string, b: string) => 5 | getContrast(a, b) < 1.5; 6 | const hasBadReadableContrast = (a: string, b: string) => getContrast(a, b) < 3; 7 | const hasBadAaContrast = (a: string, b: string) => getContrast(a, b) < 4.5; 8 | const hasBadAaaContrast = (a: string, b: string) => getContrast(a, b) < 7; 9 | 10 | function createReadablePalette( 11 | color: string, 12 | surface = '#fff', 13 | ): ReadableColorPalette { 14 | const readableContrast = readableColor(surface); 15 | 16 | return { 17 | original: color, 18 | decorative: hasBadDecorativeContrast(color, surface) 19 | ? readableContrast 20 | : color, 21 | readable: hasBadReadableContrast(color, surface) ? readableContrast : color, 22 | aa: hasBadAaContrast(color, surface) ? readableContrast : color, 23 | aaa: hasBadAaaContrast(color, surface) ? readableContrast : color, 24 | }; 25 | } 26 | 27 | export default createReadablePalette; 28 | -------------------------------------------------------------------------------- /packages/core/src/css.test.js: -------------------------------------------------------------------------------- 1 | import css from './css'; 2 | 3 | it('just re-exports the string its given', () => { 4 | expect(css`.thing {}`).toBe('.thing {}'); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/core/src/css.ts: -------------------------------------------------------------------------------- 1 | function css(strings: TemplateStringsArray, ...values: Array) { 2 | let combined = ''; 3 | 4 | for (let i = 0; i < strings.length; i += 1) { 5 | const currentString = strings[i]; 6 | const currentValue = values[i] || ''; 7 | combined += currentString + currentValue; 8 | } 9 | 10 | return combined; 11 | } 12 | 13 | export default css; 14 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ColorContext } from './ColorContext'; 2 | export { default as ColorContextProvider } from './ColorContextProvider'; 3 | export { default as createReadablePalette } from './createReadablePalette'; 4 | export { default as ThemeContext } from './ThemeContext'; 5 | export { default as ThemeProvider } from './ThemeProvider'; 6 | export { default as useColorContext } from './useColorContext'; 7 | export { default as useTheme } from './useTheme'; 8 | export { default as css } from './css'; 9 | export * from 'color2k'; 10 | export * from './types'; 11 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ReadableColorPalette { 2 | original: string; 3 | decorative: string; 4 | readable: string; 5 | aa: string; 6 | aaa: string; 7 | } 8 | 9 | export type PropsOf = T extends React.ComponentType ? U : never; 10 | 11 | export type ReactComponent = 12 | | React.ComponentType 13 | | keyof JSX.IntrinsicElements 14 | | string; 15 | 16 | type GetStyleObj = UseStylesFn extends (props: { 17 | styles: Partial; 18 | }) => any 19 | ? U 20 | : never; 21 | 22 | export interface PropsFromStyles { 23 | surface?: string; 24 | color?: string; 25 | style?: React.CSSProperties; 26 | styles?: Partial>; 27 | className?: string; 28 | } 29 | 30 | export interface StyleProps { 31 | surface?: string; 32 | color?: string; 33 | style?: React.CSSProperties; 34 | styles?: Partial; 35 | className?: string; 36 | } 37 | 38 | export type OmitStyleProps = Omit>; 39 | export type PropsFromComponent< 40 | T extends React.ComponentType 41 | > = OmitStyleProps>; 42 | 43 | export interface ColorContextValue { 44 | color: string; 45 | surface: string; 46 | } 47 | 48 | export type StyleFnArgs = { 49 | css: ( 50 | strings: TemplateStringsArray, 51 | ...values: (string | number)[] 52 | ) => string; 53 | color: ReadableColorPalette; 54 | theme: Theme; 55 | surface: string; 56 | staticVar: (value: string) => string; 57 | }; 58 | 59 | export type GetComponentProps< 60 | ComponentType extends ReactComponent 61 | > = ComponentType extends React.ComponentType 62 | ? U 63 | : ComponentType extends keyof JSX.IntrinsicElements 64 | ? JSX.IntrinsicElements[ComponentType] 65 | : any; 66 | 67 | export type UseStyles = < 68 | Props extends StyleProps 69 | >( 70 | props: Props, 71 | component?: ComponentType, 72 | ) => { 73 | Root: React.ComponentType>; 74 | styles: { [P in keyof T]: string } & { 75 | cssVariableObject: { [key: string]: string }; 76 | }; 77 | } & Omit>; 78 | 79 | export type StylesObj = { [key: string]: string }; 80 | -------------------------------------------------------------------------------- /packages/core/src/useColorContext.test.js: -------------------------------------------------------------------------------- 1 | import React, { Component, useEffect } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import ColorContextProvider from './ColorContextProvider'; 4 | import useColorContext from './useColorContext'; 5 | 6 | beforeEach(() => { 7 | jest.spyOn(console, 'error'); 8 | global.console.error.mockImplementation(() => {}); 9 | }); 10 | 11 | afterEach(() => { 12 | global.console.error.mockRestore(); 13 | }); 14 | 15 | it('returns a the current context color wrapped in `createReadablePalette` with the surface color', () => { 16 | const effectHandler = jest.fn(); 17 | 18 | function ExampleComponent() { 19 | const colorContext = useColorContext(); 20 | useEffect(() => { 21 | effectHandler(colorContext); 22 | }, [colorContext]); 23 | 24 | return null; 25 | } 26 | 27 | act(() => { 28 | create( 29 | 30 | 31 | , 32 | ); 33 | }); 34 | 35 | expect(effectHandler.mock.calls).toMatchInlineSnapshot(` 36 | Array [ 37 | Array [ 38 | Object { 39 | "color": Object { 40 | "aa": "#000", 41 | "aaa": "#000", 42 | "decorative": "red", 43 | "original": "red", 44 | "readable": "red", 45 | }, 46 | "surface": "white", 47 | }, 48 | ], 49 | ] 50 | `); 51 | }); 52 | 53 | it('accepts incoming props so that the color context can prefer to use the values from props', () => { 54 | const effectHandler = jest.fn(); 55 | 56 | function ExampleComponent(props) { 57 | const colorContext = useColorContext(props); 58 | 59 | useEffect(() => { 60 | effectHandler(colorContext); 61 | }, [colorContext]); 62 | 63 | return null; 64 | } 65 | 66 | act(() => { 67 | create( 68 | 69 | 70 | , 71 | ); 72 | }); 73 | 74 | expect(effectHandler.mock.calls).toMatchInlineSnapshot(` 75 | Array [ 76 | Array [ 77 | Object { 78 | "color": Object { 79 | "aa": "#fff", 80 | "aaa": "#fff", 81 | "decorative": "blue", 82 | "original": "blue", 83 | "readable": "#fff", 84 | }, 85 | "surface": "black", 86 | }, 87 | ], 88 | ] 89 | `); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/core/src/useColorContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | import ColorContext from './ColorContext'; 3 | import createReadablePalette from './createReadablePalette'; 4 | 5 | interface Props { 6 | color?: string; 7 | surface?: string; 8 | } 9 | 10 | function useColorContext(props?: Props) { 11 | const colorContext = useContext(ColorContext); 12 | 13 | const color = props?.color || colorContext.color; 14 | const surface = props?.surface || colorContext.surface; 15 | 16 | return useMemo( 17 | () => ({ 18 | color: createReadablePalette(color, surface), 19 | surface, 20 | }), 21 | [color, surface], 22 | ); 23 | } 24 | 25 | export default useColorContext; 26 | -------------------------------------------------------------------------------- /packages/core/src/useTheme.test.js: -------------------------------------------------------------------------------- 1 | import React, { Component, useEffect } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import ThemeProvider from './ThemeProvider'; 4 | import useTheme from './useTheme'; 5 | 6 | beforeEach(() => { 7 | jest.spyOn(console, 'error'); 8 | global.console.error.mockImplementation(() => {}); 9 | }); 10 | 11 | afterEach(() => { 12 | global.console.error.mockRestore(); 13 | }); 14 | 15 | it('returns the theme from context', () => { 16 | const effectHandler = jest.fn(); 17 | const exampleTheme = {}; 18 | 19 | function ExampleComponent() { 20 | const theme = useTheme(); 21 | 22 | useEffect(() => { 23 | effectHandler(theme); 24 | }, [theme]); 25 | 26 | return null; 27 | } 28 | 29 | act(() => { 30 | create( 31 | 32 | 33 | , 34 | ); 35 | }); 36 | 37 | expect(effectHandler).toHaveBeenCalled(); 38 | const first = effectHandler.mock.calls[0][0]; 39 | 40 | expect(first).toBe(exampleTheme); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/core/src/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import ThemeContext from './ThemeContext'; 3 | 4 | function useTheme(): T { 5 | return useContext(ThemeContext) as T; 6 | } 7 | 8 | export default useTheme; 9 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/flair/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flair", 3 | "dependencies": {}, 4 | "private": true 5 | } 6 | -------------------------------------------------------------------------------- /packages/flair/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@flair/standalone'; 2 | -------------------------------------------------------------------------------- /packages/flair/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "../", 5 | "paths": { 6 | "@flair/standalone": ["standalone/src/index.ts"], 7 | "@flair/ssr": ["ssr/src/index.ts"], 8 | "@flair/core": ["core/src/index.ts"], 9 | "@flair/babel-plugin-plugin": [ 10 | "babel-plugin-plugin/src/index.ts" 11 | ], 12 | "@flair/loader": ["loader/src/index.ts"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/loader", 3 | "dependencies": {} 4 | } 5 | -------------------------------------------------------------------------------- /packages/loader/src/index.ts: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | 3 | /** 4 | * this is an extremely simple loader that takes in the result of the babel 5 | * plugins and sends it back out to other CSS loaders in your webpack loader 6 | * chain 7 | */ 8 | const loader: webpack.loader.Loader = function () { 9 | return Buffer.from( 10 | new URLSearchParams(this.resourceQuery).get('css') || '', 11 | 'base64', 12 | ); 13 | }; 14 | 15 | export default loader; 16 | -------------------------------------------------------------------------------- /packages/loader/src/load.ts: -------------------------------------------------------------------------------- 1 | // no-op 2 | -------------------------------------------------------------------------------- /packages/loader/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/ssr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/ssr", 3 | "dependencies": { 4 | "@types/classnames": "2.2.10", 5 | "classnames": "2.2.6", 6 | "@babel/runtime": "7.9.6" 7 | }, 8 | "peerDependencies": { 9 | "@types/react": "^16.8.0", 10 | "react": "^16.8.0" 11 | }, 12 | "private": true 13 | } 14 | -------------------------------------------------------------------------------- /packages/ssr/src/createStyles.test.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { create, act } from 'react-test-renderer'; 3 | import { ThemeProvider, ColorContextProvider } from '@flair/core'; 4 | import createStyles from './createStyles'; 5 | 6 | it('takes a styles function and returns a hook', async () => { 7 | const useStyles = createStyles(({ css }) => ({ 8 | root: ['red'], 9 | classNamePrefix: 'Example-0000', 10 | })); 11 | 12 | const stylesHandler = jest.fn(); 13 | 14 | let resolve; 15 | const done = new Promise((thisResolve) => (resolve = thisResolve)); 16 | 17 | function Example(props) { 18 | const { Root, styles } = useStyles(props); 19 | 20 | useEffect(() => { 21 | stylesHandler(styles); 22 | resolve(); 23 | }, [styles]); 24 | 25 | return hello world; 26 | } 27 | 28 | let result; 29 | 30 | await act(async () => { 31 | result = create( 32 | 33 | 34 | 35 | 36 | , 37 | ); 38 | await done; 39 | }); 40 | 41 | expect(result).toMatchInlineSnapshot(` 42 |
50 | hello world 51 |
52 | `); 53 | 54 | expect(stylesHandler.mock.calls).toMatchInlineSnapshot(` 55 | Array [ 56 | Array [ 57 | Object { 58 | "cssVariableObject": Object { 59 | "--Example-0000-root-0": "red", 60 | }, 61 | "root": "Example-0000-root", 62 | }, 63 | ], 64 | ] 65 | `); 66 | }); 67 | 68 | it.todo("doesn't have re-rendering issues"); 69 | test.todo('Root component composition (styles, classNames, props, ref)'); 70 | 71 | it('propagates the color context correctly', () => { 72 | const useStyles = createStyles(({ color }) => ({ 73 | root: [color.readable], 74 | classNamePrefix: 'Example-0000', 75 | })); 76 | 77 | const stylesHandler = jest.fn(); 78 | 79 | function Example(props) { 80 | const { Root, styles } = useStyles(props); 81 | 82 | useEffect(() => { 83 | stylesHandler(styles); 84 | }, [styles]); 85 | 86 | return stuff; 87 | } 88 | 89 | let result; 90 | 91 | act(() => { 92 | result = create( 93 | 94 | {/* dark red on black is not readable */} 95 | 96 | 97 | 98 | , 99 | ); 100 | }); 101 | 102 | // because dark red on black is not readable, the expected color should be 103 | // white — the readable color for black 104 | expect(stylesHandler.mock.calls[0][0].cssVariableObject).toEqual({ 105 | '--Example-0000-root-0': '#fff', 106 | }); 107 | 108 | expect(result).toMatchInlineSnapshot(` 109 |
117 | stuff 118 |
119 | `); 120 | }); 121 | -------------------------------------------------------------------------------- /packages/ssr/src/createStyles.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useMemo } from 'react'; 2 | import { 3 | ReactComponent, 4 | StyleProps, 5 | StyleFnArgs, 6 | GetComponentProps, 7 | UseStyles, 8 | useTheme, 9 | useColorContext, 10 | } from '@flair/core'; 11 | 12 | function hashStyleObj( 13 | styleObj: { [key: string]: string | undefined } | null | undefined, 14 | ) { 15 | if (!styleObj) return ''; 16 | 17 | return Object.keys(styleObj) 18 | .map((key) => `${key}_${styleObj[key]}`) 19 | .join('__|__'); 20 | } 21 | 22 | function usePreserveReference< 23 | T extends { [key: string]: string | undefined } | null | undefined 24 | >(styleObj: T): T { 25 | return useMemo( 26 | () => styleObj, 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | [hashStyleObj(styleObj)], 29 | ); 30 | } 31 | 32 | // preserve the object reference 33 | const empty = {}; 34 | 35 | const identity = (t: T) => t; 36 | 37 | function createStyles( 38 | stylesFn: (args: StyleFnArgs) => Styles, 39 | ) { 40 | function useStyles< 41 | Props extends StyleProps, 42 | ComponentType extends ReactComponent = 'div' 43 | >( 44 | props: Props = {} as any, 45 | component?: ComponentType, 46 | ): Omit & { 47 | Root: React.ComponentType>; 48 | styles: Styles & { cssVariableObject: { [key: string]: string } }; 49 | } { 50 | const theme = useTheme(); 51 | const { color, surface } = useColorContext(props); 52 | const { 53 | color: _color, 54 | surface: _surface, 55 | className: incomingClassName, 56 | style: _incomingStyle, 57 | styles: _incomingStyles = empty as Styles, 58 | ...restOfProps 59 | } = props; 60 | 61 | const incomingStyle = usePreserveReference(_incomingStyle as any); 62 | const incomingStyles = usePreserveReference(_incomingStyles as any); 63 | 64 | // create a map of unprocessed styles 65 | const { cssVariableObject, classes, classNamePrefix } = useMemo(() => { 66 | const variableObject: any = stylesFn({ 67 | css: () => { 68 | throw new Error('css tag was executed in SSR mode'); 69 | }, 70 | color, 71 | theme, 72 | surface, 73 | staticVar: identity, 74 | }); 75 | 76 | const { classNamePrefix, ...classNamesVariableValues } = variableObject; 77 | 78 | const cssVariableObject = Object.entries(classNamesVariableValues) 79 | .map(([className, values]) => 80 | (values as string[]).map((value, i) => ({ 81 | key: `--${classNamePrefix}-${className}-${i}`, 82 | value, 83 | })), 84 | ) 85 | .flat() 86 | .reduce((acc, { key, value }) => { 87 | acc[key] = value; 88 | return acc; 89 | }, {} as { [key: string]: string }); 90 | 91 | return { 92 | cssVariableObject, 93 | classes: Object.keys(classNamesVariableValues), 94 | classNamePrefix, 95 | }; 96 | }, [color, surface, theme]); 97 | 98 | // calculate the class names 99 | const thisStyles = useMemo(() => { 100 | return classes 101 | .map((key) => [key, `${classNamePrefix}-${key}`]) 102 | .reduce((acc, [key, className]) => { 103 | acc[key as keyof Styles] = className as Styles[keyof Styles]; 104 | return acc; 105 | }, {} as Styles); 106 | }, [classNamePrefix, classes]); 107 | 108 | const mergedStyles = useMemo(() => { 109 | const thisStyleKeys = Object.keys(thisStyles) as Array; 110 | 111 | const mergedStyles = thisStyleKeys.reduce((merged, key) => { 112 | const thisStyle = thisStyles[key]; 113 | const incomingStyle = incomingStyles[key]; 114 | 115 | merged[key] = [thisStyle, incomingStyle] 116 | .filter(Boolean) 117 | .join(' ') as Styles[keyof Styles]; 118 | 119 | return merged; 120 | }, {} as Styles); 121 | 122 | return { ...mergedStyles, cssVariableObject }; 123 | }, [thisStyles, cssVariableObject, incomingStyles]); 124 | 125 | const Component = (component || 'div') as React.ComponentType; 126 | 127 | const Root = useMemo(() => { 128 | return forwardRef((rootProps: StyleProps, ref: any) => { 129 | const { className: rootClassName, style: rootStyles } = rootProps; 130 | 131 | return ( 132 | 144 | ); 145 | }) as React.ComponentType>; 146 | }, [ 147 | mergedStyles.root, 148 | incomingClassName, 149 | incomingStyle, 150 | cssVariableObject, 151 | ]); 152 | 153 | return { 154 | Root, 155 | styles: mergedStyles, 156 | ...restOfProps, 157 | }; 158 | } 159 | 160 | // This is a type-assertion so ensure that this type is compatible with the 161 | // `UseStyles` type. TODO: may want to find a better way to enforce this 162 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 163 | useStyles as UseStyles; 164 | 165 | return useStyles; 166 | } 167 | 168 | export default createStyles; 169 | -------------------------------------------------------------------------------- /packages/ssr/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createStyles } from './createStyles'; 2 | export * from '@flair/core'; 3 | -------------------------------------------------------------------------------- /packages/ssr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "../", 5 | "paths": { 6 | "@flair/core": ["core/src/index.ts"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/standalone/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flair/standalone", 3 | "dependencies": { 4 | "@types/classnames": "2.2.10", 5 | "classnames": "2.2.6", 6 | "uid": "1.0.0", 7 | "stylis": "3.5.4", 8 | "@babel/runtime": "7.9.6" 9 | }, 10 | "peerDependencies": { 11 | "@types/react": "^16.8.0", 12 | "react": "^16.8.0" 13 | }, 14 | "private": true 15 | } 16 | -------------------------------------------------------------------------------- /packages/standalone/src/createStyles.test.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { act, create } from 'react-test-renderer'; 3 | import { ThemeProvider, ColorContextProvider } from '@flair/core'; 4 | import createStyles from './createStyles'; 5 | 6 | const theme = { colors: { brand: '#00f' } }; 7 | 8 | let mockIndex = 0; 9 | 10 | jest.mock('uid', () => () => { 11 | const mockId = `id-${mockIndex}`; 12 | mockIndex += 1; 13 | return mockId; 14 | }); 15 | 16 | const delay = () => new Promise((resolve) => setTimeout(resolve, 0)); 17 | 18 | it('returns colors, styles, and the root component', () => { 19 | const stylesHandler = jest.fn(); 20 | const createStylesHandler = jest.fn(); 21 | const rootHandler = jest.fn(); 22 | 23 | const useStyles = createStyles(({ color, theme, css }) => { 24 | createStylesHandler({ color, theme, css }); 25 | 26 | return { 27 | root: 'root', 28 | title: 'title', 29 | }; 30 | }); 31 | 32 | function Component(props) { 33 | const { Root, styles } = useStyles(props); 34 | 35 | useEffect(() => { 36 | stylesHandler(styles); 37 | rootHandler(Root); 38 | }, [Root, styles]); 39 | 40 | return blah; 41 | } 42 | 43 | act(() => { 44 | create( 45 | 46 | 47 | 48 | 49 | , 50 | ); 51 | }); 52 | 53 | const styles = stylesHandler.mock.calls[0][0]; 54 | const createStylesValues = createStylesHandler.mock.calls[0][0]; 55 | const Root = rootHandler.mock.calls[0][0]; 56 | 57 | expect(styles).toMatchInlineSnapshot(` 58 | Object { 59 | "cssVariableObject": Object {}, 60 | "root": "rss_root_id-0_id-1", 61 | "title": "rss_title_id-0_id-1", 62 | } 63 | `); 64 | expect(createStylesValues).toMatchInlineSnapshot(` 65 | Object { 66 | "color": Object { 67 | "aa": "#00f", 68 | "aaa": "#00f", 69 | "decorative": "#00f", 70 | "original": "#00f", 71 | "readable": "#00f", 72 | }, 73 | "css": [Function], 74 | "theme": Object { 75 | "colors": Object { 76 | "brand": "#00f", 77 | }, 78 | }, 79 | } 80 | `); 81 | expect(Root).toBeDefined(); 82 | }); 83 | 84 | it('composes the classnames', () => { 85 | const useStyles = createStyles(() => ({ 86 | root: 'root-from-styles', 87 | title: 'title-from-styles', 88 | })); 89 | 90 | function Component(props) { 91 | const { Root, styles, title } = useStyles(props); 92 | 93 | return ( 94 | 95 |

{title}

96 |
97 | ); 98 | } 99 | 100 | let result; 101 | 102 | act(() => { 103 | result = create( 104 | 105 | 106 | 115 | 116 | , 117 | ); 118 | }); 119 | 120 | expect(result).toMatchInlineSnapshot(` 121 |
129 |

132 | test title 133 |

134 |
135 | `); 136 | }); 137 | 138 | test("the root node doesn't remount when classnames changes", async () => { 139 | let resolve; 140 | const done = new Promise((thisResolve) => { 141 | resolve = thisResolve; 142 | }); 143 | 144 | const useStyles = createStyles(() => ({ 145 | root: 'style-root', 146 | title: 'style-title', 147 | })); 148 | 149 | const rerenderHandler = jest.fn(); 150 | const rootClassHandler = jest.fn(); 151 | 152 | function Component(props) { 153 | const { Root, styles } = useStyles(props); 154 | 155 | useEffect(() => { 156 | rerenderHandler(); 157 | }, []); 158 | 159 | useEffect(() => { 160 | rootClassHandler(styles.root); 161 | }, [styles.root]); 162 | 163 | return test; 164 | } 165 | 166 | function Parent() { 167 | const [count, setCount] = useState(0); 168 | 169 | useEffect(() => { 170 | (async () => { 171 | for (let i = 0; i < 3; i += 1) { 172 | await delay(); 173 | setCount((count) => count + 1); 174 | } 175 | resolve(); 176 | })(); 177 | }, []); 178 | 179 | return ; 180 | } 181 | 182 | await act(async () => { 183 | create( 184 | 185 | 186 | 187 | 188 | , 189 | ); 190 | await done; 191 | }); 192 | 193 | expect(rerenderHandler).toHaveBeenCalledTimes(1); 194 | 195 | const classNamesOverTime = rootClassHandler.mock.calls.map((args) => args[0]); 196 | expect(classNamesOverTime).toMatchInlineSnapshot(` 197 | Array [ 198 | "rss_root_id-4_id-5 count-0", 199 | "rss_root_id-4_id-5 count-1", 200 | "rss_root_id-4_id-5 count-2", 201 | "rss_root_id-4_id-5 count-3", 202 | ] 203 | `); 204 | }); 205 | 206 | it('memoizes the Root component reference and the styles reference', async () => { 207 | let resolve; 208 | const done = new Promise((thisResolve) => (resolve = thisResolve)); 209 | 210 | const useStyles = createStyles(() => ({ 211 | root: 'style-root', 212 | title: 'style-title', 213 | })); 214 | 215 | const rerenderHandler = jest.fn(); 216 | const rootComponentHandler = jest.fn(); 217 | const stylesHandler = jest.fn(); 218 | 219 | function Component(props) { 220 | const { Root, styles } = useStyles(props); 221 | 222 | useEffect(() => { 223 | rerenderHandler(); 224 | }, []); 225 | 226 | useEffect(() => { 227 | rootComponentHandler(Root); 228 | }, [Root]); 229 | 230 | useEffect(() => { 231 | stylesHandler(styles); 232 | }, [styles]); 233 | 234 | return test; 235 | } 236 | 237 | function Parent() { 238 | const [, setCount] = useState(0); 239 | 240 | useEffect(() => { 241 | (async () => { 242 | for (let i = 0; i < 3; i += 1) { 243 | await delay(); 244 | setCount((count) => count + 1); 245 | } 246 | resolve(); 247 | })(); 248 | }, []); 249 | 250 | return ( 251 | 255 | ); 256 | } 257 | 258 | await act(async () => { 259 | create( 260 | 261 | 262 | 263 | 264 | , 265 | ); 266 | await done; 267 | }); 268 | 269 | expect(rerenderHandler).toHaveBeenCalledTimes(1); 270 | expect(rootComponentHandler).toHaveBeenCalledTimes(1); 271 | expect(stylesHandler).toHaveBeenCalledTimes(1); 272 | }); 273 | 274 | it('adds a style sheet to the DOM', async () => { 275 | let resolve; 276 | const done = new Promise((thisResolve) => { 277 | resolve = thisResolve; 278 | }); 279 | 280 | const useStyles = createStyles(({ css }) => ({ 281 | root: css` 282 | background-color: red; 283 | `, 284 | })); 285 | 286 | function Example(props) { 287 | const { Root, styles } = useStyles(props); 288 | 289 | useEffect(() => { 290 | resolve(styles); 291 | }, [styles]); 292 | 293 | return blah; 294 | } 295 | 296 | let styles; 297 | await act(async () => { 298 | create( 299 | 300 | 301 | 302 | 303 | , 304 | ); 305 | 306 | styles = await done; 307 | }); 308 | 309 | expect(styles).toMatchInlineSnapshot(` 310 | Object { 311 | "cssVariableObject": Object {}, 312 | "root": "rss_root_id-8_id-9", 313 | } 314 | `); 315 | const styleEls = Array.from(document.querySelectorAll('style')); 316 | 317 | const lastStyleEl = styleEls[styleEls.length - 1]; 318 | 319 | expect(lastStyleEl.innerHTML).toMatchInlineSnapshot( 320 | `".rss_root_id-8_id-9{background-color:red;}"`, 321 | ); 322 | }); 323 | -------------------------------------------------------------------------------- /packages/standalone/src/createStyles.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useMemo, useLayoutEffect } from 'react'; 2 | import classNames from 'classnames'; 3 | import uid from 'uid'; 4 | // the types for stylis seems to have been deleted at this time of writing 5 | // @ts-ignore 6 | import stylis from 'stylis'; 7 | import { 8 | ReactComponent, 9 | StyleProps, 10 | StyleFnArgs, 11 | UseStyles, 12 | css, 13 | useTheme, 14 | useColorContext, 15 | } from '@flair/core'; 16 | import tryGetCurrentFilename from './tryGetCurrentFilename'; 17 | 18 | type GetComponentProps< 19 | ComponentType extends ReactComponent 20 | > = ComponentType extends React.ComponentType 21 | ? U 22 | : ComponentType extends keyof JSX.IntrinsicElements 23 | ? JSX.IntrinsicElements[ComponentType] 24 | : any; 25 | 26 | function hashStyleObj( 27 | styleObj: { [key: string]: string | undefined } | null | undefined, 28 | ) { 29 | if (!styleObj) return ''; 30 | 31 | return Object.keys(styleObj) 32 | .map((key) => `${key}_${styleObj[key]}`) 33 | .join('__|__'); 34 | } 35 | 36 | function usePreserveReference< 37 | T extends { [key: string]: string | undefined } | null | undefined 38 | >(styleObj: T): T { 39 | return useMemo( 40 | () => styleObj, 41 | // eslint-disable-next-line react-hooks/exhaustive-deps 42 | [hashStyleObj(styleObj)], 43 | ); 44 | } 45 | // preserve the object reference 46 | const empty = {}; 47 | 48 | const identity = (t: T) => t; 49 | 50 | function createStyles( 51 | stylesFn: (args: StyleFnArgs) => Styles, 52 | ) { 53 | const sheetId = uid(); 54 | const fileName = tryGetCurrentFilename(); 55 | 56 | // this makes it work in browser but a no-op in node. 57 | const doc = typeof document !== 'undefined' ? document : null; 58 | const sheetEl = doc?.createElement('style'); 59 | if (sheetEl) { 60 | sheetEl.dataset.flairStyles = 'true'; 61 | sheetEl.id = sheetId; 62 | // NOTE: this is, in-fact, a side effect 63 | doc?.head.appendChild(sheetEl); 64 | } 65 | 66 | function useStyles< 67 | Props extends StyleProps, 68 | ComponentType extends ReactComponent = 'div' 69 | >( 70 | props: Props = {} as any, 71 | component?: ComponentType, 72 | ): Omit & { 73 | Root: React.ComponentType>; 74 | styles: Styles & { cssVariableObject: { [key: string]: string } }; 75 | } { 76 | const theme = useTheme(); 77 | const { color, surface } = useColorContext(props); 78 | 79 | const { 80 | color: _color, 81 | surface: _surface, 82 | style: _incomingStyle, 83 | className: incomingClassName, 84 | styles: _incomingStyles = empty as Styles, 85 | ...restOfProps 86 | } = props; 87 | 88 | const incomingStyle = usePreserveReference(_incomingStyle as any); 89 | const incomingStyles = usePreserveReference(_incomingStyles as any); 90 | 91 | // create a map of unprocessed styles 92 | const unprocessedStyles = useMemo(() => { 93 | return stylesFn({ 94 | css, 95 | color, 96 | theme, 97 | surface, 98 | staticVar: identity, 99 | }); 100 | }, [color, surface, theme]); 101 | 102 | const styleId = useMemo(uid, [unprocessedStyles]); 103 | 104 | // calculate the class names 105 | const thisStyles = useMemo(() => { 106 | return Object.keys(unprocessedStyles) 107 | .map((key) => [ 108 | key, 109 | // the replace is ensure the class name only uses css safe characters 110 | `${fileName || 'rss'}_${key}_${sheetId}_${styleId}`.replace( 111 | /[^a-z0-9-_]/gi, 112 | '', 113 | ), 114 | ]) 115 | .reduce((acc, [key, className]) => { 116 | acc[key as keyof Styles] = className as Styles[keyof Styles]; 117 | return acc; 118 | }, {} as Styles); 119 | }, [styleId, unprocessedStyles]); 120 | 121 | // mount the styles to the dom 122 | useLayoutEffect(() => { 123 | const keys = Object.keys(thisStyles); 124 | 125 | const processedSheet = keys 126 | .map((key) => { 127 | const className = thisStyles[key]; 128 | const unprocessedStyle = unprocessedStyles[key]; 129 | 130 | const processedStyle: string = stylis( 131 | `.${className}`, 132 | unprocessedStyle, 133 | ); 134 | 135 | return processedStyle; 136 | }) 137 | .join('\n\n'); 138 | 139 | if (sheetEl) { 140 | sheetEl.innerHTML += processedSheet; 141 | } 142 | }, [thisStyles, unprocessedStyles]); 143 | 144 | const mergedStyles = useMemo(() => { 145 | const thisStyleKeys = Object.keys(thisStyles) as Array; 146 | 147 | const mergedStyles = thisStyleKeys.reduce((merged, key) => { 148 | const thisStyle = thisStyles[key]; 149 | const incomingStyle = incomingStyles[key]; 150 | 151 | merged[key] = classNames( 152 | thisStyle, 153 | incomingStyle, 154 | ) as Styles[keyof Styles]; 155 | 156 | return merged; 157 | }, {} as Styles); 158 | 159 | return { ...mergedStyles, cssVariableObject: {} as any }; 160 | }, [incomingStyles, thisStyles]); 161 | 162 | const Component = (component || 'div') as React.ComponentType; 163 | 164 | const Root = useMemo(() => { 165 | return forwardRef((rootProps: StyleProps, ref: any) => { 166 | const { className: rootClassName, style: rootStyles } = rootProps; 167 | 168 | return ( 169 | 182 | ); 183 | }) as React.ComponentType>; 184 | }, [mergedStyles.root, incomingClassName, incomingStyle]); 185 | 186 | return { 187 | Root, 188 | styles: mergedStyles, 189 | ...restOfProps, 190 | }; 191 | } 192 | 193 | // This is a type-assertion so ensure that this type is compatible with the 194 | // `UseStyles` type. TODO: may want to find a better way to enforce this 195 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 196 | useStyles as UseStyles; 197 | 198 | return useStyles; 199 | } 200 | 201 | export default createStyles; 202 | -------------------------------------------------------------------------------- /packages/standalone/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createStyles } from './createStyles'; 2 | export * from '@flair/core'; 3 | -------------------------------------------------------------------------------- /packages/standalone/src/tryGetCurrentFilename.test.js: -------------------------------------------------------------------------------- 1 | // TODO: not really sure how to test this one. 2 | // might just have to see how it works in different browsers 3 | test.todo('try get current file name'); 4 | -------------------------------------------------------------------------------- /packages/standalone/src/tryGetCurrentFilename.ts: -------------------------------------------------------------------------------- 1 | function tryGetCurrentFilename(): string { 2 | if (process.env.NODE_ENV === 'production') return ''; 3 | 4 | // TODO: try this out in other browsers 5 | const e = new Error(); 6 | const { stack } = e; 7 | if (!stack) return ''; 8 | 9 | const match = /at.*\/(.*)\.(?:t|j)sx?.*\(.*\)/.exec(stack); 10 | if (!match) return ''; 11 | 12 | const fileName = match[1]; 13 | return fileName.replace(/\./g, '-').replace(/[^a-z-_]/gi, '-'); 14 | } 15 | 16 | export default tryGetCurrentFilename; 17 | -------------------------------------------------------------------------------- /packages/standalone/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "../", 5 | "paths": { 6 | "@flair/core": ["core/src/index.ts"], 7 | "@flair/common": ["common/src/index.ts"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "groupName": "all", 4 | "rebaseWhen": "behind-base-branch", 5 | "ignoreDeps": ["stylis", "flair"] 6 | } 7 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import { get } from 'lodash'; 4 | 5 | const extensions = ['.js', '.ts', '.tsx']; 6 | 7 | const nodePlugins = [ 8 | resolve({ extensions, preferBuiltins: true }), 9 | babel({ 10 | babelrc: false, 11 | presets: [ 12 | ['@babel/preset-env', { targets: { node: true } }], 13 | '@babel/preset-typescript', 14 | ], 15 | extensions, 16 | include: ['packages/**/*'], 17 | }), 18 | ]; 19 | 20 | const umdPlugins = [ 21 | resolve({ 22 | extensions, 23 | }), 24 | babel({ 25 | babelrc: false, 26 | presets: [ 27 | '@babel/preset-env', 28 | '@babel/preset-react', 29 | '@babel/preset-typescript', 30 | ], 31 | babelHelpers: 'bundled', 32 | extensions, 33 | }), 34 | ]; 35 | 36 | const esmPlugins = [ 37 | resolve({ 38 | extensions, 39 | modulesOnly: true, 40 | }), 41 | babel({ 42 | babelrc: false, 43 | presets: ['@babel/preset-react', '@babel/preset-typescript'], 44 | plugins: ['@babel/plugin-transform-runtime'], 45 | babelHelpers: 'runtime', 46 | extensions, 47 | }), 48 | ]; 49 | 50 | const getExternal = (name) => [ 51 | ...Object.keys(require(`./packages/${name}/package.json`).dependencies || []), 52 | ...Object.keys( 53 | require(`./packages/${name}/package.json`).peerDependencies || [], 54 | ), 55 | ...Object.keys( 56 | get( 57 | require(`./packages/${name}/tsconfig.json`), 58 | ['compilerOptions', 'paths'], 59 | {}, 60 | ), 61 | ), 62 | // mark all babel runtime deps are external 63 | ...(require(`./packages/${name}/package.json`).dependencies['@babel/runtime'] 64 | ? [/^@babel\/runtime/] 65 | : []), 66 | ]; 67 | 68 | module.exports = [ 69 | // BABEL 70 | { 71 | input: './packages/babel-plugin-plugin/src/index.ts', 72 | output: { 73 | file: './dist/babel-plugin-plugin/index.js', 74 | format: 'cjs', 75 | sourcemap: true, 76 | }, 77 | plugins: nodePlugins, 78 | external: ['fs', 'path', ...getExternal('babel-plugin-plugin')], 79 | }, 80 | // COLLECT 81 | { 82 | input: './packages/collect/src/index.ts', 83 | output: { 84 | file: './dist/collect/index.js', 85 | format: 'cjs', 86 | sourcemap: true, 87 | }, 88 | plugins: nodePlugins, 89 | external: ['fs', 'path', ...getExternal('collect')], 90 | }, 91 | // LOADER 92 | { 93 | input: './packages/loader/src/index.ts', 94 | output: [ 95 | { 96 | file: './dist/loader/index.js', 97 | format: 'cjs', 98 | sourcemap: true, 99 | }, 100 | ], 101 | plugins: nodePlugins, 102 | }, 103 | // LOADER no-op 104 | { 105 | input: './packages/loader/src/load.ts', 106 | output: [ 107 | { 108 | file: './dist/loader/load.rss-css', 109 | format: 'cjs', 110 | }, 111 | ], 112 | plugins: nodePlugins, 113 | }, 114 | // COMMON 115 | { 116 | input: './packages/common/src/index.ts', 117 | output: { 118 | file: './dist/common/index.js', 119 | format: 'cjs', 120 | sourcemap: true, 121 | }, 122 | plugins: nodePlugins, 123 | external: ['fs', 'path', ...getExternal('common')], 124 | }, 125 | { 126 | input: './packages/common/src/index.ts', 127 | output: { 128 | file: './dist/common/index.esm.js', 129 | format: 'esm', 130 | sourcemap: true, 131 | }, 132 | plugins: esmPlugins, 133 | external: ['fs', 'path', ...getExternal('common')], 134 | }, 135 | // CORE 136 | { 137 | input: './packages/core/src/index.ts', 138 | output: { 139 | file: './dist/core/index.js', 140 | format: 'umd', 141 | sourcemap: true, 142 | name: 'Flair', 143 | globals: { 144 | react: 'React', 145 | color2k: 'color2k', 146 | }, 147 | }, 148 | plugins: umdPlugins, 149 | external: getExternal('core'), 150 | }, 151 | { 152 | input: './packages/core/src/index.ts', 153 | output: { 154 | file: './dist/core/index.esm.js', 155 | format: 'esm', 156 | sourcemap: true, 157 | }, 158 | plugins: esmPlugins, 159 | external: getExternal('core'), 160 | }, 161 | // SSR 162 | { 163 | input: './packages/ssr/src/index.ts', 164 | output: { 165 | file: './dist/ssr/index.js', 166 | format: 'umd', 167 | sourcemap: true, 168 | name: 'Flair', 169 | globals: { 170 | react: 'React', 171 | '@flair/core': 'Flair', 172 | classnames: 'classNames', 173 | }, 174 | }, 175 | plugins: umdPlugins, 176 | external: getExternal('ssr'), 177 | }, 178 | { 179 | input: './packages/ssr/src/index.ts', 180 | output: { 181 | file: './dist/ssr/index.esm.js', 182 | format: 'esm', 183 | sourcemap: true, 184 | }, 185 | plugins: esmPlugins, 186 | external: getExternal('ssr'), 187 | }, 188 | // STANDALONE 189 | { 190 | input: './packages/standalone/src/index.ts', 191 | output: { 192 | file: './dist/standalone/index.js', 193 | format: 'umd', 194 | sourcemap: true, 195 | name: 'Flair', 196 | globals: { 197 | react: 'React', 198 | '@flair/core': 'Flair', 199 | classnames: 'classNames', 200 | uid: 'uid', 201 | stylis: 'stylis', 202 | color2k: 'color2k', 203 | }, 204 | }, 205 | plugins: umdPlugins, 206 | external: getExternal('standalone'), 207 | }, 208 | { 209 | input: './packages/standalone/src/index.ts', 210 | output: { 211 | file: './dist/standalone/index.esm.js', 212 | format: 'esm', 213 | sourcemap: true, 214 | }, 215 | plugins: esmPlugins, 216 | external: getExternal('standalone'), 217 | }, 218 | // ROOT 219 | { 220 | input: './packages/flair/src/index.ts', 221 | output: { 222 | file: './dist/flair/index.js', 223 | format: 'umd', 224 | sourcemap: true, 225 | name: 'Flair', 226 | globals: { 227 | react: 'React', 228 | '@flair/standalone': 'Flair', 229 | classnames: 'classNames', 230 | uid: 'uid', 231 | stylis: 'stylis', 232 | color2k: 'color2k', 233 | }, 234 | }, 235 | plugins: umdPlugins, 236 | external: ['@flair/standalone'], 237 | }, 238 | { 239 | input: './packages/flair/src/index.ts', 240 | output: { 241 | file: './dist/flair/index.esm.js', 242 | format: 'esm', 243 | sourcemap: true, 244 | }, 245 | plugins: esmPlugins, 246 | external: ['@flair/standalone'], 247 | }, 248 | ]; 249 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { exec } = require('child_process'); 4 | const { hashElement } = require('folder-hash'); 5 | const { get } = require('lodash'); 6 | 7 | const args = process.argv.slice(2); 8 | 9 | function execute(command) { 10 | return new Promise((resolve, reject) => { 11 | exec(command, (error, stdout, stderr) => { 12 | if (error) { 13 | console.error(stdout); 14 | console.error(stderr); 15 | reject(error); 16 | } else { 17 | console.warn(stderr); 18 | console.log(stdout); 19 | resolve(); 20 | } 21 | }); 22 | }); 23 | } 24 | 25 | async function main() { 26 | console.log('cleaning…'); 27 | await execute('rm -rf dist'); 28 | 29 | console.log('linting…'); 30 | await execute('npx eslint packages --ext .ts,.tsx,.js,.jsx'); 31 | 32 | console.log('generating types…'); 33 | await execute('npx tsc'); 34 | 35 | console.log('rolling…'); 36 | await execute('npx rollup -c'); 37 | 38 | const hash = await hashElement(path.resolve(__dirname, '../dist'), { 39 | encoding: 'hex', 40 | }); 41 | const buildHash = hash.hash.substring(0, 9); 42 | 43 | console.log('writing `package.json`s…'); 44 | const topLevelPackageJson = require('../package.json'); 45 | 46 | const { 47 | private: _private, 48 | scripts: _scripts, 49 | devDependencies: _devDependencies, 50 | peerDependencies: _peerDependencies, 51 | name: _name, 52 | version: packageVersion, 53 | ...restOfTopLevelPackageJson 54 | } = topLevelPackageJson; 55 | 56 | const folderNames = await fs.promises.readdir( 57 | path.resolve(__dirname, '../packages'), 58 | ); 59 | 60 | const version = args.includes('--use-package-version') 61 | ? packageVersion 62 | : `0.0.0-${buildHash}`; 63 | 64 | for (const folder of folderNames) { 65 | const { 66 | name, 67 | dependencies, 68 | } = require(`../packages/${folder}/package.json`); 69 | 70 | const tsconfig = require(`../packages/${folder}/tsconfig.json`); 71 | 72 | const siblingDependencies = Object.keys( 73 | get(tsconfig, ['compilerOptions', 'paths'], {}), 74 | ).reduce((acc, next) => { 75 | acc[next] = version; 76 | return acc; 77 | }, {}); 78 | 79 | const buildFiles = await fs.promises.readdir( 80 | path.resolve(__dirname, `../dist/${folder}`), 81 | ); 82 | const containsEsmBuild = buildFiles.includes('index.esm.js'); 83 | 84 | const packageJson = { 85 | name, 86 | version, 87 | ...restOfTopLevelPackageJson, 88 | main: './index.js', 89 | types: './src', 90 | ...(containsEsmBuild 91 | ? { 92 | module: './index.esm.js', 93 | } 94 | : null), 95 | dependencies: { 96 | ...siblingDependencies, 97 | ...dependencies, 98 | }, 99 | }; 100 | 101 | await fs.promises.writeFile( 102 | path.resolve(__dirname, `../dist/${folder}/package.json`), 103 | JSON.stringify(packageJson, null, 2), 104 | ); 105 | } 106 | 107 | // TODO: copy README into dist folder 108 | 109 | console.log('DONE!'); 110 | } 111 | 112 | main() 113 | .then(() => process.exit(0)) 114 | .catch(e => { 115 | console.error(e); 116 | process.exit(1); 117 | }); 118 | -------------------------------------------------------------------------------- /scripts/check-dependencies.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | async function checkDependencies() { 5 | const folderNames = await fs.promises.readdir( 6 | path.resolve(__dirname, '../packages'), 7 | ); 8 | 9 | const topLevelPackageJson = require('../package.json'); 10 | 11 | if (topLevelPackageJson.dependencies) { 12 | throw new Error('Top-level package.json dependencies is not allowed'); 13 | } 14 | 15 | for (const folderName of folderNames) { 16 | const folderPath = path.resolve(`./packages/${folderName}`); 17 | const packageFolders = await fs.promises.readdir(folderPath); 18 | 19 | if (packageFolders.includes('node_modules')) { 20 | throw new Error(`[${folderName}] had node_modules`); 21 | } 22 | 23 | if (packageFolders.includes('package-lock.json')) { 24 | throw new Error(`[${folderName}] had package-lock.json`); 25 | } 26 | 27 | const packageJson = require(`${folderPath}/package.json`); 28 | const { 29 | name, 30 | devDependencies, 31 | dependencies, 32 | peerDependencies, 33 | } = packageJson; 34 | 35 | if (!name) { 36 | throw new Error(`[${folderName}] did not have a name in package.json`); 37 | } 38 | 39 | if (!dependencies) { 40 | throw new Error(`[${folderName}] did not have a dependencies object.`); 41 | } 42 | 43 | if (devDependencies) { 44 | throw new Error(`[${folderName}] had devDependencies`); 45 | } 46 | 47 | for (const [packageName, incomingVersion] of Object.entries(dependencies)) { 48 | const expectedVersion = topLevelPackageJson.devDependencies[packageName]; 49 | if (incomingVersion !== expectedVersion) { 50 | throw new Error( 51 | `[${folderName}] had a mismatching dependency. Saw '"${packageName}": "${incomingVersion}"' but expected '"${packageName}": ${expectedVersion}'`, 52 | ); 53 | } 54 | } 55 | 56 | for (const [packageName, incomingVersion] of Object.entries( 57 | peerDependencies || {}, 58 | )) { 59 | const expectedVersion = topLevelPackageJson.peerDependencies[packageName]; 60 | if (incomingVersion !== expectedVersion) { 61 | throw new Error( 62 | `[${folderName}] had a mismatching peer-dependency. Saw '"${packageName}": "${incomingVersion}"' but expected '"${packageName}": ${expectedVersion}'`, 63 | ); 64 | } 65 | } 66 | } 67 | } 68 | 69 | checkDependencies() 70 | .then(() => { 71 | console.log('Dependencies good!'); 72 | process.exit(0); 73 | }) 74 | .catch(e => { 75 | console.error(e && e.message); 76 | process.exit(1); 77 | }); 78 | -------------------------------------------------------------------------------- /scripts/publish.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { exec } = require('child_process'); 4 | const { createInterface } = require('readline'); 5 | 6 | const rl = createInterface({ 7 | input: process.stdin, 8 | output: process.stdout, 9 | }); 10 | 11 | const question = (query) => 12 | new Promise((resolve) => rl.question(query, resolve)); 13 | 14 | function execute(command) { 15 | return new Promise((resolve, reject) => { 16 | exec(command, (error, stdout, stderr) => { 17 | if (error) { 18 | console.error(stdout); 19 | console.error(stderr); 20 | reject(error); 21 | } else { 22 | console.warn(stderr); 23 | console.log(stdout); 24 | resolve(); 25 | } 26 | }); 27 | }); 28 | } 29 | 30 | async function publish() { 31 | const folderNames = await fs.promises.readdir( 32 | path.resolve(__dirname, '../dist'), 33 | ); 34 | 35 | if (folderNames.length <= 0) { 36 | throw new Error( 37 | 'Nothing found in dist folder. Please run `npm run build` first.', 38 | ); 39 | } 40 | 41 | const tag = await question('tag (experimental): '); 42 | const otp = await question('npm OTP: '); 43 | 44 | for (const folderName of folderNames.slice().reverse()) { 45 | console.log(`Publishing ${folderName}`); 46 | try { 47 | await execute( 48 | `npm publish --otp=${otp} --access=public --tag=${ 49 | tag || 'experimental' 50 | } ${path.resolve(__dirname, `../dist/${folderName}`)}`, 51 | ); 52 | } catch (e) { 53 | console.warn(e); 54 | } 55 | } 56 | } 57 | 58 | publish() 59 | .then(() => process.exit(0)) 60 | .catch((e) => { 61 | console.error(e); 62 | process.exit(1); 63 | }); 64 | -------------------------------------------------------------------------------- /scripts/resolve-dependencies.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { exec } = require('child_process'); 4 | const semver = require('semver'); 5 | const { isEqual } = require('lodash'); 6 | 7 | function execute(command) { 8 | return new Promise((resolve, reject) => { 9 | exec(command, (error, stdout, stderr) => { 10 | if (error) { 11 | console.error(stdout); 12 | console.error(stderr); 13 | reject(error); 14 | } else { 15 | console.warn(stderr); 16 | console.log(stdout); 17 | resolve(); 18 | } 19 | }); 20 | }); 21 | } 22 | 23 | const parseVersion = version => { 24 | const match = /\D?([\d.]*)/.exec(version); 25 | if (!match) throw new Error('could not parse version'); 26 | return match[1]; 27 | }; 28 | 29 | async function resolveDependencies() { 30 | await execute('node ./scripts/check-dependencies'); 31 | 32 | const folderNames = await fs.promises.readdir( 33 | path.resolve(__dirname, '../packages'), 34 | ); 35 | 36 | const folders = folderNames.map(folderName => 37 | path.resolve(__dirname, `../packages/${folderName}`), 38 | ); 39 | 40 | const allDependencies = folders 41 | .map(folder => { 42 | const { dependencies } = require(`${folder}/package.json`); 43 | 44 | return Object.entries(dependencies || []); 45 | }) 46 | .flat() 47 | .filter(([k, v]) => Boolean(k) && Boolean(v)); 48 | 49 | const allPeerDependencies = folders 50 | .map(folder => { 51 | const { peerDependencies } = require(`${folder}/package.json`); 52 | 53 | return Object.entries(peerDependencies || []); 54 | }) 55 | .flat() 56 | .filter(([k, v]) => Boolean(k) && Boolean(v)); 57 | 58 | const devDependencies = allDependencies.reduce( 59 | (deps, [packageName, version]) => { 60 | if (!deps[packageName]) { 61 | deps[packageName] = version; 62 | return deps; 63 | } 64 | 65 | const incomingVersion = parseVersion(version); 66 | const currentVersion = parseVersion(deps[packageName]); 67 | 68 | if (semver.gt(incomingVersion, currentVersion)) { 69 | deps[packageName] = incomingVersion; 70 | return deps; 71 | } 72 | 73 | return deps; 74 | }, 75 | {}, 76 | ); 77 | 78 | const peerDependencies = allPeerDependencies.reduce( 79 | (deps, [packageName, version]) => { 80 | if (!deps[packageName]) { 81 | deps[packageName] = version; 82 | return deps; 83 | } 84 | 85 | const incomingVersion = parseVersion(version); 86 | const currentVersion = parseVersion(deps[packageName]); 87 | 88 | if (semver.lt(incomingVersion, currentVersion)) { 89 | deps[packageName] = incomingVersion; 90 | return deps; 91 | } 92 | 93 | return deps; 94 | }, 95 | {}, 96 | ); 97 | 98 | const topLevelPackageJson = require('../package.json'); 99 | await fs.promises.writeFile( 100 | require.resolve('../package.json'), 101 | JSON.stringify( 102 | { 103 | ...topLevelPackageJson, 104 | devDependencies: { 105 | ...topLevelPackageJson.devDependencies, 106 | ...devDependencies, 107 | }, 108 | peerDependencies, 109 | }, 110 | null, 111 | 2, 112 | ), 113 | ); 114 | 115 | const packageLockBefore = JSON.parse( 116 | (await fs.promises.readFile(require.resolve('../package-lock.json'))).toString(), 117 | ); 118 | await execute('npm i'); 119 | const packageLockAfter = JSON.parse( 120 | (await fs.promises.readFile(require.resolve('../package-lock.json'))).toString(), 121 | ); 122 | 123 | if (!isEqual(packageLockBefore, packageLockAfter)) { 124 | throw new Error('package-lock.json changed after install'); 125 | } 126 | } 127 | 128 | resolveDependencies() 129 | .then(() => { 130 | console.log('Dependencies resolved!'); 131 | process.exit(0); 132 | }) 133 | .catch(e => { 134 | console.error(e && e.message); 135 | process.exit(1); 136 | }); 137 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "allowJs": true, 5 | "allowSyntheticDefaultImports": true, 6 | "strict": true, 7 | "jsx": "preserve", 8 | "esModuleInterop": true, 9 | "module": "ES2015", 10 | "outDir": "./dist", 11 | "forceConsistentCasingInFileNames": true, 12 | "moduleResolution": "Node", 13 | "emitDeclarationOnly": true, 14 | "declaration": true, 15 | "skipLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@flair/*": ["packages/*/src/index.ts"] 19 | } 20 | }, 21 | "include": ["packages/**/*"], 22 | "exclude": ["packages/**/*.test.js"] 23 | } 24 | -------------------------------------------------------------------------------- /why-another-css-in-js-lib.md: -------------------------------------------------------------------------------- 1 | ## Why another CSS-in-JS lib? 2 | 3 | > **~**
4 | > **a lil preface 😅**:
5 | > **~** 6 | > 7 | > I think by the nature of styling itself, the way we style can be just as opinionated as the styles themselves. 8 | > 9 | > Please read this with an open mind and feel free to [open an issue](https://github.com/ricokahler/flair/issues) or [email me](mailto:ricokahler@me.com) with any feedback or corrections! 10 | 11 | Why another CSS-in-JS lib? 12 | 13 | Because there's no one lib that checks all the boxes for me. See below for an explanation. 14 | 15 | ### 1. Component-centric semantics for styles 16 | 17 | If you used Material UI or JSS, then you're familiar with using `withStyles` or `makeStyles`. e.g. 18 | 19 | ```js 20 | // Component.js in [material-ui] 21 | import React from 'react'; 22 | import { makeStyles } from '@material-ui/core'; 23 | 24 | const useStyles = makeStyles((theme) => ({ 25 | root: { 26 | /* styles go here */ 27 | }, 28 | title: { 29 | /* styles go here */ 30 | }, 31 | })); 32 | 33 | function Component(props) { 34 | const classes = useStyles(props); 35 | // classes.root… 36 | // classes.title… 37 | } 38 | 39 | export default Component; 40 | ``` 41 | 42 | This pattern is great because it creates styles on a component level and it's simple for a parent component to override child styles. For example, in Material UI, a parent component can override `title` styles like so: 43 | 44 | ```js 45 | // Parent.js in [material-ui] 46 | import React from 'react'; 47 | import { makeStyles } from '@material-ui/core'; 48 | import Component from './Component'; 49 | 50 | const useStyles = makeStyles(theme => ({ 51 | root: {/* ... */}, 52 | modifedTitle: {/* ... */}, 53 | }); 54 | 55 | function Parent(props) { 56 | const classes = useStyles(props); 57 | 58 | return ( 59 | <> 60 | 61 | 62 | ); 63 | } 64 | ``` 65 | 66 | This is great because it quickly transforms your class names into part of your component's API. To me, this is an execellent way to enable composition on a style-level and eliminate unscalable style-related props like `underlined`, `hasBorder` etc. I think the ability to augment a style like this is just as powerful as the [`children` prop is in React](https://youtu.be/3XaXKiXtNjw?t=651). 67 | 68 | In contrast, emotion and styled-components do not share these component rooted semantics. With emotion/styled-components, you're always writing styles for an individual element, not a component. 69 | 70 | ```js 71 | // [emotion] or [styled-compoennts] like example 72 | import React from 'react'; 73 | import styled from 'styled-components'; 74 | 75 | // no component semantics 76 | const Title = styled.div` 77 | font-weight: bold; 78 | `; 79 | 80 | // no built-in ability to override the `Title` class 81 | function Component() { 82 | return ( 83 | <> 84 | {/* ... */} 85 | 86 | {/* ... */} 87 | </> 88 | ); 89 | } 90 | ``` 91 | 92 | ### 2. Embrace HTML semantics via `className`s 93 | 94 | Another issue I have with styled-components is the syntax of `const Title = styled.div`. This syntax abstracts away from HTML semantics and makes it challenging to use class names. Going back to Material UI again, their styling solution embraces class names and HTML semantics. Making it easy to use tools like [`classnames`](https://github.com/JedWatson/classnames) to conditionally apply CSS classnames. 95 | 96 | ```js 97 | // [material-ui] example 98 | import React from 'react'; 99 | import classNames from 'classnames'; 100 | import { makeStyles } from '@material-ui/core'; 101 | 102 | const useStyles = makeStyles((theme) => ({ 103 | root: { 104 | /* ... */ 105 | }, 106 | button: { 107 | /* ... */ 108 | }, 109 | title: { 110 | /* ... */ 111 | }, 112 | highlighted: { 113 | /* ... */ 114 | }, 115 | })); 116 | 117 | function Component(props) { 118 | const classes = useStyles(props); 119 | const [on, setOn] = useState(false); 120 | 121 | return ( 122 | <> 123 | <button className={classes.button} onClick={() => setOn(!on)}> 124 | toggle color 125 | </button> 126 | <h1 127 | className={classNames(classes.title, { 128 | [classes.highlighted]: on, 129 | })} 130 | > 131 | color 132 | </h1> 133 | </> 134 | ); 135 | } 136 | ``` 137 | 138 | It's possible to do the above with styled-components syntax, however it requires passing props into the styled component. This is odd because it adds to the API footprint of the styled component and further takes away from the raw HTML element. 139 | 140 | ```js 141 | // [styled-components] example 142 | import React from 'react'; 143 | import styled from 'styled-components'; 144 | 145 | const Root = styled.div`/* ... */`; 146 | // note: if you were using typescript, you'd have to write different props for this one now 147 | const Title = styled.h1` 148 | color: ${props => props.highlighted ? 'red' : 'black'} 149 | `; 150 | 151 | function Component() { 152 | const [on, setOn] = useState(false); 153 | 154 | return ( 155 | <Root> 156 | <button onClick={() => setOn(!on)}>toggle color</button> 157 | <Title highlighted={on}> 158 | </Root> 159 | ); 160 | } 161 | ``` 162 | 163 | The issue I have with the above is that it becomes easy to forget that the `Title` component is an HTML `h1` tag (e.g., it's under a different name and the props are different now). 164 | 165 | When you forget that HTML is HTML, you forget to do things like add `aria-label`s, linters have a harder time giving you HTML suggestions, concepts like class names become foreign, and you almost grow resentment towards using "raw" HTML elements. It's like the raw `button` element is ugly because it's not uppercase 🤷‍♀️ 166 | 167 | Embracing HTML makes it easier to embrace HTML semantic elements which is better for a11y and SEO. 168 | 169 | ### 3. Write actual CSS 170 | 171 | This is where Material UI's styling solution falls short. I think it's better to write actual CSS (vs the JS object styling syntax) because: 172 | 173 | 1. It allows for better DX by being able to copy and paste CSS examples directly into code. 174 | 2. It allows for editors to "switch modes". Specifically, another language service could be booted up inside of `css` tags allowing for autocomplete without using the TypeScript language service. There are many plugins/extensions for many different editors that do this. 175 | 176 | ### 4. The ability to be define the color of a component dynamically, including derived states, in the context of a component 177 | 178 | This issue is a bit specific but important regarding the color systems for components libs like [Hacker UI](https://hacker-ui.com/) so bare with me hear for a bit… 179 | 180 | If you take a look at the styles for Material UI, you can see that they have two styles for both the "primary" and "secondary" color that are exactly the same besides the `primary` `secondary` values. 181 | 182 | ```js 183 | // taken from [material-ui] 184 | /* Styles applied to the root element if `variant="contained"` and `color="primary"`. */ 185 | containedPrimary: { 186 | color: theme.palette.primary.contrastText, 187 | backgroundColor: theme.palette.primary.main, 188 | '&:hover': { 189 | backgroundColor: theme.palette.primary.dark, 190 | // Reset on touch devices, it doesn't add specificity 191 | '@media (hover: none)': { 192 | backgroundColor: theme.palette.primary.main, 193 | }, 194 | }, 195 | }, 196 | /* Styles applied to the root element if `variant="contained"` and `color="secondary"`. */ 197 | containedSecondary: { 198 | color: theme.palette.secondary.contrastText, 199 | backgroundColor: theme.palette.secondary.main, 200 | '&:hover': { 201 | backgroundColor: theme.palette.secondary.dark, 202 | // Reset on touch devices, it doesn't add specificity 203 | '@media (hover: none)': { 204 | backgroundColor: theme.palette.secondary.main, 205 | }, 206 | }, 207 | }, 208 | ``` 209 | 210 | [source](https://github.com/mui-org/material-ui/blob/f2d74e9144ffec1ba6a098528573c7dfb3957b48/packages/material-ui/src/Button/Button.js#L137-L160) 211 | 212 | This is an issue because it doesn't scale. 213 | 214 | So here's the goal: instead of having two or three related classes _just_ for colors, let's define a way to dynamically define one style class that works for all possible colors, and let the user pass in the color via a prop. 215 | 216 | The end goal is to be able to write styles like this: 217 | 218 | ```js 219 | // Button.js 220 | import React from 'react'; 221 | import { createStyles, readableColor } from 'flair'; 222 | 223 | const useStyles = createStyles((color) => ({ 224 | button: css` 225 | background-color: ${color}, 226 | color: ${readableColor(color)}; 227 | `, 228 | })); 229 | 230 | function Button(props) { 231 | // ... 232 | } 233 | ``` 234 | 235 | ```js 236 | // Parent.js 237 | import Button from './Button'; 238 | 239 | function Parent() { 240 | return ( 241 | <> 242 | {/* allow the user to pass in any color, the component styles will handle it. */} 243 | <Button color="red" /> 244 | <Button color="blue" /> 245 | </> 246 | ); 247 | } 248 | ``` 249 | 250 | ### 5. The ability to ship mostly static CSS (for better SSR/SEO/performance) 251 | 252 | If you're not familiar, linaria is a zero runtime CSS-in-JS solution that solved a lot of performance issues because it extracts all the styles you write with it to static CSS. 253 | 254 | > Note: by ability to ship static CSS, I mean that there is little to no javascript code related to styling left in the final bundle. This is different than SSR support. 255 | > 256 | > For example, Material UI/JSS supports server-side rendered CSS but the resulting JavaScript will still includs the code to create the styles. Because the JS still includes the styling code, it will increase download times and slow down [TTI](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive). 257 | 258 | ### Feature comparison 259 | 260 | | | Material UI/JSS | styled-components | emotion | linaria | flair | 261 | | ----------------------------- | --------------- | ----------------- | ------- | ------- | ------------------ | 262 | | Component-centric semantics | ✅ | 🔴 | 🔴 | 🔴 | ✅ | 263 | | Embraces HTML | ✅ | 🔴 | ✅ | ✅ | ✅ | 264 | | Actual CSS | 🔴 | ✅ | ✅ | ✅ | ✅ | 265 | | Contextual component coloring | 🔴 | 🔴 | 🔴 | 🔴 | ✅ | 266 | | Ship static CSS | 🔴 | 🔴 | 🔴 | ✅ | ✅ | 267 | --------------------------------------------------------------------------------