diff --git a/README.md b/README.md index 116bce95..d320c1e0 100644 --- a/README.md +++ b/README.md @@ -467,6 +467,25 @@ function App() { } ``` +### Creating global styles + +Use the `globalCss` API to create global styles: + +```js +import { globalCss } from '@pigment-css/react'; + +globalCss` + body { + margin: 0; + padding: 0; + } +`; +``` + +The `globalCss` function should to be called at the top level of your javascript file, usually from the entry point of the application. + +Calling inside a function or a component will not work as expected. Also, the extraction of global styles will always take place regardless of conditional rendering. + ### Theming Theming is an **optional** feature that lets you reuse the same values, such as colors, spacing, and typography, across your application. It is a plain object of any structure that you can define in your config file. diff --git a/packages/pigment-css-react/exports/globalCss.js b/packages/pigment-css-react/exports/globalCss.js new file mode 100644 index 00000000..0cfecb09 --- /dev/null +++ b/packages/pigment-css-react/exports/globalCss.js @@ -0,0 +1,5 @@ +Object.defineProperty(exports, '__esModule', { + value: true, +}); + +exports.default = require('../processors/globalCss').GlobalCssProcessor; diff --git a/packages/pigment-css-react/src/globalCss.d.ts b/packages/pigment-css-react/src/globalCss.d.ts new file mode 100644 index 00000000..2a61b4db --- /dev/null +++ b/packages/pigment-css-react/src/globalCss.d.ts @@ -0,0 +1,22 @@ +import type { CSSObjectNoCallback } from './base'; +import type { ThemeArgs } from './theme'; + +type Primitve = string | null | undefined | boolean | number; + +type CssArg = ((themeArgs: ThemeArgs) => CSSObjectNoCallback) | CSSObjectNoCallback; +type CssFn = (themeArgs: ThemeArgs) => string | number; + +interface GlobalCss { + /** + * @returns {string} The generated css class name to be referenced. + */ + (arg: TemplateStringsArray, ...templateArgs: (Primitve | CssFn)[]): string; + /** + * @returns {string} The generated css class name to be referenced. + */ + (...arg: CssArg[]): string; +} + +declare const globalCss: GlobalCss; + +export default globalCss; diff --git a/packages/pigment-css-react/src/globalCss.js b/packages/pigment-css-react/src/globalCss.js new file mode 100644 index 00000000..65e0cfca --- /dev/null +++ b/packages/pigment-css-react/src/globalCss.js @@ -0,0 +1,5 @@ +export default function globalCss() { + throw new Error( + `${process.env.PACKAGE_NAME}: You were trying to call "globalCss" function without configuring your bundler. Make sure to install the bundler specific plugin and use it. @pigment-css/vite-plugin for Vite integration or @pigment-css/nextjs-plugin for Next.js integration.`, + ); +} diff --git a/packages/pigment-css-react/src/processors/globalCss.ts b/packages/pigment-css-react/src/processors/globalCss.ts new file mode 100644 index 00000000..3c5244fd --- /dev/null +++ b/packages/pigment-css-react/src/processors/globalCss.ts @@ -0,0 +1,167 @@ +import type { Expression } from '@babel/types'; +import type { + CallParam, + TemplateParam, + Params, + TailProcessorParams, + ValueCache, +} from '@wyw-in-js/processor-utils'; +import { serializeStyles, Interpolation } from '@emotion/serialize'; +import { type Replacements, type Rules, ValueType } from '@wyw-in-js/shared'; +import type { CSSInterpolation } from '@emotion/css'; +import { validateParams } from '@wyw-in-js/processor-utils'; +import BaseProcessor from './base-processor'; +import type { IOptions } from './styled'; +import { cache } from '../utils/emotion'; +import { getGlobalSelector } from '../utils/preprocessor'; + +export type Primitive = string | number | boolean | null | undefined; + +export type TemplateCallback = (params: Record | undefined) => string | number; + +export class GlobalCssProcessor extends BaseProcessor { + callParam: CallParam | TemplateParam; + + constructor(params: Params, ...args: TailProcessorParams) { + super([params[0]], ...args); + if (params.length < 2) { + throw BaseProcessor.SKIP; + } + validateParams( + params, + ['callee', ['call', 'template']], + `Invalid use of ${this.tagSource.imported} tag.`, + ); + + const [, callParams] = params; + if (callParams[0] === 'call') { + this.dependencies.push(callParams[1]); + } else if (callParams[0] === 'template') { + callParams[1].forEach((element) => { + if ('kind' in element && element.kind !== ValueType.CONST) { + this.dependencies.push(element); + } + }); + } + this.callParam = callParams; + } + + build(values: ValueCache) { + if (this.artifacts.length > 0) { + throw new Error(`MUI: "${this.tagSource.imported}" is already built`); + } + + const [callType] = this.callParam; + + if (callType === 'template') { + this.handleTemplate(this.callParam, values); + } else { + this.handleCall(this.callParam, values); + } + } + + private handleTemplate([, callArgs]: TemplateParam, values: ValueCache) { + const templateStrs: string[] = []; + // @ts-ignore @TODO - Fix this. No idea how to initialize a Tagged String array. + templateStrs.raw = []; + const templateExpressions: Primitive[] = []; + const { themeArgs } = this.options as IOptions; + + callArgs.forEach((item) => { + if ('kind' in item) { + switch (item.kind) { + case ValueType.FUNCTION: { + const value = values.get(item.ex.name) as TemplateCallback; + templateExpressions.push(value(themeArgs)); + break; + } + case ValueType.CONST: + templateExpressions.push(item.value); + break; + case ValueType.LAZY: { + const evaluatedValue = values.get(item.ex.name); + if (typeof evaluatedValue === 'function') { + templateExpressions.push(evaluatedValue(themeArgs)); + } else { + templateExpressions.push(evaluatedValue as Primitive); + } + break; + } + default: + break; + } + } else if (item.type === 'TemplateElement') { + templateStrs.push(item.value.cooked as string); + // @ts-ignore + templateStrs.raw.push(item.value.raw); + } + }); + this.generateArtifacts(templateStrs, ...templateExpressions); + } + + generateArtifacts(styleObjOrTaggged: CSSInterpolation | string[], ...args: Primitive[]) { + const { styles: cssText } = serializeStyles( + args.length > 0 + ? [styleObjOrTaggged as Interpolation<{}>, ...args] + : [styleObjOrTaggged as Interpolation<{}>], + cache.registered, + ); + + const rules: Rules = { + [this.asSelector]: { + className: this.className, + cssText, + displayName: this.displayName, + start: this.location?.start ?? null, + }, + }; + const sourceMapReplacements: Replacements = [ + { + length: cssText.length, + original: { + start: { + column: this.location?.start.column ?? 0, + line: this.location?.start.line ?? 0, + }, + end: { + column: this.location?.end.column ?? 0, + line: this.location?.end.line ?? 0, + }, + }, + }, + ]; + this.artifacts.push(['css', [rules, sourceMapReplacements]]); + } + + private handleCall([, callArg]: CallParam, values: ValueCache) { + let styleObj: CSSInterpolation; + if (callArg.kind === ValueType.LAZY) { + styleObj = values.get(callArg.ex.name) as CSSInterpolation; + } else if (callArg.kind === ValueType.FUNCTION) { + const { themeArgs } = this.options as IOptions; + const value = values.get(callArg.ex.name) as ( + args: Record | undefined, + ) => CSSInterpolation; + styleObj = value(themeArgs); + } + if (styleObj) { + this.generateArtifacts(styleObj); + } + } + + doEvaltimeReplacement() { + this.replacer(this.value, false); + } + + doRuntimeReplacement() { + this.doEvaltimeReplacement(); + } + + get asSelector() { + return getGlobalSelector(this.className); + } + + get value(): Expression { + return this.astService.nullLiteral(); + } +} diff --git a/packages/pigment-css-react/src/utils/preprocessor.ts b/packages/pigment-css-react/src/utils/preprocessor.ts index 48b135fc..816e68d7 100644 --- a/packages/pigment-css-react/src/utils/preprocessor.ts +++ b/packages/pigment-css-react/src/utils/preprocessor.ts @@ -34,6 +34,10 @@ const stylis = (css: string, serializerParam = serializer) => const defaultGetDirSelector = (dir: 'ltr' | 'rtl') => `[dir=${dir}]`; +export function getGlobalSelector(asSelector: string) { + return `$$GLOBAL-${asSelector}`; +} + export function preprocessor( selector: string, cssText: string, @@ -45,14 +49,16 @@ export function preprocessor( getDirSelector = defaultGetDirSelector, } = options || {}; let css = ''; - if (cssText.startsWith('@keyframes')) { + const isGlobal = selector.startsWith(getGlobalSelector('')); + + if (!isGlobal && cssText.startsWith('@keyframes')) { css += stylis(cssText.replace('@keyframes', `@keyframes ${selector}`)); return css; } - css += stylis(`${selector}{${cssText}}`); + css += stylis(!isGlobal ? `${selector}{${cssText}}` : cssText); if (generateForBothDir) { css += stylis( - `${getDirSelector(defaultDirection === 'ltr' ? 'rtl' : 'ltr')} ${selector}{${cssText}}`, + `${getDirSelector(defaultDirection === 'ltr' ? 'rtl' : 'ltr')} ${!isGlobal ? `${selector}{${cssText}}` : cssText}`, serializerRtl, ); } diff --git a/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.input.js b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.input.js new file mode 100644 index 00000000..c1001433 --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.input.js @@ -0,0 +1,30 @@ +import { globalCss } from '@pigment-css/react'; + +globalCss` +* { + box-sizing: border-box; +} +@font-face { + font-family: 'Patrick Hand SC'; + font-style: normal; + font-weight: 400; + color: ${({ theme }) => theme.palette.primary.main}; + src: local('Patrick Hand SC'), + local('PatrickHandSC-Regular'), + url(https://fonts.gstatic.com/s/patrickhandsc/v4/OYFWCgfCR-7uHIovjUZXsZ71Uis0Qeb9Gqo8IZV7ckE.woff2) + format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, + U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, + U+A720-A7FF; +} +`; + +let inputGlobalStyles = globalCss(({ theme }) => ({ + '@keyframes mui-auto-fill': { from: { display: 'block', color: 'transparent' } }, + '@keyframes mui-auto-fill-cancel': { + from: { display: 'block', color: theme.palette.primary.main }, + }, +})); +if (typeof inputGlobalStyles === 'function') { + inputGlobalStyles = inputGlobalStyles(); +} diff --git a/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.output.css b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.output.css new file mode 100644 index 00000000..f6c71611 --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.output.css @@ -0,0 +1,27 @@ +* { + box-sizing: border-box; +} +@font-face { + font-family: 'Patrick Hand SC'; + font-style: normal; + font-weight: 400; + color: red; + src: + local('Patrick Hand SC'), + local('PatrickHandSC-Regular'), + url(https://fonts.gstatic.com/s/patrickhandsc/v4/OYFWCgfCR-7uHIovjUZXsZ71Uis0Qeb9Gqo8IZV7ckE.woff2) + format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF; +} +@keyframes mui-auto-fill { + from { + display: block; + color: transparent; + } +} +@keyframes mui-auto-fill-cancel { + from { + display: block; + color: red; + } +} diff --git a/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.output.js b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.output.js new file mode 100644 index 00000000..59a5426c --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss-theme.output.js @@ -0,0 +1,5 @@ +null; +let inputGlobalStyles = null; +if (typeof inputGlobalStyles === 'function') { + inputGlobalStyles = inputGlobalStyles(); +} diff --git a/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.input.js b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.input.js new file mode 100644 index 00000000..966b1041 --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.input.js @@ -0,0 +1,30 @@ +import { globalCss } from '@pigment-css/react'; + +const green = 'green'; + +globalCss` +* { + box-sizing: border-box; +} +@font-face { + font-family: 'Patrick Hand SC'; + font-style: normal; + font-weight: 400; + color: ${green}; + src: local('Patrick Hand SC'), + local('PatrickHandSC-Regular'), + url(https://fonts.gstatic.com/s/patrickhandsc/v4/OYFWCgfCR-7uHIovjUZXsZ71Uis0Qeb9Gqo8IZV7ckE.woff2) + format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, + U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, + U+A720-A7FF; +} +`; + +let inputGlobalStyles = globalCss({ + '@keyframes mui-auto-fill': { from: { display: 'block' } }, + '@keyframes mui-auto-fill-cancel': { from: { display: 'block' } }, +}); +if (typeof inputGlobalStyles === 'function') { + inputGlobalStyles = inputGlobalStyles(); +} diff --git a/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.output.css b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.output.css new file mode 100644 index 00000000..bea96799 --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.output.css @@ -0,0 +1,25 @@ +* { + box-sizing: border-box; +} +@font-face { + font-family: 'Patrick Hand SC'; + font-style: normal; + font-weight: 400; + color: green; + src: + local('Patrick Hand SC'), + local('PatrickHandSC-Regular'), + url(https://fonts.gstatic.com/s/patrickhandsc/v4/OYFWCgfCR-7uHIovjUZXsZ71Uis0Qeb9Gqo8IZV7ckE.woff2) + format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF; +} +@keyframes mui-auto-fill { + from { + display: block; + } +} +@keyframes mui-auto-fill-cancel { + from { + display: block; + } +} diff --git a/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.output.js b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.output.js new file mode 100644 index 00000000..59a5426c --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/fixtures/globalCss.output.js @@ -0,0 +1,5 @@ +null; +let inputGlobalStyles = null; +if (typeof inputGlobalStyles === 'function') { + inputGlobalStyles = inputGlobalStyles(); +} diff --git a/packages/pigment-css-react/tests/globalCss/globalCss.test.ts b/packages/pigment-css-react/tests/globalCss/globalCss.test.ts new file mode 100644 index 00000000..bbca3b00 --- /dev/null +++ b/packages/pigment-css-react/tests/globalCss/globalCss.test.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import { runTransformation, expect } from '../testUtils'; + +describe('Pigment CSS - globalCss', () => { + it('basics', async () => { + const { output, fixture } = await runTransformation( + path.join(__dirname, 'fixtures/globalCss.input.js'), + ); + + expect(output.js).to.equal(fixture.js); + expect(output.css).to.equal(fixture.css); + }); + + it('can access theme', async () => { + const { output, fixture } = await runTransformation( + path.join(__dirname, 'fixtures/globalCss-theme.input.js'), + { + themeArgs: { + theme: { + palette: { + primary: { + main: 'red', + }, + }, + }, + }, + }, + ); + + expect(output.js).to.equal(fixture.js); + expect(output.css).to.equal(fixture.css); + }); +}); diff --git a/packages/pigment-css-react/tsup.config.ts b/packages/pigment-css-react/tsup.config.ts index 5fd52716..3c238b65 100644 --- a/packages/pigment-css-react/tsup.config.ts +++ b/packages/pigment-css-react/tsup.config.ts @@ -1,7 +1,15 @@ import { Options, defineConfig } from 'tsup'; import config from '../../tsup.config'; -const processors = ['styled', 'sx', 'keyframes', 'generateAtomics', 'css', 'createUseThemeProps']; +const processors = [ + 'styled', + 'sx', + 'keyframes', + 'generateAtomics', + 'css', + 'createUseThemeProps', + 'globalCss', +]; const external = ['react', 'react-is', 'prop-types']; const baseConfig: Options = {