diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6656c27..712ebcb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,12 @@ jobs: - name: Run tests run: yarn test + + - name: Smoke source-condition package imports + run: | + node -C cssx-ts --input-type=module -e "import { cssx, useCssxLayer } from 'cssxjs'; if (typeof cssx !== 'function' || typeof useCssxLayer !== 'function') throw new Error('cssxjs source-condition import failed')" + + - name: Smoke built package imports + run: | + yarn workspace @cssxjs/css-to-rn build + node --input-type=module -e "import { compileCss, resolveCssx } from '@cssxjs/css-to-rn'; const sheet = compileCss('.root { color: red; }'); const result = resolveCssx({ styleName: 'root', layers: sheet }); if (result.props.style.color !== 'red') throw new Error('built css-to-rn import failed')" diff --git a/.gitignore b/.gitignore index ab51119..b2be2c2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # Node dependencies node_modules +packages/*/dist # npm-debug log npm-debug.* diff --git a/AGENTS.md b/AGENTS.md index bea3b03..bb25e6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,42 +1,100 @@ # Agent Guide -Read this first, then use `architecture.md` for the detailed system map. - -CSSX is a monorepo for a CSS-in-JS toolchain. Users write `styl`, `css`, or optional `pug` templates plus `styleName` and `part` props. Babel compiles that authoring syntax into style objects and runtime calls. The runtime matches class selectors, applies CSS variables/media queries, supports component parts, and can memoize with teamplay. +Read this first, then read `architecture.md` for the detailed system map. Keep +this file focused on day-to-day orientation for agents; put durable architecture +details in `architecture.md`. + +CSSX is the CSS toolchain used by StartupJS and usable on its own. Users write +`css`, `styl`, and optional `pug` templates with `styleName` and `part` props. +Babel compiles that authoring syntax into CSSX sheet IR plus runtime calls. +The unified `@cssxjs/css-to-rn` package owns CSS parsing, selector matching, +CSS variable resolution, CSS value evaluation, React Native/web style +transforms, runtime caching, provider themes, custom media, dimensions, and +React subscriptions. ## Start Here -1. Read `docs/guide/index.md`, `docs/guide/usage.md`, and `docs/guide/component-parts.md` for the public model. -2. Read `architecture.md` before changing cross-package behavior. +1. Read `architecture.md` before changing cross-package behavior. +2. Read `docs/guide/index.md`, `docs/guide/usage.md`, + `docs/guide/component-parts.md`, `docs/guide/variables.md`, + `docs/guide/theming.md`, and `docs/examples/theme.md` for the public model. 3. Use the package map below to choose the smallest code area to edit. +4. Check `plan.md` only for future/backlog items. Completed architecture should + live in `architecture.md`, not in `plan.md`. ## Package Map -- `packages/cssxjs/`: public `cssxjs` facade, CLI, wrappers, package exports. -- `packages/runtime/`: `process()`, `matcher()`, variables, dimensions, platform helpers, teamplay caching. -- `packages/loaders/`: Stylus/CSS loaders and direct compiler wrappers. -- `packages/babel-plugin-rn-stylename-inline/`: compiles inline `css` and `styl` templates. -- `packages/babel-plugin-rn-stylename-to-style/`: rewrites JSX `styleName`, `part`, old `*StyleName`, and helper calls. +- `packages/css-to-rn/`: unified compiler/runtime engine. Start here for CSS + parsing, selector IR, value resolution, color functions, property transforms, + caching, `cssx()`, `useRuntimeCss()`, variables, dimensions, provider themes, + custom media, and React tracking. +- `packages/cssxjs/`: public `cssxjs` facade, package exports, CLI, runtime + compatibility wrappers, Babel preset wrapper, loader wrappers, Metro wrappers, + and built-in theme entrypoints such as `cssxjs/themes/tailwind` and + `cssxjs/themes/shadcn`. +- `packages/loaders/`: Stylus/CSS loaders and direct compiler wrappers. Pure + CSS compilation delegates to `@cssxjs/css-to-rn`; Stylus remains a separate + preprocessing step. +- `packages/babel-plugin-rn-stylename-inline/`: compiles inline `css` and + `styl` templates, including local template interpolation lowering. +- `packages/babel-plugin-rn-stylename-to-style/`: rewrites JSX `styleName`, + `part`, legacy `*StyleName`, imported style sheets, and helper calls into + runtime calls. - `packages/babel-preset-cssxjs/`: transform ordering and public Babel options. - `packages/bundler/`: Metro hot-reload path for separate style files. -- `packages/eslint-plugin-cssxjs/`: wrapper around React Pug ESLint processor. +- `packages/eslint-plugin-cssxjs/`: wrapper around the React Pug ESLint + processor. - `docs/`: user-facing docs; update these when public behavior changes. - `example/`: web demo using Babel plus esbuild. ## Core Contracts -- `__CSS_GLOBAL__` and `__CSS_LOCAL__` connect the inline Babel plugin to the JSX/runtime plugin. -- Compiled style metadata `__hash__`, `__vars`, and `__hasMedia` connects loaders to cached and uncached runtime processing. -- Runtime calls have this shape: `runtime(styleName, fileStyles, globalStyles, localStyles, inlineStyleProps)`. -- Style priority is file styles, then global templates, then local templates, then inline props. -- Selector specificity is approximated by class count only. +- `__CSS_GLOBAL__` and `__CSS_LOCAL__` connect the inline Babel plugin to the + JSX/runtime plugin. +- Runtime calls generated by Babel keep the compatibility shape + `runtime(styleName, fileStyles, globalStyles, localStyles, inlineStyleProps)`. +- `cssxjs/runtime/*` wrappers adapt that call shape to the platform entrypoints + exported by `@cssxjs/css-to-rn`. +- Style priority is outer provider layers, inner provider layers, file/imported + sheets, global templates, local templates, then inline props. Matching rules + also sort by specificity and source order. +- Compiled sheets are JSON-serializable IR. Runtime cache, subscriptions, and + dependency tracking must stay outside the sheet. - `part='root'` maps to `style`; other parts map to `{partName}Style`. -- `css`/`styl` template interpolation is intentionally unsupported. -- Cached runtime is selected by `cache: 'teamplay'` or by importing `observer` from `teamplay` or `startupjs`. +- `:hover`, `:active`, `:part(hover)`, and `:part(active)` compile to + `hoverStyle` and `activeStyle`. +- Component tag selectors such as `Button` and `Button:part(text)` apply only + inside components wrapped with `themed('Button', Component)` or an explicit + resolver tag option. +- Provider/global `:root` custom properties compile to `sheet.rootVariables`. + Theme variables use `:root.`, for example `:root.dark`. +- `CssxProvider` layers provider styles, root variables, custom media, and the + active theme. At the root, no `theme` prop uses the persisted global + preference, or `default` when no preference exists. Root `theme='auto'` uses + the system color scheme as the initial preference, then saved/user-selected + preference takes priority. +- CSSX reserves custom media names matching `--theme-*`. `@media (--theme-dark)` + is the standard way to write theme-specific rules. +- `@custom-media --name (...)` is supported in provider and component CSS. + StartupJS UI uses breakpoint aliases such as `--breakpoint-tablet`. +- `variables` and `defaultVariables` are validating proxies. Direct + assignment/deletion works for valid `--name` keys; bulk updates use + `.assign()`, `.set()`, and `.clear()`. +- Local JS template interpolation is lowered to synthetic + `var(--__cssx_dynamic_N)` slots and passed as `values`. +- Runtime CSS uses `useRuntimeCss(rawCss)` plus + `cssx(styleName, sheet, inlineStyleProps)` for dynamic client-side + compilation. Variables come from `variables`, `defaultVariables`, provider + `:root`, or template/layer `values`. +- `getCssColor()` and `useCssColor()` bridge CSS variables/colors into JS and + return React Native-friendly values. +- `useMedia()` bridges CSSX custom media/media-query evaluation into JS. +- `cache: 'teamplay'` remains accepted as a Babel option for compatibility, but + runtime caching is owned by `@cssxjs/css-to-rn`, not Teamplay. ## Commands -Install dependencies with the repo package manager: +Install dependencies: ```sh yarn install @@ -51,9 +109,12 @@ yarn test Run targeted tests: ```sh -cd packages/runtime && yarn test +cd packages/css-to-rn && npm test +cd packages/css-to-rn && npm run test:types cd packages/babel-plugin-rn-stylename-inline && yarn test cd packages/babel-plugin-rn-stylename-to-style && yarn test +cd packages/loaders && yarn test +cd packages/cssxjs && npm test ``` Run docs locally: @@ -70,9 +131,23 @@ yarn start ## Change Guidance -- For runtime matching changes, update `packages/runtime/test/matcher.mjs` and `packages/runtime/test/process.mjs`. -- For Babel changes, update the relevant Jest snapshots. -- For public API or behavior changes, update `docs/` and `architecture.md`. -- For Pug, type checking, or ESLint behavior, check whether the implementation lives in `@react-pug/*`; this repo often only wraps it. -- For separate style files, check both Babel `compileCssImports` behavior and Metro transformer behavior. -- Prefer current source code and `docs/` over older package READMEs when they conflict. +- Treat `AGENTS.md` and `architecture.md` as living onboarding docs. If your + change alters package boundaries, public APIs, build/runtime flow, commands, + testing expectations, or maintenance rules, update these files in the same + change before handing work back. +- For CSS parsing, selector, value, transform, cache, variable, media, theme, or + React tracking behavior, update `packages/css-to-rn/test/engine/**` or + `packages/css-to-rn/test/react/**`. +- For inline template or interpolation compilation, update + `packages/babel-plugin-rn-stylename-inline` snapshots. +- For JSX `styleName`/`part` behavior, update + `packages/babel-plugin-rn-stylename-to-style` snapshots. +- For public exports or wrapper behavior, update `packages/cssxjs` smoke tests. +- For public API or behavior changes, update `docs/`, `architecture.md`, and + this guide. +- For Pug, type checking, or ESLint behavior, check whether the implementation + lives in `@react-pug/*`; this repo often only wraps it. +- For separate style files, check both Babel `compileCssImports` behavior and + Metro transformer behavior. +- Prefer current source code and `docs/` over older package READMEs when they + conflict. diff --git a/CHANGELOG.md b/CHANGELOG.md index dad8e16..d936c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,68 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + + +### Bug Fixes + +* make auto theme opt-in ([7132a31](https://github.com/startupjs/cssx/commit/7132a3189c49facdce8aa8bc47de63135313e82b)) + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package cssx + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package cssx + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package cssx + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package cssx + + + + + +# [0.4.0-alpha.1](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.0...v0.4.0-alpha.1) (2026-06-24) + +**Note:** Version bump only for package cssx + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/cssx/issues/5)) ([cd205cf](https://github.com/startupjs/cssx/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/README.md b/README.md index c2d384a..7601c5f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,18 @@ Install the following extension for full CSSX support with Pug and CSS/Stylus in [`vscode-react-pug-tsx`](https://marketplace.visualstudio.com/items?itemName=startupjs.vscode-react-pug-tsx) +## Credits + +CSSX's unified CSS-to-React-Native compiler/runtime was inspired by and replaces +the separate roles previously handled by: + +- [`css-to-react-native`](https://github.com/styled-components/css-to-react-native) +- [`css-to-react-native-transform`](https://github.com/kristerkari/css-to-react-native-transform) + +The runtime and API design also benefited from studying: + +- [`cssta`](https://github.com/jacobp100/cssta) + ## License MIT diff --git a/architecture.md b/architecture.md index bc38792..e31e2cc 100644 --- a/architecture.md +++ b/architecture.md @@ -1,492 +1,471 @@ # CSSX Architecture -CSSX is a CSS-in-JS system for React Native, react-native-web, and pure React web targets. Its public API lets users write `styl`, `css`, and optional `pug` tagged template literals, apply styles with `styleName`, expose child component override points with `part`, and update CSS variables at runtime. - -Most work happens at build time. Babel compiles template literals and `.cssx.*` imports into plain style objects, then rewrites JSX so elements receive a spread of runtime-generated style props. The runtime is deliberately small: it matches class names to compiled selectors, applies CSS variables and media queries, handles `:part()` style props, and optionally memoizes results with teamplay. - -## Repository Map - -- `docs/`: public documentation served by Rspress. Start here for expected user-facing behavior. -- `packages/cssxjs/`: umbrella package published as `cssxjs`. It exposes the public entrypoints, CLI, runtime wrappers, Babel preset wrapper, loader wrappers, and Metro wrappers. -- `packages/runtime/`: style matching, CSS variable state, media-query dimension state, platform helper injection, and cached/non-cached runtime entrypoints. -- `packages/loaders/`: webpack-compatible style loaders plus direct compiler helpers used by Babel. -- `packages/babel-plugin-rn-stylename-inline/`: compiles inline `css` and `styl` template literals into module/function-scoped style objects. -- `packages/babel-plugin-rn-stylename-to-style/`: rewrites `styleName`, `part`, `*StyleName`, and `styl(...)`/`css(...)` function calls into runtime calls. -- `packages/babel-preset-cssxjs/`: composes syntax plugins, React Pug transform, inline style compilation, and `styleName`/`part` transform. -- `packages/bundler/`: Metro config and transformer support for separate `.cssx.styl` and `.cssx.css` files. -- `packages/eslint-plugin-cssxjs/`: facade over `@react-pug/eslint-plugin-react-pug`. -- `example/`: simple web example using Babel plus esbuild directly. -- `docs-theme/` and `rspress.config.ts`: documentation theme and syntax highlighting configuration. - -The repository uses Yarn workspaces and Lerna. Root `package.json` requires Node `>=22` and defines the main scripts. - -## Public API Surface - -The published `cssxjs` package exposes: - -- `styl` and `css`: template tags processed away by Babel, and function forms used as `styl(styleName, inlineStyleProps)` / `css(...)` after Babel rewrites them. -- `pug`: template tag processed by `@react-pug/babel-plugin-react-pug`. -- `variables`: observable runtime CSS variable overrides. -- `setDefaultVariables` and `defaultVariables`: default CSS variable registry. -- `dimensions`: observable screen width state for media-query invalidation. -- `matcher`: advanced/internal class selector matcher. -- `cssxjs/babel`: Babel preset wrapper. -- `cssxjs/metro-config` and `cssxjs/metro-babel-transformer`: Metro integration wrappers. -- `cssxjs check`: CLI bridge to `@react-pug/check-types`. - -`packages/cssxjs/index.js` intentionally makes `css`, `styl`, and `pug` throw at runtime. If a user sees those errors, their file did not go through the Babel pipeline. - -## End-to-End Build Flow - -### 1. Authoring - -Users write components like: +CSSX is a CSS authoring, compilation, and runtime system for React Native and +web React. It exists so users can write normal-looking CSS near components while +React receives stable, platform-correct style props. + +The important current design decision is that the CSS-to-React-Native pipeline +is unified in `@cssxjs/css-to-rn`. Older responsibilities that used to be split +across forked `css-to-react-native`, `css-to-react-native-transform`, and +`@cssxjs/runtime` now live in this package. + +## Public Model + +Users normally import from `cssxjs`: + +- `css` and `styl` tagged templates for local component styles. +- `styleName` on JSX or Pug elements for class-style matching. +- `part` on JSX or Pug elements for externally styleable component slots. +- `CssxProvider` for global/provider styles, CSS variables, custom media, and + theme selection. +- `themed()` for components that should receive provider/global tag selectors + such as `Button` and `Button:part(text)`. +- `useRuntimeCss()` and `cssx()` for dynamic client-side CSS compilation. +- `variables`, `defaultVariables`, `useCssVariable()`, `useCssColor()`, + `getCssVariable()`, `getCssVariableRaw()`, `getCssColor()`, `useMedia()`, + and `u()` for CSS-to-JS bridges and migration helpers. +- `configureCssx()`, `setDefaultVariables()`, `useCssxSheet()`, + `useCssxTemplate()`, `useCssxConfig()`, and `useCssxRuntimeContext()` for + lower-level integration code. +- `matcher` for legacy compatibility only. New code should use CSSX runtime + APIs directly. +- Theme entrypoints such as `cssxjs/themes/tailwind` and + `cssxjs/themes/shadcn`. + +StartupJS re-exports this API and StartupJS UI uses it for its default theme and +component customization model, but CSSX is usable without either project. + +## Package Ownership + +### `packages/css-to-rn` + +The compiler/runtime engine. It owns: + +- CSS parsing through the lightweight `css` parser. +- CSS sheet IR and diagnostics. +- Selectors, cascade order, parts, pseudo states, media rules, custom media, and + component tag selectors. +- `:root` variables and `:root.` provider theme variables. +- CSS variable fallback and nested `var()` resolution. +- Runtime template interpolation values. +- CSS value functions needed by React Native, including `calc()`, `oklch()`, and + `color-mix()`. +- Unit conversion, including `u`, `rem`, viewport units, and React Native + numeric coercion. +- Property transforms such as shorthands, `box-shadow`, `filter`, animations, + transitions, and platform-specific background image output. +- Runtime matching and cache identity. +- React hooks, provider context, theme preference, dimensions/media + subscriptions, and memory-safe dependency tracking. + +### `packages/cssxjs` + +The public facade. It exports the stable `cssxjs` API, platform runtime +wrappers, Babel preset wrapper, loader wrappers, Metro wrappers, CLI helpers, +and built-in theme strings. Its runtime files preserve the historical generated +Babel call shape and delegate actual work to `@cssxjs/css-to-rn`. + +### Babel Packages + +- `packages/babel-plugin-rn-stylename-inline` compiles inline `css` and `styl` + templates. It lowers JavaScript interpolation to synthetic CSS variables and + emits local sheet registrations. +- `packages/babel-plugin-rn-stylename-to-style` rewrites `styleName`, `part`, + legacy `*StyleName`, imported style files, and helper calls into runtime + calls. +- `packages/babel-preset-cssxjs` defines transform ordering. + +### Loader And Bundler Packages + +- `packages/loaders` contains direct loader entrypoints. Stylus preprocessing + stays separate, then compiled CSS goes through `@cssxjs/css-to-rn`. +- `packages/bundler` contains Metro hot-reload handling for separate style + files. StartupJS can choose whether CSSX imports are compiled in Babel or + handled by Metro. + +### Docs, Example, ESLint + +- `docs/` is the public documentation source of truth. +- `example/` is the web demo. +- `packages/eslint-plugin-cssxjs` wraps the React Pug processor for CSSX usage. + +## Compiled Sheet IR + +The compiler produces JSON-serializable sheets: + +- `rules`: selector and declaration IR. +- `keyframes`: animation keyframe objects. +- `rootVariables`: declarations from `:root`. +- `themeVariables`: declarations from `:root.`. +- `customMedia`: declarations from `@custom-media`. +- `exports`: any exported CSS values. +- `metadata`: booleans and dependency lists for vars, media, themes, + animations, transitions, interpolations, and runtime dependencies. +- `diagnostics`: non-fatal warnings plus fatal compile errors for build mode. + +Runtime-only objects, caches, render trackers, and subscription state must never +be stored on the sheet itself. This keeps compiled sheets serializable and safe +to pass through Babel output, imports, and provider style arrays. + +## Build-Time Flow + +### Inline Templates + +Inline templates are the recommended authoring style: ```jsx -import { styl } from 'cssxjs' -import { View, Text } from 'react-native' - -function Button ({ variant, children }) { - return ( - - {children} - - ) - - styl` - .root - padding 2u - background var(--button-bg, #1677ff) - .text - color white - ` -} -``` +function Button ({ color, children }) { + return {children} -Parent components can target the exposed parts from outside: - -```jsx -function Toolbar () { - return - - styl` - .primaryButton - background #0057d9 - &:part(text) - font-weight bold + css` + .root { + background-color: ${color}; + } ` } ``` -The core authoring constructs are: - -- class-like `styleName` values: strings, arrays, and object flags. -- `part` attributes with compile-time-static names. -- `:part(name)` selectors in CSS/Stylus, used by parent/outside styles to target child component parts. -- runtime CSS variables through `var(--name, fallback)`. -- media queries and viewport units. -- optional Pug templates and embedded terminal `style` blocks. - -### 2. Babel Preset - -`packages/babel-preset-cssxjs/index.js` configures the transform stack: - -1. Syntax support for JSX, TypeScript, and TSX depending on filename. -2. `@react-pug/babel-plugin-react-pug` when `transformPug !== false`. -3. `@cssxjs/babel-plugin-rn-stylename-inline` when `transformCss !== false`. -4. `@cssxjs/babel-plugin-rn-stylename-to-style` when `transformCss !== false`. - -This order matters. Pug must become JSX before CSSX rewrites JSX attributes. Inline CSS/Stylus templates must compile before `styleName` references are converted into runtime calls. - -Preset options: - -- `platform`: passed to style compilers. Defaults to `web` or Babel caller platform. -- `reactType`: chooses runtime target, currently `web` or `react-native`. -- `cache`: chooses cached runtime, currently only `teamplay`. -- `transformPug`: disables Pug transformation when false. -- `transformCss`: disables CSS/Stylus and `styleName` transformation when false. - -### 3. Pug Transform - -Pug support is provided by external `@react-pug/*` packages. CSSX wraps those packages through: - -- `cssxjs/babel/plugin-react-pug` -- `cssxjs check` -- `eslint-plugin-cssxjs` - -Current CSSX docs recommend terminal embedded style blocks inside Pug templates: - -```jsx -return pug` - View.card - Text.title= title - - style(lang='styl') - .card - padding 2u -` -``` - -The React Pug Babel plugin turns this into JSX plus local `styl` or `css` templates, which are then handled by CSSX's inline style plugin. +The inline plugin: -### 4. Inline Style Compilation +1. Finds `css` and `styl` tagged templates. +2. Compiles Stylus to CSS when needed. +3. Compiles CSS to sheet IR. +4. Replaces interpolations with `var(--__cssx_dynamic_N)` slots. +5. Emits a runtime layer with the compiled sheet and the current interpolation + values. -`packages/babel-plugin-rn-stylename-inline/index.js` processes `css` and `styl` tagged template literals imported from magic imports. The default magic imports are `cssxjs` and `startupjs`. +The plugin only performs the historical "move unreachable style block above +return" behavior for final expression templates placed after all returns. When +templates are placed elsewhere, their lexical position is respected so local +variables and hooks stay valid. -Important behavior: +### JSX And Pug Style Rewrites -- Only imported `css`/`styl` identifiers are processed. Aliases are supported. -- Template interpolation is rejected. Dynamic values should use CSS variables or inline `style`. -- Module-level templates become a top-level `const __CSS_GLOBAL__ = ...`. -- Function-level templates become a top-level compiled object plus a function-local `const __CSS_LOCAL__ = ...`. -- The plugin removes processed template expressions. -- Compilation is delegated to `@cssxjs/loaders/compilers`. +The styleName plugin: -The generated names come from `packages/runtime/constants.cjs`: +1. Finds JSX/Pug elements with `styleName`, `part`, old `*StyleName`, or style + helper calls. +2. Rewrites them to runtime calls that resolve classes and parts. +3. Injects `useCssxLayer()` once per relevant component for compiled local or + imported sheets that need React subscriptions. +4. Rewrites `part='root'` to `style`; other parts become `{partName}Style`. +5. Preserves manually extracted part props when the component author needs to + inspect or merge them in JS. -- `GLOBAL_NAME`: `__CSS_GLOBAL__` -- `LOCAL_NAME`: `__CSS_LOCAL__` +Generated runtime calls keep the compatibility signature: -Those names are part of the transform/runtime contract. - -### 5. Style File Imports and JSX Rewriting - -`packages/babel-plugin-rn-stylename-to-style/index.js` is the main JSX transform. It has three jobs. - -First, it handles style file imports. Default extensions are `cssx.css` and `cssx.styl`, so imports such as `import './Button.cssx.styl'` are style imports. In tests the plugin is often configured with `extensions: ['styl', 'css']`. - -When `compileCssImports` is true, Babel reads and compiles the file itself and replaces the import with a compiled `const`. This is convenient but means changes to the separate style file may require restarting or clearing Babel cache. When false, the import stays in place and the bundler must compile it. - -Second, it rewrites JSX styling attributes. A JSX opening element with `styleName`, `style`, or part style props becomes a spread call: - -```jsx - -``` - -becomes conceptually: - -```jsx - -``` - -The runtime call returns an object containing `style` and any `{part}Style` props. - -Third, it rewrites function calls to imported `styl`/`css` identifiers. This supports the public spread helper form: - -```jsx - +```js +runtime(styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) ``` -The helper call is replaced with the same runtime call shape used for JSX attributes. - -Runtime import paths are chosen from plugin options and imports: - -- default: `cssxjs/runtime` -- `reactType: 'web'`: `cssxjs/runtime/web` -- `reactType: 'react-native'`: `cssxjs/runtime/react-native` -- `cache: 'teamplay'`: `cssxjs/runtime/teamplay` -- both `reactType` and `cache`: `cssxjs/runtime/web-teamplay` or `cssxjs/runtime/react-native-teamplay` - -If the file imports an `observer` named import from `teamplay` or `startupjs`, the plugin auto-selects `cache: 'teamplay'`. - -## Style Compilation - -### Loader Chain - -The style compiler path is: - -1. Stylus input goes through `stylusToCssLoader` to become CSS. -2. CSS input goes through `cssToReactNativeLoader`. -3. `cssToReactNativeLoader` calls `@startupjs/css-to-react-native-transform` to produce React Native style objects. - -`packages/loaders/compilers/*` wrap the loaders for synchronous direct use from Babel and strip the generated `module.exports =` prefix. - -### Stylus Loader - -`packages/loaders/stylusToCssLoader.js`: - -- creates a Stylus compiler for the source. -- sets `filename` for error reporting/import resolution. -- defines `$PLATFORM` and `__WEB__`, `__IOS__`, `__ANDROID__`, etc. when a platform is provided. -- auto-imports `@startupjs/ui/styles/index.styl` and `@startupjs-ui/core/styles/index.styl` if those packages are installed. -- auto-imports `styles/index.styl` from `process.cwd()` if present. -- applies `patchStylusAddUnit()` once. - -`patchStylusAddUnit()` monkey-patches Stylus units so `1u` is converted to `8px` during Stylus compilation. - -### CSS-to-RN Loader - -`packages/loaders/cssToReactNativeLoader.js`: +The wrappers in `cssxjs/runtime/web.js` and +`cssxjs/runtime/react-native.js` translate that shape into calls owned by +`@cssxjs/css-to-rn`. -- calls `@startupjs/css-to-react-native-transform` with media queries, part selectors, and keyframes enabled. -- supports `:export { ... }` values and converts exported Stylus values into JS values. -- adds `__hash__` to the compiled object for memoization keys. -- adds `__vars` with sorted CSS variable names when `var(...)` is present. -- adds `__hasMedia` when top-level `@media` rules exist. -- returns JS source in the shape `module.exports = { ... }`. +### Separate Style Files -The metadata fields are consumed by `packages/runtime/process.js` and `packages/runtime/processCached.js`; changing them requires coordinated runtime updates. +Separate `.cssx.css`, `.cssx.styl`, and legacy `.styl` files are still +supported. Babel can compile imports directly with `compileCssImports`; Metro +can also transform configured extensions for hot reloading. StartupJS defaults +to compiling `.cssx.css` in Babel so Expo can keep handling ordinary `.css`, and +keeps Stylus in Metro by default because Expo does not own `.styl`. -## Runtime +Inline templates are preferred for new component code because they keep styles +near the component and avoid large shared style files. -Runtime entrypoints live in `packages/runtime/entrypoints/*`. Each entrypoint: +## Selector Model -1. injects platform helpers through `setPlatformHelpers()`. -2. initializes the dimensions updater. -3. exports either the normal `process` function or the teamplay-cached `process` function. +CSSX supports the selectors needed by React Native component styling: -The facade package re-exports these entrypoints from `packages/cssxjs/runtime/*` and provides both default and named `runtime` exports, because the Babel plugin imports `{ runtime as _runtime }`. +- Class selectors: `.root`, `.button.primary`. +- Compound class selectors. +- Component tag selectors: `Button`, `Button:part(text)`. +- `:part(name)` and `::part(name)` for external parts. +- `:hover` and `:active`, which map to `hoverStyle` and `activeStyle`. +- `@media` wrappers, including custom media and theme media aliases. +- Provider-only `:root` and `:root.` variables. -### Platform Helpers +Descendant and arbitrary DOM selectors are intentionally outside the core +React Native model. Cross-component customization should use `themed()` plus +component tag selectors, not DOM ancestry. -`packages/runtime/platformHelpers/index.js` stores the active helper implementation. Helpers provide: +## Parts And Component Tags -- `getDimensions()` -- `getPlatform()` -- `isPureReact()` -- `initDimensionsUpdater()` +`part` is the public customization contract for component internals. -`platformHelpers/web.js` uses `window.innerWidth`/`innerHeight`, falls back to `1024x768` without `window`, reports platform `web`, and marks pure React mode true. +- `part='root'` exposes the root `style` prop. +- `part='icon'` exposes `iconStyle`. +- `part='text'` exposes `textStyle`. -`platformHelpers/react-native.js` uses React Native `Dimensions` and `Platform`, reports pure React mode false, and listens for dimension changes. +The Babel plugin automatically extracts these props from component parameters +when needed. If an author already destructures `iconStyle`, the plugin does not +add a duplicate extraction. -The runtime logs and throws if helpers are missing, which usually means Babel imported the wrong runtime entrypoint. - -### Variables and Dimensions - -`packages/runtime/variables.js` exports: - -- default observable `variables` object. -- mutable `defaultVariables`. -- `setDefaultVariables()`. - -Resolution order is: - -1. runtime `variables['--name']` -2. `defaultVariables['--name']` -3. inline fallback from `var(--name, fallback)` - -`packages/runtime/dimensions.js` exports an observable `{ width: 0 }` singleton plus an initialization flag. - -Both observables come from `@nx-js/observer-util`. The uncached runtime reads these observables while processing styles; the cached runtime reads them in its `forceUpdateWhenChanged` hook. - -### `process()` - -`packages/runtime/process.js` is the main runtime function: +Component tag selectors are only active when a component opts in: ```js -process(styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) +export default observer(themed('Button', function Button () { + return pug` + Div.root(part='root') + Span(part='text') Save + ` +})) ``` -It: - -1. transforms each style object: - - replaces CSS variables when `__vars` exists. - - listens to dimensions when `__hasMedia` exists. - - applies media queries and viewport units through vendored processors. -2. calls `matcher()`. -3. flattens nested specificity arrays into single style objects. -4. adjusts pure React values such as numeric `lineHeight` to `px` strings. -5. applies runtime `u` unit replacement for string values that still contain `u`. - -### `matcher()` - -`packages/runtime/matcher.js` is intentionally simple and class-only. - -Input `styleName` is normalized through an embedded classcat-style function. Supported shapes are strings, arrays, and object flags. - -For each selector in each style object: - -- `:part(name)` or `::part(name)` targets prop `nameStyle`. -- no part selector targets root prop `style`. -- `part(root)` is handled by Babel as root `style`, not by the matcher. -- selectors are matched by checking whether every class in the selector exists in the normalized `styleName`. -- selector specificity is approximated by number of classes. - -Application order is: - -1. file styles -2. global inline templates -3. local inline templates -4. inline style props - -Because `process()` flattens and `Object.assign`s in that order, later layers override earlier layers. Within each layer, selectors with more classes override selectors with fewer classes. - -There is also a legacy matcher mode when `inlineStyleProps` is omitted. It returns only root style arrays and exists for older `*StyleName` conversion behavior. - -### Cached Runtime - -`packages/runtime/processCached.js` wraps `process()` with `teamplay/cache` `singletonMemoize`. - -The cache normalizer hashes: - -- `styleName` -- each style object's `__hash__` or full object -- `inlineStyleProps` - -The cache invalidation hook watches: - -- `dimensions.width` when any style object has `__hasMedia`. -- specific variables listed in `__vars`. - -The cached runtime depends on `teamplay` being installed. It is selected explicitly with `cache: 'teamplay'` or implicitly by importing `observer` from `teamplay` or `startupjs`. - -## Component Parts - -Parts are a two-sided compile-time and runtime protocol. - -Parts are only addressable from the outside. A component styles its own elements with its own class selectors, such as `.text`; parent components use `:part(text)` against the child's exposed `part='text'` element. +Provider/global CSS can then target: -On the parent side, a selector like: +```css +Button { + --Button-background-color: var(--color-primary); +} -```stylus -.card:part(title) - color red +Button:part(text) { + font-weight: 600; +} ``` -is compiled as a selector that `matcher()` returns under `titleStyle` when the parent element has styleName `card`. - -On the child side, JSX like: +Inside a component's own CSS, `:part(text)` means "style this component's +exposed text part". It does not select arbitrary child components by DOM +ancestry. + +## Variables And Value Resolution + +CSSX attempts to model browser `var()` behavior closely: + +- Nested `var()` is supported. +- Fallbacks are supported. +- Variables can represent a whole value, one item in a comma-separated list, or + part of a complex value such as `box-shadow`. +- Interpolation values are implemented as synthetic variables and participate + in the same resolver. +- The resolver tracks exactly which variables and media inputs were used so + React re-renders only when relevant dependencies change. + +Variable priority is: + +1. Inline/runtime values passed with a layer. +2. Imperative runtime `variables`. +3. Provider `:root` and active `:root.` scopes, nearest provider last. +4. `defaultVariables`. +5. CSS fallback values. + +`variables` and `defaultVariables` are proxies. Valid variable keys must start +with `--`; invalid keys throw. Use `.assign()` for merge updates, `.set()` for +replace-all updates, and `.clear()` to remove all keys. + +## CSS Values And Property Transforms + +CSSX transforms CSS values into React Native-friendly style props: + +- Standard kebab-case properties become camelCase unless they need a platform + exception. +- `background-image` becomes `experimental_backgroundImage` on React Native and + `backgroundImage` on web. +- `background` may produce `backgroundColor` and/or + `experimental_backgroundImage`. +- `background-image` keeps only `linear-gradient()` and `radial-gradient()` + strings because those are the React Native-supported image forms. +- `filter` resolves variables and is passed through for modern React Native/web + targets. +- `box-shadow` supports complex shadow lists and variable substitution, then is + passed through as a `boxShadow` string for modern React Native/web targets. +- `animation` and `transition` compile to the style shapes expected by + Reanimated v4. Keyframes are static sheet data, while variables/interpolation + inside values resolve at runtime where supported by the value resolver. +- `line-height` accepts unitless raw numbers so UI text components can convert + relative values to React Native pixel values when needed. +- `u` is still supported for migration and is equivalent to `0.5rem`, but new + code should prefer `rem` or design tokens. + +Color functions are evaluated with a lightweight color stack. CSSX supports the +CSS color operations needed by the StartupJS UI theme, especially `oklch()` and +`color-mix()`. + +## Provider Styles, Themes, And Custom Media + +`CssxProvider` accepts `style` as a string, compiled sheet, tracked sheet, layer +object, or array of those values. Provider styles are where global variables, +theme variables, custom media, component tag overrides, and utility classes +live. + +Theme variables are declared with root selectors: + +```css +:root { + --background: oklch(1 0 0); + --color-background: var(--background); +} -```jsx -function Card ({ title }) { - return {title} +:root.dark { + --background: oklch(0.145 0 0); + --color-background: var(--background); } ``` -is rewritten so the closest likely React component accepts `titleStyle` and appends it to the element's root `style` prop. If props are destructured, the Babel plugin injects missing part style variables into the destructuring pattern. If no props parameter exists, it creates one. - -`part='root'` is special. It maps to `style`, so parent styles for a component's own class can reach the component's root element without a `rootStyle` prop. - -Part names must be statically knowable. Supported `part` values are: - -- string literals, including space-separated parts. -- arrays of string literals and object expressions. -- object expressions with static keys and dynamic truthy/falsy values. - -Unsupported dynamic part names intentionally throw at build time. - -## CSS Semantics and Limits - -Supported features are constrained by React Native style capabilities and `@startupjs/css-to-react-native-transform`. - -Supported in current code and docs: - -- class selectors and compound class selectors. -- `&` parent selector in Stylus. -- `:part(name)` and `::part(name)`. -- CSS variables in full or compound values. -- media queries. -- viewport units through the vendored dynamic style processor. -- keyframes, animation, and transition output from the CSS-to-RN transformer. -- `u` unit, where `1u = 8px`. -- `:export` blocks in style files. - -Not supported by design: - -- expression interpolation inside `css` or `styl` template literals. -- descendant selectors. -- attribute selectors. -- web pseudo-classes such as `:hover`, `:focus`, and `:active`. -- pseudo-elements such as `::before` and `::after`. +Rules can be scoped to active themes with reserved custom media aliases: -## Pug, Type Checking, and Linting - -CSSX does not implement the Pug parser itself. It wraps React Pug tooling: - -- Babel transform: `@react-pug/babel-plugin-react-pug`. -- type checker: `@react-pug/check-types`. -- ESLint processor: `@react-pug/eslint-plugin-react-pug`. - -`packages/cssxjs/cli.js` implements: - -```sh -npx cssxjs check [files...] [--project ] +```css +@media (--theme-dark) { + .card { + border-width: 1px; + box-shadow: none; + } +} ``` -and delegates to `packages/cssxjs/check.js`, which re-exports `@react-pug/check-types`. - -`eslint-plugin-cssxjs` is a package-name facade over `@react-pug/eslint-plugin-react-pug`, so changes to lint behavior usually belong upstream unless the wrapper API changes. +Provider `theme` values: -## Metro and Separate Style Files +- `default`: use `:root` variables. +- `light`: use `:root.light` when present, otherwise `:root`. +- any other string: use the matching `:root.`. +- `auto`: use `dark` only when the platform color scheme is dark and a `dark` + theme exists; otherwise use `default`. -Inline `css`/`styl` templates are handled by Babel and do not require Metro configuration. +At the root, omitting `theme` uses the persisted global preference. Without a +saved preference, the root provider starts in `default` so host UI such as React +Navigation does not unexpectedly switch to dark mode. Root `theme='auto'` uses +the system color scheme as the initial preference, then a saved or +user-selected preference takes priority. A non-root or explicit non-`auto` +provider `theme` forces that subtree. `useTheme()` returns `[theme, setTheme]`. +Web persists preference in `localStorage`; the React Native entrypoint imports +`@react-native-async-storage/async-storage` for persistence, so React Native +apps using theme persistence must install that optional peer. -Separate `.cssx.styl` files need bundler support for hot reloading. `packages/bundler/metro-config.js`: +`@custom-media` declarations are collected from provider styles and component +styles. They can reference CSS variables and other custom media, with cycle +diagnostics. CSSX ships default breakpoint names used by StartupJS UI, such as +`--breakpoint-tablet`, and lets provider styles override or extend them. -- starts from Expo, React Native 0.73+, or older Metro default config. -- sets `babelTransformerPath` to CSSX's Metro transformer. -- adds `css` and `styl` to `resolver.sourceExts`. -- enables package exports. -- disables Expo's CSS support when using Expo defaults. +## Built-In Themes -`packages/bundler/metro-babel-transformer.js`: +`cssxjs/themes/tailwind` exports Tailwind-compatible base design tokens as a +plain CSS string. Tailwind's non-standard `@theme` syntax is not shipped. -- compiles `.styl` through Stylus then CSS-to-RN. -- compiles `.css` through CSS-to-RN. -- passes resulting JS source to the upstream Metro Babel transformer. +`cssxjs/themes/shadcn` exports shadcn-compatible semantic variables as a plain +CSS string with default and dark theme roots. It follows the shadcn pattern of +declaring semantic values such as `--primary` and mapping them to Tailwind-like +`--color-primary` variables for use by components and future utility support. -This path is primarily for imported style files and hot reloading. The preferred component-local path remains inline templates or Pug embedded style blocks. +These exports are just CSSX provider style inputs. They do not enable a +Tailwind utility runtime by themselves. -## Example App +## Runtime APIs -`example/` is a pure web demonstration: +### Static Compiled Components -- `example/server.js` starts an HTTP server on port 3000. -- `example/_serveClient.js` runs Babel with `cssxjs/babel`, then bundles with esbuild from memory. -- `example/client.tsx` demonstrates Pug, embedded Stylus, `styleName`, `part`, media queries, and external `.cssx.styl` import. +Most application code goes through Babel. Runtime receives compiled sheets and +resolves style props by class name, parts, active theme, dimensions, variables, +and inline styles. Cache slots preserve object identity when inputs do not +change, which prevents unnecessary React child updates. -Run it with: - -```sh -yarn start -``` +### Runtime Compilation -from the repository root. +`useRuntimeCss(rawCss)` compiles raw CSS on the client and returns a tracked +sheet. This exists for cases such as AI-generated CSS: -## Testing - -Root script: - -```sh -yarn test +```jsx +const sheet = useRuntimeCss(generatedCss) +return
``` -This loops over every `packages/*` directory and runs each package's `yarn test`. +The third `cssx()` argument is inline style props. Variables come from +`variables`, `defaultVariables`, provider `:root` scopes, or layer/template +`values`. Runtime compile errors degrade gracefully through diagnostics instead +of throwing by default, because user-generated CSS can be invalid. Build-time +compilation should remain stricter. -Useful targeted tests: +### Dependency Tracking -```sh -cd packages/runtime && yarn test -cd packages/babel-plugin-rn-stylename-inline && yarn test -cd packages/babel-plugin-rn-stylename-to-style && yarn test -``` +React tracking is based on `useSyncExternalStore`. During render, CSSX records +which variables, media queries, dimensions, theme values, and interpolation +slots were actually used. On commit, the render dependencies replace the +previous dependencies. If a render suspends and never commits, the previous +committed dependencies remain active, which avoids leaking transient +subscriptions. -Runtime tests live in `packages/runtime/test/*.mjs`. +Only changes to used dependencies trigger subscribers. For example, changing +`--text` re-renders components that used `--text`; changing unrelated variables +does not. -Babel plugin tests use `babel-plugin-tester` and Jest snapshots in: +### Caching -- `packages/babel-plugin-rn-stylename-inline/__tests__/` -- `packages/babel-plugin-rn-stylename-to-style/__tests__/` +Runtime caches resolved style output per call site and input signature: -Many packages currently have placeholder tests that print `No tests yet`. - -## Maintenance Constraints - -- Treat `__CSS_GLOBAL__`, `__CSS_LOCAL__`, `__hash__`, `__vars`, and `__hasMedia` as cross-package contracts. -- Keep Babel transform order intact unless the replacement order is tested. -- Keep runtime import wrappers in `packages/cssxjs/runtime/*` compatible with the named `runtime` import used by the Babel plugin. -- If selector matching changes, update `matcher` tests and process integration tests together. -- If CSS variable metadata changes, update both cached and uncached runtime paths. -- If media-query metadata changes, update dimensions invalidation in cached and uncached runtime paths. -- If part injection changes, update tests for destructured props, named props, nested render functions, `root`, and dynamic parts. -- If default style file extensions change, update docs, Babel plugin defaults, Metro expectations, and tests together. -- Be careful with old package READMEs. Some historical README text still references StartupJS-era names or older defaults; prefer current code and `docs/` for public behavior. +- sheet identity/hash +- `styleName` +- active provider layers +- active theme +- dimensions/media snapshot +- interpolation values +- inline style value hash +- used variable versions + +Inline style objects are hashed with `JSON.stringify()` by value so ordinary +inline object literals remain ergonomic. The cache keeps the current entry for a +slot instead of growing unbounded with old value combinations. + +## Platform Entrypoints + +`@cssxjs/css-to-rn` and `cssxjs` expose web and React Native entrypoints. React +Native entrypoints install platform adapters for dimensions, color scheme, and +optional async theme persistence. Web entrypoints install browser adapters for +window dimensions, color scheme, and `localStorage`. + +The package targets modern Node and React 19. React context is read with +React's `use()` where the runtime benefits from conditional/contextual reads. + +## Diagnostics + +The compiler emits diagnostics for ignored selectors, unsupported values, +invalid variable names, custom media cycles, invalid runtime CSS, and target +limitations. Runtime compilation returns sheets with diagnostics rather than +crashing normal UI flows. Build-time callers can choose stricter behavior. + +Source identifiers used in public bundles must not leak absolute server paths. +Babel-generated cache/source IDs should be stable hashes or safe relative +identifiers. Build errors may still show real paths to help developers debug. + +## StartupJS Integration + +StartupJS uses `startupjs/babel` to run Pug, CSSX, plugin auto-loading, +Teamplay, eliminator, debug, i18n, and tree-shaking transforms in one preset. +StartupJS `StartupjsProvider` forwards `style` and `theme` to `CssxProvider`. +StartupJS UI injects its own `UiProvider` through StartupJS's plugin system and +layers the Tailwind, shadcn, and StartupJS UI theme strings into CSSX. + +CSSX should not depend on StartupJS or StartupJS UI. Integration-specific +behavior belongs in those repos unless it is a general CSSX primitive. + +## Testing Strategy + +Use the smallest relevant test surface: + +- `packages/css-to-rn/test/engine/**`: parser, selectors, variables, values, + properties, themes, custom media, runtime compile diagnostics, cache logic. +- `packages/css-to-rn/test/react/**`: provider behavior, hooks, + subscriptions, memory-leak edge cases, theme persistence, dimensions. +- `packages/css-to-rn` type tests: public TypeScript surface and node + strip-only TypeScript compatibility. +- `packages/babel-plugin-rn-stylename-inline`: inline templates, + interpolation, template placement, snapshots. +- `packages/babel-plugin-rn-stylename-to-style`: `styleName`, `part`, imported + sheets, helper hook injection, snapshots. +- `packages/loaders`: CSS/Stylus loader wrappers. +- `packages/cssxjs/test`: public facade smoke tests and theme export tests. +- `docs/` and `example/`: public behavior and integration smoke checks. + +When changing cache or subscription logic, test object identity, invalidation, +multiple components/elements, interrupted/suspended renders, and no-leak +cleanup behavior. + +## Maintenance Rules + +- Keep compiler IR serializable. +- Keep runtime compilation lightweight enough for client-side use. +- Prefer CSS standards over custom syntax. +- Do not reintroduce a hard dependency on Teamplay for CSS invalidation. +- Keep built-in themes as plain CSS strings through JS entrypoints. +- Keep StartupJS UI-specific tokens in StartupJS UI unless they are generic + enough for CSSX itself. +- Update `AGENTS.md`, this file, and public docs whenever public behavior or + package boundaries change. diff --git a/docs/api/babel.md b/docs/api/babel.md index 4c2c979..30dd1c7 100644 --- a/docs/api/babel.md +++ b/docs/api/babel.md @@ -2,6 +2,9 @@ CSSX uses a Babel preset to transform styles at build time. +For CSS strings that are generated in the client at runtime, use the +[Runtime Compilation API](/api/runtime) instead. + ## cssxjs/babel The Babel preset that transforms CSSX syntax. @@ -21,7 +24,6 @@ module.exports = { |--------|------|---------|-------------| | `platform` | `'web'` \| `'ios'` \| `'android'` | `'web'` | Target platform | | `reactType` | `'react-native'` \| `'web'` | auto | React target type | -| `cache` | `'teamplay'` | auto | Caching library | | `transformPug` | `boolean` | `true` | Enable Pug transformation | | `transformCss` | `boolean` | `true` | Enable CSS transformation | @@ -32,8 +34,7 @@ module.exports = { module.exports = { presets: [ ['cssxjs/babel', { - transformPug: false, // Disable pug if not using it - cache: 'teamplay' // Force teamplay caching + transformPug: false // Disable pug if not using it }] ] } @@ -61,7 +62,7 @@ You can also set platform-specific variables in your Stylus code: ## Caching -When `cache: 'teamplay'` is set (or auto-detected), the Babel transform generates code that integrates with [teamplay](https://github.com/startupjs/teamplay) for optimized style memoization. +CSSX uses the built-in resolver cache by default. See the [Caching guide](/guide/caching) for more details. @@ -103,6 +104,7 @@ The Babel preset converts this into optimized runtime code that: - Compiles Stylus to style objects at build time - Connects `styleName` to the compiled styles - Injects part style props automatically +- Re-renders only when used CSS variables or matching media queries change ## TypeScript diff --git a/docs/api/css.md b/docs/api/css.md index 3182fdb..290cdad 100644 --- a/docs/api/css.md +++ b/docs/api/css.md @@ -122,6 +122,60 @@ The custom `u` unit works in `css` too: } ``` +Variables can appear anywhere CSS allows `var()`: whole values, parts of +shorthands, comma-separated value chunks, and nested fallbacks. + +```css +.card { + box-shadow: var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.16)); + border: var(--border-width, 1px) solid var(--border-color, #ddd); +} +``` + +Provider/global CSS can define subtree-scoped variables with `:root`: + +```css +:root { + --primary-color: oklch(62% 0.18 250); +} +``` + +Those variables are scoped by `CssxProvider`, not stored as global defaults. + +### Modern Color Functions + +CSSX resolves `oklch()`, `oklab()`, and `color-mix()` to legacy `rgba(...)` +strings so the same CSS works on React Native: + +```css +.button { + background-color: color-mix(in oklch, var(--brand), white 20%); +} +``` + +### JavaScript Interpolation + +Function-scoped `css` templates support JavaScript interpolation in CSS value +positions: + +```jsx +function Badge({ color, size }) { + return + + css` + .badge { + background-color: ${color}; + padding: ${size}px 12px; + } + ` +} +``` + +Interpolation is an alternative to `var()`. It is only supported in the same +places a CSS value can use `var()`, and only inside function-scoped JS tagged +templates. Module-level templates, imported CSS files, and runtime CSS strings +must use plain CSS text. + ### Part Selectors ```css @@ -129,11 +183,70 @@ The custom `u` unit works in `css` too: color: red; } -.button:part(text) { +.button::part(text) { font-weight: bold; } ``` +Both `:part()` and `::part()` are supported. + +### Component Tag Selectors + +Provider/global CSS can target components wrapped with `themed()` by tag: + +```css +Button { + background: var(--button-bg); +} + +Button.primary:part(text) { + color: white; +} +``` + +Tag selectors are intended for global component overrides. Class selectors still +work as utility classes everywhere. + +### Hover and Active Styles + +CSSX maps `:hover` and `:active` to the same output as `:part(hover)` and +`:part(active)`. Components can receive those props as `hoverStyle` and +`activeStyle`. + +```css +.button:hover { + background-color: #0056b3; +} + +.button:active { + transform: scale(0.97); +} +``` + +### Filters and Background Images + +React Native supports `filter` and experimental background gradients in current +versions. CSSX passes `filter` through and maps `background-image` to +`experimental_backgroundImage` on React Native. + +```css +.hero { + filter: blur(8px) brightness(0.8); + background-image: + linear-gradient(0deg, white, rgba(238, 64, 53, 0.8), rgba(238, 64, 53, 0) 70%), + radial-gradient(circle, rgba(0, 0, 0, 0.2), transparent 70%); +} +``` + +Only `linear-gradient()` and `radial-gradient()` background images are emitted +for React Native. Other image values are ignored with a diagnostic. + +### Runtime CSS Strings + +For CSS text that is generated at runtime, use the +[Runtime Compilation API](/api/runtime). Runtime strings must be plain CSS text +and use `var()` for dynamic values. + ## Limitations The `css` template does **not** support: @@ -141,6 +254,7 @@ The `css` template does **not** support: - Stylus variables (`$var`) - Stylus mixins - Global `styles/index.styl` imports +- JavaScript interpolation in module-level templates or runtime CSS strings For these features, use the [styl template](/api/styl) instead. @@ -154,9 +268,12 @@ For these features, use the [styl template](/api/styl) instead. | Global imports | `styles/index.styl` | Not supported | | `u` unit | Yes | Yes | | CSS variables | Yes | Yes | +| Function-scoped JS interpolation | Yes | Yes | | Part selectors | Yes | Yes | +| Runtime CSS strings | No | [Runtime API](/api/runtime) | ## See Also - [styl Template](/api/styl) — Stylus syntax with variables and mixins - [styleName Prop](/api/jsx-props) — Connect elements to styles +- [Runtime Compilation](/api/runtime) — Compile generated CSS strings diff --git a/docs/api/index.md b/docs/api/index.md index 2502ccf..e2a8366 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12,8 +12,15 @@ import { variables, setDefaultVariables, defaultVariables, - dimensions, - matcher + cssx, + useRuntimeCss, + useCssVariable, + useCssVariableRaw, + useCssxSheet, + useCssxTemplate, + CssxProvider, + configureCssx, + themed } from 'cssxjs' ``` @@ -27,7 +34,10 @@ import { **Styling:** - [styl() Function](/api/styl-function) — Apply styles via spread - [JSX Props](/api/jsx-props) — `styleName`, `part` +- [Theming](/guide/theming) — Provider style layers, themes, component tags, and theme assets - [CSS Variables](/api/variables) — Runtime theming +- [Runtime Compilation](/api/runtime) — Compile generated CSS strings at runtime +- [Caching](/guide/caching) — Built-in resolver cache behavior **Configuration:** - [Babel Config](/api/babel) — Preset options @@ -40,8 +50,15 @@ import { | `styl` | Template literal / Function | Write styles in Stylus syntax, or apply styles via spread | | `css` | Template literal | Write styles in plain CSS syntax | | `pug` | Template literal | Write JSX in Pug syntax, with TypeScript expressions and embedded `style` blocks | -| `variables` | Observable object | Set CSS variable values at runtime | -| `setDefaultVariables` | Function | Set default CSS variable values | -| `defaultVariables` | Object | Read-only default variable values | -| `dimensions` | Observable object | Current screen width for media queries | -| `matcher` | Function | Internal style matching (advanced) | +| `variables` | Reactive object | Set CSS variable values at runtime; supports `.assign()`, `.set()`, `.clear()` | +| `setDefaultVariables` | Function | Replace default CSS variable values | +| `defaultVariables` | Reactive object | Default variable values; supports `.assign()`, `.set()`, `.clear()` | +| `cssx` | Function | Resolve a runtime sheet and `styleName` to props | +| `useRuntimeCss` | Hook | Compile runtime CSS text into a tracked sheet | +| `useCssVariable` | Hook | Read a CSS variable as an RN-friendly value and subscribe to it | +| `useCssVariableRaw` | Hook | Read a CSS variable as raw resolved CSS text | +| `useCssxSheet` | Hook | Track an already compiled sheet | +| `useCssxTemplate` | Hook | Track a compiled sheet with interpolation values | +| `CssxProvider` | Component | Provide runtime options and global/scoped CSS to a subtree | +| `themed` | Function | Give a component a CSS tag for provider/global component overrides | +| `configureCssx` | Function | Configure global runtime defaults | diff --git a/docs/api/jsx-props.md b/docs/api/jsx-props.md index b234ca5..9debcde 100644 --- a/docs/api/jsx-props.md +++ b/docs/api/jsx-props.md @@ -66,26 +66,30 @@ The pattern: ### Dynamic Styles -For truly dynamic values, combine `styleName` with the `style` prop: +For CSS values that come from props, prefer function-scoped template +interpolation: ```jsx import { View, Text } from 'react-native' -function ProgressBar({ progress }) { +function ProgressBar({ progress, color }) { return ( - + {progress}% ) styl` .bar + width ${progress}% height 20px - background-color #4caf50 + background-color ${color} ` } ``` +For ad hoc overrides, combine `styleName` with the regular `style` prop. + --- ## part @@ -128,26 +132,24 @@ See the [Component Parts guide](/guide/component-parts) for detailed examples. --- -## matcher +## cssx() -The internal function that matches `styleName` values against compiled styles. Advanced use only. +The low-level runtime helper that resolves a compiled or runtime sheet and +returns props to spread onto a component. Most components should use +`styleName`; use `cssx()` when CSS arrives as a runtime string or when a custom +component cannot use the Babel transform. **Signature:** ```ts -function matcher( - styleName: string, - fileStyles: object, - globalStyles: object, - localStyles: object, - inlineStyleProps: object +function cssx( + styleName: string | array | object, + sheet: string | CompiledCssSheet | TrackedCssxSheet, + inlineStyleProps?: object ): object ``` -**Parameters:** -- `styleName` - Space-separated class names (supports classnames-like syntax) -- `fileStyles` - Styles from the imported CSS file -- `globalStyles` - Module-level `styl` styles -- `localStyles` - Function-level `styl` styles -- `inlineStyleProps` - Inline style overrides +`cssx()` returns an object with `style` and any part style props such as +`titleStyle`, `hoverStyle`, or `activeStyle`. -**Returns:** An object with style props, including `style` and any `{part}Style` props. +See [Runtime Compilation](/api/runtime) for generated CSS strings, diagnostics, +tracking, and caching behavior. diff --git a/docs/api/runtime.md b/docs/api/runtime.md new file mode 100644 index 0000000..0fd1997 --- /dev/null +++ b/docs/api/runtime.md @@ -0,0 +1,265 @@ +# Runtime Compilation + +Runtime compilation is for CSS text that is not known during Babel compilation, +for example CSS generated by an AI system, loaded from a CMS, or edited inside a +client-side builder. + +Most app code should still use `styleName` with `css` or `styl` templates. Use +the runtime API when the CSS source is a string at render time. + +## Basic Usage + +```jsx +import { cssx, useRuntimeCss } from 'cssxjs' + +function Button({ generatedCss, disabled, label }) { + const sheet = useRuntimeCss(generatedCss) + + return ( +
+ {label} +
+ ) +} +``` + +`useRuntimeCss()` compiles the string into a tracked sheet. `cssx()` resolves a +`styleName` against that sheet and returns props such as `style`, `labelStyle`, +`hoverStyle`, and `activeStyle`. + +## CSS Input + +Runtime input must be plain CSS text: + +```css +.root { + padding: 12px 16px; + background: var(--button-bg, #1677ff); +} + +.root.disabled { + opacity: 0.5; +} + +.label { + color: var(--label-color, white); +} +``` + +Runtime strings do not support Stylus syntax or JavaScript template +interpolation. Use `var()` for dynamic values in generated CSS. + +## API + +```ts +useRuntimeCss(cssText, options?) +cssx(styleName, sheet, inlineStyleProps?, options?) +``` + +`styleName` accepts the same shapes as the JSX prop: + +```jsx +cssx('card', sheet) +cssx(['card', variant, { selected, disabled }], sheet) +``` + +`sheet` can be: + +- the `TrackedCssxSheet` returned by `useRuntimeCss()` +- an already compiled sheet passed through `useCssxSheet()` +- an array of sheets, ordered from lowest to highest priority + +`inlineStyleProps` uses the same prop names that components receive: + +```jsx + +``` + +Inline styles have the highest priority. + +## Diagnostics + +Runtime compilation is graceful by default. Invalid CSS does not throw during +render. The returned sheet contains diagnostics and any rules that could still +be compiled. + +```jsx +const sheet = useRuntimeCss(generatedCss) + +if (sheet.getSheet().diagnostics.length > 0) { + reportCssErrors(sheet.getSheet().diagnostics) +} +``` + +Diagnostics include a severity, code, message, and line/column when available. +This makes runtime compilation suitable for AI-generated CSS because the app can +show or feed back errors without crashing. + +Build-time template compilation is stricter where Babel needs the module to be +compiled correctly. + +## Variables And Updates + +Runtime CSS supports `var()` in the same places as build-time CSSX styles: +whole values, parts of shorthands, comma-separated chunks, nested fallbacks, and +complex values such as shadows and gradients. + +```css +.card { + border: var(--border-width, 1px) solid var(--border-color, #ddd); + box-shadow: var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.16)); +} +``` + +Only variables used by the resolved element are tracked. If `--border-color` +changes, elements that used it update. If an unrelated variable changes, they do +not. + +## Provider Styles + +`CssxProvider` can provide global CSS to a subtree through its `style` prop. +Provider styles can define utility classes, component tag overrides, and scoped +`:root` variables: + +```jsx +import { CssxProvider, themed } from 'cssxjs' + +const Button = themed('Button', function Button({ children }) { + return ( + + {children} + + ) +}) + +function App() { + return ( + + + + ) +} +``` + +Nested providers override outer `:root` variables for their subtree. Runtime +`variables['--name']` still has higher priority than provider `:root` values. +Compiled provider sheets may also use template interpolation inside `:root` +custom property values, so a precompiled provider layer can pass dynamic theme +tokens through `{ sheet, values }`. + +Use `themed(tagName, Component)` for components that should be addressable by +tag selectors in provider/global CSS. Class selectors remain global utilities +and do not require a tag. + +## Reading Variables In JS + +Use `useCssVariable()` when component logic needs the resolved value: + +```jsx +import { useCssVariable } from 'cssxjs' + +function Avatar() { + const size = useCssVariable('--avatar-size', '4u') // 32 + return +} +``` + +`useCssVariable()` returns an RN-friendly value: `2u` and `16px` become numbers, +percentages stay strings, and modern color functions are normalized. Use +`useCssVariableRaw()` when you need the raw resolved CSS string. + +## Media Queries + +Runtime CSS can use media queries: + +```css +.layout { + padding: 24px; +} + +@media (max-width: 640px) { + .layout { + padding: 12px; + } +} +``` + +CSSX subscribes only to media queries used by committed renders. Dimension and +media updates invalidate only affected elements. + +## Caching + +`useRuntimeCss()` recompiles only when the CSS string or target changes. +`cssx()` caches the resolved props for the current inputs: + +- sheet identity and content hash +- normalized `styleName` +- runtime variable and media dependencies actually used +- interpolation values for compiled templates +- `JSON.stringify()` hash of inline style props + +When those inputs are unchanged, CSSX returns the same object references. When +inputs change, it recalculates and replaces the previous cached entry instead of +keeping unbounded variants. + +## Other Runtime Hooks + +Use these helpers for lower-level integrations: + +```ts +useCssxSheet(compiledSheet, options?) +useCssxTemplate(compiledSheet, values, options?) +useCssxLayer(input, options?) +CssxProvider +configureCssx(options) +themed(tagName, Component) +useCssVariable(name, fallback?) +useCssVariableRaw(name, fallback?) +``` + +`useCssxSheet()` tracks an already compiled sheet. `useCssxTemplate()` is used by +compiled local templates with JavaScript interpolation values. `useCssxLayer()` +accepts strings, compiled sheets, tracked sheets, or layer objects and returns +the tracked equivalent. + +`CssxProvider` and `configureCssx()` configure runtime defaults such as target +and dimension debounce behavior. `CssxProvider` also accepts a `style` prop for +subtree-scoped CSS. + +## Platform Resolution + +Import from `cssxjs` in application code: + +```js +import { cssx, useRuntimeCss } from 'cssxjs' +``` + +CSSX resolves the correct web or React Native runtime through package export +conditions. Expo and React Native use the React Native target; other bundlers +use the web target by default. + +## When Not To Use It + +Use build-time `css` or `styl` templates when the CSS is authored in source +files. Babel can then precompile the sheet, lower JavaScript interpolation, and +connect `styleName` automatically. + +Runtime compilation is best reserved for CSS that truly arrives as data. + +## See Also + +- [Babel Config](/api/babel) - Build-time compilation +- [css Template](/api/css) - Plain CSS templates +- [JSX Props](/api/jsx-props) - `styleName`, `part`, and `cssx()` +- [Caching](/guide/caching) - Resolver cache behavior +- [CSS Variables](/api/variables) - Runtime theming diff --git a/docs/api/styl.md b/docs/api/styl.md index 5a3974a..96f2b40 100644 --- a/docs/api/styl.md +++ b/docs/api/styl.md @@ -208,6 +208,28 @@ CSSX adds a custom `u` unit where `1u = 8px` (Material Design grid): See [CSS Variables](/api/variables) for runtime variable updates. +### JavaScript Interpolation + +Function-scoped `styl` templates support JavaScript interpolation in CSS value +positions: + +```jsx +function Button({ color, spacing }) { + return + + styl` + .button + background ${color} + padding ${spacing}px 12px + ` +} +``` + +Interpolation is lowered through the same runtime value path as `var()`, so it +can be used for whole values, parts of shorthands, and values nested inside +functions. It is not supported in module-level templates because there is no +render-time value array there. + ## Selectors | Selector | Description | @@ -216,10 +238,12 @@ See [CSS Variables](/api/variables) for runtime variable updates. | `.class1.class2` | Multiple classes (same element) | | `&.modifier` | Modifier class (used within parent) | | `:part(name)` | Part selector | +| `:hover` | Emits `hoverStyle`, same as `:part(hover)` | +| `:active` | Emits `activeStyle`, same as `:part(active)` | > **Note:** Descendant selectors (`.parent .child`) are not supported. Apply modifiers directly to each element that needs styling. -> **Note:** Pseudo-classes (`:hover`, `:focus`, `:active`, etc.) and pseudo-elements (`::before`, `::after`) are not supported. Use state-based modifiers instead (e.g., `&.focused`, `&.active`). +> **Note:** `:focus`, other pseudo-classes, and pseudo-elements (`::before`, `::after`) are not supported. Use state-based modifiers for those cases. ### Part Selector @@ -248,12 +272,13 @@ When the same property is defined in multiple places (highest to lowest): ## Limitations -- No expression interpolations: `` styl`color ${color}` `` is not allowed -- Must be a plain template literal -- For dynamic values, use CSS variables or the `style` prop +- JavaScript interpolation is local-only: module-level `styl` templates must be plain template literals +- Interpolation is value-only, not selector or property-name interpolation +- For runtime-generated plain CSS strings, use the [Runtime Compilation API](/api/runtime) ## See Also - [css Template](/api/css) — Plain CSS alternative - [styl() Function](/api/styl-function) — Apply styles via spread - [styleName Prop](/api/jsx-props) — Connect elements to styles +- [Runtime Compilation](/api/runtime) — Compile generated CSS strings diff --git a/docs/api/variables.md b/docs/api/variables.md index d3a25b8..7787f82 100644 --- a/docs/api/variables.md +++ b/docs/api/variables.md @@ -4,42 +4,54 @@ CSSX provides a reactive system for CSS variables that works at runtime. ## variables -A reactive object for setting CSS variable values at runtime. Assigning values triggers automatic re-renders in components using those variables. +A reactive object for setting CSS variable values at runtime. Assigning values +triggers automatic re-renders in components using those variables. -**Type:** `Observable>` +**Type:** `CssxVariableStore` ```jsx import { variables } from 'cssxjs' // Set a variable -variables['--primary-color'] = '#007bff' +variables['--toast-offset'] = '1rem' // Read a variable -console.log(variables['--primary-color']) +console.log(variables['--toast-offset']) -// Set multiple variables -Object.assign(variables, { - '--primary-color': '#007bff', - '--text-color': '#333' +// Merge multiple variables +variables.assign({ + '--toast-offset': '1.5rem', + '--toast-color': 'var(--color-primary)' }) + +// Replace the whole runtime variable set +variables.set({ + '--toast-offset': '1rem' +}) + +// Clear all runtime variables +variables.clear() ``` +Only valid CSS custom property names can be assigned. Names must start with +`--`; invalid names throw. + **Reactivity:** -When you assign to `variables`, all components using those CSS variables automatically re-render with the new values. +When you assign to `variables`, components that used those specific variables in +their resolved styles automatically re-render with the new values. ```jsx import { Pressable, Text } from 'react-native' -function ThemeToggle() { - const toggleDark = () => { - variables['--bg-color'] = '#1a1a1a' - variables['--text-color'] = '#ffffff' - // All components using these variables re-render +function ToastOffsetButton() { + const moveToast = () => { + variables['--toast-offset'] = '2rem' + // Components using this variable re-render } return ( - - Dark Mode + + Move toast ) } @@ -49,7 +61,12 @@ function ThemeToggle() { ## setDefaultVariables -Sets default values for CSS variables at app startup. These values are used as fallbacks when runtime values aren't set. +Sets global default values for CSS variables. These values are used as +fallbacks when runtime values and provider `:root` values are not set. + +For app themes, prefer `CssxProvider style` with `:root` variables. Use +`setDefaultVariables()` for compatibility, low-level defaults, or non-React +integrations. **Signature:** ```ts @@ -64,16 +81,9 @@ function setDefaultVariables(vars: Record): void ```jsx import { setDefaultVariables } from 'cssxjs' -// Call early in app initialization (e.g., App.jsx or index.js) setDefaultVariables({ - '--primary-color': '#007bff', - '--secondary-color': '#6c757d', - '--text-color': '#333', - '--background-color': '#fff', - '--border-radius': '8px', - '--spacing-sm': '8px', - '--spacing-md': '16px', - '--spacing-lg': '24px' + '--legacy-radius': '8px', + '--legacy-gap': '16px' }) ``` @@ -81,48 +91,64 @@ setDefaultVariables({ ## defaultVariables -A read-only object containing the default variable values set by `setDefaultVariables`. +A reactive object containing default variable values. It supports the same +`.assign()`, `.set()`, and `.clear()` methods as `variables`. -**Type:** `Record` +**Type:** `CssxVariableStore` ```jsx import { defaultVariables } from 'cssxjs' -console.log(defaultVariables['--primary-color']) // '#007bff' +console.log(defaultVariables['--legacy-radius']) // '8px' ``` ---- - -## dimensions +`setDefaultVariables(vars)` is an alias for `defaultVariables.set(vars)`. -A reactive object containing the current screen width. Used internally for media query support. +## Reading Variables In Components -**Type:** `Observable<{ width: number }>` +Use `useCssVariable()` when JavaScript needs the resolved value: ```jsx -import { dimensions } from 'cssxjs' +import { useCssVariable } from 'cssxjs' -console.log(dimensions.width) // e.g., 375 +function Box() { + const gap = useCssVariable('--gap', '1rem') // 16 + return +} ``` -The `width` property automatically updates when the screen size changes, triggering re-renders in components using media queries. +`useCssVariable()` subscribes only to the variables it resolves, including nested +`var()` references. It returns RN-friendly values: `1rem` and `16px` become +numbers, percentages remain strings, and modern color functions are normalized. ---- +Use `useCssVariableRaw()` to read raw resolved CSS text. Outside React, use +`getCssVariable()` and `getCssVariableRaw()` for global variables only. ## Variable Resolution Order CSS variables resolve in this priority (highest first): -1. **Runtime:** `variables['--name']` -2. **Default:** `setDefaultVariables({ '--name': value })` -3. **Inline fallback:** `var(--name, fallback)` +1. **Template interpolation values** +2. **Runtime:** `variables['--name']` +3. **Nearest provider `:root` variable** +4. **Outer provider `:root` variables** +5. **Default:** `defaultVariables['--name']` +6. **Inline fallback:** `var(--name, fallback)` ```jsx -setDefaultVariables({ '--color': 'blue' }) // Priority 2 -variables['--color'] = 'red' // Priority 1 (wins) +setDefaultVariables({ '--color': 'blue' }) +variables['--color'] = 'red' // wins over provider and defaults styl` .box - color var(--color, green) // Will be 'red' + color var(--color, green) // Will be 'red' ` ``` + +`var()` supports nested fallbacks and complex CSS values: + +```stylus +.card + box-shadow var(--card-shadow, 0 4px 12px rgba(0, 0, 0, 0.16)) + border var(--border-width, 1px) solid var(--border-color, #ddd) +``` diff --git a/docs/examples/theme.md b/docs/examples/theme.md index fde255f..29610e1 100644 --- a/docs/examples/theme.md +++ b/docs/examples/theme.md @@ -1,142 +1,161 @@ # Theme System -Complete dark/light theme implementation using CSS variables. +Complete light/dark theme implementation using provider CSS variables and +`useTheme()`. -## Theme Configuration +## Theme CSS ```jsx // theme.js -import { setDefaultVariables, variables } from 'cssxjs' - -const themes = { - light: { - '--bg-primary': '#ffffff', - '--bg-secondary': '#f5f5f5', - '--bg-tertiary': '#e0e0e0', - '--text-primary': '#333333', - '--text-secondary': '#666666', - '--text-muted': '#999999', - '--accent': '#007bff', - '--accent-hover': '#0056b3', - '--border': '#e0e0e0', - '--shadow': 'rgba(0,0,0,0.1)' - }, - dark: { - '--bg-primary': '#1a1a1a', - '--bg-secondary': '#2d2d2d', - '--bg-tertiary': '#404040', - '--text-primary': '#ffffff', - '--text-secondary': '#b0b0b0', - '--text-muted': '#808080', - '--accent': '#bb86fc', - '--accent-hover': '#9a67ea', - '--border': '#404040', - '--shadow': 'rgba(0,0,0,0.3)' +import { css } from 'cssxjs' + +export const appTheme = css` + :root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(0.98 0 0); + --card-foreground: var(--foreground); + --primary: oklch(0.58 0.22 260); + --primary-foreground: oklch(0.98 0.02 260); + --border: oklch(0.9 0 0); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-border: var(--border); } -} -// Initialize with saved preference or default to light -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Appearance } from 'react-native' + :root.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: var(--foreground); + --primary: oklch(0.72 0.16 260); + --primary-foreground: oklch(0.145 0 0); + --border: oklch(0.28 0 0); + } +` +``` -const systemDark = Appearance.getColorScheme() === 'dark' -const initialTheme = systemDark ? 'dark' : 'light' +## Theme Toggle -setDefaultVariables(themes[initialTheme]) +```jsx +// ThemeToggle.jsx +import { css, useTheme } from 'cssxjs' +import { Pressable, Text } from 'react-native' -export async function setTheme(themeName) { - Object.assign(variables, themes[themeName]) - await AsyncStorage.setItem('theme', themeName) -} +export default function ThemeToggle () { + const [theme, setTheme] = useTheme() + const dark = theme === 'dark' -export function toggleTheme() { - const current = variables['--bg-primary'] === themes.dark['--bg-primary'] - ? 'dark' : 'light' - setTheme(current === 'dark' ? 'light' : 'dark') + return ( + setTheme(dark ? 'light' : 'dark')}> + + {dark ? 'Light mode' : 'Dark mode'} + + + ) + + css` + .themeToggle { + padding: 0.5rem 1rem; + background-color: var(--color-primary); + border-radius: 0.5rem; + } + + .themeToggleText { + color: var(--color-primary-foreground); + font-weight: 600; + } + ` } ``` -## Themed App Component +## Themed App ```jsx // App.jsx -import { styl } from 'cssxjs' -import { View, Text, Pressable, ScrollView } from 'react-native' -import { toggleTheme } from './theme' +import { CssxProvider, css } from 'cssxjs' +import { View, Text, ScrollView } from 'react-native' +import { appTheme } from './theme' +import ThemeToggle from './ThemeToggle' -function App() { +export default function App () { return ( - - - My App - - Toggle Theme - - - - - - Welcome - - This card automatically updates when the theme changes. - + + + + My App + - - + + + + Welcome + + This card automatically updates when the theme changes. + + + + + ) - styl` - .app - flex 1 - background var(--bg-primary) - - .header - flex-direction row - justify-content space-between - align-items center - padding 16px 24px - background var(--bg-secondary) - border-bottom-width 1px - border-bottom-color var(--border) - - .logo - font-size 24px - font-weight 600 - color var(--text-primary) - - .theme-toggle - padding 8px 16px - background var(--accent) - border-radius 6px - - .toggle-text - color white - - .main - padding 24px - - .card - background var(--bg-secondary) - border-radius 12px - padding 24px - - .card-title - margin-bottom 12px - color var(--text-primary) - font-size 18px - font-weight 600 - - .card-text - color var(--text-secondary) - line-height 24px + css` + .app { + flex: 1; + background-color: var(--color-background); + } + + .header { + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background-color: var(--color-card); + border-bottom-width: 1px; + border-bottom-color: var(--color-border); + } + + .logo { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-foreground); + } + + .main { + padding: 1.5rem; + } + + .card { + background-color: var(--color-card); + border-radius: 0.75rem; + padding: 1.5rem; + } + + .cardTitle { + margin-bottom: 0.75rem; + color: var(--color-card-foreground); + font-size: 1.125rem; + font-weight: 600; + } + + .cardText { + color: var(--color-card-foreground); + line-height: 1.5rem; + } ` } ``` ## Key Concepts -- **`setDefaultVariables`** for initial theme values -- **`variables` object** for runtime theme switching -- **Automatic re-renders** when variables change -- **System preference detection** with `Appearance.getColorScheme()` -- **Persistence** with AsyncStorage +- **Provider CSS** with `:root` and `:root.dark` defines theme variables. +- **`useTheme()`** returns `[theme, setTheme]` for toggles and settings UI. +- **Persistence** is automatic: `localStorage` on web and AsyncStorage on React Native. +- **Default startup** uses the `default` theme unless a user preference was saved. +- **`theme='auto'`** follows the OS color scheme when a `dark` theme exists and no user preference overrides it. +- **Controlled providers** can still force a subtree with `theme='dark'`, `theme='light'`, `theme='default'`, or a custom theme name. diff --git a/docs/guide/animations.md b/docs/guide/animations.md index b64ce5f..bd3755f 100644 --- a/docs/guide/animations.md +++ b/docs/guide/animations.md @@ -323,8 +323,14 @@ CSSX compiles animations in a way Reanimated v4 expects: This means you write standard CSS and get native-compatible animations automatically. +Animation and transition declarations use the same value resolver as other CSSX +styles, so values may use `var()` and local template interpolation wherever CSS +values are supported. Animation names, keyframe names, and the `@keyframes` +block structure must remain statically knowable so CSSX can inline the matching +keyframes for Reanimated. + ## Next Steps -- [Caching](/guide/caching) — Performance optimization with teamplay +- [Caching](/guide/caching) — Built-in style caching - [Examples](/examples/) — More code examples - [styl Template](/api/styl) — Full syntax reference diff --git a/docs/guide/caching.md b/docs/guide/caching.md index a372bf2..adbe8c3 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -1,59 +1,36 @@ -# Caching with teamplay +# Caching -CSSX can cache style computations to improve rendering performance. This is particularly useful when components re-render frequently but their styles don't change. - -> **Note:** Caching currently requires the [teamplay](https://github.com/startupjs/teamplay) library. In future versions, CSSX may include built-in caching that works independently. +CSSX caches resolved style props by default and tracks the runtime dependencies +used by each element. ## How It Works -Without caching, CSSX computes styles on every render: +For Babel-compiled styles, generated code calls the CSSX runtime with the +compiled sheet and the current `styleName` value. For runtime CSS strings, +`useRuntimeCss()` compiles the string and wraps the compiled sheet in a tracked +runtime object. -```jsx -import { View, Text } from 'react-native' +The resolver caches the final props object for the current inputs: -function Card({ title }) { - // Style computation runs on EVERY render - return ( - - {title} - - ) +- the compiled sheet identity and content hash +- the normalized `styleName` +- local interpolation values +- the JSON hash of inline style props +- only the CSS variables and media queries that were actually used - styl` - .card - padding 16px - background white - ` -} -``` - -With caching enabled, CSSX memoizes the results: +When those inputs are unchanged, CSSX returns the same object references for +`style`, `textStyle`, `hoverStyle`, `activeStyle`, and other part style props. +That keeps React and React Native from seeing new style objects on every render. -1. First render: computes and caches the style object -2. Subsequent renders: returns the cached result instantly -3. Cache invalidates automatically when: - - CSS variable values change - - Screen dimensions change (for media queries) - - The `styleName` value changes - -## Setup - -### Step 1: Install teamplay - -```bash -npm install teamplay -``` +## No Setup Required -### Step 2: Wrap Components with observer - -For caching to work, components using `styleName` must be wrapped with `observer`: +Use `styleName` normally: ```jsx -import { observer } from 'teamplay' import { styl } from 'cssxjs' import { View, Text } from 'react-native' -const Card = observer(function Card({ title, children }) { +function Card({ title, children }) { return ( {title} @@ -72,192 +49,90 @@ const Card = observer(function Card({ title, children }) { .content color #666 ` -}) -``` - -That's it! The Babel transform automatically detects `observer` and enables the cached runtime. - -## Automatic Detection - -CSSX automatically enables caching when it detects `observer` imported from: -- `teamplay` -- `startupjs` - -No additional configuration is needed. - -## Manual Configuration - -You can force caching behavior in your Babel config: - -```js -// babel.config.js -module.exports = { - presets: [ - ['cssxjs/babel', { - cache: 'teamplay' // Always use teamplay caching - }] - ] } ``` -## What Gets Cached - -The caching system stores: -- Computed style objects for each unique `styleName` combination -- Results of CSS variable substitutions -- Media query evaluations - -### Cache Key Components +The Babel preset inserts the runtime calls for you. -Each cache entry is keyed by: -1. The `styleName` value -2. Current CSS variable values (if styles use `var()`) -3. Current screen dimensions (if styles use media queries) -4. Any inline style props +## Dependency-Aware Updates -### Automatic Invalidation - -The cache invalidates when reactive dependencies change: +CSSX tracks the specific runtime dependencies used by each resolved element. +Changing an unrelated variable does not invalidate that element. ```jsx -import { variables } from 'cssxjs' -import { observer } from 'teamplay' -import { View, Text } from 'react-native' +import { variables, styl } from 'cssxjs' +import { View } from 'react-native' -const ThemedCard = observer(function ThemedCard() { - // Cache invalidates when --card-bg changes - return ( - - Themed content - - ) +function ThemedCard() { + return styl` .card background var(--card-bg, white) ` -}) +} -// Later: changing this automatically re-renders affected components -variables['--card-bg'] = '#f0f0f0' +variables['--card-bg'] = '#f0f0f0' // ThemedCard updates +variables['--text-color'] = 'red' // ThemedCard does not update ``` -## Performance Impact - -Caching is most beneficial when: -- Components re-render frequently (lists, animations, form inputs) -- Styles are complex (many classes, nested selectors) -- Multiple components share the same styles - -Example with a list: +Variable notifications are batched in a microtask. Media query updates use the +runtime dimension store and browser media listeners when available, so CSSX only +rerenders components whose committed media result changed. Web resize handling +can be configured globally through `configureCssx()`. ```jsx -import { observer } from 'teamplay' -import { styl } from 'cssxjs' -import { View, Text } from 'react-native' - -const ListItem = observer(function ListItem({ item, isSelected }) { - return ( - - {item.name} - {item.price} - - ) +import { configureCssx } from 'cssxjs' - styl` - .item - flex-direction row - justify-content space-between - padding 12px 16px - border-bottom-width 1px - border-bottom-color #eee - &.selected - background #e3f2fd - .name - font-weight 500 - .price - color #666 - ` +configureCssx({ + dimensionsDebounceMs: 50 }) - -// Rendering 1000 items benefits significantly from caching -function ProductList({ products, selectedId }) { - return ( - - {products.map(item => ( - - ))} - - ) -} ``` -## Using with startupjs +## Runtime CSS Strings -If you're using the [startupjs](https://github.com/startupjs/startupjs) framework, caching is automatically configured. Just import `observer` from `startupjs`: +For client-generated CSS, use `useRuntimeCss()` and `cssx()`. Runtime +compilation has its own API reference covering diagnostics, subscriptions, and +platform behavior: [Runtime Compilation](/api/runtime). -```jsx -import { observer, styl } from 'startupjs' -import { View, Text } from 'react-native' +## Inline Style Hashing -export default observer(function MyComponent() { - return ( - - Content - - ) +Inline styles are deep-hashed with `JSON.stringify()`. This means callers can +write natural inline objects without manually memoizing every object: - styl` - .box - padding 16px - ` -}) +```jsx + ``` -## Best Practices +If the inline style values serialize to the same JSON string, the cache can +reuse the previous result. -### Wrap All Styled Components +## Template Interpolation Cache -For consistent behavior, wrap any component that uses `styleName`: +Function-scoped `css` and `styl` templates can use JavaScript interpolation in +CSS value positions: ```jsx -import { Pressable, Text } from 'react-native' +function Button({ color }) { + return -// Good: observer wrapper enables caching -const Button = observer(function Button({ children }) { - return ( - - {children} - - ) - styl`.button { padding 12px 24px } .text { color white }` -}) - -// Without observer: no caching, styles compute every render -function Button({ children }) { - return ( - - {children} - - ) - styl`.button { padding 12px 24px } .text { color white }` + css` + .button { + background-color: ${color}; + } + ` } ``` -## Debugging - -To verify caching is working, you can check if components are using the teamplay runtime. In development, the imported runtime path will be one of: - -- `cssxjs/runtime/react-native-teamplay` (React Native with caching) -- `cssxjs/runtime/web-teamplay` (Web with caching) -- `cssxjs/runtime/react-native` (React Native without caching) -- `cssxjs/runtime/web` (Web without caching) +Each compiled template has one cache slot for its latest interpolation values. +If `color` changes, CSSX recalculates the sheet result and replaces the previous +cached variant instead of keeping every historical value combination. ## Next Steps -- [Examples](/examples/) - Complete component examples -- [API Reference](/api/) - Complete API documentation +- [CSS Variables](/guide/variables) - Runtime theming +- [Runtime Compilation](/api/runtime) - Generated CSS strings +- [css Template](/api/css) - Plain CSS templates and interpolation +- [Animations](/guide/animations) - Reanimated v4 output diff --git a/docs/guide/index.md b/docs/guide/index.md index 413f7e8..fa53dcd 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -113,31 +113,68 @@ styl` ` ``` -### Dynamic CSS Variables +### CSS Variables -Use CSS `var()` syntax with runtime updates: +Use CSS `var()` syntax with provider-scoped theme variables: ```jsx -import { variables } from 'cssxjs' +import { CssxProvider, css } from 'cssxjs' -// Change theme at runtime -variables['--primary-color'] = isDarkMode ? '#fff' : '#000' +const theme = css` + :root { + --primary: oklch(0.58 0.22 260); + --color-primary: var(--primary); + } +` + + + + +``` + +### Provider Theming + +Use `CssxProvider style` for scoped theme variables, theme selection, global +utility classes, and component tag overrides: + +```jsx +import { CssxProvider, css } from 'cssxjs' + +const theme = css` + :root { + --primary: oklch(0.58 0.22 260); + --color-primary: var(--primary); + } + + Button { + border-radius: var(--radius-md); + } +` + + + + ``` -### Material Design Grid +### Standard CSS Units -Built-in `u` unit (1u = 8px) for consistent spacing: +Use `rem`, CSS variables, and `calc()` for spacing: ```css -.card - padding 2u /* 16px */ - margin 1u /* 8px */ - gap 0.5u /* 4px */ +.card { + padding: 1rem; + gap: calc(var(--spacing) * 2); +} ``` +The legacy `u` unit still compiles for migration (`1u = 8px`), but new styles +should prefer standard CSS units. + ### Performance Optimized -Automatic style caching prevents unnecessary re-renders. With the optional teamplay integration, styles are memoized and only recalculated when dependencies change. +Automatic style caching prevents unnecessary re-renders. Styles are memoized by +sheet, `styleName`, inline styles, interpolation values, and only the variables +or media queries that were actually used. ## How It Works @@ -208,5 +245,6 @@ function App() { - [Installation](/guide/installation) - Set up CSSX in your project - [TypeScript Support](/guide/typescript) - Type-check Pug templates - [Basic Usage](/guide/usage) - Learn the core concepts +- [Theming](/guide/theming) - Provider styles, theme assets, and component tags - [Component Parts](/guide/component-parts) - Style component internals - [CSS Variables](/guide/variables) - Dynamic theming diff --git a/docs/guide/installation.mdx b/docs/guide/installation.mdx index fe8968b..5ff1d63 100644 --- a/docs/guide/installation.mdx +++ b/docs/guide/installation.mdx @@ -6,6 +6,18 @@ import { PackageManagerTabs } from '@theme' +If you use CSSX theme persistence in React Native, also install AsyncStorage: + +```sh +npm install @react-native-async-storage/async-storage +# or +yarn add @react-native-async-storage/async-storage +# or +pnpm add @react-native-async-storage/async-storage +``` + +Web theme persistence uses `localStorage` automatically. + ## Configure Babel Add the CSSX preset to your `babel.config.js`: diff --git a/docs/guide/theming.md b/docs/guide/theming.md new file mode 100644 index 0000000..0e4ccda --- /dev/null +++ b/docs/guide/theming.md @@ -0,0 +1,368 @@ +# Theming + +CSSX theming is built from normal CSS: + +- `:root` defines scoped CSS variables for a provider subtree. +- `:root.dark` and other `:root.` blocks define named theme overrides. +- `CssxProvider style` supplies global/provider CSS. +- `CssxProvider theme` selects the active theme. +- Component tags and parts let provider CSS customize reusable components. + +Most apps should prefer provider CSS variables over imperative runtime variable +mutation. Runtime variables still exist for escape hatches, but provider styles +make the theme visible, scoped, and easy to override. + +## Provider Styles + +Use `CssxProvider` when using CSSX directly: + +```jsx +import { CssxProvider, css } from 'cssxjs' + +const appStyle = css` + :root { + --primary: oklch(0.58 0.22 260); + --primary-foreground: oklch(0.98 0.02 260); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + } + + Button { + border-radius: var(--radius-md); + } + + Button:part(text) { + font-weight: var(--font-weight-semibold); + } +` + +export default function App () { + return ( + + + + ) +} +``` + +`style` accepts a compiled `css` sheet, a runtime CSS string, a tracked runtime +sheet, or an array of those values. Later entries override earlier entries at +the same priority layer. + +Nested providers are scoped. Inner provider variables override outer provider +variables only for the inner subtree. + +## Theme Selection + +`theme` controls which `:root.` block is active: + +```jsx + + + +``` + +Supported values: + +| Value | Behavior | +| --- | --- | +| `default` | Uses only `:root`. This is the initial root preference when there is no saved user preference. | +| `auto` | Uses the OS color scheme and selects `dark` when the provider style defines `:root.dark`. | +| `light` | Alias for `default` unless `:root.light` exists. | +| `dark` | Uses `:root` plus `:root.dark`. | +| custom name | Uses `:root` plus `:root.`. | + +When the root `CssxProvider` does not receive a `theme` prop, CSSX uses the +persisted global preference. Without a saved preference, the root provider +starts in the `default` theme so host UI such as React Navigation does not +unexpectedly switch to dark mode. Use `useTheme()` to read and update the +preference: + +```jsx +import { useTheme } from 'cssxjs' +import { Pressable, Text } from 'react-native' + +function ThemeToggle () { + const [theme, setTheme] = useTheme() + const dark = theme === 'dark' + + return ( + setTheme(dark ? 'light' : 'dark')}> + {dark ? 'Light mode' : 'Dark mode'} + + ) +} +``` + +CSSX stores this preference in `localStorage` on web and in +`@react-native-async-storage/async-storage` on React Native. If a provider +receives an explicit non-`auto` `theme` prop, that prop forces the theme for its +subtree; `useTheme().setTheme()` still updates the saved preference, but the +forced subtree keeps using the prop value until it changes or is removed. At +the root, `theme='auto'` uses the system color scheme as the initial preference, +then a saved or user-selected preference takes priority. + +Define themes with variable-only root blocks: + +```css +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --color-background: var(--background); + --color-foreground: var(--foreground); +} + +:root.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); +} +``` + +Only custom properties are valid inside `:root` and `:root.` blocks. +Normal style declarations inside those blocks are ignored and reported as +diagnostics. + +## Theme Assets + +CSSX ships theme token assets: + +```jsx +import tailwindTheme from 'cssxjs/themes/tailwind' +import shadcnTheme from 'cssxjs/themes/shadcn' +``` + +These are normal CSSX sheets that can be passed to `CssxProvider`: + +```jsx +import { CssxProvider, css } from 'cssxjs' +import tailwindTheme from 'cssxjs/themes/tailwind' +import shadcnTheme from 'cssxjs/themes/shadcn' + +const overrides = css` + :root { + --primary: oklch(0.56 0.22 255); + --color-primary: var(--primary); + } +` + +function App () { + return ( + + + + ) +} +``` + +The public API is the entrypoint import. The live source files are useful as +variable references: + +- `packages/cssxjs/themes/tailwind.js` +- `packages/cssxjs/themes/shadcn.js` + +Bare CSSX does not automatically install either theme. Frameworks or component +libraries can choose to layer them for their own users. + +## Token Pattern + +The recommended token structure is: + +1. Raw scale tokens, such as Tailwind palette, spacing, font, radius, and + breakpoint variables. +2. Semantic shadcn-style variables, such as `--primary`, `--background`, and + `--border`. +3. Tailwind-compatible consumption variables, such as `--color-primary` and + `--color-background`. +4. Component-specific variables, such as `--Button-radius`. + +Example: + +```css +:root { + --primary: oklch(0.58 0.22 260); + --primary-foreground: oklch(0.98 0.02 260); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --Button-radius: var(--radius-md); +} +``` + +Component styles should usually consume `--color-*` variables. App themes should +usually override semantic variables first, then map them to `--color-*`. + +## Theme-Specific Styles + +Use built-in theme media aliases for normal styles that should only apply in a +specific theme: + +```css +Card { + box-shadow: var(--shadow-sm); +} + +@media (--theme-dark) { + Card { + box-shadow: none; + border-color: var(--color-border); + } +} +``` + +`--theme-dark`, `--theme-light`, `--theme-default`, and `--theme-` are +reserved by CSSX. They match the active provider theme and can compose with +ordinary media queries or custom media aliases. + +## Custom Media + +Provider styles can define standard `@custom-media` aliases: + +```css +:root { + --tablet: 48rem; + --desktop: 64rem; +} + +@custom-media --breakpoint-tablet (width >= var(--tablet)); +@custom-media --breakpoint-desktop (width >= var(--desktop)); +``` + +Use the aliases from component styles or provider overrides: + +```css +@media (--breakpoint-tablet) { + Button { + min-width: 12rem; + } +} +``` + +CSSX provides fallback aliases for `mobile`, `tablet`, `desktop`, and `wide` +when a provider does not define them. + +## Component Tags + +Use `themed(tagName, Component)` for components that should be globally +customizable by provider CSS: + +```jsx +import { themed } from 'cssxjs' +import { Pressable, Text } from 'react-native' + +function Button ({ children }) { + return ( + + {children} + + ) + + css` + .root { + background-color: var(--color-primary); + } + + .text { + color: var(--color-primary-foreground); + } + ` +} + +export default themed('Button', Button) +``` + +Provider CSS can then target every themed button: + +```css +Button { + min-height: 2.5rem; +} + +Button:part(text) { + text-transform: uppercase; +} +``` + +Class selectors remain utility classes. Component tags are for reusable +components that explicitly opt in through `themed()`. + +## Parts + +`part` exposes semantic inner elements to parent/provider CSS: + +```jsx + + + +``` + +Mappings: + +| Part | Prop | +| --- | --- | +| `root` | `style` | +| `label` | `labelStyle` | +| `icon` | `iconStyle` | + +Use `:part()` or `::part()` in provider or parent CSS: + +```css +Button:part(icon) { + opacity: 0.8; +} +``` + +Use direct `style` and `*Style` props as per-instance escape hatches. + +## Reading Theme Values In JS + +Prefer CSS for visual styling. When JavaScript needs a resolved token, use the +CSSX hooks: + +```jsx +import { useCssColor, useCssVariable, useMedia } from 'cssxjs' + +function Avatar () { + const online = useCssColor('success') + const size = useCssVariable('--Avatar-size', '2.5rem') + const media = useMedia() + + return ( + + ) +} +``` + +`useCssColor('primary')` resolves `var(--color-primary)`. Passing +`useCssColor('primary', 0.15)` mixes the color with transparent by 15 percent. +Pass `var(--custom)` when you need an exact variable expression. + +Outside React, `getCssColor()` and `getCssVariable()` read global/default +variables only. They are escape hatches and are not provider-scoped in this +batch. + +## Runtime Variables + +The `variables` and `defaultVariables` stores remain available for imperative +updates: + +```jsx +import { variables } from 'cssxjs' + +variables.assign({ + '--toast-offset': '1rem', + '--toast-color': 'var(--color-primary)' +}) +``` + +Use them when the value truly lives outside the provider tree or must be changed +imperatively. For app themes, prefer `CssxProvider style`. + +## StartupJS + +When CSSX is used through StartupJS, `StartupjsProvider style` and +`StartupjsProvider theme` forward to CSSX. Bare StartupJS does not include any +theme assets by default. Component libraries such as startupjs-ui can add their +own provider layer and still let app `StartupjsProvider style` override it. diff --git a/docs/guide/usage.md b/docs/guide/usage.md index a10b061..b7fcee9 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -36,21 +36,8 @@ function Button({ children, variant, disabled }) { CSSX provides two template literals: -- **`styl`** — [Stylus](https://stylus-lang.com/) syntax (recommended) -- **`css`** — Plain CSS syntax - -### styl (Stylus) - -Clean, indentation-based syntax without braces or semicolons: - -```jsx -styl` - .card - padding 16px - background white - border-radius 8px -` -``` +- **`css`** — Plain CSS syntax, recommended for new code and shared themes +- **`styl`** — [Stylus](https://stylus-lang.com/) syntax for existing code or projects that still want Stylus preprocessing ### css (Plain CSS) @@ -66,6 +53,19 @@ css` ` ``` +### styl (Stylus) + +Clean, indentation-based syntax without braces or semicolons: + +```jsx +styl` + .card + padding 16px + background white + border-radius 8px +` +``` + > See [styl Template](/api/styl) and [css Template](/api/css) for full syntax reference. ## Applying Styles with styleName @@ -167,7 +167,26 @@ function Card({ variant, highlighted, compact, children }) { ## Dynamic Values -For truly dynamic values, combine `styleName` with the `style` prop: +For component props that should feed CSS values, use JavaScript interpolation in +function-scoped `css` or `styl` templates: + +```jsx +import { View } from 'react-native' + +function ProgressBar({ progress, color }) { + return + + styl` + .bar + height 20px + width ${progress}% + background ${color} + ` +} +``` + +Interpolation is supported only in CSS value positions. For ad hoc overrides, +combine `styleName` with the `style` prop: ```jsx import { View } from 'react-native' @@ -185,7 +204,8 @@ function ProgressBar({ progress }) { } ``` -Or use [CSS Variables](/guide/variables) for runtime theming. +Use [Theming](/guide/theming) for app-wide provider themes and shared tokens. +Use [CSS Variables](/guide/variables) for imperative runtime variable updates. ## Style Placement @@ -223,9 +243,9 @@ function ButtonA() { } ``` -## The `u` Unit +## The legacy `u` unit -CSSX includes a custom unit where `1u = 8px` (Material Design grid): +CSSX still supports the old custom unit where `1u = 8px`: ```stylus .card @@ -234,6 +254,8 @@ CSSX includes a custom unit where `1u = 8px` (Material Design grid): gap 0.5u // 4px ``` +For new code, prefer `rem`, CSS variables, and `calc()`. + ## CSS Support CSSX runs on React Native, so not all CSS features are available. @@ -246,11 +268,15 @@ CSSX runs on React Native, so not all CSS features are available. | Compound selectors | `.card.featured` | Same element | | Parent reference `&` | `&.active`, `&.disabled` | `styl` only | | Part selectors | `:part(icon)`, `:part(text)` | | +| Hover and active aliases | `:hover`, `:active` | Emits `hoverStyle` and `activeStyle` | | CSS variables | `var(--color)`, `var(--size, 16px)` | | +| JavaScript interpolation | ``color ${value}`` | Function-scoped templates only | | Animations | `animation fadeIn 0.3s ease` | Reanimated v4 components only | | Keyframes | `@keyframes fadeIn` | Reanimated v4 components only | | Transitions | `transition background 0.2s` | Reanimated v4 components only | | Media queries | `@media (min-width: 768px)` | | +| Filters | `filter blur(8px)` | Current React Native versions | +| Background gradients | `background-image linear-gradient(...)` | RN emits `experimental_backgroundImage` | | Most CSS properties | `padding`, `margin`, `flex`, `color`, etc. | | | Custom `u` unit | `padding 2u` | 1u = 8px | @@ -260,44 +286,57 @@ CSSX runs on React Native, so not all CSS features are available. | Feature | Alternative | |---------|-------------| -| `:hover` | Use `onPressIn`/`onPressOut` with `&.pressed` modifier | | `:focus` | Use `onFocus`/`onBlur` with `&.focused` modifier | -| `:active` | Use state with `&.active` modifier | | `::before`, `::after` | Use a real element with its own styles | | Descendant selectors | `.parent .child` — add modifier to child directly | | Attribute selectors | `[type="text"]` — use class modifiers instead | | `:first-child`, `:nth-child` | Handle in JS when rendering | -| `linear-gradient`, `radial-gradient` | Use solid colors or images | +| URL background images | Use platform image components | -### Example: Replacing :hover +### Hover and Active Props -Instead of `:hover`, track state and use a modifier: +CSSX emits `hoverStyle` and `activeStyle` for `:hover` and `:active`. Components +can choose how to apply those props: ```jsx import { useState } from 'react' import { Pressable, Text } from 'react-native' -function Button({ children, onPress }) { +function InteractiveBox({ style, hoverStyle, activeStyle, children, onPress }) { + const [hovered, setHovered] = useState(false) const [pressed, setPressed] = useState(false) return ( setHovered(true)} + onHoverOut={() => setHovered(false)} onPressIn={() => setPressed(true)} onPressOut={() => setPressed(false)} onPress={onPress} > - {children} + {children} ) +} + +function Button({ children, onPress }) { + return ( + + {children} + + ) styl` .button background #007bff - &.pressed + &:hover background #0056b3 + &:active + transform scale(0.97) + .text color white ` @@ -338,5 +377,6 @@ function Button({ children, onPress }) { ## Next Steps - [Component Parts](/guide/component-parts) — Style child component internals +- [Theming](/guide/theming) — Provider styles, theme assets, and component tags - [CSS Variables](/guide/variables) — Dynamic theming - [styl Template API](/api/styl) — Full syntax reference including variables, mixins, selectors diff --git a/docs/guide/variables.md b/docs/guide/variables.md index b56eb35..3d63a4d 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -1,306 +1,206 @@ # CSS Variables -CSSX supports CSS custom properties (`var()`) with a twist: you can change variable values at runtime, and your components will automatically re-render with the new values. +CSSX supports standard CSS custom properties with runtime-aware resolution. Use +`var(--name)` in CSS values, provider `:root` blocks for scoped theme defaults, +and the imperative variable stores only for lower-level global overrides. + +For app-wide themes, start with [Theming](/guide/theming). This page focuses on +`var()` usage, variable priority, and the imperative APIs. ## Basic Usage -Use standard CSS `var()` syntax in your styles: +Use normal CSS `var()` syntax: ```jsx -import { styl } from 'cssxjs' +import { css } from 'cssxjs' import { Pressable, Text } from 'react-native' -function ThemedButton({ children }) { +function ThemedButton ({ children }) { return ( - - {children} + + {children} ) - styl` - .button - background-color var(--primary-color, #007bff) - padding var(--button-padding, 12px 24px) - border-radius var(--border-radius, 8px) - .text - color var(--text-color, white) + css` + .button { + background-color: var(--color-primary, #007bff); + padding: var(--Button-padding, 0.75rem 1rem); + border-radius: var(--radius-md, 0.5rem); + } + + .text { + color: var(--color-primary-foreground, white); + } ` } ``` -The second argument in `var()` is the fallback value used when the variable is not set. - -## Setting Default Variables - -Use `setDefaultVariables` to define your theme at app startup: - -```jsx -// App.jsx or theme.js -import { setDefaultVariables } from 'cssxjs' - -// Call this early in your app initialization -setDefaultVariables({ - '--primary-color': '#007bff', - '--secondary-color': '#6c757d', - '--success-color': '#28a745', - '--danger-color': '#dc3545', - '--text-color': '#333', - '--background-color': '#fff', - '--border-radius': '8px', - '--spacing-sm': '8px', - '--spacing-md': '16px', - '--spacing-lg': '24px' -}) -``` - -These values take precedence over inline fallbacks in `var()`. +The second argument in `var()` is the fallback used when the variable cannot be +resolved. -## Dynamic Variables (Runtime Updates) +## Provider Variables -Import `variables` to change values at runtime: +Provider styles define scoped variables with `:root`: ```jsx -import { useState } from 'react' -import { variables } from 'cssxjs' -import { Pressable, Text } from 'react-native' +import { CssxProvider, css } from 'cssxjs' + +const appTheme = css` + :root { + --primary: oklch(0.58 0.22 260); + --primary-foreground: oklch(0.98 0.02 260); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --Button-padding: 0.75rem 1rem; + } -function ThemeToggle() { - const [isDark, setIsDark] = useState(false) - - const toggleTheme = () => { - const newIsDark = !isDark - setIsDark(newIsDark) - - if (newIsDark) { - variables['--primary-color'] = '#bb86fc' - variables['--background-color'] = '#121212' - variables['--text-color'] = '#ffffff' - } else { - variables['--primary-color'] = '#007bff' - variables['--background-color'] = '#ffffff' - variables['--text-color'] = '#333333' - } + :root.dark { + --primary: oklch(0.72 0.16 260); + --primary-foreground: oklch(0.145 0 0); } +` +export default function App () { return ( - - {isDark ? 'Light Mode' : 'Dark Mode'} - + + + ) } ``` -When you assign to `variables`, all components using those variables automatically re-render. - -## Variable Priority +Nested providers can override outer provider variables for their subtree. +Root/theme blocks accept only CSS custom property declarations. -Variables are resolved in this order (highest priority first): +## Imperative Variables -1. **Runtime variables** (`variables['--name']`) -2. **Default variables** (`setDefaultVariables()`) -3. **Inline fallback** (`var(--name, fallback)`) +Use `variables` when a global value is controlled outside the provider tree or +must be mutated imperatively: ```jsx -setDefaultVariables({ '--color': 'blue' }) // Priority 2 -variables['--color'] = 'red' // Priority 1 (wins) +import { variables } from 'cssxjs' -styl` - .box - color var(--color, green) // Will be 'red' -` +variables['--toast-offset'] = '1rem' + +variables.assign({ + '--toast-offset': '1.5rem', + '--toast-color': 'var(--color-primary)' +}) + +variables.clear(['--toast-offset']) ``` -## Using Variables in Complex Values +When you assign to `variables`, components that used those exact variables in +their resolved styles automatically re-render. Unrelated variable changes do +not invalidate the component. -Variables work within compound CSS values: +`defaultVariables` is a lower-priority fallback store: ```jsx -styl` - .card - box-shadow var(--shadow-x, 0) var(--shadow-y, 4px) var(--shadow-blur, 8px) var(--shadow-color, rgba(0,0,0,0.1)) +import { defaultVariables, setDefaultVariables } from 'cssxjs' - border var(--border-width, 1px) solid var(--border-color, #ddd) +defaultVariables.assign({ + '--legacy-radius': '0.5rem' +}) - transform translateX(var(--translate-x, 0)) scale(var(--scale, 1)) -` +setDefaultVariables({ + '--legacy-gap': '1rem' +}) ``` -## Practical Example: Theme System +`setDefaultVariables(vars)` is a compatibility alias for +`defaultVariables.set(vars)`. Prefer provider `:root` variables for app themes. -Here's a complete theming implementation: - -```jsx -// theme.js -import { setDefaultVariables, variables } from 'cssxjs' - -const lightTheme = { - '--bg-primary': '#ffffff', - '--bg-secondary': '#f5f5f5', - '--text-primary': '#333333', - '--text-secondary': '#666666', - '--accent': '#007bff', - '--border': '#e0e0e0' -} +## Variable Priority -const darkTheme = { - '--bg-primary': '#1a1a1a', - '--bg-secondary': '#2d2d2d', - '--text-primary': '#ffffff', - '--text-secondary': '#b0b0b0', - '--accent': '#bb86fc', - '--border': '#404040' -} +Variables resolve in this order, from highest to lowest priority: -// Initialize with light theme -setDefaultVariables(lightTheme) +1. Template interpolation values used by the current style layer. +2. Runtime variables (`variables['--name']`). +3. Nearest provider `:root` variable. +4. Outer provider `:root` variables. +5. Default variables (`defaultVariables['--name']`). +6. Inline fallback (`var(--name, fallback)`). -export function setTheme(theme) { - const values = theme === 'dark' ? darkTheme : lightTheme - Object.assign(variables, values) -} +```jsx +setDefaultVariables({ '--color': 'blue' }) +variables['--color'] = 'red' +``` -export function getTheme() { - return variables['--bg-primary'] === darkTheme['--bg-primary'] - ? 'dark' - : 'light' +```css +.box { + color: var(--color, green); /* resolves to red */ } ``` -```jsx -// App.jsx -import { styl } from 'cssxjs' -import { View, Text, Pressable } from 'react-native' -import { setTheme } from './theme' - -function App() { - return ( - - - My App - setTheme('dark')}> - Dark - - setTheme('light')}> - Light - - - - Content here - - - ) - - styl` - .app - flex 1 - background var(--bg-primary) - - .header - background var(--bg-secondary) - padding 16px - border-bottom-width 1px - border-bottom-color var(--border) +## Complex Values - .title - font-size 20px - color var(--text-primary) +Variables work inside shorthands, nested fallbacks, comma-separated chunks, and +supported CSS functions: - .content - padding 24px - - .text - color var(--text-primary) - ` +```css +.card { + border: var(--border-width, 1px) solid var(--color-border, #ddd); + box-shadow: var(--shadow-x, 0) var(--shadow-y, 0.25rem) var(--shadow-blur, 0.75rem) var(--shadow-color, rgba(0, 0, 0, 0.16)); + background-image: var(--hero-gradient, linear-gradient(0deg, white, transparent)); } ``` -## Variables with `u` Units +Nested fallbacks are supported: -Combine CSS variables with the `u` unit system: - -```jsx -setDefaultVariables({ - '--card-padding': '2u', // 16px - '--button-height': '5u', // 40px - '--spacing': '1u' // 8px -}) +```css +.text { + color: var(--Button-text-color, var(--color-primary-foreground, white)); +} ``` -## Tips and Best Practices +Cycles and unresolved variables invalidate only the containing declaration. -### Naming Convention +## Reading Variables In JS -Use a consistent naming scheme: +Use `useCssVariable()` when component logic needs a resolved value: ```jsx -setDefaultVariables({ - // Colors - '--color-primary': '#007bff', - '--color-secondary': '#6c757d', - '--color-background': '#fff', - '--color-text': '#333', - - // Typography - '--font-size-sm': '12px', - '--font-size-md': '14px', - '--font-size-lg': '18px', - - // Spacing - '--space-xs': '4px', - '--space-sm': '8px', - '--space-md': '16px', - '--space-lg': '24px', - - // Components - '--button-bg': 'var(--color-primary)', - '--button-text': '#fff', - '--card-shadow': '0 2px 8px rgba(0,0,0,0.1)' -}) -``` +import { useCssVariable } from 'cssxjs' -### Always Provide Fallbacks - -In case a variable isn't set, provide sensible defaults: +function Avatar () { + const size = useCssVariable('--Avatar-size', '2.5rem') + return +} +``` -```stylus -.button - // Good - has fallback - background var(--button-bg, #007bff) +`useCssVariable()` is provider-aware and subscribes only to the variables it +actually resolves, including nested `var()` dependencies. It returns +React-Native-friendly values: `16px` becomes `16`, `0.5rem` becomes `8`, +percentages remain strings, and computed colors become compatible color +strings. - // Risky - no fallback - background var(--button-bg) -``` +Use `useCssVariableRaw()` to read the raw resolved CSS text. Outside React, +`getCssVariable()` and `getCssVariableRaw()` read global/default variables +only; they are not provider-scoped. -### Group Related Variables +For colors, prefer `useCssColor()`: ```jsx -// colors.js -export const colors = { - '--color-primary': '#007bff', - '--color-secondary': '#6c757d', - // ... -} +import { useCssColor } from 'cssxjs' -// spacing.js -export const spacing = { - '--space-sm': '8px', - '--space-md': '16px', - // ... -} +const primary = useCssColor('primary') +const subtlePrimary = useCssColor('primary', 0.15) +``` -// theme.js -import { setDefaultVariables } from 'cssxjs' -import { colors } from './colors' -import { spacing } from './spacing' +## Naming Tips -setDefaultVariables({ - ...colors, - ...spacing -}) -``` +- Use full CSS custom property names starting with `--`. +- Use semantic names for theme tokens, such as `--primary`, `--background`, + and `--border`. +- Use Tailwind-compatible `--color-*` variables for consumption, such as + `--color-primary`. +- Use component prefixes for component-specific variables, such as + `--Button-height-m` or `--TextInput-border-color-focused`. ## Next Steps -- [Pug Templates](/guide/pug) - Alternative JSX syntax -- [Animations](/guide/animations) - CSS transitions and keyframes -- [Caching](/guide/caching) - Performance optimization with teamplay +- [Theming](/guide/theming) - Provider themes, `:root.dark`, and component tags +- [Runtime CSS](/api/runtime) - Client-side CSS compilation +- [Caching](/guide/caching) - Dependency-aware style caching diff --git a/docs/index.md b/docs/index.md index e17c67c..cf51ea6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,12 +24,12 @@ features: details: Style internal elements of child components using :part() selectors, similar to CSS Shadow Parts icon: 🧩 - title: Dynamic CSS Variables - details: Use var(--name) with runtime updates. Change themes instantly and components re-render automatically + details: Use provider-scoped :root variables, var(--name), and precise runtime subscriptions icon: ⚡ - - title: 8px Grid System - details: Built-in 'u' unit (1u = 8px) for consistent spacing following Material Design guidelines + - title: Provider Themes + details: Layer Tailwind tokens, shadcn-style semantics, component defaults, and app overrides icon: 📐 - title: Performance Caching - details: Optional style caching with teamplay prevents unnecessary re-renders for optimal performance + details: Built-in dependency-aware style caching reuses stable style props without observer wrappers icon: 🚀 --- diff --git a/docs/migration-guides/0.4.md b/docs/migration-guides/0.4.md new file mode 100644 index 0000000..1e36b6a --- /dev/null +++ b/docs/migration-guides/0.4.md @@ -0,0 +1,307 @@ +# Upgrade 0.3 to 0.4 + +- Change `cssxjs` in your `package.json` to `^0.4` +- Change `eslint-plugin-cssxjs` and any directly installed `@cssxjs/*` packages to `^0.4` +- Make sure your app is on React 19 +- Run your package manager install command after the version bump + +CSSX 0.4 is a breaking release focused on the new unified CSS engine, runtime CSS compilation, CSS variables, provider-scoped theming, and CSS-first customization. + +## Quick Migration Checklist + +1. Keep `cssxjs/babel` in your Babel presets. +2. Replace any direct runtime CSS hook usage of `useCompiledCss()` with `useRuntimeCss()`. +3. Move app-wide CSS variables, global classes, component tag overrides, and theme overrides into `CssxProvider style`. +4. Use `theme='auto'` if you want the provider to select `:root.dark` on dark systems. +5. Replace JS theme/color helpers with `useCssVariable()`, `useCssColor()`, `getCssVariable()`, and `getCssColor()`. +6. Move media helpers to CSSX `useMedia()` and `@custom-media`. +7. Replace new usage of `u` units with `rem`, `calc()`, or CSS variables. Existing `u` usage still compiles for migration. +8. If you use component override styles, use component tag selectors and `:part()` / `::part()` from outside the component. +9. Run your tests and app build so Babel recompiles all CSSX templates. + +## Unified CSS Engine + +CSSX now owns the full CSS-to-React-Native/web style pipeline through `@cssxjs/css-to-rn`. + +This replaces the previous split across CSSX, `css-to-react-native-transform`, and `css-to-react-native`. The compiler, runtime resolver, CSS variable system, media tracking, style caching, and React subscriptions now live in one engine. + +Most projects do not need to import `@cssxjs/css-to-rn` directly. Keep using the public `cssxjs` API: + +```js +import { + css, + styl, + pug, + CssxProvider, + cssx, + useRuntimeCss, + useCssVariable, + useCssColor, + useMedia +} from 'cssxjs' +``` + +## Runtime Caching + +CSSX now caches resolved style props by default. The cache tracks only the variables, media queries, dimensions, and inline values used by a resolved style. + +You no longer need a wrapper around every component for CSSX style invalidation. The old Babel option `cache: 'teamplay'` is still accepted for compatibility, but CSSX runtime caching is now handled by `@cssxjs/css-to-rn`. + +## Provider Styles And Themes + +Use `CssxProvider style` for global/provider CSS: + +```jsx +import { CssxProvider, css } from 'cssxjs' + +const appStyle = css` + :root { + --primary: oklch(0.55 0.2 250); + --primary-foreground: white; + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + } + + :root.dark { + --primary: oklch(0.75 0.18 250); + } + + Button { + border-radius: var(--radius-md); + } + + Button:part(text) { + font-weight: 600; + } +` + +export default function App () { + return ( + + + + ) +} +``` + +`theme` values: + +- `default`: applies only `:root`. +- `auto`: uses `dark` when the OS color scheme is dark and the provider styles define `:root.dark`. +- `dark`: applies `:root` plus `:root.dark`. +- `light`: aliases `default` unless an explicit `:root.light` exists. +- any custom name: applies `:root` plus `:root.`. + +When the root provider has no saved theme preference and no `theme` prop, CSSX +starts with `default`. Use `theme='auto'` when you intentionally want system +light/dark selection. + +Root theme blocks accept only CSS custom properties. Use theme media for normal styles: + +```css +@media (--theme-dark) { + Button { + box-shadow: none; + border-color: var(--color-border); + } +} +``` + +## Theme Assets + +CSSX ships optional Tailwind and shadcn-compatible token layers: + +```js +import tailwindTheme from 'cssxjs/themes/tailwind' +import shadcnTheme from 'cssxjs/themes/shadcn' + +const appStyle = css` + :root { + --primary: oklch(0.52 0.2 250); + --color-primary: var(--primary); + } +` + + + + +``` + +These assets are plain CSSX-compatible CSS. They do not enable Tailwind utility classes by themselves. + +## Variables + +CSS variables can be provided through `CssxProvider style` or through the imperative global variable store: + +```js +import { variables } from 'cssxjs' + +variables['--accent'] = 'red' +delete variables['--accent'] + +variables.set('--accent', 'red') +variables.assign({ + '--surface': 'white', + '--text': 'black' +}) +variables.clear() +``` + +Variable names must be valid CSS custom property names. + +In React, prefer provider-scoped hooks: + +```js +import { useCssVariable, useCssColor } from 'cssxjs' + +function Avatar () { + const size = useCssVariable('--Avatar-size', '2.5rem') + const online = useCssColor('success') +} +``` + +`useCssColor('primary')` resolves `var(--color-primary)`. Passing `var(--custom)` resolves that exact CSS expression. Passing `--primary` is intentionally unsupported because it is ambiguous. + +## Runtime CSS Compilation + +Use runtime compilation when CSS is generated on the client, for example by AI or by user-authored configuration: + +```jsx +import { cssx, useRuntimeCss } from 'cssxjs' + +function Button ({ generatedCss, label }) { + const sheet = useRuntimeCss(generatedCss) + + return ( +
+ {label} +
+ ) +} +``` + +Runtime compilation degrades gracefully by default. Invalid generated CSS produces diagnostics on the returned sheet instead of forcing every caller to wrap compilation in `try`/`catch`. + +## Template Interpolation + +JS tagged template interpolation is supported in local `css` and `styl` templates: + +```jsx +function Card ({ pad }) { + const color = useThemeColor('primary') + + return + + css` + .root { + color: ${color}; + padding: ${pad} 0.5rem; + } + ` +} +``` + +Interpolations are lowered to dynamic CSS variables and can be used in the same value positions where CSS `var()` is valid. Nested `var()` and interpolation inside shorthand values are supported. + +If a template is the terminal unreachable expression after a component return, CSSX treats it as the special local style block and hoists it safely. If you place a template before conditional returns, CSSX leaves it where you wrote it. + +## Parts And Component Overrides + +`part` is for external styling of a component from its parent/global stylesheet. + +Inside a component: + +```jsx +function Button ({ children }) { + return ( + + {children} + + ) +} +``` + +From outside the component: + +```css +Button { + background-color: var(--color-primary); +} + +Button:part(text), +Button::part(text) { + color: var(--color-primary-foreground); +} +``` + +Do not rely on `&:part(text)` inside the same component to style its own child. Parts are addressable from outside. + +`part='root'` maps to `style`; other parts map to `{partName}Style`. `:hover` and `:active` compile to `hoverStyle` and `activeStyle`. + +## Custom Media And `useMedia()` + +CSSX supports standard `@custom-media` plus provider-aware `useMedia()`: + +```css +:root { + --tablet: 48rem; + --desktop: 64rem; +} + +@custom-media --breakpoint-tablet (width >= var(--tablet)); +@custom-media --breakpoint-desktop (width >= var(--desktop)); +``` + +```js +import { useMedia } from 'cssxjs' + +function Layout () { + const media = useMedia() + return media.desktop ? : +} +``` + +If no provider aliases are defined, CSSX supplies fallback `mobile`, `tablet`, `desktop`, and `wide` aliases. + +## CSS Feature Additions + +CSSX 0.4 adds or expands support for: + +- nested `var()` and fallback resolution +- `calc()` in supported numeric contexts +- `oklch()` and `color-mix()` color resolution +- variables inside complex shorthands such as `box-shadow` +- `filter` +- `background-image` and `background` gradients through React Native `experimental_backgroundImage` +- CSS-defined animations and transitions in the shape expected by Reanimated v4 +- `:hover` and `:active` output props + +React Native background images support only `linear-gradient()` and `radial-gradient()` strings. URL images in `background-image` are dropped. + +Animations and transitions can still use `var()` or template interpolation in CSS value positions where normal CSS variable substitution is valid. + +## Deprecated `u` + +Existing `u` units and the JS `u()` helper remain available for migration, but new code should use `rem`, `var(--spacing)`, or `calc()`: + +```css +/* old */ +padding: 2u; + +/* new */ +padding: 1rem; +padding: calc(var(--spacing) * 4); +``` + +`1u` equals `0.5rem`, or 8px with the default 16px rem base. + +## Validation + +After migrating: + +```sh +npx cssxjs check +npx eslint . +``` + +Then run your app build, unit tests, and visual smoke checks so Babel recompiles the new CSSX runtime calls. diff --git a/example/CHANGELOG.md b/example/CHANGELOG.md index b943060..439a9f9 100644 --- a/example/CHANGELOG.md +++ b/example/CHANGELOG.md @@ -3,6 +3,65 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + +**Note:** Version bump only for package example + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package example + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package example + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package example + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package example + + + + + +# [0.4.0-alpha.1](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.0...v0.4.0-alpha.1) (2026-06-24) + +**Note:** Version bump only for package example + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/cssx/issues/5)) ([cd205cf](https://github.com/startupjs/cssx/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/example/package.json b/example/package.json index 4ab3f00..1632eef 100644 --- a/example/package.json +++ b/example/package.json @@ -1,20 +1,21 @@ { "name": "example", "private": true, - "version": "0.3.0", + "version": "0.4.0-alpha.6", "type": "module", "scripts": { "start": "node --watch server.js" }, "dependencies": { "@babel/core": "^7.0.0", - "cssxjs": "^0.3.0", + "cssxjs": "^0.4.0-alpha.6", "esbuild": "^0.21.4", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.2.7", + "react-dom": "19.2.7" }, "devDependencies": { - "@types/react-dom": "^18.3.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", "cli-highlight": "^2.1.11" } } diff --git a/lerna.json b/lerna.json index 15f38f1..ad16520 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.3.0", + "version": "0.4.0-alpha.6", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index 1f2af6f..f049cc5 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,16 @@ }, "devDependencies": { "@rspress/core": "^2.0.0", - "@types/react": "~18.2.45", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", "eslint": "^9.39.4", "eslint-plugin-cssxjs": "^0.3.0-alpha.0", "husky": "^4.3.0", "lerna": "^9.0.3", "lint-staged": "^15.2.2", "neostandard": "^0.13.0", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "19.2.7", + "react-dom": "19.2.7", "ts-node": "^10.9.2", "typescript": "^5.1.3" }, diff --git a/packages/babel-plugin-rn-stylename-inline/CHANGELOG.md b/packages/babel-plugin-rn-stylename-inline/CHANGELOG.md index 4567cd5..1d5a1a4 100644 --- a/packages/babel-plugin-rn-stylename-inline/CHANGELOG.md +++ b/packages/babel-plugin-rn-stylename-inline/CHANGELOG.md @@ -3,6 +3,65 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-inline + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-inline + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-inline + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-inline + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-inline + + + + + +# [0.4.0-alpha.1](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.0...v0.4.0-alpha.1) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-inline + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/startupjs/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/startupjs/issues/5)) ([cd205cf](https://github.com/startupjs/startupjs/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/startupjs/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/babel-plugin-rn-stylename-inline/__tests__/__snapshots__/index.spec.js.snap b/packages/babel-plugin-rn-stylename-inline/__tests__/__snapshots__/index.spec.js.snap index a26100c..3b6ad2e 100644 --- a/packages/babel-plugin-rn-stylename-inline/__tests__/__snapshots__/index.spec.js.snap +++ b/packages/babel-plugin-rn-stylename-inline/__tests__/__snapshots__/index.spec.js.snap @@ -25,17 +25,101 @@ myCss\` import React from "react"; import { css as myCss, styl as myStyl, observer } from "cssxjs"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_d20fva", + contentHash: "cssx_9sdtvn", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "red", + raw: "color: red", + order: 0, + line: 3, + column: 5, + }, + { + property: "background-color", + value: "green", + raw: "background-color: green", + order: 1, + line: 4, + column: 5, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { color: "red", backgroundColor: "green", }, - __hash__: 1762191192, + __hash__: 1605997709, }; const _localCssInstance = { + version: 1, + id: "cssx_6737ah", + contentHash: "cssx_bj97x3", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "#00f", + raw: "color: #00f", + order: 0, + line: 2, + column: 3, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { color: "#00f", }, - __hash__: -1529996446, + __hash__: -1569640170, }; export default observer(function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -43,6 +127,47 @@ export default observer(function Card() { }); +`; + +exports[`@cssxjs/babel-plugin-rn-stylename-inline CSS template as exported value: CSS template as exported value 1`] = ` + +import { css } from 'cssxjs' + +export default css\` + :root { + --color-primary: red; + } +\` + + ↓ ↓ ↓ ↓ ↓ ↓ + +import { css } from "cssxjs"; +export default { + version: 1, + id: "cssx_bte0b8", + contentHash: "cssx_nf5kz8", + rules: [], + keyframes: {}, + rootVariables: { + "--color-primary": "red", + }, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], + __hash__: -1545719102, +}; + + `; exports[`@cssxjs/babel-plugin-rn-stylename-inline Custom magic import. Simple: Custom magic import. Simple 1`] = ` @@ -66,11 +191,57 @@ css\` import React from "react"; import { css, observer } from "startupjs"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_d20fva", + contentHash: "cssx_9sdtvn", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "red", + raw: "color: red", + order: 0, + line: 3, + column: 5, + }, + { + property: "background-color", + value: "green", + raw: "background-color: green", + order: 1, + line: 4, + column: 5, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { color: "red", backgroundColor: "green", }, - __hash__: 1762191192, + __hash__: 1605997709, }; export default observer(function Card() { return ; @@ -116,26 +287,151 @@ import React from "react"; import { css, styl } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_e4ok3b", + contentHash: "cssx_sae16k", + rules: [ + { + selector: ".active", + tag: null, + classes: ["active"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "background-color", + value: "#f00", + raw: "background-color: #f00", + order: 0, + line: 2, + column: 3, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], active: { backgroundColor: "#f00", }, - __hash__: -1767660834, + __hash__: 354619258, }; const _localCssInstance2 = { + version: 1, + id: "cssx_8a2l4b", + contentHash: "cssx_fubghw", + rules: [ + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "margin-top", + value: "16px", + raw: "margin-top: 16px", + order: 0, + line: 3, + column: 7, + }, + { + property: "border-radius", + value: "8px", + raw: "border-radius: 8px", + order: 1, + line: 4, + column: 7, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], root: { marginTop: 16, - borderRadius: 8, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8, }, - __hash__: -1053412432, + __hash__: 105450578, }; const _localCssInstance = { + version: 1, + id: "cssx_94aplp", + contentHash: "cssx_j0akch", + rules: [ + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "padding", + value: "8px 16px", + raw: "padding: 8px 16px", + order: 0, + line: 2, + column: 3, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], root: { paddingTop: 8, paddingRight: 16, paddingBottom: 8, paddingLeft: 16, }, - __hash__: -1823792365, + __hash__: 1904140285, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -192,26 +488,151 @@ import React from "react"; import { css } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_fgl1hb", + contentHash: "cssx_62w8qm", + rules: [ + { + selector: ".active", + tag: null, + classes: ["active"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "background-color", + value: "red", + raw: "background-color: red", + order: 0, + line: 3, + column: 5, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], active: { backgroundColor: "red", }, - __hash__: -1812576046, + __hash__: -1943713921, }; const _localCssInstance2 = { + version: 1, + id: "cssx_8a2l4b", + contentHash: "cssx_fubghw", + rules: [ + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "margin-top", + value: "16px", + raw: "margin-top: 16px", + order: 0, + line: 3, + column: 7, + }, + { + property: "border-radius", + value: "8px", + raw: "border-radius: 8px", + order: 1, + line: 4, + column: 7, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], root: { marginTop: 16, - borderRadius: 8, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8, }, - __hash__: -1053412432, + __hash__: 105450578, }; const _localCssInstance = { + version: 1, + id: "cssx_e3vlw8", + contentHash: "cssx_7lhxx3", + rules: [ + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "padding", + value: "8px 16px", + raw: "padding: 8px 16px", + order: 0, + line: 3, + column: 7, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], root: { paddingTop: 8, paddingRight: 16, paddingBottom: 8, paddingLeft: 16, }, - __hash__: -1823792365, + __hash__: -720330568, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -262,6 +683,90 @@ import React from "react"; import { css } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_5snslk", + contentHash: "cssx_c10gwr", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "padding", + value: "8px 16px", + raw: "padding: 8px 16px", + order: 0, + line: 3, + column: 5, + }, + ], + }, + { + selector: ".line", + tag: null, + classes: ["line"], + part: null, + specificity: 1, + order: 1, + media: null, + declarations: [ + { + property: "margin-top", + value: "16px", + raw: "margin-top: 16px", + order: 0, + line: 6, + column: 5, + }, + { + property: "border-radius", + value: "8px", + raw: "border-radius: 8px", + order: 1, + line: 7, + column: 5, + }, + ], + }, + { + selector: ".active", + tag: null, + classes: ["active"], + part: null, + specificity: 1, + order: 2, + media: null, + declarations: [ + { + property: "background-color", + value: "red", + raw: "background-color: red", + order: 0, + line: 10, + column: 5, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { paddingTop: 8, paddingRight: 16, @@ -270,12 +775,15 @@ const __CSS_GLOBAL__ = { }, line: { marginTop: 16, - borderRadius: 8, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8, }, active: { backgroundColor: "red", }, - __hash__: 1310335761, + __hash__: -2126506810, }; export default function Card() { return ( @@ -312,11 +820,57 @@ css\` import React from "react"; import { css, observer } from "cssxjs"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_d20fva", + contentHash: "cssx_9sdtvn", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "red", + raw: "color: red", + order: 0, + line: 3, + column: 5, + }, + { + property: "background-color", + value: "green", + raw: "background-color: green", + order: 1, + line: 4, + column: 5, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { color: "red", backgroundColor: "green", }, - __hash__: 1762191192, + __hash__: 1605997709, }; export default observer(function Card() { return ; @@ -355,6 +909,90 @@ import React from "react"; import { styl } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_ccmade", + contentHash: "cssx_oj63s5", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "padding", + value: "8px 16px", + raw: "padding: 8px 16px", + order: 0, + line: 2, + column: 3, + }, + ], + }, + { + selector: ".line", + tag: null, + classes: ["line"], + part: null, + specificity: 1, + order: 1, + media: null, + declarations: [ + { + property: "margin-top", + value: "16px", + raw: "margin-top: 16px", + order: 0, + line: 5, + column: 3, + }, + { + property: "border-radius", + value: "8px", + raw: "border-radius: 8px", + order: 1, + line: 6, + column: 3, + }, + ], + }, + { + selector: ".active", + tag: null, + classes: ["active"], + part: null, + specificity: 1, + order: 2, + media: null, + declarations: [ + { + property: "background-color", + value: "#f00", + raw: "background-color: #f00", + order: 0, + line: 9, + column: 3, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { paddingTop: 8, paddingRight: 16, @@ -363,12 +1001,15 @@ const __CSS_GLOBAL__ = { }, line: { marginTop: 16, - borderRadius: 8, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8, }, active: { backgroundColor: "#f00", }, - __hash__: 553324671, + __hash__: -1822880232, }; export default function Card() { return ( @@ -404,17 +1045,335 @@ styl\` import React from "react"; import { styl, observer } from "cssxjs"; const __CSS_GLOBAL__ = { + version: 1, + id: "cssx_5vvl7n", + contentHash: "cssx_ask4pp", + rules: [ + { + selector: ".card", + tag: null, + classes: ["card"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "#f00", + raw: "color: #f00", + order: 0, + line: 2, + column: 3, + }, + { + property: "background-color", + value: "#008000", + raw: "background-color: #008000", + order: 1, + line: 3, + column: 3, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], card: { color: "#f00", backgroundColor: "#008000", }, - __hash__: 772349652, + __hash__: -1793453535, }; export default observer(function Card() { return ; }); +`; + +exports[`@cssxjs/babel-plugin-rn-stylename-inline Local css interpolation declared after early return. Should error: Local css interpolation declared after early return. Should error 1`] = ` + +import React from 'react' +import { css } from 'cssxjs' +import { View } from 'react-native' +import { useThemeColor } from './theme' + +export default function Card ({ ready }) { + if (!ready) return + const color = useThemeColor('primary') + return + + css\` + .loader { + color: \${color}; + } + .root { + color: \${color}; + } + \` +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +SyntaxError: unknown file: [@cssxjs/babel-plugin-rn-stylename-inline] Interpolated CSS value "color" is not available before the first return that can use local styles. +Move the declaration before the first styled return, or pass the value through props/CSS variables. + 9 | return + 10 | +> 11 | css\` + | ^ + 12 | .loader { + 13 | color: \${color}; + 14 | } + +`; + +exports[`@cssxjs/babel-plugin-rn-stylename-inline Local css interpolation: Local css interpolation 1`] = ` + +import React from 'react' +import { css } from 'cssxjs' +import { View } from 'react-native' +import { useThemeColor } from './theme' + +export default function Card ({ ready, pad }) { + const color = useThemeColor('primary') + if (!ready) return + return + + css\` + .loader { + color: \${color}; + } + .root { + color: \${color}; + padding: \${pad} 2u; + } + \` +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +import React from "react"; +import { css } from "cssxjs"; +import { View } from "react-native"; +import { useThemeColor } from "./theme"; +const _localCssInstance = { + version: 1, + id: "cssx_h9kswq", + contentHash: "cssx_ytmomb", + rules: [ + { + selector: ".loader", + tag: null, + classes: ["loader"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "var(--__cssx_dynamic_0)", + raw: "color: var(--__cssx_dynamic_0)", + order: 0, + dynamicSlots: [0], + line: 3, + column: 7, + }, + ], + }, + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 1, + media: null, + declarations: [ + { + property: "color", + value: "var(--__cssx_dynamic_1)", + raw: "color: var(--__cssx_dynamic_1)", + order: 0, + dynamicSlots: [1], + line: 6, + column: 7, + }, + { + property: "padding", + value: "var(--__cssx_dynamic_2) 2u", + raw: "padding: var(--__cssx_dynamic_2) 2u", + order: 1, + dynamicSlots: [2], + line: 7, + column: 7, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: true, + vars: ["--__cssx_dynamic_0", "--__cssx_dynamic_1", "--__cssx_dynamic_2"], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: true, + hasDynamicRuntimeDependencies: true, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], + __hash__: -851391434, +}; +export default function Card({ ready, pad }) { + const color = useThemeColor("primary"); + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, color, pad], + }; + if (!ready) return ; + return ; +} + + +`; + +exports[`@cssxjs/babel-plugin-rn-stylename-inline Reachable local css interpolation after early return. Should error: Reachable local css interpolation after early return. Should error 1`] = ` + +import React from 'react' +import { css } from 'cssxjs' +import { View } from 'react-native' +import { useThemeColor } from './theme' + +export default function Card ({ ready }) { + if (!ready) return + const color = useThemeColor('primary') + + css\` + .root { + color: \${color}; + } + \` + + return +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +SyntaxError: unknown file: [@cssxjs/babel-plugin-rn-stylename-inline] Local css/styl templates must be declared before the first return, unless they are trailing CSSX style blocks at the end of the component. +Move this template before the first return, or place it after all returns as the final component statement. + 8 | const color = useThemeColor('primary') + 9 | +> 10 | css\` + | ^ + 11 | .root { + 12 | color: \${color}; + 13 | } + +`; + +exports[`@cssxjs/babel-plugin-rn-stylename-inline Reachable local css interpolation before return: Reachable local css interpolation before return 1`] = ` + +import React from 'react' +import { css } from 'cssxjs' +import { View } from 'react-native' +import { useThemeColor } from './theme' + +export default function Card ({ pad }) { + const color = useThemeColor('primary') + + css\` + .root { + color: \${color}; + padding: \${pad} 2u; + } + \` + + return +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +import React from "react"; +import { css } from "cssxjs"; +import { View } from "react-native"; +import { useThemeColor } from "./theme"; +const _localCssInstance = { + version: 1, + id: "cssx_fjh55d", + contentHash: "cssx_4m17p8", + rules: [ + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "var(--__cssx_dynamic_0)", + raw: "color: var(--__cssx_dynamic_0)", + order: 0, + dynamicSlots: [0], + line: 3, + column: 7, + }, + { + property: "padding", + value: "var(--__cssx_dynamic_1) 2u", + raw: "padding: var(--__cssx_dynamic_1) 2u", + order: 1, + dynamicSlots: [1], + line: 4, + column: 7, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: true, + vars: ["--__cssx_dynamic_0", "--__cssx_dynamic_1"], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: true, + hasDynamicRuntimeDependencies: true, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], + __hash__: -1761350370, +}; +export default function Card({ pad }) { + const color = useThemeColor("primary"); + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad], + }; + return ; +} + + `; exports[`@cssxjs/babel-plugin-rn-stylename-inline Should remove css and styl from cssxjs import: Should remove css and styl from cssxjs import 1`] = ` diff --git a/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js b/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js index 189e584..1712a94 100644 --- a/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js +++ b/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js @@ -54,6 +54,15 @@ pluginTester({ \` ` }, + 'CSS template as exported value': /* js */` + import { css } from 'cssxjs' + + export default css\` + :root { + --color-primary: red; + } + \` + `, 'Global styl. Simple': /* js */` import React from 'react' import { styl, observer } from 'cssxjs' @@ -197,6 +206,93 @@ pluginTester({ background-color: green; } \` - ` + `, + 'Local css interpolation': /* js */` + import React from 'react' + import { css } from 'cssxjs' + import { View } from 'react-native' + import { useThemeColor } from './theme' + + export default function Card ({ ready, pad }) { + const color = useThemeColor('primary') + if (!ready) return + return + + css\` + .loader { + color: \${color}; + } + .root { + color: \${color}; + padding: \${pad} 2u; + } + \` + } + `, + 'Local css interpolation declared after early return. Should error': { + error: /Interpolated CSS value "color" is not available before the first return/, + code: /* js */` + import React from 'react' + import { css } from 'cssxjs' + import { View } from 'react-native' + import { useThemeColor } from './theme' + + export default function Card ({ ready }) { + if (!ready) return + const color = useThemeColor('primary') + return + + css\` + .loader { + color: \${color}; + } + .root { + color: \${color}; + } + \` + } + ` + }, + 'Reachable local css interpolation before return': /* js */` + import React from 'react' + import { css } from 'cssxjs' + import { View } from 'react-native' + import { useThemeColor } from './theme' + + export default function Card ({ pad }) { + const color = useThemeColor('primary') + + css\` + .root { + color: \${color}; + padding: \${pad} 2u; + } + \` + + return + } + `, + 'Reachable local css interpolation after early return. Should error': { + error: /Local css\/styl templates must be declared before the first return/, + code: /* js */` + import React from 'react' + import { css } from 'cssxjs' + import { View } from 'react-native' + import { useThemeColor } from './theme' + + export default function Card ({ ready }) { + if (!ready) return + const color = useThemeColor('primary') + + css\` + .root { + color: \${color}; + } + \` + + return + } + ` + } } }) diff --git a/packages/babel-plugin-rn-stylename-inline/index.js b/packages/babel-plugin-rn-stylename-inline/index.js index 8be96ec..82b405b 100644 --- a/packages/babel-plugin-rn-stylename-inline/index.js +++ b/packages/babel-plugin-rn-stylename-inline/index.js @@ -1,11 +1,11 @@ -const { GLOBAL_NAME, LOCAL_NAME } = - require('@cssxjs/runtime/constants') const template = require('@babel/template').default const parser = require('@babel/parser') const t = require('@babel/types') const COMPILERS = require('@cssxjs/loaders/compilers') const DEFAULT_MAGIC_IMPORTS = ['cssxjs', 'startupjs'] const DEFAULT_PLATFORM = 'web' +const GLOBAL_NAME = '__CSS_GLOBAL__' +const LOCAL_NAME = '__CSS_LOCAL__' const buildConst = template(` const %%variable%% = %%value%% @@ -29,22 +29,42 @@ const getVisitor = ({ $program, usedCompilers }) => ({ // 0. process only templates which are in usedCompilers (imported from our library) if (!shouldProcess($this, usedCompilers)) return - // I. validate template - validateTemplate($this) - const compiler = usedCompilers.get($this.node.tag.name) + const { source, expressions } = lowerTemplate($this.node.quasi) + const hasExpressions = expressions.length > 0 + + // I. find parent function or program + const $function = $this.getFunctionParent() + if (hasExpressions && !$function) { + throw $this.buildCodeFrameError(` + [@cssxjs/babel-plugin-rn-stylename-inline] Expression interpolations are supported only inside function-scoped css\`\` and styl\`\` templates. + `) + } // II. compile template - const source = $this.node.quasi.quasis[0]?.value?.raw || '' const filename = state.file?.opts?.filename const platform = state.opts?.platform || state.file?.opts?.caller?.platform || DEFAULT_PLATFORM - const compiledString = compiler(source, filename, { platform }) + const compiledString = compiler(source, filename, { + platform, + template: hasExpressions + }) const compiledExpression = parser.parseExpression(compiledString) - // III. find parent function or program - const $function = $this.getFunctionParent() + // Expression-position css`...` / styl`...` is a value, for example: + // export default css`...` + // It must compile to the sheet object instead of registering global/local + // component styles. + if (!isStandaloneStyleTemplate($this)) { + if (hasExpressions) { + throw $this.buildCodeFrameError(` + [@cssxjs/babel-plugin-rn-stylename-inline] Expression-position css\`\` and styl\`\` templates must be static. + `) + } + $this.replaceWith(compiledExpression) + return + } - // IV. LOCAL. if parent is function -- handle local + // III. LOCAL. if parent is function -- handle local if ($function) { // 1. define a `const` variable at the top of the file // with the unique identifier @@ -54,14 +74,21 @@ const getVisitor = ({ $program, usedCompilers }) => ({ value: compiledExpression })) - // 2. reassign this unique identifier to a constant LOCAL_NAME + const localValue = hasExpressions + ? t.objectExpression([ + t.objectProperty(t.identifier('sheet'), localIdentifier), + t.objectProperty(t.identifier('values'), t.arrayExpression(expressions)) + ]) + : localIdentifier + + // 2. reassign this unique identifier or local dynamic layer to a constant LOCAL_NAME // in the scope of current function - $function.get('body').unshiftContainer('body', buildConst({ + insertLocalCss($function, $this, buildConst({ variable: t.identifier(LOCAL_NAME), - value: localIdentifier - })) + value: localValue + }), expressions, usedCompilers) - // V. GLOBAL. if parent is program -- handle global + // IV. GLOBAL. if parent is program -- handle global } else { // 1. define a `const` variable at the top of the file // with the constant GLOBAL_NAME @@ -71,7 +98,7 @@ const getVisitor = ({ $program, usedCompilers }) => ({ })) } - // VI. Remove template expression after processing + // V. Remove template expression after processing $this.remove() // TODO: Throw error if global styles were already added or @@ -79,6 +106,14 @@ const getVisitor = ({ $program, usedCompilers }) => ({ } }) +function isStandaloneStyleTemplate ($template) { + const $statement = $template.getStatementParent() + return ( + $statement?.isExpressionStatement() && + $statement.get('expression').node === $template.node + ) +} + function insertAfterImports ($program, expressionStatement) { const lastImport = $program .get('body') @@ -92,20 +127,166 @@ function insertAfterImports ($program, expressionStatement) { } } +function insertLocalCss ($function, $template, statement, expressions, usedCompilers) { + const $body = $function.get('body') + if (!$body.isBlockStatement()) { + $body.replaceWith(t.blockStatement([ + t.returnStatement($body.node) + ])) + } + + const $statement = $template.getStatementParent() + const $functionBody = $function.get('body') + const $firstReturn = findFirstReturnStatement($functionBody) + + if ($statement?.parentPath !== $functionBody) { + $functionBody.unshiftContainer('body', statement) + return + } + + const $target = isTrailingStyleTemplateStatement($statement, usedCompilers) + ? ($firstReturn || $statement) + : $statement + + validateLocalCssPosition($functionBody, $firstReturn, $target, $template) + + validateInterpolationBindings($function, $functionBody, $target, expressions, $template) + + $target.insertBefore(statement) +} + +function validateLocalCssPosition ($functionBody, $firstReturn, $target, $template) { + if (!$firstReturn || $target.node !== $template.getStatementParent()?.node) return + + const statements = $functionBody.get('body') + const returnIndex = statements.findIndex($statement => $statement.node === $firstReturn.node) + const targetIndex = statements.findIndex($statement => $statement.node === $target.node) + if (returnIndex < 0 || targetIndex < 0 || targetIndex < returnIndex) return + + throw $template.buildCodeFrameError([ + '[@cssxjs/babel-plugin-rn-stylename-inline] Local css/styl templates must be declared before the first return, unless they are trailing CSSX style blocks at the end of the component.', + 'Move this template before the first return, or place it after all returns as the final component statement.' + ].join('\n')) +} + +function isTrailingStyleTemplateStatement ($statement, usedCompilers) { + let $current = $statement + while ($current?.node) { + if (!isStyleTemplateStatement($current, usedCompilers)) return false + $current = $current.getNextSibling() + } + return true +} + +function isStyleTemplateStatement ($statement, usedCompilers) { + if (!$statement.isExpressionStatement()) return false + const expression = $statement.get('expression') + if (!expression.isTaggedTemplateExpression()) return false + return shouldProcess(expression, usedCompilers) +} + +function findFirstReturnStatement ($functionBody) { + return $functionBody.get('body').find($statement => statementCanReturn($statement)) +} + +function statementCanReturn ($statement) { + if ($statement.isReturnStatement()) return true + + let canReturn = false + $statement.traverse({ + Function ($nestedFunction) { + $nestedFunction.skip() + }, + ReturnStatement ($return) { + canReturn = true + $return.stop() + } + }) + return canReturn +} + +function validateInterpolationBindings ($function, $functionBody, $target, expressions, $template) { + if (!$target || expressions.length === 0) return + + const statements = $functionBody.get('body') + const targetIndex = statements.findIndex($statement => $statement.node === $target.node) + if (targetIndex < 0) return + + for (const name of getReferencedNames(expressions)) { + const binding = $template.scope.getBinding(name) + if (!binding) continue + if (binding.kind === 'module' || binding.kind === 'param' || binding.kind === 'hoisted') continue + if (binding.path.getFunctionParent() !== $function) continue + + const $bindingStatement = binding.path.getStatementParent() + const bindingIndex = statements.findIndex($statement => $statement.node === $bindingStatement?.node) + if (bindingIndex >= 0 && bindingIndex < targetIndex) continue + + throw $template.buildCodeFrameError([ + `[@cssxjs/babel-plugin-rn-stylename-inline] Interpolated CSS value "${name}" is not available before the first return that can use local styles.`, + 'Move the declaration before the first styled return, or pass the value through props/CSS variables.' + ].join('\n')) + } +} + +function getReferencedNames (expressions) { + const names = new Set() + for (const expression of expressions) collectReferencedNames(expression, names) + return names +} + +function collectReferencedNames (node, names) { + if (!node) return + + if (t.isIdentifier(node)) { + names.add(node.name) + return + } + + if (t.isFunction(node)) return + + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + collectReferencedNames(node.object, names) + if (node.computed) collectReferencedNames(node.property, names) + return + } + + if (t.isObjectProperty(node)) { + if (node.computed) collectReferencedNames(node.key, names) + collectReferencedNames(node.value, names) + return + } + + const keys = t.VISITOR_KEYS[node.type] || [] + for (const key of keys) { + const value = node[key] + if (Array.isArray(value)) { + for (const child of value) collectReferencedNames(child, names) + } else { + collectReferencedNames(value, names) + } + } +} + function shouldProcess ($template, usedCompilers) { if (!$template.get('tag').isIdentifier()) return if (!usedCompilers.has($template.node.tag.name)) return return true } -function validateTemplate ($template) { - const { node: { quasi } } = $template +function lowerTemplate (quasi) { + let source = '' + const expressions = [] - if (quasi.expressions.length > 0) { - throw $template.buildCodeFrameError(` - [@cssxjs/babel-plugin-rn-stylename-inline] Expression interpolations are not supported in css\`\` and styl\`\`. - `) + for (let index = 0; index < quasi.quasis.length; index++) { + source += quasi.quasis[index]?.value?.raw || '' + const expression = quasi.expressions[index] + if (!expression) continue + source += `var(--__cssx_dynamic_${expressions.length})` + expressions.push(expression) } + + return { source, expressions } } function getUsedCompilers ($program, state) { diff --git a/packages/babel-plugin-rn-stylename-inline/package.json b/packages/babel-plugin-rn-stylename-inline/package.json index f345aee..796484d 100644 --- a/packages/babel-plugin-rn-stylename-inline/package.json +++ b/packages/babel-plugin-rn-stylename-inline/package.json @@ -1,6 +1,6 @@ { "name": "@cssxjs/babel-plugin-rn-stylename-inline", - "version": "0.3.0", + "version": "0.4.0-alpha.6", "publishConfig": { "access": "public" }, @@ -14,7 +14,7 @@ ], "main": "index.js", "scripts": { - "test": "jest" + "test": "NO_COLOR=1 FORCE_COLOR=0 NODE_OPTIONS=\"${NODE_OPTIONS:-} --experimental-vm-modules -C cssx-ts\" jest --runInBand" }, "author": "Pavel Zhukov", "license": "MIT", @@ -26,12 +26,25 @@ "@babel/parser": "^7.0.0", "@babel/template": "^7.4.0", "@babel/types": "^7.0.0", - "@cssxjs/loaders": "^0.3.0", - "@cssxjs/runtime": "^0.3.0" + "@cssxjs/loaders": "^0.4.0-alpha.6" }, "devDependencies": { "@babel/plugin-syntax-jsx": "^7.0.0", "babel-plugin-tester": "^9.1.0", "jest": "^30.0.4" + }, + "jest": { + "transform": { + "^.+\\.ts$": "./test/ts-transform.cjs" + }, + "testEnvironmentOptions": { + "customExportConditions": [ + "cssx-ts", + "node" + ] + }, + "extensionsToTreatAsEsm": [ + ".ts" + ] } } diff --git a/packages/babel-plugin-rn-stylename-inline/test/ts-transform.cjs b/packages/babel-plugin-rn-stylename-inline/test/ts-transform.cjs new file mode 100644 index 0000000..525047c --- /dev/null +++ b/packages/babel-plugin-rn-stylename-inline/test/ts-transform.cjs @@ -0,0 +1,17 @@ +const ts = require('typescript') + +module.exports = { + process (sourceText, sourcePath) { + const result = ts.transpileModule(sourceText, { + fileName: sourcePath, + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + sourceMap: false, + inlineSourceMap: false, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove + } + }) + return { code: result.outputText } + } +} diff --git a/packages/babel-plugin-rn-stylename-to-style/CHANGELOG.md b/packages/babel-plugin-rn-stylename-to-style/CHANGELOG.md index 96cc58e..7eed95f 100644 --- a/packages/babel-plugin-rn-stylename-to-style/CHANGELOG.md +++ b/packages/babel-plugin-rn-stylename-to-style/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.2](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-to-style + + + + + +# [0.4.0-alpha.1](https://github.com/startupjs/startupjs/compare/v0.4.0-alpha.0...v0.4.0-alpha.1) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/babel-plugin-rn-stylename-to-style + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/startupjs/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/startupjs/issues/5)) ([cd205cf](https://github.com/startupjs/startupjs/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/startupjs/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/babel-plugin-rn-stylename-to-style/README.md b/packages/babel-plugin-rn-stylename-to-style/README.md index c73dde5..5ca2c3e 100644 --- a/packages/babel-plugin-rn-stylename-to-style/README.md +++ b/packages/babel-plugin-rn-stylename-to-style/README.md @@ -131,8 +131,7 @@ so these files shouldn't frequently change. **Default:** `undefined` -Whether to use integration with some caching library. Currently supported ones: -- `"teamplay"` +Legacy compatibility option. New projects should omit it. #### `platform` diff --git a/packages/babel-plugin-rn-stylename-to-style/__tests__/__snapshots__/index.spec.js.snap b/packages/babel-plugin-rn-stylename-to-style/__tests__/__snapshots__/index.spec.js.snap index 0161a57..ec41cb1 100644 --- a/packages/babel-plugin-rn-stylename-to-style/__tests__/__snapshots__/index.spec.js.snap +++ b/packages/babel-plugin-rn-stylename-to-style/__tests__/__snapshots__/index.spec.js.snap @@ -18,40 +18,39 @@ function Test ({ items, ...props }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ itemStyle: _itemStyle, style: _style, items, ...props }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( {(() => { const __pugEachResult = []; for (const item of items) { __pugEachResult.push( {item} @@ -83,9 +82,20 @@ function Test ({ style, active, submit, disabled }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ style, active, submit, disabled }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); const titleStyle = { color: "red", fontWeight: "bold", @@ -99,39 +109,27 @@ function Test({ style, active, submit, disabled }) { active, }, ], - _css, - typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__, - typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__, + _file__css, + _global, + _local, { style: style, } )} > Title Description @@ -141,9 +139,9 @@ function Test({ style, active, submit, disabled }) { submit, disabled, }, - _css, - typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__, - typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__, + _file__css, + _global, + _local, { style: { color: "pink", @@ -158,6 +156,83 @@ function Test({ style, active, submit, disabled }) { } +`; + +exports[`@startupjs/babel-plugin-rn-stylename-to-style Compiles only configured CSS imports: Compiles only configured CSS imports 1`] = ` + +import theme from './style.cssx.css' +import './index.styl' + +function Test () { + return
+} + + ↓ ↓ ↓ ↓ ↓ ↓ + +import _css from "./index.styl"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; +const _cssx = _runtime; +const theme = { + version: 1, + id: "cssx_4kwwyc", + sourceId: "cssx_skkhdh", + contentHash: "cssx_11rebh", + rules: [ + { + selector: ".root", + tag: null, + classes: ["root"], + part: null, + specificity: 1, + order: 0, + media: null, + declarations: [ + { + property: "color", + value: "red", + raw: "color: red", + order: 0, + line: 2, + column: 3, + }, + ], + }, + ], + keyframes: {}, + metadata: { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, + }, + diagnostics: [], + root: { + color: "red", + }, + __hash__: -889768088, +}; +function Test() { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); + return
; +} + + `; exports[`@startupjs/babel-plugin-rn-stylename-to-style DEPRECATED! Legacy CJS version when "useImport: false".: DEPRECATED! Legacy CJS version when "useImport: false". 1`] = ` @@ -176,50 +251,27 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( -
- - Title - - +
+ Title + Description -
@@ -243,9 +295,20 @@ function Test ({ style, active, submit, disabled, titleStyle }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ style, active, submit, disabled, titleStyle }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( ); @@ -331,20 +388,24 @@ export default observer(Layout) ↓ ↓ ↓ ↓ ↓ ↓ import { observer, useBackPress } from "startupjs"; -import { runtime as _runtime } from "cssxjs/runtime/teamplay"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Layout({ style, children }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); return ( {children} @@ -377,7 +438,11 @@ function Menu ({ style, children, value, variant, activeBorder, iconPosition, ac ↓ ↓ ↓ ↓ ↓ ↓ import { observer, useBackPress } from "startupjs"; -import { runtime as _runtime } from "cssxjs/runtime/teamplay"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Menu({ style, @@ -389,30 +454,24 @@ function Menu({ activeColor, ...props }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); return (
{children}
@@ -439,22 +498,27 @@ export default function ComponentFactory (title) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export default function ComponentFactory(title) { return function Component(_props) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function renderItem() { return ( {title} @@ -462,28 +526,16 @@ export default function ComponentFactory(title) { } const renderFooter = () => (
); return (
{renderItem()} {renderFooter()} @@ -505,20 +557,25 @@ export default function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export default function Test(_props) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return (
); } @@ -539,35 +596,34 @@ export const Test = () => { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export const Test = (_props) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); const renderItem = () => { return (
); }; return (
{renderItem()}
@@ -590,35 +646,34 @@ export const Test = () => { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export const Test = (_props) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function renderItem() { return (
); } return (
{renderItem()}
@@ -638,20 +693,25 @@ export const Test = function () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export const Test = function (_props) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return (
); }; @@ -669,25 +729,74 @@ export const Test = () => { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export const Test = (_props) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return (
); }; +`; + +exports[`@startupjs/babel-plugin-rn-stylename-to-style Local css interpolation after hook: Local css interpolation after hook 1`] = ` + +import { useThemeColor } from './theme' +import { View } from 'react-native' + +function Card ({ ready, pad }) { + const color = useThemeColor('primary') + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad] + } + if (!ready) return + return +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +import { useThemeColor } from "./theme"; +import { View } from "react-native"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; +const _cssx = _runtime; +function Card({ ready, pad }) { + const color = useThemeColor("primary"); + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad], + }; + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + if (!ready) return ; + return ; +} + + `; exports[`@startupjs/babel-plugin-rn-stylename-to-style No styles file: No styles file 1`] = ` @@ -698,20 +807,20 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - return ( -
+ const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + return
; } @@ -729,20 +838,21 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime/teamplay"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - return ( -
+ const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + const _file__css = _cssxLayer(_css); + return
; } @@ -760,20 +870,21 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime/react-native-teamplay"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/react-native-teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - return ( -
+ const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); + return
; } @@ -791,20 +902,21 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime/react-native"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/react-native"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - return ( -
+ const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + const _file__css = _cssxLayer(_css); + return
; } @@ -822,23 +934,71 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime/web"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/web"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - return ( -
+ const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + const _file__css = _cssxLayer(_css); + return
; } +`; + +exports[`@startupjs/babel-plugin-rn-stylename-to-style Provider style props are CSSX provider input, not RN style: Provider style props are CSSX provider input, not RN style 1`] = ` + +import { observer, CssxProvider, StartupjsProvider } from 'startupjs' +export default observer(function Test ({ style }) { + return ( + + +
+ + + ) +}) + + ↓ ↓ ↓ ↓ ↓ ↓ + +import { observer, CssxProvider, StartupjsProvider } from "startupjs"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; +const _cssx = _runtime; +export default observer(function Test({ style }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + return ( + + +
+ + + ); +}); + + `; exports[`@startupjs/babel-plugin-rn-stylename-to-style Puts compiled attribute to the end of attributes list: Puts compiled attribute to the end of attributes list 1`] = ` @@ -855,9 +1015,20 @@ function Test ({ style, active, submit, disabled }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ style, active, submit, disabled }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return (
); @@ -915,67 +1080,52 @@ function Test ({ style }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ style }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); const titleStyle = { color: "red", fontWeight: "bold", }; return (
Title Description -
@@ -1001,50 +1151,27 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( -
- - Title - - +
+ Title + Description -
@@ -1071,52 +1198,44 @@ export default observer(function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import { observer } from "startupjs"; -import { runtime as _runtime } from "cssxjs/runtime/teamplay"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export default observer(function Test() { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); return (
Hello @@ -1190,9 +1309,20 @@ function Test ({ style, active, submit, disabled, titleStyle }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ style, active, submit, disabled, titleStyle }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( ); @@ -1263,7 +1387,11 @@ const Test = ({ style, layout, cardStyle: myCardStyle, contentStyle, title, ...p ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; const Test = ({ columnStyle: _columnStyle, @@ -1275,41 +1403,36 @@ const Test = ({ title, ...props }) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function render() { return ( ); @@ -1341,7 +1464,11 @@ const Test = ({ style, cardStyle: myCardStyle, contentStyle, title, ...props }) ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; const Test = ({ activeStyle: _activeStyle, @@ -1351,37 +1478,32 @@ const Test = ({ title, ...props }) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function render() { return ( ); @@ -1413,7 +1535,11 @@ const Test = ({ style, cardStyle: myCardStyle, contentStyle, title, ...props }) ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; const Test = ({ activeStyle: _activeStyle, @@ -1423,40 +1549,35 @@ const Test = ({ title, ...props }) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function render() { return ( ); @@ -1482,12 +1603,12 @@ SyntaxError: unknown file: 'part' attribute only supports literal or string keys in object. Dynamic keys or spreads are not supported. -  2 | function Test ({ variant }) { -  3 | return ( -> 4 | <Card part={{[variant]: true}} /> -  | ^^^^^^^^^^^^^^^ -  5 | ) -  6 | } + 2 | function Test ({ variant }) { + 3 | return ( +> 4 | + | ^^^^^^^^^^^^^^^ + 5 | ) + 6 | } `; @@ -1505,12 +1626,12 @@ function Test ({ variant }) { SyntaxError: unknown file: 'part' attribute only supports static strings or objects inside an array. -  2 | function Test ({ variant }) { -  3 | return ( -> 4 | <Card part={['card', variant]} /> -  | ^^^^^^^ -  5 | ) -  6 | } + 2 | function Test ({ variant }) { + 3 | return ( +> 4 | + | ^^^^^^^ + 5 | ) + 6 | } `; @@ -1533,12 +1654,12 @@ SyntaxError: unknown file: Basically the rule is that the name of the part must be static so that it is possible to determine at compile time which parts are being used. -  2 | function Test ({ variant }) { -  3 | return ( -> 4 | <Card part={variant} /> -  | ^^^^^^^^^^^^^^ -  5 | ) -  6 | } + 2 | function Test ({ variant }) { + 3 | return ( +> 4 | + | ^^^^^^^^^^^^^^ + 5 | ) + 6 | } `; @@ -1560,7 +1681,11 @@ function Test ({ title, style, ...props }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ activeStyle: _activeStyle, @@ -1569,36 +1694,31 @@ function Test({ style, ...props }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( ); @@ -1625,7 +1745,11 @@ function Test ({ title, ...props }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ activeStyle: _activeStyle, @@ -1634,36 +1758,31 @@ function Test({ title, ...props }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( ); @@ -1695,48 +1814,47 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test(_props) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( + - ); @@ -1766,7 +1884,11 @@ const Test = ({ style, cardStyle: myCardStyle, contentStyle, title, ...props }) ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; const Test = ({ activeStyle: _activeStyle, @@ -1776,37 +1898,32 @@ const Test = ({ title, ...props }) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function render() { return ( ); @@ -1835,7 +1952,11 @@ function Test ({ style, cardStyle, title, ...props }) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ activeStyle: _activeStyle, @@ -1845,36 +1966,31 @@ function Test({ title, ...props }) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( ); @@ -1901,39 +2017,38 @@ function Test (props) { ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test(props) { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); return ( ); @@ -1968,7 +2083,11 @@ const Test = ({ style, active, variant, cardStyle: myCardStyle, contentStyle, ti ↓ ↓ ↓ ↓ ↓ ↓ import _css from "./index.styl"; -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; const Test = ({ style, @@ -1979,26 +2098,27 @@ const Test = ({ title, ...props }) => { + const _local = _cssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _cssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _cssxLayer(_css); function render() { return ( diff --git a/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js b/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js index 4648421..28cde9d 100644 --- a/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js +++ b/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js @@ -1,4 +1,5 @@ const pluginTester = require('babel-plugin-tester').default +const path = require('path') const plugin = require('../index.js') // const { name: pluginName } = require('../package.json') @@ -81,6 +82,18 @@ pluginTester({ ) }) `, + 'Provider style props are CSSX provider input, not RN style': /* js */` + import { observer, CssxProvider, StartupjsProvider } from 'startupjs' + export default observer(function Test ({ style }) { + return ( + + +
+ + + ) + }) + `, 'Regular string': /* js */` import './index.styl' function Test () { @@ -93,6 +106,23 @@ pluginTester({ ) } `, + 'Compiles only configured CSS imports': { + pluginOptions: { + extensions: ['cssx.css', 'styl'], + compileCssImports: ['cssx.css'] + }, + babelOptions: { + filename: path.join(__dirname, 'component.js') + }, + code: /* js */` + import theme from './style.cssx.css' + import './index.styl' + + function Test () { + return
+ } + ` + }, 'Regular string with existing style': /* js */` import './index.styl' function Test ({ style }) { @@ -119,6 +149,20 @@ pluginTester({ ) } `, + 'Local css interpolation after hook': /* js */` + import { useThemeColor } from './theme' + import { View } from 'react-native' + + function Card ({ ready, pad }) { + const color = useThemeColor('primary') + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad] + } + if (!ready) return + return + } + `, 'Puts compiled attribute to the end of attributes list': /* js */` import './index.styl' function Test ({ style, active, submit, disabled }) { diff --git a/packages/babel-plugin-rn-stylename-to-style/__tests__/style.cssx.css b/packages/babel-plugin-rn-stylename-to-style/__tests__/style.cssx.css new file mode 100644 index 0000000..d810e45 --- /dev/null +++ b/packages/babel-plugin-rn-stylename-to-style/__tests__/style.cssx.css @@ -0,0 +1,3 @@ +.root { + color: red; +} diff --git a/packages/babel-plugin-rn-stylename-to-style/index.js b/packages/babel-plugin-rn-stylename-to-style/index.js index 9ba95bc..16897d6 100644 --- a/packages/babel-plugin-rn-stylename-to-style/index.js +++ b/packages/babel-plugin-rn-stylename-to-style/index.js @@ -3,7 +3,6 @@ const fs = require('fs') const t = require('@babel/types') const template = require('@babel/template').default const parser = require('@babel/parser') -const { GLOBAL_NAME, LOCAL_NAME } = require('@cssxjs/runtime/constants') const { addNamed } = require('@babel/helper-module-imports') const COMPILERS = require('@cssxjs/loaders/compilers') @@ -14,12 +13,17 @@ const STYLE_NAME_REGEX = /(?:^s|S)tyleName$/ const STYLE_REGEX = /(?:^s|S)tyle$/ const ROOT_STYLE_PROP_NAME = 'style' const RUNTIME_IMPORT_NAME = 'runtime' +const RUNTIME_LAYER_HOOK_NAME = 'useCssxLayer' const RUNTIME_FRIENDLY_NAME = 'cssx' +const RUNTIME_LAYER_HOOK_FRIENDLY_NAME = 'cssxLayer' +const GLOBAL_NAME = '__CSS_GLOBAL__' +const LOCAL_NAME = '__CSS_LOCAL__' const OPTIONS_CACHE = ['teamplay'] const OPTIONS_REACT_TYPES = ['react-native', 'web'] const DEFAULT_MAGIC_IMPORTS = ['cssxjs', 'startupjs'] const DEFAULT_OBSERVER_NAME = 'observer' const DEFAULT_OBSERVER_IMPORTS = ['teamplay', 'startupjs'] +const PROVIDER_STYLE_COMPONENTS = new Set(['CssxProvider', 'StartupjsProvider']) const buildSafeVar = template.expression(` typeof %%variable%% !== 'undefined' && %%variable%% @@ -41,6 +45,7 @@ module.exports = function (babel) { let $program let usedCompilers let runtime + let useCssxLayer function getOrCreateRuntime (state) { if (runtime) return runtime @@ -68,15 +73,49 @@ module.exports = function (babel) { return runtime } - function getStyleFromExpression (expression, state) { + function getOrCreateUseCssxLayer (state) { + if (useCssxLayer) return useCssxLayer + const runtimePath = getRuntimePath($program, state, hasObserver) + const imported = addNamedImport($program, RUNTIME_LAYER_HOOK_NAME, runtimePath) + useCssxLayer = $program.scope.generateUidIdentifier(RUNTIME_LAYER_HOOK_FRIENDLY_NAME) + + insertAfterImports($program, buildRuntimeVar({ + name: useCssxLayer, + imported + })) + + return useCssxLayer + } + + function getStyleFromExpression ($path, expression, state) { const cssStyles = cssIdentifier.name const processCall = t.callExpression( getOrCreateRuntime(state), - [expression, t.identifier(cssStyles)] + [ + expression, + getTrackedLayer($path, state, t.identifier(cssStyles), `file:${cssStyles}`) + ] ) return processCall } + function getTrackedLayer ($path, state, expression, key) { + const $fnComponent = findReactFnComponent($path) + if (!$fnComponent) return expression + + const dataKey = `cssxTrackedLayer:${key}` + const existing = $fnComponent.getData(dataKey) + if (existing) return t.identifier(existing) + + const identifier = $fnComponent.scope.generateUidIdentifier(key.replace(/[^a-zA-Z0-9_$]/g, '_')) + $fnComponent.setData(dataKey, identifier.name) + insertIntoFunctionBody($fnComponent, buildConst({ + variable: identifier, + value: t.callExpression(getOrCreateUseCssxLayer(state), [expression]) + })) + return identifier + } + function addPartStyleToProps ($jsxAttribute) { const parts = getParts($jsxAttribute.get('value')) const $fnComponent = findReactFnComponent($jsxAttribute) @@ -139,9 +178,9 @@ module.exports = function (babel) { const partStyle = styleHash[ROOT_STYLE_PROP_NAME]?.partStyle const inlineStyles = [] - // Always process if 'observer' import is found in the file - // which is needed for styles caching. - // Otherwise, if no 'observer' found and no 'styleName' or 'part' found then skip + // Keep old observer-triggered behavior for files that relied on cached + // inline style prop normalization without styleName/part attributes. + // Normal styleName handling does not require observer(). if (!(hasObserver || styleName || partStyle)) return // Check if styleName exists and if it can be processed @@ -193,10 +232,25 @@ module.exports = function (babel) { ) : t.stringLiteral(''), cssIdentifier - ? t.identifier(cssIdentifier.name) + ? getTrackedLayer( + jsxOpeningElementPath, + state, + t.identifier(cssIdentifier.name), + `file:${cssIdentifier.name}` + ) : t.objectExpression([]), - buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }), - buildSafeVar({ variable: t.identifier(LOCAL_NAME) }), + getTrackedLayer( + jsxOpeningElementPath, + state, + buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }), + 'global' + ), + getTrackedLayer( + jsxOpeningElementPath, + state, + buildSafeVar({ variable: t.identifier(LOCAL_NAME) }), + 'local' + ), t.objectExpression(inlineStyles) ] ) @@ -237,11 +291,11 @@ module.exports = function (babel) { if (t.isStringLiteral(styleName.node.value)) { expressions = [ - getStyleFromExpression(styleName.node.value, state) + getStyleFromExpression(styleName, styleName.node.value, state) ] } else if (t.isJSXExpressionContainer(styleName.node.value)) { expressions = [ - getStyleFromExpression(styleName.node.value.expression, state) + getStyleFromExpression(styleName, styleName.node.value.expression, state) ] } @@ -276,6 +330,7 @@ module.exports = function (babel) { $program = undefined usedCompilers = undefined runtime = undefined + useCssxLayer = undefined }, visitor: { Program: { @@ -331,7 +386,10 @@ module.exports = function (babel) { $this.node.specifiers = [specifier] } - const compileCssImports = state.opts.compileCssImports ?? true + const compileCssImports = shouldCompileCssImport( + state.opts.compileCssImports ?? true, + source + ) // if we compile css imports, we need to replace the import with a variable declaration if (compileCssImports) { const localName = specifier.local.name @@ -351,7 +409,10 @@ module.exports = function (babel) { const compiledString = compiler( styleFileContent, styleFilepath, - { platform } + { + platform, + sourceIdentity: normalizePath(nodePath.relative(process.cwd(), styleFilepath)) + } ) const compiledExpression = parser.parseExpression(compiledString) @@ -394,7 +455,11 @@ module.exports = function (babel) { styleHash[convertedName].styleName = $this // Some react-native built-in stuff might have props like 'barStyle' which // is a string. We skip those. - } else if (STYLE_REGEX.test(name) && !$this.get('value').isStringLiteral()) { + } else if ( + STYLE_REGEX.test(name) && + !$this.get('value').isStringLiteral() && + !isProviderStyleAttribute($this) + ) { if (!styleHash[name]) styleHash[name] = {} styleHash[name].style = $this } else if (name === 'part') { @@ -417,10 +482,25 @@ module.exports = function (babel) { ? $this.get('arguments.0').node : t.stringLiteral(''), cssIdentifier - ? t.identifier(cssIdentifier.name) + ? getTrackedLayer( + $this, + state, + t.identifier(cssIdentifier.name), + `file:${cssIdentifier.name}` + ) : t.objectExpression([]), - buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }), - buildSafeVar({ variable: t.identifier(LOCAL_NAME) }), + getTrackedLayer( + $this, + state, + buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }), + 'global' + ), + getTrackedLayer( + $this, + state, + buildSafeVar({ variable: t.identifier(LOCAL_NAME) }), + 'local' + ), $this.get('arguments.1') ? $this.get('arguments.1').node : t.objectExpression([]) @@ -467,6 +547,19 @@ function validatePart ($jsxAttribute) { `) } +function isProviderStyleAttribute ($jsxAttribute) { + const $openingElement = $jsxAttribute.findParent(path => path.isJSXOpeningElement()) + if (!$openingElement) return false + + return PROVIDER_STYLE_COMPONENTS.has(getJsxElementName($openingElement.node.name)) +} + +function getJsxElementName (name) { + if (t.isJSXIdentifier(name)) return name.name + if (t.isJSXMemberExpression(name)) return getJsxElementName(name.property) + return '' +} + function validateDynamicPartObject ($object) { for (const $property of $object.get('properties')) { if (!$property.isObjectProperty() || $property.node.computed) { @@ -520,7 +613,8 @@ function buildDynamicPart (expr, part) { } } -// if cache is 'teamplay' +// Legacy cache compatibility: observer imports still select the old +// cssxjs/runtime/*-teamplay entrypoints, which now wrap the unified runtime. function checkObserverImport ($import, state) { const observerImports = state.opts.observerImports || DEFAULT_OBSERVER_IMPORTS const observerName = state.opts.observerName || DEFAULT_OBSERVER_NAME @@ -579,6 +673,23 @@ function getUsedCompilers ($program, state) { return res } +function shouldCompileCssImport (compileCssImports, source) { + if (typeof compileCssImports === 'boolean') return compileCssImports + + if (Array.isArray(compileCssImports)) { + return compileCssImports.some(ext => source.endsWith(`.${ext}`)) + } + + throw Error(` + The 'compileCssImports' option must be a boolean or an array of extensions + like ['cssx.css']. + `) +} + +function normalizePath (filepath) { + return filepath.split(nodePath.sep).join('/') +} + function getRuntimePath ($node, state, hasObserver) { let cache = state.opts.cache if (cache && !OPTIONS_CACHE.includes(cache)) { @@ -586,8 +697,8 @@ function getRuntimePath ($node, state, hasObserver) { `Invalid cache option value: "${cache}". Supported values: ${OPTIONS_CACHE.join(', ')}` ) } - // If observer() is used in this file then we force cache to 'teamplay' - // TODO: this is a bit of a hack, think of a better way to do this + // Preserve the old import path shape for codebases that still use observer(). + // The runtime behind that path no longer imports Teamplay. if (!cache && hasObserver) cache = 'teamplay' const reactType = state.opts.reactType if (reactType && !OPTIONS_REACT_TYPES.includes(reactType)) { @@ -614,6 +725,31 @@ function addNamedImport ($program, name, sourceName) { }) } +function insertIntoFunctionBody ($function, statement) { + const $body = $function.get('body') + if (!$body.isBlockStatement()) { + $body.replaceWith(t.blockStatement([ + t.returnStatement($body.node) + ])) + } + + const body = $function.get('body') + const statements = body.get('body') + const localCssDeclaration = statements.find($statement => { + if (!$statement.isVariableDeclaration()) return false + return $statement.node.declarations.some(declaration => ( + t.isIdentifier(declaration.id) && + declaration.id.name === LOCAL_NAME + )) + }) + + if (localCssDeclaration) { + localCssDeclaration.insertAfter(statement) + } else { + body.unshiftContainer('body', statement) + } +} + function insertAfterImports ($program, expressionStatement) { const lastImport = $program .get('body') diff --git a/packages/babel-plugin-rn-stylename-to-style/package.json b/packages/babel-plugin-rn-stylename-to-style/package.json index de3874e..9c0752f 100644 --- a/packages/babel-plugin-rn-stylename-to-style/package.json +++ b/packages/babel-plugin-rn-stylename-to-style/package.json @@ -1,6 +1,6 @@ { "name": "@cssxjs/babel-plugin-rn-stylename-to-style", - "version": "0.3.0", + "version": "0.4.0-alpha.2", "publishConfig": { "access": "public" }, @@ -14,7 +14,7 @@ ], "main": "index.js", "scripts": { - "test": "jest" + "test": "NO_COLOR=1 FORCE_COLOR=0 jest" }, "author": "Pavel Zhukov", "license": "MIT", @@ -26,8 +26,7 @@ "@babel/helper-module-imports": "^7.0.0", "@babel/parser": "^7.0.0", "@babel/template": "^7.4.0", - "@babel/types": "^7.0.0", - "@cssxjs/runtime": "^0.3.0" + "@babel/types": "^7.0.0" }, "devDependencies": { "@babel/plugin-syntax-jsx": "^7.0.0", diff --git a/packages/babel-preset-cssxjs/CHANGELOG.md b/packages/babel-preset-cssxjs/CHANGELOG.md index de0da6b..eefc9d8 100644 --- a/packages/babel-preset-cssxjs/CHANGELOG.md +++ b/packages/babel-preset-cssxjs/CHANGELOG.md @@ -3,6 +3,65 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + +**Note:** Version bump only for package babel-preset-cssxjs + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package babel-preset-cssxjs + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package babel-preset-cssxjs + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package babel-preset-cssxjs + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package babel-preset-cssxjs + + + + + +# [0.4.0-alpha.1](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.0...v0.4.0-alpha.1) (2026-06-24) + +**Note:** Version bump only for package babel-preset-cssxjs + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/cssx/issues/5)) ([cd205cf](https://github.com/startupjs/cssx/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/babel-preset-cssxjs/index.js b/packages/babel-preset-cssxjs/index.js index cb5d9a5..b0ef840 100644 --- a/packages/babel-preset-cssxjs/index.js +++ b/packages/babel-preset-cssxjs/index.js @@ -2,8 +2,8 @@ // On React Native this should be passed. // reactType - force the React target platform (e.g. 'react-native', 'web'). Default: undefined. // This shouldn't be needed in most cases since it will be automatically detected. -// cache - force the CSS caching library instance (e.g. 'teamplay'). Default: undefined -// This shouldn't be needed in most cases since it will be automatically detected. +// cache - legacy compatibility option. 'teamplay' is still accepted but caching +// is owned by cssxjs internally. module.exports = (api, { platform, reactType, @@ -45,6 +45,7 @@ module.exports = (api, { transformCss && [require('@cssxjs/babel-plugin-rn-stylename-to-style'), { useImport: true, reactType, + platform, cache }] ].filter(Boolean) diff --git a/packages/babel-preset-cssxjs/package.json b/packages/babel-preset-cssxjs/package.json index 04a0977..7cfac5c 100644 --- a/packages/babel-preset-cssxjs/package.json +++ b/packages/babel-preset-cssxjs/package.json @@ -1,6 +1,6 @@ { "name": "babel-preset-cssxjs", - "version": "0.3.0", + "version": "0.4.0-alpha.6", "description": "Babel preset for compiling CSSX", "main": "index.js", "exports": { @@ -16,8 +16,8 @@ "dependencies": { "@babel/plugin-syntax-jsx": "^7.0.0", "@babel/plugin-syntax-typescript": "^7.23.3", - "@cssxjs/babel-plugin-rn-stylename-inline": "^0.3.0", - "@cssxjs/babel-plugin-rn-stylename-to-style": "^0.3.0", + "@cssxjs/babel-plugin-rn-stylename-inline": "^0.4.0-alpha.6", + "@cssxjs/babel-plugin-rn-stylename-to-style": "^0.4.0-alpha.2", "@react-pug/babel-plugin-react-pug": "^0.1.18" } } diff --git a/packages/bundler/CHANGELOG.md b/packages/bundler/CHANGELOG.md index 7dafb90..9cd40b5 100644 --- a/packages/bundler/CHANGELOG.md +++ b/packages/bundler/CHANGELOG.md @@ -3,6 +3,54 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/bundler + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/bundler + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/bundler + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/bundler + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/bundler + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/bundler + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/bundler/package.json b/packages/bundler/package.json index 96b1774..0e9f0b3 100644 --- a/packages/bundler/package.json +++ b/packages/bundler/package.json @@ -1,6 +1,6 @@ { "name": "@cssxjs/bundler", - "version": "0.3.0", + "version": "0.4.0-alpha.6", "description": "Compile CSSX styles in React Native and Web bundlers", "exports": { "./metro-config": "./metro-config.js", @@ -18,6 +18,6 @@ }, "license": "MIT", "dependencies": { - "@cssxjs/loaders": "^0.3.0" + "@cssxjs/loaders": "^0.4.0-alpha.6" } } diff --git a/packages/css-to-rn/CHANGELOG.md b/packages/css-to-rn/CHANGELOG.md new file mode 100644 index 0000000..51e11fb --- /dev/null +++ b/packages/css-to-rn/CHANGELOG.md @@ -0,0 +1,46 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + + +### Bug Fixes + +* make auto theme opt-in ([7132a31](https://github.com/startupjs/cssx/commit/7132a3189c49facdce8aa8bc47de63135313e82b)) + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/css-to-rn + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/css-to-rn + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/css-to-rn + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/cssx/issues/5)) ([cd205cf](https://github.com/startupjs/cssx/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) diff --git a/packages/css-to-rn/package.json b/packages/css-to-rn/package.json new file mode 100644 index 0000000..89e8713 --- /dev/null +++ b/packages/css-to-rn/package.json @@ -0,0 +1,84 @@ +{ + "name": "@cssxjs/css-to-rn", + "version": "0.4.0-alpha.6", + "description": "Unified CSS to React Native style compiler and runtime resolver for CSSX", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "cssx-ts": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./react": { + "react-native": { + "cssx-ts": "./src/react-native.ts", + "types": "./dist/react-native.d.ts", + "default": "./dist/react-native.js" + }, + "cssx-ts": "./src/web.ts", + "types": "./dist/web.d.ts", + "default": "./dist/web.js" + }, + "./react-native": { + "cssx-ts": "./src/react-native.ts", + "types": "./dist/react-native.d.ts", + "default": "./dist/react-native.js" + }, + "./web": { + "cssx-ts": "./src/web.ts", + "types": "./dist/web.d.ts", + "default": "./dist/web.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "npm run test:engine && npm run test:react && npm run test:types", + "test:engine": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/engine/**/*.test.ts'", + "test:react": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/react/**/*.test.ts'", + "test:types": "tsc -p tsconfig.json --noEmit", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@colordx/core": "5.4.3", + "css": "^3.0.0", + "css-mediaquery": "^0.1.2", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "*", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + }, + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + }, + "devDependencies": { + "@types/jsdom": "^28.0.3", + "@types/node": "^22.8.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "jsdom": "^29.1.1", + "mocha": "^8.4.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "typescript": "^6.0.3" + }, + "license": "MIT" +} diff --git a/packages/css-to-rn/src/colors.ts b/packages/css-to-rn/src/colors.ts new file mode 100644 index 0000000..5331f04 --- /dev/null +++ b/packages/css-to-rn/src/colors.ts @@ -0,0 +1,263 @@ +import { colordx, extend } from '@colordx/core' +import names from '@colordx/core/plugins/names' + +extend([names]) + +const COLOR_FUNCTIONS = ['color-mix', 'oklch', 'oklab'] + +export function evaluateCssColors (input: string): string { + let output = '' + let index = 0 + + while (index < input.length) { + const next = findNextColorFunction(input, index) + if (next == null) { + output += input.slice(index) + break + } + + output += input.slice(index, next.start) + + const open = next.start + next.name.length + const close = findMatchingParen(input, open) + if (close === -1) { + output += input.slice(next.start) + break + } + + const raw = input.slice(next.start, close + 1) + const body = input.slice(open + 1, close) + const replacement = next.name === 'color-mix' + ? evaluateColorMix(body) + : normalizeColor(raw) + + output += replacement ?? raw + index = close + 1 + } + + return output +} + +export function isCssColor (input: string): boolean { + const color = colordx(evaluateCssColors(input.trim())) + return color.isValid() +} + +function evaluateColorMix (body: string): string | null { + const parts = splitTopLevelComma(body).map(part => part.trim()).filter(Boolean) + if (parts.length !== 3) return null + + const spaceMatch = parts[0].match(/^in\s+([_a-zA-Z][_a-zA-Z0-9-]*)/i) + if (!spaceMatch) return null + + const first = parseColorStop(parts[1]) + const second = parseColorStop(parts[2]) + const weights = normalizeWeights(first.weight, second.weight) + const colorA = colordx(evaluateCssColors(first.color)) + const colorB = colordx(evaluateCssColors(second.color)) + + if (!colorA.isValid() || !colorB.isValid()) return null + + const space = spaceMatch[1].toLowerCase() + if (space === 'oklch') { + return mixOklch(colorA, colorB, weights.first) + } + + if (space === 'oklab') { + return mixOklab(colorA, colorB, weights.first) + } + + if (space === 'srgb' || space === 'rgb') { + return mixRgb(colorA, colorB, weights.first) + } + + return null +} + +function parseColorStop (input: string): { color: string, weight?: number } { + const tokens = splitTopLevelWhitespace(input.trim()) + const last = tokens[tokens.length - 1] + if (last?.endsWith('%')) { + const weight = Number(last.slice(0, -1)) + if (Number.isFinite(weight)) { + return { + color: tokens.slice(0, -1).join(' '), + weight: weight / 100 + } + } + } + + return { + color: input.trim() + } +} + +function normalizeWeights ( + rawFirst: number | undefined, + rawSecond: number | undefined +): { first: number, second: number } { + const first = rawFirst ?? (rawSecond == null ? 0.5 : 1 - rawSecond) + const second = rawSecond ?? (rawFirst == null ? 0.5 : 1 - rawFirst) + const total = first + second + + if (!Number.isFinite(total) || total <= 0) { + return { first: 0.5, second: 0.5 } + } + + return { + first: clamp(first / total, 0, 1), + second: clamp(second / total, 0, 1) + } +} + +function mixRgb ( + colorA: ReturnType, + colorB: ReturnType, + firstWeight: number +): string { + const a = colorA.toRgb() + const b = colorB.toRgb() + const secondWeight = 1 - firstWeight + const alphaValue = alpha(a.alpha * firstWeight + b.alpha * secondWeight) + + if (alphaValue === 0) { + return rgbaString({ r: 0, g: 0, b: 0, alpha: 0 }) + } + + return rgbaString({ + r: round((a.r * a.alpha * firstWeight + b.r * b.alpha * secondWeight) / alphaValue), + g: round((a.g * a.alpha * firstWeight + b.g * b.alpha * secondWeight) / alphaValue), + b: round((a.b * a.alpha * firstWeight + b.b * b.alpha * secondWeight) / alphaValue), + alpha: alphaValue + }) +} + +function mixOklab ( + colorA: ReturnType, + colorB: ReturnType, + firstWeight: number +): string | null { + const a = colorA.toOklab() + const b = colorB.toOklab() + const secondWeight = 1 - firstWeight + return normalizeColor( + `oklab(${mix(a.l, b.l, firstWeight)} ${mix(a.a, b.a, firstWeight)} ${mix(a.b, b.b, firstWeight)} / ${mix(a.alpha, b.alpha, firstWeight, secondWeight)})` + ) +} + +function mixOklch ( + colorA: ReturnType, + colorB: ReturnType, + firstWeight: number +): string | null { + const a = colorA.toOklch() + const b = colorB.toOklch() + const secondWeight = 1 - firstWeight + const hue = mixHue(a.h, b.h, firstWeight) + return normalizeColor( + `oklch(${mix(a.l, b.l, firstWeight)} ${mix(a.c, b.c, firstWeight)} ${hue} / ${mix(a.alpha, b.alpha, firstWeight, secondWeight)})` + ) +} + +function normalizeColor (input: string): string | null { + const color = colordx(input) + return color.isValid() ? rgbaString(color.toRgb()) : null +} + +function rgbaString (input: { r: number, g: number, b: number, alpha: number }): string { + return `rgba(${round(input.r)}, ${round(input.g)}, ${round(input.b)}, ${alpha(input.alpha)})` +} + +function mix ( + first: number, + second: number, + firstWeight: number, + secondWeight = 1 - firstWeight +): number { + return first * firstWeight + second * secondWeight +} + +function mixHue (first: number, second: number, firstWeight: number): number { + let delta = second - first + if (delta > 180) delta -= 360 + if (delta < -180) delta += 360 + return (first + delta * (1 - firstWeight) + 360) % 360 +} + +function round (value: number): number { + return Math.round(clamp(value, 0, 255)) +} + +function alpha (value: number): number { + return Math.round(clamp(value, 0, 1) * 1000) / 1000 +} + +function clamp (value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function findNextColorFunction ( + input: string, + fromIndex: number +): { name: string, start: number } | null { + let best: { name: string, start: number } | null = null + + for (const name of COLOR_FUNCTIONS) { + const start = input.indexOf(`${name}(`, fromIndex) + if (start === -1) continue + if (best == null || start < best.start) best = { name, start } + } + + return best +} + +function findMatchingParen (input: string, openIndex: number): number { + let depth = 0 + for (let index = openIndex; index < input.length; index++) { + const char = input[index] + if (char === '(') depth++ + if (char === ')') { + depth-- + if (depth === 0) return index + } + } + return -1 +} + +function splitTopLevelComma (input: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (char === '(') depth++ + if (char === ')') depth-- + if (char === ',' && depth === 0) { + parts.push(input.slice(start, index)) + start = index + 1 + } + } + + parts.push(input.slice(start)) + return parts +} + +function splitTopLevelWhitespace (input: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (char === '(') depth++ + if (char === ')') depth-- + if (/\s/.test(char) && depth === 0) { + if (start !== index) parts.push(input.slice(start, index)) + start = index + 1 + } + } + + if (start < input.length) parts.push(input.slice(start)) + return parts +} diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts new file mode 100644 index 0000000..f3e87e4 --- /dev/null +++ b/packages/css-to-rn/src/compiler.ts @@ -0,0 +1,676 @@ +import parseCss from 'css/lib/parse/index.js' +import mediaQuery from 'css-mediaquery' +import valueParser from 'postcss-value-parser' +import { addDiagnostic, diagnostic } from './diagnostics.ts' +import { cssxHash } from './hash.ts' +import { parseSelector } from './selectors.ts' +import { transformDeclarations } from './transform/index.ts' +import { resolveCssValue } from './values.ts' +import type { + CompileCssOptions, + CompileCssTemplateOptions, + CompileState, + CompiledCssSheet, + CssxDeclaration, + CssxDiagnostic, + CssxKeyframe, + CssxMetadata, + CssxRule, + CssxTarget +} from './types.ts' + +const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/ +const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/ +const THEME_ROOT_SELECTOR_RE = /^:root\.([A-Za-z0-9_-]+)$/ +const THEME_MEDIA_RE = /\(--theme-[A-Za-z0-9_-]+\)/ +const CUSTOM_MEDIA_NAME_RE = /^--[A-Za-z0-9_-]+$/ +const THEME_CUSTOM_MEDIA_NAME_RE = /^--theme-[A-Za-z0-9_-]+$/ +const MEDIA_RANGE_RE = /\((?:width|height)\s*(?:<=|>=|<|>)/ +const VIEWPORT_UNIT_RE = /(?:^|[^\w-])[-+]?(?:\d*\.)?\d+(?:vh|vw|vmin|vmax)\b/ +const DYNAMIC_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\b/g +const ANIMATION_PROPS = new Set([ + 'animation', + 'animation-name', + 'animation-duration', + 'animation-timing-function', + 'animation-delay', + 'animation-iteration-count', + 'animation-direction', + 'animation-fill-mode', + 'animation-play-state' +]) +const TRANSITION_PROPS = new Set([ + 'transition', + 'transition-property', + 'transition-duration', + 'transition-timing-function', + 'transition-delay' +]) + +export function compileCss (css: string, options: CompileCssOptions = {}): CompiledCssSheet { + return compileCssInternal(css, options) +} + +export function compileCssTemplate ( + css: string, + options: CompileCssTemplateOptions = {} +): CompiledCssSheet { + return compileCssInternal(css, { + ...options, + sourceIdentity: options.sourceIdentity ?? options.id + }, true) +} + +function compileCssInternal ( + css: string, + options: CompileCssOptions, + isTemplate = false +): CompiledCssSheet { + const mode = options.mode ?? 'runtime' + const state: CompileState = { mode, diagnostics: [] } + const contentHash = options.contentHash ?? cssxHash(css) + const sourceId = options.sourceId ?? (options.sourceIdentity ? cssxHash(options.sourceIdentity) : undefined) + const id = options.id ?? cssxHash(`${sourceId ?? 'runtime'}:${contentHash}`) + const empty = (): CompiledCssSheet => createSheet({ + id, + sourceId, + contentHash, + diagnostics: state.diagnostics, + error: state.diagnostics.find(item => item.level === 'error') + }) + + let ast: CssAst + try { + ast = parseCss(css, { silent: false }) as CssAst + } catch (error) { + const err = error as Error & { line?: number, column?: number, reason?: string } + const item = diagnostic( + 'CSS_SYNTAX_ERROR', + err.reason ?? err.message, + 'error', + { line: err.line, column: err.column } + ) + addDiagnostic(state, item) + return empty() + } + + const rules: CssxRule[] = [] + const keyframes: Record = {} + const rootVariables: Record = {} + const themeVariables: Record> = {} + const customMedia: Record = {} + const exports: Record = {} + let order = 0 + + for (const rule of ast.stylesheet?.rules ?? []) { + if (rule.type === 'rule') { + const styleRule = rule as CssStyleRuleAst + compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, rootVariables, themeVariables, state, orderRef(() => order++), isTemplate, exports, options.target) + continue + } + + if (rule.type === 'media') { + const mediaRule = rule as CssMediaAst + const media = `@media ${mediaRule.media ?? ''}`.trim() + const mediaIsValid = validateMedia(mediaRule, state, isTemplate) + if (!mediaIsValid && state.mode === 'build') continue + for (const child of mediaRule.rules ?? []) { + if (child.type !== 'rule') continue + compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, rootVariables, themeVariables, state, orderRef(() => order++), isTemplate, exports, options.target) + } + continue + } + + if (rule.type === 'keyframes') { + const keyframesRule = rule as CssKeyframesAst + const name = keyframesRule.name + if (!name) continue + keyframes[name] = compileKeyframes(keyframesRule, state, orderRef(() => order++), isTemplate, options.target) + continue + } + + if (rule.type === 'custom-media') { + compileCustomMedia(rule as CssCustomMediaAst, customMedia, state, isTemplate) + continue + } + + if (rule.type !== 'comment') { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_AT_RULE', + `Unsupported at-rule or CSS rule type "${rule.type}" ignored.`, + 'warning', + positionOf(rule) + )) + } + } + + const metadata = buildMetadata(rules, keyframes, rootVariables, themeVariables, customMedia, isTemplate) + return createSheet({ + id, + sourceId, + contentHash, + rules, + keyframes, + rootVariables: Object.keys(rootVariables).length > 0 ? rootVariables : undefined, + themeVariables: Object.keys(themeVariables).length > 0 ? themeVariables : undefined, + customMedia: Object.keys(customMedia).length > 0 ? customMedia : undefined, + exports: Object.keys(exports).length > 0 ? exports : undefined, + metadata, + diagnostics: state.diagnostics, + error: state.diagnostics.find(item => item.level === 'error') + }) +} + +function compileRuleList ( + selectors: string[], + declarations: CssDeclarationAst[], + media: string | null, + output: CssxRule[], + rootVariables: Record, + themeVariables: Record>, + state: CompileState, + nextOrder: () => number, + isTemplate: boolean, + exports: Record, + target: CssxTarget | undefined +): void { + let compiledDeclarations: CssxDeclaration[] | undefined + + for (const selector of selectors) { + if (selector === ':export') { + compileExports(declarations, exports, state, isTemplate) + continue + } + + if (selector.trim() === ':root') { + if (media != null) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_SELECTOR', + `Unsupported selector "${selector}" inside media query ignored. CSSX provider root variables are unconditional.`, + 'warning' + )) + continue + } + compileRootVariables(declarations, rootVariables, state, ':root') + continue + } + + const themeMatch = selector.trim().match(THEME_ROOT_SELECTOR_RE) + if (themeMatch) { + if (media != null) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_SELECTOR', + `Unsupported selector "${selector}" inside media query ignored. CSSX provider theme variables are unconditional.`, + 'warning' + )) + continue + } + const themeName = themeMatch[1] + themeVariables[themeName] ??= {} + compileRootVariables(declarations, themeVariables[themeName], state, selector.trim()) + continue + } + + if (selector.trim().startsWith(':root')) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_SELECTOR', + `Unsupported selector "${selector}" ignored. CSSX supports only :root and :root. for provider CSS variables.`, + 'warning' + )) + continue + } + + const parsed = parseSelector(selector, positionOfDeclarationList(declarations)) + if (parsed.diagnostic) { + addDiagnostic(state, parsed.diagnostic) + continue + } + if (!parsed.result) continue + compiledDeclarations ??= compileDeclarations(declarations, state, isTemplate, target) + + output.push({ + selector: parsed.result.selector, + tag: parsed.result.tag, + classes: parsed.result.classes, + part: parsed.result.part, + specificity: parsed.result.specificity, + order: nextOrder(), + media, + declarations: compiledDeclarations + }) + } +} + +function compileRootVariables ( + declarations: CssDeclarationAst[], + rootVariables: Record, + state: CompileState, + selector: string +): void { + for (const declaration of declarations) { + if (declaration.type !== 'declaration') continue + const property = declaration.property + if (!property) continue + + if (!VAR_NAME_RE.test(property)) { + addDiagnostic(state, diagnostic( + 'INVALID_THEME_BLOCK', + `Only CSS custom properties are supported inside ${selector}. Declaration "${property}" ignored.`, + 'warning', + positionOf(declaration) + )) + continue + } + + const value = declaration.value ?? '' + rootVariables[property] = value + } +} + +function compileExports ( + declarations: CssDeclarationAst[], + exports: Record, + state: CompileState, + isTemplate: boolean +): void { + for (const declaration of declarations) { + if (declaration.type !== 'declaration') continue + if (isTemplate && hasDynamicSlots(declaration.value ?? '')) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_INTERPOLATION_POSITION', + 'Interpolation is not supported inside :export blocks.', + 'error', + positionOf(declaration) + )) + continue + } + if (declaration.property) exports[declaration.property] = declaration.value ?? '' + } +} + +function compileCustomMedia ( + rule: CssCustomMediaAst, + customMedia: Record, + state: CompileState, + isTemplate: boolean +): void { + const name = rule.name ?? '' + const media = rule.media ?? '' + + if (!CUSTOM_MEDIA_NAME_RE.test(name)) { + addDiagnostic(state, diagnostic( + 'INVALID_CUSTOM_MEDIA', + `Invalid @custom-media name "${name}". Custom media names must start with "--".`, + 'warning', + positionOf(rule) + )) + return + } + + if (THEME_CUSTOM_MEDIA_NAME_RE.test(name)) { + addDiagnostic(state, diagnostic( + 'INVALID_CUSTOM_MEDIA', + `Custom media name "${name}" is reserved by CSSX theme media aliases.`, + 'warning', + positionOf(rule) + )) + return + } + + if (isTemplate && hasDynamicSlots(media)) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_INTERPOLATION_POSITION', + 'Interpolation is not supported inside @custom-media queries.', + 'error', + positionOf(rule) + )) + return + } + + customMedia[name] = media.trim() +} + +function compileDeclarations ( + declarations: CssDeclarationAst[], + state: CompileState, + isTemplate: boolean, + target: CssxTarget | undefined +): CssxDeclaration[] { + const output: CssxDeclaration[] = [] + let order = 0 + + for (const declaration of declarations) { + if (declaration.type !== 'declaration') continue + const property = declaration.property + const value = declaration.value ?? '' + if (!property) continue + + if (property.startsWith('--')) { + addDiagnostic(state, diagnostic( + 'INVALID_DECLARATION', + `CSS custom property declaration "${property}" ignored. Use variables or setDefaultVariables() instead.`, + 'warning', + positionOf(declaration) + )) + continue + } + + const dynamicSlots = isTemplate ? getDynamicSlots(value) : undefined + const compiledDeclaration: CssxDeclaration = { + property, + value, + raw: `${property}: ${value}`, + order: order++, + dynamicSlots, + line: declaration.position?.start?.line, + column: declaration.position?.start?.column + } + + validateBuildDeclaration(compiledDeclaration, state, target) + output.push(compiledDeclaration) + } + + return output +} + +function compileKeyframes ( + rule: CssKeyframesAst, + state: CompileState, + nextOrder: () => number, + isTemplate: boolean, + target: CssxTarget | undefined +): CssxKeyframe[] { + const output: CssxKeyframe[] = [] + for (const frame of rule.keyframes ?? []) { + output.push({ + selector: (frame.values ?? []).join(', '), + declarations: compileDeclarations(frame.declarations ?? [], state, isTemplate, target), + order: nextOrder() + }) + } + return output +} + +function validateBuildDeclaration ( + declaration: CssxDeclaration, + state: CompileState, + target: CssxTarget | undefined +): void { + if (state.mode !== 'build') return + + if ( + declaration.dynamicSlots?.length || + declaration.value.includes('var(') + ) { + return + } + + const position = { + line: declaration.line, + column: declaration.column + } + const resolved = resolveCssValue(declaration.value, { + dimensions: { + width: 100, + height: 100 + }, + deprecateUUnits: true + }) + + if (resolved.valid) { + for (const item of resolved.diagnostics) { + addDiagnostic(state, diagnostic( + item.code, + item.message, + item.level, + position + )) + } + } + + if (!resolved.valid) { + for (const item of resolved.diagnostics) { + addDiagnostic(state, diagnostic( + item.code, + item.message, + 'error', + position + )) + } + return + } + + const transformed = transformDeclarations([{ + property: declaration.property, + value: resolved.value, + raw: `${declaration.property}: ${resolved.value}`, + order: declaration.order + }], { + platform: target ?? 'react-native' + }) + + for (const item of transformed.diagnostics) { + addDiagnostic(state, diagnostic( + item.code, + item.message, + 'error', + position + )) + } +} + +function validateMedia ( + rule: CssMediaAst, + state: CompileState, + isTemplate: boolean +): boolean { + if (isTemplate && hasDynamicSlots(rule.media ?? '')) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_INTERPOLATION_POSITION', + 'Interpolation is not supported inside media queries.', + 'error', + positionOf(rule) + )) + return false + } + + try { + if (THEME_MEDIA_RE.test(rule.media ?? '') || MEDIA_RANGE_RE.test(rule.media ?? '')) return true + mediaQuery.parse(rule.media ?? '') + return true + } catch (error) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_AT_RULE', + `Unsupported media query "${rule.media ?? ''}" ignored: ${(error as Error).message}`, + 'warning', + positionOf(rule) + )) + return false + } +} + +function buildMetadata ( + rules: CssxRule[], + keyframes: Record, + rootVariables: Record, + themeVariables: Record>, + customMedia: Record, + isTemplate: boolean +): CssxMetadata { + const vars = new Set() + let hasMedia = false + let hasViewportUnits = false + let hasAnimations = Object.keys(keyframes).length > 0 + let hasTransitions = false + let hasInterpolations = isTemplate + + for (const rule of rules) { + if (rule.media) hasMedia = true + scanDeclarations(rule.declarations) + } + for (const frames of Object.values(keyframes)) { + for (const frame of frames) scanDeclarations(frame.declarations) + } + for (const value of Object.values(rootVariables)) { + collectVars(value, vars) + if (VIEWPORT_UNIT_RE.test(value)) hasViewportUnits = true + } + for (const variables of Object.values(themeVariables)) { + for (const value of Object.values(variables)) { + collectVars(value, vars) + if (VIEWPORT_UNIT_RE.test(value)) hasViewportUnits = true + } + } + for (const value of Object.values(customMedia)) collectVars(value, vars) + + function scanDeclarations (declarations: CssxDeclaration[]): void { + for (const declaration of declarations) { + collectVars(declaration.value, vars) + if (VIEWPORT_UNIT_RE.test(declaration.value)) hasViewportUnits = true + if (ANIMATION_PROPS.has(declaration.property)) hasAnimations = true + if (TRANSITION_PROPS.has(declaration.property)) hasTransitions = true + if (declaration.dynamicSlots && declaration.dynamicSlots.length > 0) hasInterpolations = true + } + } + + return { + hasVars: vars.size > 0, + vars: Array.from(vars).sort(), + hasMedia, + hasViewportUnits, + hasInterpolations, + hasDynamicRuntimeDependencies: vars.size > 0 || hasMedia || hasViewportUnits || hasInterpolations, + hasAnimations, + hasTransitions, + hasThemes: Object.keys(themeVariables).length > 0, + hasCustomMedia: Object.keys(customMedia).length > 0 + } +} + +function collectVars (value: string, vars: Set): void { + const parsed = valueParser(value) + parsed.walk(node => { + if (node.type !== 'function' || node.value !== 'var') return + const first = node.nodes.find(child => child.type === 'word') + if (first?.value && VAR_RE.test(`var(${first.value})`)) vars.add(first.value) + }) +} + +function getDynamicSlots (value: string): number[] | undefined { + const slots: number[] = [] + DYNAMIC_SLOT_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = DYNAMIC_SLOT_RE.exec(value)) != null) { + slots.push(Number(match[1])) + } + return slots.length > 0 ? slots : undefined +} + +function hasDynamicSlots (value: string): boolean { + DYNAMIC_SLOT_RE.lastIndex = 0 + return DYNAMIC_SLOT_RE.test(value) +} + +function createSheet (input: Partial & { + id: string + contentHash: string + diagnostics: CssxDiagnostic[] +}): CompiledCssSheet { + return { + version: 1, + id: input.id, + sourceId: input.sourceId, + contentHash: input.contentHash, + rules: input.rules ?? [], + keyframes: input.keyframes ?? {}, + rootVariables: input.rootVariables, + themeVariables: input.themeVariables, + customMedia: input.customMedia, + exports: input.exports, + metadata: input.metadata ?? { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false + }, + diagnostics: input.diagnostics, + error: input.error + } +} + +function orderRef (next: () => number): () => number { + return next +} + +function positionOf (node: CssPositioned): { line?: number, column?: number } { + return { + line: node.position?.start?.line, + column: node.position?.start?.column + } +} + +function positionOfDeclarationList (declarations: CssDeclarationAst[]): { line?: number, column?: number } | undefined { + const first = declarations.find(item => item.position) + return first ? positionOf(first) : undefined +} + +interface CssAst { + stylesheet?: { + rules?: CssRuleAst[] + } +} + +type CssRuleAst = CssStyleRuleAst | CssMediaAst | CssKeyframesAst | CssCustomMediaAst | CssUnsupportedAst + +interface CssPositioned { + position?: { + start?: { + line?: number + column?: number + } + } +} + +interface CssStyleRuleAst extends CssPositioned { + type: 'rule' + selectors?: string[] + declarations?: CssDeclarationAst[] +} + +interface CssMediaAst extends CssPositioned { + type: 'media' + media?: string + rules?: CssStyleRuleAst[] +} + +interface CssKeyframesAst extends CssPositioned { + type: 'keyframes' + name?: string + keyframes?: Array +} + +interface CssCustomMediaAst extends CssPositioned { + type: 'custom-media' + name?: string + media?: string +} + +interface CssDeclarationAst extends CssPositioned { + type: 'declaration' | string + property?: string + value?: string +} + +interface CssUnsupportedAst extends CssPositioned { + type: string +} diff --git a/packages/css-to-rn/src/diagnostics.ts b/packages/css-to-rn/src/diagnostics.ts new file mode 100644 index 0000000..3db8892 --- /dev/null +++ b/packages/css-to-rn/src/diagnostics.ts @@ -0,0 +1,24 @@ +import type { CompileState, CssxDiagnostic, CssxDiagnosticCode, CssxDiagnosticLevel } from './types.ts' + +export function diagnostic ( + code: CssxDiagnosticCode, + message: string, + level: CssxDiagnosticLevel = 'warning', + position?: { line?: number, column?: number } +): CssxDiagnostic { + return { + level, + code, + message, + line: position?.line, + column: position?.column + } +} + +export function addDiagnostic (state: CompileState, item: CssxDiagnostic): void { + state.diagnostics.push(item) + if (state.mode === 'build' && item.level === 'error') { + const location = item.line == null ? '' : ` (${item.line}:${item.column ?? 1})` + throw new Error(`[cssx] ${item.code}${location}: ${item.message}`) + } +} diff --git a/packages/css-to-rn/src/hash.ts b/packages/css-to-rn/src/hash.ts new file mode 100644 index 0000000..9c7a70d --- /dev/null +++ b/packages/css-to-rn/src/hash.ts @@ -0,0 +1,11 @@ +// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0 +export function simpleNumericHash (value: string): number { + let i = 0 + let h = 0 + for (; i < value.length; i++) h = Math.imul(31, h) + value.charCodeAt(i) | 0 + return h +} + +export function cssxHash (value: string): string { + return `cssx_${Math.abs(simpleNumericHash(value)).toString(36)}` +} diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts new file mode 100644 index 0000000..fa6cf5f --- /dev/null +++ b/packages/css-to-rn/src/index.ts @@ -0,0 +1,53 @@ +/// + +export { + compileCss, + compileCssTemplate +} from './compiler.ts' +export { + cssxHash, + simpleNumericHash +} from './hash.ts' +export { + resolveCssValue +} from './values.ts' +export { + u +} from './units.ts' +export { + createCssxCache, + cssx, + resolveCssx +} from './resolve.ts' + +export type { + CompileCssOptions, + CompileCssTemplateOptions, + CompileMode, + CompiledCssSheet, + CssxDeclaration, + CssxDiagnostic, + CssxDiagnosticCode, + CssxKeyframe, + CssxMetadata, + CssxRule, + CssxTarget +} from './types.ts' +export type { + InterpolationValue, + ResolveCssValueOptions, + ResolveCssValueResult +} from './values.ts' +export type { + CssxCache, + CssxDimensions, + CssxLayerInput, + CssxMediaQueryEvaluator, + InlineStyleInput, + ResolveCssxDependencies, + ResolveCssxLayer, + ResolveCssxOptions, + ResolveCssxResult, + ResolvedStyleProps, + StyleNameValue +} from './resolve.ts' diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts new file mode 100644 index 0000000..575f2aa --- /dev/null +++ b/packages/css-to-rn/src/react-native.ts @@ -0,0 +1,215 @@ +/// + +export { + compileCss, + compileCssTemplate +} from './compiler.ts' +export { + resolveCssValue +} from './values.ts' +export { + u +} from './units.ts' +import { + resetUWarningForTests +} from './units.ts' +import { + cssx as baseCssx, + clearRawCssCacheForTests +} from './react/cssx.ts' +import { + useCssxLayer as baseUseCssxLayer, + useRuntimeCss as baseUseRuntimeCss, + useCssxSheet as baseUseCssxSheet, + useCssxTemplate as baseUseCssxTemplate +} from './react/hooks.ts' +import { + createTrackedCssxSheet +} from './react/tracker.ts' +import { + configureColorSchemeAdapter, + configureDimensionsAdapter, + configureMediaQueryAdapter, + configureThemeStorageAdapter, + defaultVariables, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setColorSchemeForTests, + setDefaultVariables, + setDimensionsForTests, + subscribeVariablesForTests, + variables +} from './react/store.ts' +// @ts-ignore react-native is an optional peer for non-RN consumers. +import { Dimensions } from 'react-native' +// @ts-ignore react-native is an optional peer for non-RN consumers. +import { Appearance } from 'react-native' +// @ts-ignore async-storage is an optional peer for non-RN consumers. +import AsyncStorage from '@react-native-async-storage/async-storage' + +export type { + CompileCssOptions, + CompileCssTemplateOptions, + CompiledCssSheet +} from './types.ts' +export type { + CssxResolvedProps, + CssxRuntimeOptions, + CssxStyleName +} from './react/cssx.ts' +export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, + CssxProviderProps, + CssxReactConfig, + CssxRuntimeContextValue, + CssxThemeHookResult, + CssxThemeSetter +} from './react/config.ts' +export type { + TrackedCssxSheetOptions +} from './react/tracker.ts' +export type { + CssxColorSchemeAdapter, + CssxThemeStorageAdapter, + CssxVariableStore +} from './react/store.ts' + +export { + CssxProvider, + configureCssx, + themed, + useTheme, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext +} from './react/config.ts' +export { + getCssColor, + getCssVariable, + getCssVariableRaw, + useMedia, + useCssColor, + useCssVariable, + useCssVariableRaw +} from './react/hooks.ts' +export type { + CssColorMixInput +} from './react/hooks.ts' +export { + TrackedCssxSheet, + isTrackedCssxSheet +} from './react/tracker.ts' +export { + defaultVariables, + setDefaultVariables, + variables +} + +installReactNativeColorSchemeAdapter() +installReactNativeDimensionsAdapter() +installReactNativeThemeStorageAdapter() + +export function cssx ( + ...args: Parameters +): ReturnType { + const [styleName, sheet, inlineStyleProps, options] = args + return baseCssx(styleName, sheet, inlineStyleProps, { + target: 'react-native', + ...(options ?? {}) + }) +} + +export function useRuntimeCss ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseRuntimeCss(input, { + target: 'react-native', + ...(options ?? {}) + }) +} + +export function useCssxLayer ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseCssxLayer(input, { + target: 'react-native', + ...(options ?? {}) + }) +} + +export function useCssxSheet ( + ...args: Parameters +): ReturnType { + const [sheet, options] = args + return baseUseCssxSheet(sheet, { + target: 'react-native', + ...(options ?? {}) + }) +} + +export function useCssxTemplate ( + ...args: Parameters +): ReturnType { + const [sheet, values, options] = args + return baseUseCssxTemplate(sheet, values, { + target: 'react-native', + ...(options ?? {}) + }) +} + +export const __cssxInternals = { + clearRawCssCacheForTests, + configureColorSchemeAdapterForTests: configureColorSchemeAdapter, + configureDimensionsAdapterForTests: configureDimensionsAdapter, + configureMediaQueryAdapterForTests: configureMediaQueryAdapter, + configureThemeStorageAdapterForTests: configureThemeStorageAdapter, + createTrackedCssxSheet, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + resetUWarningForTests, + setColorSchemeForTests, + setDimensionsForTests, + subscribeVariablesForTests +} + +function installReactNativeColorSchemeAdapter (): void { + configureColorSchemeAdapter({ + get: () => Appearance.getColorScheme(), + subscribe: listener => { + const subscription = Appearance.addChangeListener(listener) + return () => { + subscription.remove() + } + } + }) +} + +function installReactNativeDimensionsAdapter (): void { + configureDimensionsAdapter({ + get: () => { + const next = Dimensions.get('window') + return { + width: next.width, + height: next.height + } + }, + subscribe: listener => { + const subscription = Dimensions.addEventListener('change', listener) + return () => { + subscription.remove() + } + } + }) +} + +function installReactNativeThemeStorageAdapter (): void { + configureThemeStorageAdapter({ + get: () => AsyncStorage.getItem('cssx-theme'), + set: theme => AsyncStorage.setItem('cssx-theme', theme) + }) +} diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts new file mode 100644 index 0000000..9519e1b --- /dev/null +++ b/packages/css-to-rn/src/react/config.ts @@ -0,0 +1,553 @@ +import { + createContext, + createElement, + type ComponentProps, + type ComponentType, + use, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useSyncExternalStore, + type ReactNode +} from 'react' +import { compileCss } from '../compiler.ts' +import type { CompiledCssSheet } from '../types.ts' +import { + getColorScheme, + getColorSchemeVersion, + getRuntimeConfig, + getThemePreference, + getThemePreferenceSnapshot, + setThemePreference, + setRuntimeConfig, + subscribeColorScheme, + subscribeThemePreference, + type CssxRuntimeConfig +} from './store.ts' +import { + isTrackedCssxSheet, + TrackedCssxSheet, + type TrackedCssxSheetOptions +} from './tracker.ts' +import type { + ResolveCssxLayer +} from '../resolve.ts' +import type { CssxMetadata } from '../types.ts' + +export interface CssxReactConfig extends CssxRuntimeConfig, TrackedCssxSheetOptions {} + +export type CssxProviderStyleInput = + | string + | CompiledCssSheet + | TrackedCssxSheet + | CssxProviderStyleLayer + | null + | undefined + | false + | readonly CssxProviderStyleInput[] + +export interface CssxProviderStyleLayer { + sheet: string | CompiledCssSheet | TrackedCssxSheet + values?: readonly unknown[] + cacheKey?: unknown +} + +export interface CssxRuntimeContextValue { + config: CssxReactConfig + layers: CssxRuntimeLayerInput[] + scopedVariables: Record[] + customMedia: Record + componentTag: string | null + theme: string + themePreference: string + themeControlled: boolean + themeNames: string[] +} + +export type CssxRuntimeLayerInput = + | string + | CompiledCssSheet + | TrackedCssxSheet + | ResolveCssxLayer + +export interface CssxProviderProps { + value?: CssxReactConfig + style?: CssxProviderStyleInput + theme?: string + children?: ReactNode +} + +export type CssxThemeSetter = (theme: string) => void +export type CssxThemeHookResult = readonly [theme: string, setTheme: CssxThemeSetter] +interface CssxThemePreferenceSnapshot { + preference: string + explicit: boolean +} + +export const CssxRuntimeContext = createContext(null) +const useCommitEffect = typeof window === 'undefined' + ? useEffect + : useLayoutEffect +const EMPTY_METADATA: CssxMetadata = { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false +} +const EMPTY_TRACKING_SHEET: CompiledCssSheet = { + version: 1, + id: 'cssx_theme_tracker', + contentHash: 'cssx_theme_tracker', + rules: [], + keyframes: {}, + metadata: EMPTY_METADATA, + diagnostics: [] +} +const DYNAMIC_ROOT_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g + +export function configureCssx (config: CssxReactConfig): void { + setRuntimeConfig(config) +} + +export function CssxProvider (props: CssxProviderProps): ReactNode { + const parentContext = use(CssxRuntimeContext) + const parent = parentContext ?? getDefaultCssxRuntimeContext() + const globalThemePreference = useGlobalThemePreference( + parentContext == null && (props.theme == null || props.theme === 'auto') + ) + const providerStyles = useMemo( + () => normalizeProviderStyles(props.style), + [props.style] + ) + const layers = useMemo( + () => parent.layers.concat(providerStyles.layers), + [parent.layers, providerStyles.layers] + ) + const themeNames = useMemo( + () => mergeThemeNames(parent.themeNames, providerStyles.themeNames), + [parent.themeNames, providerStyles.themeNames] + ) + const themePreference = getProviderThemePreference( + props.theme, + parentContext, + parent.themePreference, + globalThemePreference + ) + const colorSchemeVersion = useAutoThemeColorSchemeVersion(themePreference) + const theme = useMemo( + () => resolveProviderTheme(themePreference, themeNames), + [themePreference, themeNames, colorSchemeVersion] + ) + const themeControlled = ( + props.theme != null && + !(parentContext == null && props.theme === 'auto') + ) || parent.themeControlled + const scopedVariables = useMemo(() => { + const scopes = [...parent.scopedVariables] + collectProviderRootVariables(providerStyles.layers, scopes, theme) + return scopes + }, [parent.scopedVariables, providerStyles.layers, theme]) + const customMedia = useMemo( + () => ({ + ...parent.customMedia, + ...providerStyles.customMedia + }), + [parent.customMedia, providerStyles.customMedia] + ) + const value = useMemo(() => ({ + config: { + ...parent.config, + ...(props.value ?? {}) + }, + layers, + scopedVariables, + customMedia, + componentTag: parent.componentTag, + theme, + themePreference, + themeControlled, + themeNames + }), [parent.config, parent.componentTag, props.value, layers, scopedVariables, customMedia, theme, themePreference, themeControlled, themeNames]) + + return createElement(CssxRuntimeContext.Provider, { + value + }, props.children) +} + +export function useCssxConfig (): CssxReactConfig { + return useCssxRuntimeContext().config +} + +export function useCssxRuntimeContext (): CssxRuntimeContextValue { + return use(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext() +} + +export function useTheme (): CssxThemeHookResult { + const context = useCssxRuntimeContext() + const setTheme = useCallback((theme: string) => { + setThemePreference(theme) + }, []) + + return [context.theme, setTheme] +} + +export function useCssxComponentTag (): string | null { + return useCssxRuntimeContext().componentTag +} + +export function themed> ( + componentTag: string, + Component: C +): C { + function ThemedComponent (props: ComponentProps): ReactNode { + const parent = useCssxRuntimeContext() + const tracker = useCssxRenderTracker(parent.config) + const value = useMemo(() => ({ + ...parent, + layers: parent.layers.concat(tracker), + componentTag + }), [parent, tracker]) + + return createElement( + CssxRuntimeContext.Provider, + { value }, + createElement(Component, props) + ) + } + + ThemedComponent.displayName = `themed(${Component.displayName ?? Component.name ?? componentTag})` + return ThemedComponent as unknown as C +} + +function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet { + const trackerRef = useRef(null) + const committedTracker = trackerRef.current + const tracker = committedTracker?.matches(EMPTY_TRACKING_SHEET, options) + ? committedTracker + : new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options) + const renderDependencies = tracker.startRender() + + useSyncExternalStore( + tracker.subscribe, + tracker.getSnapshot, + tracker.getServerSnapshot + ) + + useCommitEffect(() => { + tracker.commitRender(renderDependencies) + trackerRef.current = tracker + }) + + return tracker +} + +export function getDefaultCssxRuntimeContext (): CssxRuntimeContextValue { + const themePreference = getThemePreference() + + return { + config: getRuntimeConfig(), + layers: [], + scopedVariables: [], + customMedia: {}, + componentTag: null, + theme: resolveProviderTheme(themePreference, []), + themePreference, + themeControlled: false, + themeNames: [] + } +} + +function useGlobalThemePreference (enabled: boolean): CssxThemePreferenceSnapshot { + const snapshot = useSyncExternalStore( + enabled ? subscribeThemePreference : noopSubscribe, + enabled ? getThemePreferenceSnapshot : getThemePreferenceSnapshot, + getThemePreferenceSnapshot + ) + + return parseThemePreferenceSnapshot(snapshot) +} + +function getProviderThemePreference ( + propTheme: string | undefined, + parentContext: CssxRuntimeContextValue | null, + parentThemePreference: string, + globalThemePreference: CssxThemePreferenceSnapshot +): string { + if (propTheme === 'auto' && parentContext == null) { + return globalThemePreference.explicit + ? globalThemePreference.preference + : 'auto' + } + + if (propTheme != null) return propTheme + if (parentContext == null) return globalThemePreference.preference + return parentThemePreference +} + +function parseThemePreferenceSnapshot ( + snapshot: string +): CssxThemePreferenceSnapshot { + return { + explicit: snapshot[0] === '1', + preference: snapshot.slice(2) || 'default' + } +} + +function normalizeProviderStyles ( + style: CssxProviderStyleInput +): { layers: CssxRuntimeLayerInput[], themeNames: string[], customMedia: Record } { + const layers: CssxRuntimeLayerInput[] = [] + const themeNames = new Set() + const customMedia: Record = {} + + collectProviderStyle(style, layers, themeNames, customMedia) + + return { + layers, + themeNames: Array.from(themeNames).sort(), + customMedia + } +} + +function collectProviderStyle ( + input: CssxProviderStyleInput, + layers: CssxRuntimeLayerInput[], + themeNames: Set, + customMedia: Record +): void { + if (!input) return + + if (Array.isArray(input)) { + for (const item of input) collectProviderStyle(item, layers, themeNames, customMedia) + return + } + + if (typeof input === 'string') { + const sheet = compileCss(input, { mode: 'runtime' }) + layers.push(sheet) + collectThemeNames(sheet, themeNames) + collectCustomMedia(sheet, customMedia) + return + } + + if (isTrackedCssxSheet(input)) { + const sheet = input.getSheet() + layers.push({ sheet, cacheKey: input }) + collectThemeNames(sheet, themeNames) + collectCustomMedia(sheet, customMedia) + return + } + + if (isCompiledSheet(input)) { + layers.push(input) + collectThemeNames(input, themeNames) + collectCustomMedia(input, customMedia) + return + } + + if (isProviderStyleLayer(input)) { + const layer = normalizeProviderStyleLayer(input) + layers.push(layer) + const sheet = typeof layer.sheet === 'string' + ? compileCss(layer.sheet, { mode: 'runtime' }) + : layer.sheet + collectThemeNames(sheet, themeNames) + collectCustomMedia(sheet, customMedia) + } +} + +function normalizeProviderStyleLayer ( + input: CssxProviderStyleLayer +): ResolveCssxLayer { + if (typeof input.sheet === 'string') { + return { + sheet: compileCss(input.sheet, { mode: 'runtime' }), + values: input.values, + cacheKey: input.cacheKey + } + } + + if (isTrackedCssxSheet(input.sheet)) { + return { + sheet: input.sheet.getSheet(), + values: input.values, + cacheKey: input.cacheKey ?? input.sheet + } + } + + return { + sheet: input.sheet, + values: input.values, + cacheKey: input.cacheKey + } +} + +function collectProviderRootVariables ( + layers: readonly CssxRuntimeLayerInput[], + scopedVariables: Record[], + theme: string +): void { + for (const input of layers) { + const layer = normalizeRuntimeLayer(input) + if (layer == null) continue + + if (layer.sheet.rootVariables != null) { + scopedVariables.push(applyLayerValuesToRootVariables(layer.sheet.rootVariables, layer.values)) + } + + const themeRootVariables = getThemeVariables(layer.sheet, theme) + if (themeRootVariables != null) { + scopedVariables.push(applyLayerValuesToRootVariables(themeRootVariables, layer.values)) + } + } +} + +function normalizeRuntimeLayer ( + input: CssxRuntimeLayerInput +): { sheet: CompiledCssSheet, values: readonly unknown[] } | null { + if (typeof input === 'string') { + return { sheet: compileCss(input, { mode: 'runtime' }), values: [] } + } + + if (isTrackedCssxSheet(input)) { + return { + sheet: input.getSheet(), + values: input.getOptions().values ?? [] + } + } + + if (isCompiledSheet(input)) { + return { sheet: input, values: [] } + } + + const sheet = typeof input.sheet === 'string' + ? compileCss(input.sheet, { mode: 'runtime' }) + : input.sheet + + return { + sheet, + values: input.values ?? [] + } +} + +function collectThemeNames ( + sheet: CompiledCssSheet, + themeNames: Set +): void { + if (sheet.themeVariables == null) return + for (const name of Object.keys(sheet.themeVariables)) themeNames.add(name) +} + +function collectCustomMedia ( + sheet: CompiledCssSheet, + customMedia: Record +): void { + if (sheet.customMedia == null) return + Object.assign(customMedia, sheet.customMedia) +} + +function getThemeVariables ( + sheet: CompiledCssSheet, + theme: string +): Record | undefined { + if (sheet.themeVariables == null) return undefined + if (theme === 'light') return sheet.themeVariables.light ?? sheet.themeVariables.default + if (theme === 'default') return sheet.themeVariables.default + return sheet.themeVariables[theme] +} + +function mergeThemeNames ( + parentNames: readonly string[], + providerNames: readonly string[] +): string[] { + if (parentNames.length === 0) return [...providerNames] + if (providerNames.length === 0) return [...parentNames] + return Array.from(new Set([...parentNames, ...providerNames])).sort() +} + +function useAutoThemeColorSchemeVersion (themePreference: string): number { + const shouldSubscribe = themePreference === 'auto' + return useSyncExternalStore( + shouldSubscribe ? subscribeColorScheme : noopSubscribe, + shouldSubscribe ? getColorSchemeVersion : zeroSnapshot, + zeroSnapshot + ) +} + +function resolveProviderTheme ( + themePreference: string, + themeNames: readonly string[] +): string { + const themeSet = new Set(themeNames) + + if (themePreference === 'auto') { + return getColorScheme() === 'dark' && themeSet.has('dark') + ? 'dark' + : 'default' + } + + if (themePreference === 'light') { + return themeSet.has('light') ? 'light' : 'default' + } + + return themePreference || 'default' +} + +function noopSubscribe (): () => void { + return noop +} + +function zeroSnapshot (): number { + return 0 +} + +function noop (): void {} + +function applyLayerValuesToRootVariables ( + rootVariables: Record, + values: readonly unknown[] +): Record { + if (values.length === 0) return rootVariables + + const output: Record = {} + for (const name of Object.keys(rootVariables)) { + const value = rootVariables[name] + let valid = true + const next = value.replace(DYNAMIC_ROOT_SLOT_RE, (_match, rawIndex: string) => { + const interpolation = values[Number(rawIndex)] + if (typeof interpolation === 'string') return interpolation + if (typeof interpolation === 'number') return String(interpolation) + valid = false + return '' + }) + if (valid) output[name] = next + } + return output +} + +function isProviderStyleLayer (value: unknown): value is CssxProviderStyleLayer { + return Boolean( + value && + typeof value === 'object' && + 'sheet' in value && + !isCompiledSheet(value) && + !isTrackedCssxSheet(value) + ) +} + +function isCompiledSheet (value: unknown): value is CompiledCssSheet { + return Boolean( + value && + typeof value === 'object' && + (value as { version?: unknown }).version === 1 && + Array.isArray((value as { rules?: unknown }).rules) + ) +} diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts new file mode 100644 index 0000000..046e6d3 --- /dev/null +++ b/packages/css-to-rn/src/react/cssx.ts @@ -0,0 +1,257 @@ +import { use } from 'react' +import type { CompiledCssSheet, CssxTarget } from '../types.ts' +import { + clearCssxRuntimeCachesForTests, + resolveCssx, + type CssxCache, + type CssxLayerInput, + type InlineStyleInput, + type ResolvedStyleProps, + type ResolveCssxLayer, + type StyleNameValue +} from '../resolve.ts' +import { + CssxRuntimeContext, + getDefaultCssxRuntimeContext +} from './config.ts' +import { + evaluateMediaQuery, + getMediaQueryEvaluator, + getDefaultVariableValues, + getDimensions, + getDimensionsVersion, + getVariableValues, + getVariableVersion, + type CssxDependencyCollector +} from './store.ts' +import { + isTrackedCssxSheet, + type TrackedCssxSheet +} from './tracker.ts' +import { DEFAULT_CUSTOM_MEDIA } from './customMedia.ts' + +export type CssxStyleName = StyleNameValue +export type CssxResolvedProps = ResolvedStyleProps + +export interface CssxRuntimeOptions { + target?: CssxTarget + values?: readonly unknown[] + cache?: boolean | CssxCache + componentTag?: string | null +} + +export type CssxSheetInput = + | string + | CompiledCssSheet + | TrackedCssxSheet + | CssxReactLayer + | CssxOpaqueSheetRecord + | readonly CssxSheetInput[] + +export type CssxOpaqueSheetRecord = Record + +export interface CssxReactLayer { + sheet: string | CompiledCssSheet | TrackedCssxSheet + values?: readonly unknown[] + cacheKey?: unknown +} + +interface NormalizedReactLayers { + layers: CssxLayerInput | CssxLayerInput[] + collectors: CssxDependencyCollector[] + cache?: boolean | CssxCache + target?: CssxTarget +} + +export function cssx ( + styleName: CssxStyleName, + sheetInput: CssxSheetInput, + inlineStyleProps?: InlineStyleInput, + options: CssxRuntimeOptions = {} +): CssxResolvedProps { + const runtimeContext = readRuntimeContext() + const normalized = normalizeSheetInput([ + runtimeContext.layers, + sheetInput + ], options) + const result = resolveCssx({ + styleName, + layers: normalized.layers, + inlineStyleProps, + target: options.target ?? normalized.target ?? 'react-native', + componentTag: options.componentTag ?? runtimeContext.componentTag, + variables: getVariableValues(), + scopedVariables: runtimeContext.scopedVariables, + defaultVariables: getDefaultVariableValues(), + customMedia: { + ...DEFAULT_CUSTOM_MEDIA, + ...runtimeContext.customMedia + }, + dimensions: getDimensions(), + mediaQueryEvaluator: getMediaQueryEvaluator(), + theme: runtimeContext.theme, + cache: options.cache ?? normalized.cache + }) + + for (const collector of normalized.collectors) { + recordDependencies(collector, result) + } + + return result.props +} + +function readRuntimeContext () { + try { + return use(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext() + } catch { + return getDefaultCssxRuntimeContext() + } +} + +export function clearRawCssCacheForTests (): void { + clearCssxRuntimeCachesForTests() +} + +function normalizeSheetInput ( + input: CssxSheetInput, + options: CssxRuntimeOptions +): NormalizedReactLayers { + const rawLayers = Array.isArray(input) ? input : [input] + const layers: CssxLayerInput[] = [] + const collectors: CssxDependencyCollector[] = [] + let cache: boolean | CssxCache | undefined + let target: CssxTarget | undefined + + for (const rawLayer of rawLayers) { + const normalized = normalizeLayer(rawLayer, options) + if (Array.isArray(normalized.layers)) layers.push(...normalized.layers) + else layers.push(normalized.layers) + collectors.push(...normalized.collectors) + cache ??= normalized.cache + target ??= normalized.target + } + + return { + layers, + collectors, + cache, + target + } +} + +function normalizeLayer ( + input: CssxSheetInput, + options: CssxRuntimeOptions +): NormalizedReactLayers { + if (Array.isArray(input)) return normalizeSheetInput(input, options) + + if (isTrackedCssxSheet(input)) { + const trackerOptions = input.getOptions() + const layer: ResolveCssxLayer = { + sheet: input.getSheet(), + values: options.values ?? trackerOptions.values ?? [], + cacheKey: input + } + + return { + layers: layer, + collectors: [input], + cache: options.cache ?? input.getCache(), + target: options.target ?? trackerOptions.target + } + } + + if (isReactLayer(input)) { + const nested = normalizeLayer(input.sheet, options) + const baseLayers = Array.isArray(nested.layers) + ? nested.layers + : [nested.layers] + const layers = baseLayers.map(layer => { + if (typeof layer === 'string') { + return { + sheet: layer, + values: input.values ?? options.values ?? [], + cacheKey: input.cacheKey + } + } + if ('sheet' in layer) { + return { + ...layer, + values: input.values ?? layer.values ?? options.values ?? [], + cacheKey: input.cacheKey ?? layer.cacheKey + } + } + return { + sheet: layer, + values: input.values ?? options.values ?? [], + cacheKey: input.cacheKey + } + }) + + return { + ...nested, + layers + } + } + + if (typeof input === 'string') { + return { + layers: input, + collectors: [], + cache: options.cache + } + } + + if (isCompiledSheet(input)) { + return { + layers: { + sheet: input, + values: options.values ?? [] + }, + collectors: [], + cache: options.cache + } + } + + return { + layers: [], + collectors: [], + cache: options.cache + } +} + +function isReactLayer (value: unknown): value is CssxReactLayer { + return Boolean( + value && + typeof value === 'object' && + 'sheet' in value && + !isTrackedCssxSheet(value) && + !isCompiledSheet(value) + ) +} + +function isCompiledSheet (value: unknown): value is CompiledCssSheet { + return Boolean( + value && + typeof value === 'object' && + (value as { version?: unknown }).version === 1 && + Array.isArray((value as { rules?: unknown }).rules) + ) +} + +function recordDependencies ( + collector: CssxDependencyCollector, + result: { dependencies: { vars: string[], dimensions: boolean, media: string[], mediaMatches?: Record } } +): void { + for (const name of result.dependencies.vars) { + collector.recordVariable(name, getVariableVersion(name)) + } + + if (result.dependencies.dimensions) { + collector.recordDimensions(getDimensionsVersion()) + } + + for (const query of result.dependencies.media) { + collector.recordMedia(query, result.dependencies.mediaMatches?.[query] ?? evaluateMediaQuery(query)) + } +} diff --git a/packages/css-to-rn/src/react/customMedia.ts b/packages/css-to-rn/src/react/customMedia.ts new file mode 100644 index 0000000..cb284f5 --- /dev/null +++ b/packages/css-to-rn/src/react/customMedia.ts @@ -0,0 +1,6 @@ +export const DEFAULT_CUSTOM_MEDIA: Record = { + '--breakpoint-mobile': '(width < 48rem)', + '--breakpoint-tablet': '(width >= 48rem)', + '--breakpoint-desktop': '(width >= 64rem)', + '--breakpoint-wide': '(width >= 80rem)' +} diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts new file mode 100644 index 0000000..2c694ea --- /dev/null +++ b/packages/css-to-rn/src/react/hooks.ts @@ -0,0 +1,581 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useSyncExternalStore +} from 'react' +import { compileCss } from '../compiler.ts' +import { isCssColor } from '../colors.ts' +import { evaluateCssxMediaQuery } from '../resolve.ts' +import type { CompiledCssSheet } from '../types.ts' +import { + useCssxConfig, + useCssxRuntimeContext, + type CssxReactConfig +} from './config.ts' +import { + coerceCssValue, + resolveCssValue, + type ResolveCssValueResult +} from '../values.ts' +import { + createDependencySnapshot, + getDefaultVariableValues, + getDimensions, + getDimensionsVersion, + getMediaQueryEvaluator, + getRuntimeVersion, + getVariableValues, + getVariableVersion, + retainMediaQuery, + subscribeRuntimeStore, + type CssxDependencySnapshot +} from './store.ts' +import { TrackedCssxSheet } from './tracker.ts' +import { DEFAULT_CUSTOM_MEDIA } from './customMedia.ts' + +const useCommitEffect = typeof window === 'undefined' + ? useEffect + : useLayoutEffect +const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/ +const CSS_COLOR_FUNCTION_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i +const CSS_COLOR_TOKEN_RE = /^[A-Za-z][A-Za-z0-9_-]*$/ +const EMPTY_METADATA = { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false, + hasThemes: false, + hasCustomMedia: false +} +const EMPTY_LAYER_SHEET: CompiledCssSheet = { + version: 1, + id: 'cssx_empty_layer', + contentHash: 'cssx_empty_layer', + rules: [], + keyframes: {}, + metadata: EMPTY_METADATA, + diagnostics: [] +} + +export type CssxLayerHookInput = + | string + | CompiledCssSheet + | TrackedCssxSheet + | { + sheet: string | CompiledCssSheet | TrackedCssxSheet + values?: readonly unknown[] + } + | null + | undefined + | false + +export type CssxLayerHookOutput = + | string + | TrackedCssxSheet + | { + sheet: string | TrackedCssxSheet + values?: readonly unknown[] + } + | null + | undefined + | false + +export type CssColorMixInput = + | number + | string + | { + mix?: number | string + with?: string + } + +export function useCssxSheet ( + sheet: CompiledCssSheet, + options: CssxReactConfig = {} +): TrackedCssxSheet { + const context = useCssxConfig() + const trackerRef = useRef(null) + const mergedOptions = { + ...context, + ...options + } + const committedTracker = trackerRef.current + const tracker = committedTracker?.matches(sheet, mergedOptions) + ? committedTracker + : new TrackedCssxSheet(sheet, mergedOptions) + const renderDependencies = tracker.startRender() + + useSyncExternalStore( + tracker.subscribe, + tracker.getSnapshot, + tracker.getServerSnapshot + ) + + useCommitEffect(() => { + tracker.commitRender(renderDependencies) + trackerRef.current = tracker + }) + + return tracker +} + +export function useRuntimeCss ( + input: string | CompiledCssSheet, + options: CssxReactConfig = {} +): TrackedCssxSheet { + const context = useCssxConfig() + const target = options.target ?? context.target + const sheet = useMemo(() => { + if (typeof input !== 'string') return input + return compileCss(input, { target }) + }, [input, target]) + + return useCssxSheet(sheet, options) +} + +export function useCssxTemplate ( + sheet: CompiledCssSheet, + values: readonly unknown[], + options: CssxReactConfig = {} +): TrackedCssxSheet { + return useCssxSheet(sheet, { + ...options, + values + }) +} + +export function useCssxLayer ( + input: CssxLayerHookInput, + options: CssxReactConfig = {} +): CssxLayerHookOutput { + const context = useCssxConfig() + const target = options.target ?? context.target + const normalized = useMemo( + () => normalizeLayerHookInput(input, target), + [input, target] + ) + const tracker = useCssxSheet(normalized.sheet, { + ...options, + values: normalized.values + }) + + switch (normalized.kind) { + case 'empty': + return input as null | undefined | false + case 'tracked': + return input as CssxLayerHookOutput + case 'layerTracked': + return input as CssxLayerHookOutput + case 'layerString': { + const layerInput = input as { + sheet: string | CompiledCssSheet | TrackedCssxSheet + values?: readonly unknown[] + } + return { + ...layerInput, + sheet: tracker + } as CssxLayerHookOutput + } + case 'compiled': + case 'string': + case 'layerCompiled': + return tracker + case 'unknown': + default: + return input as CssxLayerHookOutput + } +} + +export function useCssVariableRaw ( + name: string, + fallback?: unknown +): string | undefined { + assertCssVariableName(name) + const context = useCssxRuntimeContext() + const committedDependenciesRef = useRef(createDependencySnapshot()) + const result = resolveCssVariableRaw(name, fallback, context.scopedVariables) + const renderDependencies = createCssValueDependencySnapshot(result) + + useSyncExternalStore( + listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current), + getRuntimeVersion, + getRuntimeVersion + ) + + useCommitEffect(() => { + committedDependenciesRef.current = renderDependencies + }) + + return result.value +} + +export function useCssVariable ( + name: string, + fallback?: unknown +): unknown { + const value = useCssVariableRaw(name, fallback) + return value == null ? value : coerceCssValue(value) +} + +export function useCssColor ( + color: string, + mix?: CssColorMixInput +): string | undefined { + const context = useCssxRuntimeContext() + const committedDependenciesRef = useRef(createDependencySnapshot()) + const result = resolveCssColor(color, mix, context.scopedVariables) + const renderDependencies = createCssValueDependencySnapshot(result) + + useSyncExternalStore( + listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current), + getRuntimeVersion, + getRuntimeVersion + ) + + useCommitEffect(() => { + committedDependenciesRef.current = renderDependencies + }) + + return result.value +} + +export function getCssVariableRaw ( + name: string, + fallback?: unknown +): string | undefined { + assertCssVariableName(name) + return resolveCssVariableRaw(name, fallback).value +} + +export function getCssVariable ( + name: string, + fallback?: unknown +): unknown { + const value = getCssVariableRaw(name, fallback) + return value == null ? value : coerceCssValue(value) +} + +export function getCssColor ( + color: string, + mix?: CssColorMixInput +): string | undefined { + return resolveCssColor(color, mix).value +} + +export function useMedia (): Record { + const context = useCssxRuntimeContext() + const committedDependenciesRef = useRef(createDependencySnapshot()) + const mediaQueryReleasesRef = useRef void>>(new Map()) + const media = { + ...DEFAULT_CUSTOM_MEDIA, + ...context.customMedia + } + const result = resolveMedia(media, context) + const renderDependencies = createMediaDependencySnapshot(result) + + useSyncExternalStore( + listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current), + getRuntimeVersion, + getRuntimeVersion + ) + + useCommitEffect(() => { + committedDependenciesRef.current = renderDependencies + syncMediaQuerySubscriptions(mediaQueryReleasesRef.current, renderDependencies) + return () => { + releaseMediaQuerySubscriptions(mediaQueryReleasesRef.current) + } + }) + + return result.value +} + +function isCompiledSheet (value: unknown): value is CompiledCssSheet { + return Boolean( + value && + typeof value === 'object' && + (value as { version?: unknown }).version === 1 && + Array.isArray((value as { rules?: unknown }).rules) + ) +} + +function isLayerObject (value: unknown): value is { + sheet: string | CompiledCssSheet | TrackedCssxSheet + values?: readonly unknown[] +} { + return Boolean( + value && + typeof value === 'object' && + 'sheet' in value + ) +} + +type NormalizedLayerHookInput = + | { + kind: 'empty' | 'unknown' | 'tracked' | 'layerTracked' + sheet: CompiledCssSheet + values?: readonly unknown[] + } + | { + kind: 'string' | 'compiled' | 'layerString' | 'layerCompiled' + sheet: CompiledCssSheet + values?: readonly unknown[] + } + +function normalizeLayerHookInput ( + input: CssxLayerHookInput, + target: CssxReactConfig['target'] +): NormalizedLayerHookInput { + if (!input) { + return { + kind: 'empty', + sheet: EMPTY_LAYER_SHEET + } + } + + if (typeof input === 'string') { + return { + kind: 'string', + sheet: compileCss(input, { target }) + } + } + + if (input instanceof TrackedCssxSheet) { + return { + kind: 'tracked', + sheet: EMPTY_LAYER_SHEET + } + } + + if (isCompiledSheet(input)) { + return { + kind: 'compiled', + sheet: input + } + } + + if (isLayerObject(input)) { + const sheet = input.sheet + if (typeof sheet === 'string') { + return { + kind: 'layerString', + sheet: compileCss(sheet, { target }), + values: input.values + } + } + if (sheet instanceof TrackedCssxSheet) { + return { + kind: 'layerTracked', + sheet: EMPTY_LAYER_SHEET + } + } + if (isCompiledSheet(sheet)) { + return { + kind: 'layerCompiled', + sheet, + values: input.values + } + } + } + + return { + kind: 'unknown', + sheet: EMPTY_LAYER_SHEET + } +} + +function resolveCssVariableRaw ( + name: string, + fallback?: unknown, + scopedVariables?: readonly Record[] +) { + const fallbackText = fallback == null ? '' : `, ${String(fallback)}` + return resolveCssValue(`var(${name}${fallbackText})`, { + variables: getVariableValues(), + scopedVariables, + defaultVariables: getDefaultVariableValues(), + dimensions: getDimensions() + }) +} + +function resolveCssColor ( + color: string, + mix?: CssColorMixInput, + scopedVariables?: readonly Record[] +): ResolveCssValueResult { + return resolveCssValue(createCssColorExpression(color, mix), { + variables: getVariableValues(), + scopedVariables, + defaultVariables: getDefaultVariableValues(), + dimensions: getDimensions() + }) +} + +function createCssValueDependencySnapshot ( + result: ResolveCssValueResult +): CssxDependencySnapshot { + const dependencies = createDependencySnapshot() + for (const name of result.dependencies.vars) { + dependencies.vars.set(name, getVariableVersion(name)) + } + if (result.dependencies.dimensions) { + dependencies.dimensionsVersion = getDimensionsVersion() + } + return dependencies +} + +function createCssColorExpression ( + color: string, + mix?: CssColorMixInput +): string { + const base = normalizeCssColorExpression(color) + const mixOptions = normalizeColorMix(mix) + if (mixOptions == null) return base + + return `color-mix(in srgb, ${base} ${mixOptions.weight}, ${normalizeCssColorExpression(mixOptions.with)})` +} + +function normalizeCssColorExpression (input: string): string { + const value = input.trim() + if (value === '') return value + if (/^var\(/i.test(value)) return value + if (value.startsWith('--')) { + throw new TypeError(`Ambiguous CSS color token "${input}". Use "var(${value})" or a semantic token such as "primary".`) + } + if ( + CSS_COLOR_FUNCTION_RE.test(value) || + isCssColor(value) || + !CSS_COLOR_TOKEN_RE.test(value) + ) { + return value + } + + return `var(--color-${value})` +} + +function normalizeColorMix ( + input: CssColorMixInput | undefined +): { weight: string, with: string } | null { + if (input == null) return null + if (typeof input === 'number' || typeof input === 'string') { + return { + weight: normalizeMixWeight(input), + with: 'transparent' + } + } + + if (input.mix == null) return null + return { + weight: normalizeMixWeight(input.mix), + with: input.with ?? 'transparent' + } +} + +function normalizeMixWeight (input: number | string): string { + if (typeof input === 'string') { + const value = input.trim() + if (/^(?:\d+|\d*\.\d+)%$/.test(value)) return value + throw new TypeError(`Invalid CSS color mix weight "${input}". Expected a percentage string such as "15%".`) + } + + if (!Number.isFinite(input) || input < 0 || input > 1) { + throw new TypeError(`Invalid CSS color mix weight "${input}". Expected a number from 0 to 1.`) + } + + return `${input * 100}%` +} + +function resolveMedia ( + media: Record, + context: ReturnType +): { + value: Record + dependencies: { + vars: string[] + dimensions: boolean + media: Record + } + } { + const value: Record = {} + const vars = new Set() + let dimensions = false + const mediaDependencies: Record = {} + + for (const [name, query] of Object.entries(media)) { + const result = evaluateCssxMediaQuery(query, { + variables: getVariableValues(), + scopedVariables: context.scopedVariables, + defaultVariables: getDefaultVariableValues(), + customMedia: media, + dimensions: getDimensions(), + mediaQueryEvaluator: getMediaQueryEvaluator(), + theme: context.theme + }) + value[normalizeMediaName(name)] = result.matches + for (const varName of result.dependencies.vars) vars.add(varName) + if (result.dependencies.dimensions) dimensions = true + mediaDependencies[query] = result.matches + } + + return { + value, + dependencies: { + vars: Array.from(vars), + dimensions, + media: mediaDependencies + } + } +} + +function createMediaDependencySnapshot ( + result: ReturnType +): CssxDependencySnapshot { + const dependencies = createDependencySnapshot() + for (const name of result.dependencies.vars) { + dependencies.vars.set(name, getVariableVersion(name)) + } + dependencies.dimensionsVersion = getDimensionsVersion() + for (const [query, matches] of Object.entries(result.dependencies.media)) { + dependencies.media.set(query, matches) + } + return dependencies +} + +function normalizeMediaName (name: string): string { + const trimmed = name.replace(/^--/, '').replace(/^breakpoint-/, '') + return trimmed.replace(/-([a-z0-9])/g, (_match, character: string) => character.toUpperCase()) +} + +function syncMediaQuerySubscriptions ( + releases: Map void>, + dependencies: CssxDependencySnapshot +): void { + const nextQueries = new Set(dependencies.media.keys()) + for (const [query, release] of Array.from(releases)) { + if (nextQueries.has(query)) continue + release() + releases.delete(query) + } + + for (const query of nextQueries) { + if (releases.has(query)) continue + releases.set(query, retainMediaQuery(query)) + } +} + +function releaseMediaQuerySubscriptions ( + releases: Map void> +): void { + for (const release of releases.values()) release() + releases.clear() +} + +function assertCssVariableName (name: string): void { + if (CSS_VARIABLE_NAME_RE.test(name)) return + throw new TypeError(`Invalid CSS custom property name "${name}". CSSX variables must start with "--".`) +} diff --git a/packages/css-to-rn/src/react/index.ts b/packages/css-to-rn/src/react/index.ts new file mode 100644 index 0000000..b5c9d25 --- /dev/null +++ b/packages/css-to-rn/src/react/index.ts @@ -0,0 +1,70 @@ +export { + cssx, + clearRawCssCacheForTests +} from './cssx.ts' +export { + CssxProvider, + configureCssx, + themed, + useTheme, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext +} from './config.ts' +export { + useCssxLayer, + getCssVariable, + getCssVariableRaw, + useCssVariable, + useCssVariableRaw, + useMedia, + useRuntimeCss, + useCssxSheet, + useCssxTemplate +} from './hooks.ts' +export { + TrackedCssxSheet, + createTrackedCssxSheet, + isTrackedCssxSheet +} from './tracker.ts' +export { + defaultVariables, + flushMicrotasksForTests, + configureColorSchemeAdapter, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setColorSchemeForTests, + setDefaultVariables, + setDimensionsForTests, + subscribeVariablesForTests, + variables +} from './store.ts' + +export type { + CssxResolvedProps, + CssxRuntimeOptions, + CssxStyleName +} from './cssx.ts' +export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, + CssxProviderProps, + CssxReactConfig, + CssxRuntimeContextValue, + CssxThemeHookResult, + CssxThemeSetter +} from './config.ts' +export type { + CssxLayerHookInput, + CssxLayerHookOutput +} from './hooks.ts' +export type { + CssxDependencySnapshot, + CssxColorSchemeAdapter, + CssxThemeStorageAdapter, + CssxVariableStore, + CssxRuntimeConfig +} from './store.ts' +export type { + TrackedCssxSheetOptions +} from './tracker.ts' diff --git a/packages/css-to-rn/src/react/store.ts b/packages/css-to-rn/src/react/store.ts new file mode 100644 index 0000000..7ab988e --- /dev/null +++ b/packages/css-to-rn/src/react/store.ts @@ -0,0 +1,778 @@ +import mediaQuery from 'css-mediaquery' + +export interface CssxRuntimeConfig { + dimensionsDebounceMs?: number +} + +export interface CssxDimensionsSnapshot { + width: number + height: number +} + +export interface CssxDimensionsAdapter { + get: () => CssxDimensionsSnapshot + subscribe: (listener: () => void) => () => void +} + +export interface CssxMediaQueryAdapter { + evaluate: (query: string) => boolean + subscribe?: (query: string, listener: () => void) => () => void +} + +export type CssxColorScheme = 'light' | 'dark' + +export interface CssxColorSchemeAdapter { + get: () => CssxColorScheme | null | undefined + subscribe: (listener: () => void) => () => void +} + +export interface CssxThemeStorageAdapter { + get: () => string | null | undefined | Promise + set: (theme: string) => void | Promise +} + +export interface CssxDependencySnapshot { + vars: Map + media: Map + dimensionsVersion: number | null +} + +export interface CssxDependencyCollector { + recordVariable: (name: string, version: number) => void + recordMedia: (query: string, matches: boolean) => void + recordDimensions: (version: number) => void +} + +export interface RuntimeChangeSnapshot { + vars: readonly string[] + dimensions: boolean + media: boolean +} + +export interface CssxVariableStore extends Record { + set: (next: Record) => void + assign: (next: Record) => void + clear: () => void +} + +type RuntimeSubscriber = { + listener: (change: RuntimeChangeSnapshot) => void + getDependencies: () => CssxDependencySnapshot +} + +const FALLBACK_DIMENSIONS = { width: 1024, height: 768 } +const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/ + +const variableValues: Record = Object.create(null) +const defaultVariableValues: Record = Object.create(null) +const variableVersions = new Map() +const runtimeSubscribers = new Set() +const colorSchemeSubscribers = new Set<() => void>() +const themePreferenceSubscribers = new Set<() => void>() +const pendingVariableNames = new Set() +const retainedMediaQueries = new Map void) | null +}>() + +let runtimeConfig: Required = { + dimensionsDebounceMs: 0 +} +let variableVersion = 0 +let dimensionsAdapter: CssxDimensionsAdapter | null = null +let dimensionsAdapterUnsubscribe: (() => void) | null = null +let mediaQueryAdapter: CssxMediaQueryAdapter | null = null +let colorSchemeAdapter: CssxColorSchemeAdapter | null = null +let colorSchemeAdapterUnsubscribe: (() => void) | null = null +let colorScheme = readColorScheme() +let colorSchemeVersion = 0 +let themeStorageAdapter: CssxThemeStorageAdapter | null = null +let themeStorageLoadToken = 0 +let themePreference = 'default' +let themePreferenceExplicit = false +let dimensions = readWindowDimensions() +let dimensionsVersion = 0 +let pendingDimensionsChanged = false +let pendingMediaChanged = false +let notifyScheduled = false +let resizeListener: (() => void) | null = null +let resizeTimer: ReturnType | null = null + +export const variables = createVariableProxy(variableValues) +export const defaultVariables = createVariableProxy(defaultVariableValues) + +export function setDefaultVariables (next: Record): void { + defaultVariables.set(next) +} + +export function getVariableValues (): Record { + return variableValues +} + +export function getDefaultVariableValues (): Record { + return defaultVariableValues +} + +export function getVariableVersion (name: string): number { + return variableVersions.get(name) ?? 0 +} + +export function getRuntimeVersion (): number { + return variableVersion + dimensionsVersion +} + +export function createDependencySnapshot (): CssxDependencySnapshot { + return { + vars: new Map(), + media: new Map(), + dimensionsVersion: null + } +} + +export function getDimensions (): { width: number, height: number } { + return dimensions +} + +export function getDimensionsVersion (): number { + return dimensionsVersion +} + +export function setDimensionsForTests (next: { width: number, height: number }): void { + applyDimensions(next) +} + +export function configureDimensionsAdapter ( + adapter: CssxDimensionsAdapter | null +): void { + if (dimensionsAdapter === adapter) return + removeWindowResizeListener() + dimensionsAdapter = adapter + refreshRetainedMediaQueryListeners() + applyDimensions(readWindowDimensions()) + if (runtimeSubscribers.size > 0) ensureWindowResizeListener() +} + +export function configureMediaQueryAdapter ( + adapter: CssxMediaQueryAdapter | null +): void { + if (mediaQueryAdapter === adapter) return + mediaQueryAdapter = adapter + refreshRetainedMediaQueryListeners() + markMediaChanged() +} + +export function configureColorSchemeAdapter ( + adapter: CssxColorSchemeAdapter | null +): void { + if (colorSchemeAdapter === adapter) return + removeColorSchemeListener() + colorSchemeAdapter = adapter + applyColorScheme(readColorScheme()) + if (colorSchemeSubscribers.size > 0) ensureColorSchemeListener() +} + +export function configureThemeStorageAdapter ( + adapter: CssxThemeStorageAdapter | null +): void { + themeStorageAdapter = adapter + const loadToken = ++themeStorageLoadToken + if (adapter == null) return + + Promise.resolve(adapter.get()).then(value => { + if (loadToken !== themeStorageLoadToken) return + if (typeof value !== 'string' || value.trim() === '') return + applyThemePreference(value, false) + }).catch(noop) +} + +export function getColorScheme (): CssxColorScheme { + return colorScheme +} + +export function getColorSchemeVersion (): number { + return colorSchemeVersion +} + +export function setColorSchemeForTests (next: CssxColorScheme): void { + applyColorScheme(next) +} + +export function subscribeColorScheme ( + listener: () => void +): () => void { + colorSchemeSubscribers.add(listener) + ensureColorSchemeListener() + + return () => { + colorSchemeSubscribers.delete(listener) + if (colorSchemeSubscribers.size === 0) removeColorSchemeListener() + } +} + +export function getThemePreference (): string { + return themePreference +} + +export function getThemePreferenceSnapshot (): string { + return `${themePreferenceExplicit ? '1' : '0'}:${themePreference}` +} + +export function setThemePreference (next: string): void { + applyThemePreference(next, true) +} + +export function subscribeThemePreference ( + listener: () => void +): () => void { + themePreferenceSubscribers.add(listener) + + return () => { + themePreferenceSubscribers.delete(listener) + } +} + +export function getMediaQueryEvaluator (): (query: string) => boolean { + return query => evaluateMediaQuery(query) +} + +export function evaluateMediaQuery (query: string): boolean { + const normalized = stripMediaPrefix(query) + + if (mediaQueryAdapter != null) { + return mediaQueryAdapter.evaluate(normalized) + } + + if (canUseBrowserMatchMedia()) { + return window.matchMedia(normalized).matches + } + + try { + return mediaQuery.match(normalized, mediaValues(dimensions)) + } catch { + return false + } +} + +export function setRuntimeConfig (next: CssxRuntimeConfig): void { + runtimeConfig = { + ...runtimeConfig, + ...next + } +} + +export function getRuntimeConfig (): Required { + return runtimeConfig +} + +export function subscribeRuntimeStore ( + listener: (change: RuntimeChangeSnapshot) => void, + getDependencies: () => CssxDependencySnapshot +): () => void { + const subscriber = { listener, getDependencies } + runtimeSubscribers.add(subscriber) + ensureWindowResizeListener() + + return () => { + runtimeSubscribers.delete(subscriber) + if (runtimeSubscribers.size === 0) removeWindowResizeListener() + } +} + +export function retainMediaQuery (query: string): () => void { + const normalized = stripMediaPrefix(query) + let entry = retainedMediaQueries.get(normalized) + + if (entry == null) { + entry = { + count: 0, + unsubscribe: subscribeToMediaQuery(normalized) + } + retainedMediaQueries.set(normalized, entry) + } + + entry.count += 1 + + return () => { + const current = retainedMediaQueries.get(normalized) + if (current == null) return + + current.count -= 1 + if (current.count > 0) return + + current.unsubscribe?.() + retainedMediaQueries.delete(normalized) + } +} + +export function hasStaleDependencies (dependencies: CssxDependencySnapshot): boolean { + for (const [name, version] of dependencies.vars) { + if (getVariableVersion(name) !== version) return true + } + + if ( + dependencies.dimensionsVersion != null && + dependencies.dimensionsVersion !== dimensionsVersion + ) { + return true + } + + for (const [query, matches] of dependencies.media) { + if (evaluateMediaQuery(query) !== matches) return true + } + + return false +} + +export function subscribeVariablesForTests ( + names: readonly string[], + listener: (changedNames: readonly string[]) => void +): () => void { + const dependencies = createDependencySnapshot() + for (const name of names) { + dependencies.vars.set(name, getVariableVersion(name)) + } + return subscribeRuntimeStore( + change => listener(change.vars), + () => dependencies + ) +} + +export function getRuntimeSubscriberCountForTests (): number { + return runtimeSubscribers.size +} + +export async function flushMicrotasksForTests (): Promise { + await Promise.resolve() + await Promise.resolve() +} + +export function resetStoreForTests (): void { + clearRecord(variableValues) + clearRecord(defaultVariableValues) + variableVersions.clear() + pendingVariableNames.clear() + variableVersion = 0 + removeWindowResizeListener() + releaseAllRetainedMediaQueries() + dimensionsAdapter = null + mediaQueryAdapter = null + removeColorSchemeListener() + colorSchemeAdapter = null + colorScheme = 'light' + colorSchemeVersion = 0 + colorSchemeSubscribers.clear() + themeStorageAdapter = null + themeStorageLoadToken += 1 + themePreference = 'default' + themePreferenceExplicit = false + themePreferenceSubscribers.clear() + dimensions = FALLBACK_DIMENSIONS + dimensionsVersion = 0 + pendingDimensionsChanged = false + pendingMediaChanged = false + notifyScheduled = false + runtimeSubscribers.clear() +} + +function createVariableProxy (target: Record): CssxVariableStore { + const methods = { + set (next: Record): void { + replaceVariables(target, next) + }, + assign (next: Record): void { + assignVariables(target, next) + }, + clear (): void { + replaceVariables(target, {}) + } + } + + return new Proxy(target, { + get (record, property, receiver) { + if (property === 'set') return methods.set + if (property === 'assign') return methods.assign + if (property === 'clear') return methods.clear + return Reflect.get(record, property, receiver) + }, + has (record, property) { + return property === 'set' || + property === 'assign' || + property === 'clear' || + Reflect.has(record, property) + }, + set (record, property, value) { + if (typeof property !== 'string') { + return Reflect.set(record, property, value) + } + assertCssVariableName(property) + if (Object.is(record[property], value)) return true + record[property] = value + markVariablesChanged([property]) + return true + }, + deleteProperty (record, property) { + if (typeof property !== 'string') { + return Reflect.deleteProperty(record, property) + } + assertCssVariableName(property) + if (!Object.prototype.hasOwnProperty.call(record, property)) return true + delete record[property] + markVariablesChanged([property]) + return true + } + }) as CssxVariableStore +} + +function replaceVariables ( + target: Record, + next: Record +): void { + const changed = new Set() + for (const name of Object.keys(next)) assertCssVariableName(name) + + for (const name of Object.keys(target)) { + if (!Object.prototype.hasOwnProperty.call(next, name)) { + delete target[name] + changed.add(name) + } + } + + for (const [name, value] of Object.entries(next)) { + if (Object.is(target[name], value)) continue + target[name] = value + changed.add(name) + } + + markVariablesChanged(Array.from(changed)) +} + +function assignVariables ( + target: Record, + next: Record +): void { + const changed = new Set() + + for (const [name, value] of Object.entries(next)) { + assertCssVariableName(name) + if (Object.is(target[name], value)) continue + target[name] = value + changed.add(name) + } + + markVariablesChanged(Array.from(changed)) +} + +function assertCssVariableName (name: string): void { + if (CSS_VARIABLE_NAME_RE.test(name)) return + throw new TypeError(`Invalid CSS custom property name "${name}". CSSX variables must start with "--".`) +} + +function markVariablesChanged (names: readonly string[]): void { + if (names.length === 0) return + + for (const name of names) { + variableVersion += 1 + variableVersions.set(name, variableVersion) + pendingVariableNames.add(name) + } + + scheduleNotification() +} + +function applyDimensions (next: { width: number, height: number }): void { + if ( + Object.is(dimensions.width, next.width) && + Object.is(dimensions.height, next.height) + ) { + return + } + + dimensions = next + dimensionsVersion += 1 + pendingDimensionsChanged = true + scheduleNotification() +} + +function applyColorScheme (next: CssxColorScheme | null | undefined): void { + const normalized = next === 'dark' ? 'dark' : 'light' + if (colorScheme === normalized) return + colorScheme = normalized + colorSchemeVersion += 1 + for (const listener of Array.from(colorSchemeSubscribers)) { + listener() + } +} + +function applyThemePreference (next: string, persist: boolean): void { + const normalized = normalizeThemePreference(next) + const changed = themePreference !== normalized + const becameExplicit = !themePreferenceExplicit + if (!changed && !becameExplicit) return + themePreference = normalized + themePreferenceExplicit = true + + if (persist) { + Promise.resolve(themeStorageAdapter?.set(normalized)).catch(noop) + } + + for (const listener of Array.from(themePreferenceSubscribers)) { + listener() + } +} + +function normalizeThemePreference (next: string): string { + if (typeof next !== 'string') { + throw new TypeError('CSSX theme must be a string.') + } + const normalized = next.trim() + if (normalized) return normalized + throw new TypeError('CSSX theme must be a non-empty string.') +} + +function noop (): void {} + +function markMediaChanged (): void { + pendingMediaChanged = true + scheduleNotification() +} + +function scheduleNotification (): void { + if (notifyScheduled) return + notifyScheduled = true + + queueMicrotask(() => { + notifyScheduled = false + flushNotifications() + }) +} + +function flushNotifications (): void { + const vars = Array.from(pendingVariableNames) + const dimensionsChanged = pendingDimensionsChanged + const mediaChanged = pendingMediaChanged + + pendingVariableNames.clear() + pendingDimensionsChanged = false + pendingMediaChanged = false + + if (vars.length === 0 && !dimensionsChanged && !mediaChanged) return + + const change = { + vars, + dimensions: dimensionsChanged, + media: mediaChanged + } + + for (const subscriber of Array.from(runtimeSubscribers)) { + if (shouldNotifySubscriber(subscriber.getDependencies(), change)) { + subscriber.listener(change) + } + } +} + +function shouldNotifySubscriber ( + dependencies: CssxDependencySnapshot, + change: RuntimeChangeSnapshot +): boolean { + for (const name of change.vars) { + if (dependencies.vars.has(name)) return true + } + + if (change.dimensions && dependencies.dimensionsVersion != null) return true + + if (change.dimensions || change.media) { + for (const [query, matches] of dependencies.media) { + if (evaluateMediaQuery(query) !== matches) return true + } + } + + return false +} + +function refreshRetainedMediaQueryListeners (): void { + for (const entry of retainedMediaQueries.values()) { + entry.unsubscribe?.() + entry.unsubscribe = null + } + + for (const [query, entry] of retainedMediaQueries) { + if (entry.count > 0) entry.unsubscribe = subscribeToMediaQuery(query) + } +} + +function releaseAllRetainedMediaQueries (): void { + for (const entry of retainedMediaQueries.values()) { + entry.unsubscribe?.() + } + retainedMediaQueries.clear() +} + +function subscribeToMediaQuery (query: string): (() => void) | null { + if (mediaQueryAdapter?.subscribe != null) { + return mediaQueryAdapter.subscribe(query, markMediaChanged) + } + + if (!canUseBrowserMatchMedia()) return null + + const media = window.matchMedia(query) + const listener = () => { + markMediaChanged() + } + + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', listener) + return () => { + media.removeEventListener('change', listener) + } + } + + media.addListener(listener) + return () => { + media.removeListener(listener) + } +} + +function ensureWindowResizeListener (): void { + if (dimensionsAdapter != null) { + if (dimensionsAdapterUnsubscribe != null) return + dimensionsAdapterUnsubscribe = dimensionsAdapter.subscribe(() => { + applyDimensions(readWindowDimensions()) + }) + applyDimensions(readWindowDimensions()) + return + } + + if (resizeListener != null || typeof window === 'undefined') return + + resizeListener = () => { + const hasPendingTrailingUpdate = resizeTimer != null + if (resizeTimer != null) clearTimeout(resizeTimer) + + const delay = runtimeConfig.dimensionsDebounceMs + if (delay <= 0) { + applyDimensions(readWindowDimensions()) + return + } + + if (!hasPendingTrailingUpdate) { + applyDimensions(readWindowDimensions()) + } + + resizeTimer = setTimeout(() => { + resizeTimer = null + applyDimensions(readWindowDimensions()) + }, delay) + } + + window.addEventListener('resize', resizeListener) + applyDimensions(readWindowDimensions()) +} + +function removeWindowResizeListener (): void { + if (resizeTimer != null) { + clearTimeout(resizeTimer) + resizeTimer = null + } + + if (dimensionsAdapterUnsubscribe != null) { + dimensionsAdapterUnsubscribe() + dimensionsAdapterUnsubscribe = null + } + + if (resizeListener == null || typeof window === 'undefined') { + resizeListener = null + return + } + + window.removeEventListener('resize', resizeListener) + resizeListener = null +} + +function readWindowDimensions (): { width: number, height: number } { + if (dimensionsAdapter != null) return dimensionsAdapter.get() + + if (typeof window === 'undefined') return FALLBACK_DIMENSIONS + + return { + width: window.innerWidth || FALLBACK_DIMENSIONS.width, + height: window.innerHeight || FALLBACK_DIMENSIONS.height + } +} + +function readColorScheme (): CssxColorScheme { + if (colorSchemeAdapter != null) { + return colorSchemeAdapter.get() === 'dark' ? 'dark' : 'light' + } + + if (canUseBrowserMatchMedia() && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + + return 'light' +} + +function ensureColorSchemeListener (): void { + if (colorSchemeAdapter != null) { + if (colorSchemeAdapterUnsubscribe != null) return + colorSchemeAdapterUnsubscribe = colorSchemeAdapter.subscribe(() => { + applyColorScheme(readColorScheme()) + }) + applyColorScheme(readColorScheme()) + return + } + + if (!canUseBrowserMatchMedia() || colorSchemeAdapterUnsubscribe != null) return + + const media = window.matchMedia('(prefers-color-scheme: dark)') + const listener = () => { + applyColorScheme(readColorScheme()) + } + + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', listener) + colorSchemeAdapterUnsubscribe = () => { + media.removeEventListener('change', listener) + } + return + } + + media.addListener(listener) + colorSchemeAdapterUnsubscribe = () => { + media.removeListener(listener) + } +} + +function removeColorSchemeListener (): void { + if (colorSchemeAdapterUnsubscribe == null) return + colorSchemeAdapterUnsubscribe() + colorSchemeAdapterUnsubscribe = null +} + +function canUseBrowserMatchMedia (): boolean { + return ( + dimensionsAdapter == null && + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' + ) +} + +function stripMediaPrefix (query: string): string { + return query.trim().replace(/^@media\s+/i, '').trim() +} + +function mediaValues (next: { width: number, height: number }): Record { + return { + type: 'screen', + width: `${next.width}px`, + height: `${next.height}px`, + 'device-width': `${next.width}px`, + 'device-height': `${next.height}px`, + orientation: next.width >= next.height ? 'landscape' : 'portrait' + } +} + +function clearRecord (record: Record): void { + for (const key of Object.keys(record)) { + delete record[key] + } +} diff --git a/packages/css-to-rn/src/react/tracker.ts b/packages/css-to-rn/src/react/tracker.ts new file mode 100644 index 0000000..69bc48b --- /dev/null +++ b/packages/css-to-rn/src/react/tracker.ts @@ -0,0 +1,233 @@ +import type { CompiledCssSheet } from '../types.ts' +import { + createCssxCache, + type CssxCache +} from '../resolve.ts' +import { + createDependencySnapshot, + hasStaleDependencies, + retainMediaQuery, + subscribeRuntimeStore, + type CssxDependencyCollector, + type CssxDependencySnapshot, + type RuntimeChangeSnapshot +} from './store.ts' + +const TRACKED_SHEET = Symbol.for('cssx.trackedSheet') + +export interface TrackedCssxSheetOptions { + target?: 'react-native' | 'web' + values?: readonly unknown[] + cacheMaxEntries?: number +} + +export class TrackedCssxSheet implements CssxDependencyCollector { + readonly [TRACKED_SHEET] = true + + private sheet: CompiledCssSheet + private options: TrackedCssxSheetOptions + private pendingDependencies: CssxDependencySnapshot | null = null + private committedDependencies = createDependencySnapshot() + private listeners = new Set<() => void>() + private unsubscribeRuntimeStore: (() => void) | null = null + private mediaQueryReleases = new Map void>() + private snapshotVersion = 0 + private cache: CssxCache + + constructor (sheet: CompiledCssSheet, options: TrackedCssxSheetOptions = {}) { + this.sheet = sheet + this.options = options + this.cache = createCssxCache({ maxEntries: options.cacheMaxEntries }) + } + + getSheet (): CompiledCssSheet { + return this.sheet + } + + getOptions (): TrackedCssxSheetOptions { + return this.options + } + + getCache (): CssxCache { + return this.cache + } + + matches (sheet: CompiledCssSheet, options: TrackedCssxSheetOptions = {}): boolean { + return this.sheet === sheet && sameOptions(this.options, options) + } + + update (sheet: CompiledCssSheet, options: TrackedCssxSheetOptions = {}): void { + this.sheet = sheet + this.options = options + if (options.cacheMaxEntries !== this.cache.maxEntries) { + this.cache.maxEntries = options.cacheMaxEntries ?? this.cache.maxEntries + } + } + + startRender (): CssxDependencySnapshot { + this.pendingDependencies = createDependencySnapshot() + return this.pendingDependencies + } + + commitRender (dependencies: CssxDependencySnapshot | null = this.pendingDependencies): void { + if (dependencies == null) return + + if (this.pendingDependencies === dependencies) { + this.pendingDependencies = null + } + this.committedDependencies = dependencies + this.syncMediaQuerySubscriptions() + + if (hasStaleDependencies(dependencies)) { + this.emitChange() + } + } + + recordVariable (name: string, version: number): void { + this.pendingDependencies?.vars.set(name, version) + } + + recordMedia (query: string, matches: boolean): void { + this.pendingDependencies?.media.set(query, matches) + } + + recordDimensions (version: number): void { + if (this.pendingDependencies == null) return + this.pendingDependencies.dimensionsVersion = version + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + + if (this.unsubscribeRuntimeStore == null) { + this.unsubscribeRuntimeStore = subscribeRuntimeStore( + this.handleRuntimeChange, + () => this.committedDependencies + ) + this.syncMediaQuerySubscriptions() + } + + return () => { + this.listeners.delete(listener) + + if (this.listeners.size === 0 && this.unsubscribeRuntimeStore != null) { + this.unsubscribeRuntimeStore() + this.unsubscribeRuntimeStore = null + this.releaseMediaQuerySubscriptions() + } + } + } + + getSnapshot = (): number => { + return this.snapshotVersion + } + + getServerSnapshot = (): number => { + return this.snapshotVersion + } + + getCommittedDependenciesForTests (): CssxDependencySnapshot { + return cloneDependencySnapshot(this.committedDependencies) + } + + getPendingDependenciesForTests (): CssxDependencySnapshot | null { + return this.pendingDependencies == null + ? null + : cloneDependencySnapshot(this.pendingDependencies) + } + + private handleRuntimeChange = (_change: RuntimeChangeSnapshot): void => { + this.emitChange() + } + + private emitChange (): void { + this.snapshotVersion += 1 + for (const listener of Array.from(this.listeners)) { + listener() + } + } + + private syncMediaQuerySubscriptions (): void { + if (this.unsubscribeRuntimeStore == null) return + + const nextQueries = new Set(this.committedDependencies.media.keys()) + for (const [query, release] of Array.from(this.mediaQueryReleases)) { + if (nextQueries.has(query)) continue + release() + this.mediaQueryReleases.delete(query) + } + + for (const query of nextQueries) { + if (this.mediaQueryReleases.has(query)) continue + this.mediaQueryReleases.set(query, retainMediaQuery(query)) + } + } + + private releaseMediaQuerySubscriptions (): void { + for (const release of this.mediaQueryReleases.values()) { + release() + } + this.mediaQueryReleases.clear() + } +} + +export function isTrackedCssxSheet (value: unknown): value is TrackedCssxSheet { + return Boolean( + value != null && + typeof value === 'object' && + (value as { [TRACKED_SHEET]?: true })[TRACKED_SHEET] === true + ) +} + +export function createTrackedCssxSheet ( + sheet: CompiledCssSheet, + options: TrackedCssxSheetOptions = {} +): TrackedCssxSheet { + return new TrackedCssxSheet(sheet, options) +} + +function cloneDependencySnapshot ( + input: CssxDependencySnapshot +): CssxDependencySnapshot { + return { + vars: new Map(input.vars), + media: new Map(input.media), + dimensionsVersion: input.dimensionsVersion + } +} + +function sameOptions ( + left: TrackedCssxSheetOptions, + right: TrackedCssxSheetOptions +): boolean { + const keys = new Set([ + ...Object.keys(left), + ...Object.keys(right) + ]) + + for (const key of keys) { + const leftValue = left[key as keyof TrackedCssxSheetOptions] + const rightValue = right[key as keyof TrackedCssxSheetOptions] + if (key === 'values') { + if (!sameValues(leftValue as readonly unknown[] | undefined, rightValue as readonly unknown[] | undefined)) { + return false + } + continue + } + if (!Object.is(leftValue, rightValue)) return false + } + + return true +} + +function sameValues ( + left: readonly unknown[] | undefined, + right: readonly unknown[] | undefined +): boolean { + if (left == null || right == null) return left == null && right == null + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i++) { + if (!Object.is(left[i], right[i])) return false + } + return true +} diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts new file mode 100644 index 0000000..62cd927 --- /dev/null +++ b/packages/css-to-rn/src/resolve.ts @@ -0,0 +1,1185 @@ +import mediaQuery from 'css-mediaquery' +import { compileCss } from './compiler.ts' +import { diagnostic } from './diagnostics.ts' +import { simpleNumericHash } from './hash.ts' +import { transformDeclarations } from './transform/index.ts' +import type { + CssDeclaration, + TransformStyle, + TransformStyleValue +} from './transform/index.ts' +import { coerceCssValue, resolveCssValue } from './values.ts' +import type { + CompiledCssSheet, + CssxDeclaration, + CssxDiagnostic, + CssxKeyframe, + CssxRule, + CssxTarget +} from './types.ts' + +export type StyleNameValue = + | string + | number + | null + | undefined + | false + | Record + | readonly StyleNameValue[] + +export type CssxLayerInput = + | string + | CompiledCssSheet + | ResolveCssxLayer + +export interface ResolveCssxLayer { + sheet: CompiledCssSheet | string + values?: readonly unknown[] + cacheKey?: unknown +} + +export interface ResolveCssxOptions { + styleName: StyleNameValue + layers?: CssxLayerInput | readonly CssxLayerInput[] + inlineStyleProps?: InlineStyleInput + variables?: Record + scopedVariables?: readonly Record[] + defaultVariables?: Record + customMedia?: Record + dimensions?: CssxDimensions + mediaQueryEvaluator?: CssxMediaQueryEvaluator + target?: CssxTarget + componentTag?: string | null + theme?: string | null + cache?: boolean | CssxCache + cacheMaxEntries?: number +} + +export interface CssxDimensions { + width?: number + height?: number + type?: string +} + +export type CssxMediaQueryEvaluator = ( + query: string, + dimensions: CssxDimensions | undefined +) => boolean + +export type InlineStyleInput = + | TransformStyle + | ResolvedStyleProps + | null + | undefined + | false + +export interface ResolvedStyleProps { + [propName: string]: TransformStyleValue +} + +export interface ResolveCssxResult { + props: ResolvedStyleProps + diagnostics: CssxDiagnostic[] + dependencies: ResolveCssxDependencies + cacheHit: boolean +} + +export interface ResolveCssxDependencies { + vars: string[] + dimensions: boolean + media: string[] + mediaMatches?: Record + sheets: string[] +} + +export interface CssxCache { + maxEntries: number + entries: Map +} + +interface ResolveCacheEntry { + dynamicSignature: string + values: readonly unknown[] + result: ResolveCssxResult +} + +interface NormalizedLayer { + sheet: CompiledCssSheet + values: readonly unknown[] + cacheKey?: unknown +} + +interface MutableDependencies { + vars: Set + dimensions: boolean + media: Map + sheets: Set +} + +interface ResolutionContext { + target: CssxTarget + variables?: Record + scopedVariables?: readonly Record[] + defaultVariables?: Record + customMedia?: Record + dimensions?: CssxDimensions + mediaQueryEvaluator?: CssxMediaQueryEvaluator + componentTag?: string | null + theme: string + dependencies: MutableDependencies + diagnostics: CssxDiagnostic[] +} + +interface MatchedRule { + rule: CssxRule + layer: NormalizedLayer + layerIndex: number +} + +let lastRawCss: string | undefined +let lastRawSheet: CompiledCssSheet | undefined +let unknownIdentityCounter = 0 +const unknownObjectIds = new WeakMap() +const unknownPrimitiveIds = new Map() +const defaultCache = createCssxCache() +const DYNAMIC_ROOT_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g +const THEME_MEDIA_RE = /^\(--theme-([A-Za-z0-9_-]+)\)$/ +const CUSTOM_MEDIA_RE = /^\((--[A-Za-z0-9_-]+)\)$/ +const RANGE_MEDIA_RE = /^\((width|height)\s*(<=|>=|<|>)\s*(.+)\)$/ + +export function createCssxCache (options: { maxEntries?: number } = {}): CssxCache { + return { + maxEntries: options.maxEntries ?? 100, + entries: new Map() + } +} + +export function clearCssxRuntimeCachesForTests (): void { + lastRawCss = undefined + lastRawSheet = undefined + defaultCache.entries.clear() + unknownPrimitiveIds.clear() +} + +export function cssx ( + styleName: StyleNameValue, + layers?: CssxLayerInput | readonly CssxLayerInput[], + inlineStyleProps?: InlineStyleInput, + options: Omit = {} +): ResolvedStyleProps { + return resolveCssx({ + ...options, + styleName, + layers, + inlineStyleProps + }).props +} + +export function resolveCssx (options: ResolveCssxOptions): ResolveCssxResult { + const layers = normalizeLayers(options.layers) + const classNames = normalizeStyleName(options.styleName) + const inlineHash = hashInlineStyleProps(options.inlineStyleProps) + const values = flattenLayerValues(layers) + const cache = options.cache === false + ? undefined + : options.cache === true || options.cache == null + ? defaultCache + : options.cache + const stableKey = inlineHash == null + ? undefined + : createStableKey(options, classNames, layers, inlineHash) + const cached = cache && stableKey + ? cache.entries.get(stableKey) + : undefined + + if (cached && sameValues(cached.values, values)) { + const currentSignature = createDynamicSignature( + cached.result.dependencies, + options, + layers + ) + if (currentSignature === cached.dynamicSignature) { + return { + ...cached.result, + cacheHit: true + } + } + } + + const result = resolveCssxUncached(options, layers, classNames) + const dynamicSignature = createDynamicSignature(result.dependencies, options, layers) + + if (cache && stableKey) { + remember(cache, stableKey, { + dynamicSignature, + values, + result + }) + } + + return result +} + +function resolveCssxUncached ( + options: ResolveCssxOptions, + layers: readonly NormalizedLayer[], + classNames: readonly string[] +): ResolveCssxResult { + const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme) + const customMedia = collectCustomMedia(options.customMedia, layers) + const context: ResolutionContext = { + target: options.target ?? 'react-native', + variables: options.variables, + scopedVariables, + defaultVariables: options.defaultVariables, + customMedia, + dimensions: options.dimensions, + mediaQueryEvaluator: options.mediaQueryEvaluator, + componentTag: options.componentTag ?? null, + theme: normalizeTheme(options.theme), + dependencies: createDependencies(), + diagnostics: [], + } + const classSet = new Set(classNames) + const props: ResolvedStyleProps = {} + + for (const layer of layers) { + context.dependencies.sheets.add(layer.sheet.id) + context.diagnostics.push(...layer.sheet.diagnostics) + } + + const matchedRules = getMatchedRules(layers, classSet, context) + const byProp = new Map() + for (const matched of matchedRules) { + const propName = getPartPropName(matched.rule.part) + const rules = byProp.get(propName) + if (rules) rules.push(matched) + else byProp.set(propName, [matched]) + } + + for (const [propName, rules] of byProp) { + const style = resolvePropStyle(rules, context) + if (Object.keys(style).length > 0) mergeStyleProp(props, propName, style) + } + + mergeInlineStyleProps(props, options.inlineStyleProps, context) + + return { + props, + diagnostics: context.diagnostics, + dependencies: serializeDependencies(context.dependencies), + cacheHit: false + } +} + +function getMatchedRules ( + layers: readonly NormalizedLayer[], + classSet: ReadonlySet, + context: ResolutionContext +): MatchedRule[] { + const matched: MatchedRule[] = [] + + layers.forEach((layer, layerIndex) => { + for (const rule of layer.sheet.rules) { + if (!ruleMatchesTag(rule, context.componentTag)) continue + if (!ruleMatchesClasses(rule, classSet)) continue + if (!ruleMatchesMedia(rule, context)) continue + matched.push({ rule, layer, layerIndex }) + } + }) + + return matched.sort((left, right) => + left.layerIndex - right.layerIndex || + left.rule.specificity - right.rule.specificity || + left.rule.order - right.rule.order + ) +} + +function resolvePropStyle ( + rules: readonly MatchedRule[], + context: ResolutionContext +): TransformStyle { + const declarations: CssDeclaration[] = [] + const keyframeNames = new Set() + let order = 0 + + for (const matched of rules) { + for (const declaration of matched.rule.declarations) { + const resolved = resolveDeclarationValue(declaration, matched.layer, context) + if (!resolved) continue + declarations.push({ + property: declaration.property, + value: resolved, + raw: `${declaration.property}: ${resolved}`, + order: order++ + }) + } + } + + const transformed = transformDeclarations(declarations, { + platform: context.target, + keyframes: {}, + }) + context.diagnostics.push(...transformed.diagnostics.map(toCssxDiagnostic)) + + collectAnimationNames(transformed.style.animationName, keyframeNames) + if (keyframeNames.size > 0) { + const keyframes = resolveKeyframes(rules, keyframeNames, context) + inlineAnimationKeyframes(transformed.style, keyframes) + } + + return transformed.style +} + +function resolveDeclarationValue ( + declaration: CssxDeclaration, + layer: NormalizedLayer, + context: ResolutionContext +): string | undefined { + const result = resolveCssValue(declaration.value, { + values: layer.values, + variables: context.variables, + scopedVariables: context.scopedVariables, + defaultVariables: context.defaultVariables, + dimensions: context.dimensions + }) + + for (const varName of result.dependencies.vars) context.dependencies.vars.add(varName) + if (result.dependencies.dimensions) context.dependencies.dimensions = true + context.diagnostics.push(...result.diagnostics) + + return result.valid ? result.value : undefined +} + +function resolveKeyframes ( + rules: readonly MatchedRule[], + keyframeNames: ReadonlySet, + context: ResolutionContext +): Record { + const resolved: Record = {} + const seen = new Set() + + for (let index = rules.length - 1; index >= 0; index--) { + const layer = rules[index].layer + + for (const keyframeName of keyframeNames) { + if (seen.has(keyframeName)) continue + const keyframes = layer.sheet.keyframes[keyframeName] + if (!keyframes) continue + resolved[keyframeName] = resolveSingleKeyframes(keyframes, layer, context) + seen.add(keyframeName) + } + } + + return resolved +} + +function resolveSingleKeyframes ( + keyframes: readonly CssxKeyframe[], + layer: NormalizedLayer, + context: ResolutionContext +): TransformStyle { + const style: TransformStyle = {} + + for (const frame of keyframes) { + const declarations: CssDeclaration[] = [] + for (const declaration of frame.declarations) { + const resolved = resolveDeclarationValue(declaration, layer, context) + if (!resolved) continue + declarations.push({ + property: declaration.property, + value: resolved, + raw: `${declaration.property}: ${resolved}`, + order: declaration.order + }) + } + + const transformed = transformDeclarations(declarations, { + platform: context.target, + keyframes: {}, + }) + context.diagnostics.push(...transformed.diagnostics.map(toCssxDiagnostic)) + style[frame.selector] = transformed.style + } + + return style +} + +function inlineAnimationKeyframes ( + style: TransformStyle, + keyframes: Record +): void { + if (style.animationName == null) return + + if (Array.isArray(style.animationName)) { + style.animationName = style.animationName.map(value => + typeof value === 'string' && value !== 'none' && keyframes[value] != null + ? keyframes[value] + : value + ) + return + } + + if ( + typeof style.animationName === 'string' && + style.animationName !== 'none' && + keyframes[style.animationName] != null + ) { + style.animationName = keyframes[style.animationName] + } +} + +function collectAnimationNames ( + value: TransformStyleValue, + output: Set +): void { + if (typeof value === 'string') { + if (value !== 'none') output.add(value) + return + } + + if (!Array.isArray(value)) return + for (const item of value) collectAnimationNames(item, output) +} + +function ruleMatchesClasses ( + rule: CssxRule, + classSet: ReadonlySet +): boolean { + return rule.classes.every(className => classSet.has(className)) +} + +function ruleMatchesTag ( + rule: CssxRule, + componentTag: string | null | undefined +): boolean { + return rule.tag == null || rule.tag === componentTag +} + +function ruleMatchesMedia ( + rule: CssxRule, + context: ResolutionContext +): boolean { + if (!rule.media) return true + + const query = stripMediaPrefix(rule.media) + const result = evaluateCssxMediaQuery(query, { + variables: context.variables, + scopedVariables: context.scopedVariables, + defaultVariables: context.defaultVariables, + customMedia: context.customMedia, + dimensions: context.dimensions, + mediaQueryEvaluator: context.mediaQueryEvaluator, + theme: context.theme + }) + for (const varName of result.dependencies.vars) context.dependencies.vars.add(varName) + if (result.dependencies.dimensions) context.dependencies.dimensions = true + context.diagnostics.push(...result.diagnostics) + for (const [mediaQuery, matches] of Object.entries(result.dependencies.media)) { + context.dependencies.media.set(mediaQuery, matches) + } + return result.matches +} + +interface CssxMediaQueryEvaluationOptions { + variables?: Record + scopedVariables?: readonly Record[] + defaultVariables?: Record + customMedia?: Record + dimensions?: CssxDimensions + mediaQueryEvaluator?: CssxMediaQueryEvaluator + theme?: string | null +} + +interface CssxMediaQueryEvaluationResult { + matches: boolean + dependencies: { + vars: string[] + dimensions: boolean + media: Record + } + diagnostics: CssxDiagnostic[] +} + +export function evaluateCssxMediaQuery ( + query: string, + options: CssxMediaQueryEvaluationOptions +): CssxMediaQueryEvaluationResult { + const dependencies = { + vars: new Set(), + dimensions: false, + media: new Map() + } + const diagnostics: CssxDiagnostic[] = [] + const matches = matchesMediaQueryBranchList(query, options, dependencies, diagnostics, []) + + return { + matches, + dependencies: { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions, + media: Object.fromEntries(Array.from(dependencies.media.entries()).sort()) + }, + diagnostics + } +} + +function matchesMediaQuery ( + query: string, + dimensions: CssxDimensions | undefined, + evaluator?: CssxMediaQueryEvaluator, + theme?: string | null, + customMedia?: Record, + variables?: Record, + scopedVariables?: readonly Record[], + defaultVariables?: Record +): boolean { + return evaluateCssxMediaQuery(query, { + dimensions, + mediaQueryEvaluator: evaluator, + theme, + customMedia, + variables, + scopedVariables, + defaultVariables + }).matches +} + +function matchesMediaQueryBranchList ( + query: string, + options: CssxMediaQueryEvaluationOptions, + dependencies: { vars: Set, dimensions: boolean, media: Map }, + diagnostics: CssxDiagnostic[], + customMediaStack: string[] +): boolean { + const normalized = stripMediaPrefix(query) + const branches = splitTopLevelComma(normalized) + if (branches.length > 1) { + return branches.some(branch => matchesSingleMediaQuery(branch, options, dependencies, diagnostics, customMediaStack)) + } + + return matchesSingleMediaQuery(normalized, options, dependencies, diagnostics, customMediaStack) +} + +function matchesSingleMediaQuery ( + query: string, + options: CssxMediaQueryEvaluationOptions, + dependencies: { vars: Set, dimensions: boolean, media: Map }, + diagnostics: CssxDiagnostic[], + customMediaStack: string[] +): boolean { + const parts = splitTopLevelAnd(query) + const rest: string[] = [] + + for (const part of parts) { + const trimmed = part.trim() + const themeMatch = trimmed.match(THEME_MEDIA_RE) + if (themeMatch) { + if (!matchesThemeName(themeMatch[1], normalizeTheme(options.theme))) return false + continue + } + + const customMediaMatch = trimmed.match(CUSTOM_MEDIA_RE) + if (customMediaMatch && options.customMedia?.[customMediaMatch[1]] != null) { + const customMediaName = customMediaMatch[1] + if (customMediaStack.includes(customMediaName)) { + diagnostics.push(diagnostic( + 'INVALID_CUSTOM_MEDIA', + `Custom media cycle detected: ${customMediaStack.concat(customMediaName).join(' -> ')}.`, + 'warning' + )) + return false + } + if (!matchesMediaQueryBranchList( + options.customMedia[customMediaName], + options, + dependencies, + diagnostics, + customMediaStack.concat(customMediaName) + )) { + return false + } + continue + } + + const rangeMatch = trimmed.match(RANGE_MEDIA_RE) + if (rangeMatch) { + const rangeMatches = evaluateRangeMedia(rangeMatch, options, dependencies, diagnostics) + if (!rangeMatches) return false + continue + } + + if (trimmed) rest.push(trimmed) + } + + if (rest.length === 0) return true + + const restQuery = resolveMediaQueryValue(rest.join(' and '), options, dependencies, diagnostics) + if (restQuery == null) return false + const matches = options.mediaQueryEvaluator + ? options.mediaQueryEvaluator(restQuery, options.dimensions) + : matchesNativeMediaQuery(restQuery, options.dimensions) + + dependencies.media.set(restQuery, matches) + return matches +} + +function matchesNativeMediaQuery ( + query: string, + dimensions: CssxDimensions | undefined +): boolean { + try { + return mediaQuery.match(query, mediaValues(dimensions)) + } catch { + return false + } +} + +function evaluateRangeMedia ( + match: RegExpMatchArray, + options: CssxMediaQueryEvaluationOptions, + dependencies: { vars: Set, dimensions: boolean, media: Map }, + diagnostics: CssxDiagnostic[] +): boolean { + dependencies.dimensions = true + + const feature = match[1] as 'width' | 'height' + const operator = match[2] + const rawValue = match[3].trim() + const resolved = resolveMediaQueryValue(rawValue, options, dependencies, diagnostics) + const expected = resolved == null ? null : parseMediaLength(resolved) + if (expected == null) return false + + const actual = feature === 'width' + ? options.dimensions?.width ?? 0 + : options.dimensions?.height ?? 0 + + switch (operator) { + case '>=': + return actual >= expected + case '>': + return actual > expected + case '<=': + return actual <= expected + case '<': + return actual < expected + default: + return false + } +} + +function resolveMediaQueryValue ( + input: string, + options: CssxMediaQueryEvaluationOptions, + dependencies: { vars: Set, dimensions: boolean, media: Map }, + diagnostics: CssxDiagnostic[] +): string | null { + const result = resolveCssValue(input, { + variables: options.variables, + scopedVariables: options.scopedVariables, + defaultVariables: options.defaultVariables, + dimensions: options.dimensions + }) + + for (const varName of result.dependencies.vars) dependencies.vars.add(varName) + if (result.dependencies.dimensions) dependencies.dimensions = true + diagnostics.push(...result.diagnostics) + + return result.valid ? result.value ?? input : null +} + +function parseMediaLength (input: string): number | null { + const match = input.trim().match(/^([-+]?(?:\d*\.)?\d+)(px|rem|em|u)?$/i) + if (match == null) return null + + const number = Number(match[1]) + const unit = (match[2] ?? 'px').toLowerCase() + if (!Number.isFinite(number)) return null + if (unit === 'px') return number + if (unit === 'rem' || unit === 'em') return number * 16 + if (unit === 'u') return number * 8 + return null +} + +function mediaValues (dimensions: CssxDimensions | undefined): Record { + const width = dimensions?.width ?? 0 + const height = dimensions?.height ?? 0 + + return { + type: dimensions?.type ?? 'screen', + width: `${width}px`, + height: `${height}px`, + 'device-width': `${width}px`, + 'device-height': `${height}px`, + orientation: width >= height ? 'landscape' : 'portrait' + } +} + +function stripMediaPrefix (media: string): string { + return media.replace(/^@media\s*/i, '').trim() +} + +function splitTopLevelComma (input: string): string[] { + return splitTopLevelToken(input, ',') +} + +function splitTopLevelAnd (input: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + let index = 0 + + while (index < input.length) { + const char = input[index] + if (char === '(') depth += 1 + else if (char === ')') depth = Math.max(0, depth - 1) + else if ( + depth === 0 && + input.slice(index, index + 3).toLowerCase() === 'and' && + isWordBoundary(input[index - 1]) && + isWordBoundary(input[index + 3]) + ) { + parts.push(input.slice(start, index)) + index += 3 + start = index + continue + } + index += 1 + } + + parts.push(input.slice(start)) + return parts +} + +function splitTopLevelToken (input: string, token: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (char === '(') depth += 1 + else if (char === ')') depth = Math.max(0, depth - 1) + else if (depth === 0 && char === token) { + parts.push(input.slice(start, index)) + start = index + 1 + } + } + + parts.push(input.slice(start)) + return parts +} + +function isWordBoundary (char: string | undefined): boolean { + return char == null || !/[A-Za-z0-9_-]/.test(char) +} + +function matchesThemeName (queryTheme: string, activeTheme: string): boolean { + if (queryTheme === 'default' || queryTheme === 'light') { + return activeTheme === 'default' || activeTheme === 'light' + } + return queryTheme === activeTheme +} + +function normalizeTheme (theme: string | null | undefined): string { + return theme || 'default' +} + +function getPartPropName (part: string | null): string { + return part && part !== 'root' ? `${part}Style` : 'style' +} + +function normalizeLayers ( + layers: CssxLayerInput | readonly CssxLayerInput[] | undefined +): NormalizedLayer[] { + const input = layers == null + ? [] + : Array.isArray(layers) + ? layers + : [layers] + + return input.map(layer => { + if (typeof layer === 'string') { + return { sheet: compileRawCss(layer), values: [] } + } + + if (isCompiledSheet(layer)) { + return { sheet: layer, values: [] } + } + + const sheet = typeof layer.sheet === 'string' + ? compileRawCss(layer.sheet) + : layer.sheet + + return { + sheet, + values: layer.values ?? [], + cacheKey: layer.cacheKey + } + }) +} + +function compileRawCss (css: string): CompiledCssSheet { + if (css === lastRawCss && lastRawSheet) return lastRawSheet + lastRawCss = css + lastRawSheet = compileCss(css, { mode: 'runtime' }) + return lastRawSheet +} + +function isCompiledSheet (value: unknown): value is CompiledCssSheet { + return Boolean( + value && + typeof value === 'object' && + (value as { version?: unknown }).version === 1 && + Array.isArray((value as { rules?: unknown }).rules) + ) +} + +function normalizeStyleName (value: StyleNameValue): string[] { + const className = classcat(value) + return className.split(/\s+/).filter(Boolean).sort() +} + +function classcat (value: StyleNameValue): string { + if (value == null || value === false) return '' + if (typeof value === 'string' || typeof value === 'number') return value ? String(value) : '' + + if (Array.isArray(value)) { + let output = '' + for (const item of value) { + const nested = classcat(item) + if (nested) output += (output ? ' ' : '') + nested + } + return output + } + + let output = '' + const record = value as Record + for (const key of Object.keys(record)) { + if (record[key]) output += (output ? ' ' : '') + key + } + return output +} + +function mergeInlineStyleProps ( + props: ResolvedStyleProps, + inlineStyleProps: InlineStyleInput, + context: ResolutionContext +): void { + if (!inlineStyleProps) return + + if (isStylePropsInput(inlineStyleProps)) { + for (const propName of Object.keys(inlineStyleProps)) { + mergeStyleProp(props, propName, resolveInlineStyleValue(inlineStyleProps[propName], context)) + } + return + } + + mergeStyleProp(props, 'style', resolveInlineStyleValue(inlineStyleProps, context)) +} + +function isStylePropsInput (value: TransformStyle | ResolvedStyleProps): value is ResolvedStyleProps { + return Object.keys(value).some(key => key === 'style' || key.endsWith('Style')) +} + +function mergeStyleProp ( + props: ResolvedStyleProps, + propName: string, + style: TransformStyleValue +): void { + if (style == null || style === false) return + + const current = props[propName] + const flattened: TransformStyle = {} + flattenStyleInto(current, flattened) + flattenStyleInto(style, flattened) + props[propName] = flattened +} + +function flattenStyleInto ( + value: TransformStyleValue, + output: TransformStyle +): void { + if (value == null || value === false) return + if (Array.isArray(value)) { + for (const item of value) flattenStyleInto(item, output) + return + } + if (typeof value === 'object') Object.assign(output, value) +} + +function resolveInlineStyleValue ( + value: TransformStyleValue, + context: ResolutionContext +): TransformStyleValue { + if (typeof value === 'string') { + const result = resolveCssValue(value, { + variables: context.variables, + scopedVariables: context.scopedVariables, + defaultVariables: context.defaultVariables, + dimensions: context.dimensions + }) + + for (const varName of result.dependencies.vars) context.dependencies.vars.add(varName) + if (result.dependencies.dimensions) context.dependencies.dimensions = true + context.diagnostics.push(...result.diagnostics) + + return result.valid + ? coerceCssValue(result.value) as TransformStyleValue + : undefined + } + + if (Array.isArray(value)) { + return value.map(item => resolveInlineStyleValue(item, context)) + } + + if (value && typeof value === 'object') { + if (!isPlainObject(value)) return value + + const output: TransformStyle = {} + for (const [key, child] of Object.entries(value)) { + output[key] = resolveInlineStyleValue(child, context) + } + return output + } + + return value +} + +function isPlainObject (value: object): value is Record { + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function createStableKey ( + options: ResolveCssxOptions, + classNames: readonly string[], + layers: readonly NormalizedLayer[], + inlineHash: string +): string { + return JSON.stringify({ + target: options.target ?? 'react-native', + componentTag: options.componentTag ?? null, + styleName: classNames, + inline: inlineHash, + layers: layers.map(layer => ({ + id: layer.sheet.id, + contentHash: layer.sheet.contentHash, + cacheKey: layer.cacheKey == null ? undefined : identityFor(layer.cacheKey) + })) + }) +} + +function createDynamicSignature ( + dependencies: ResolveCssxDependencies, + options: ResolveCssxOptions, + layers: readonly NormalizedLayer[] +): string { + const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme) + const customMedia = collectCustomMedia(options.customMedia, layers) + return JSON.stringify({ + theme: normalizeTheme(options.theme), + vars: dependencies.vars.map(name => [ + name, + valueFromRecord(options.variables, name) ?? + valueFromScopedRecords(scopedVariables, name) ?? + valueFromRecord(options.defaultVariables, name) + ]), + dimensions: dependencies.dimensions + ? { + width: options.dimensions?.width ?? 0, + height: options.dimensions?.height ?? 0, + type: options.dimensions?.type ?? 'screen' + } + : undefined, + media: dependencies.media.map(query => [ + query, + matchesMediaQuery( + query, + options.dimensions, + options.mediaQueryEvaluator, + options.theme, + customMedia, + options.variables, + scopedVariables, + options.defaultVariables + ) + ]) + }) +} + +function hashInlineStyleProps (inlineStyleProps: InlineStyleInput): string | undefined { + if (!inlineStyleProps) return '0' + + try { + return String(simpleNumericHash(JSON.stringify(inlineStyleProps))) + } catch { + return undefined + } +} + +function flattenLayerValues (layers: readonly NormalizedLayer[]): readonly unknown[] { + const values: unknown[] = [] + for (const layer of layers) values.push(...layer.values) + return values +} + +function collectScopedVariables ( + explicitScopes: readonly Record[] | undefined, + layers: readonly NormalizedLayer[], + theme?: string | null +): readonly Record[] | undefined { + const scopes: Record[] = explicitScopes ? [...explicitScopes] : [] + const activeTheme = normalizeTheme(theme) + + for (const layer of layers) { + if (layer.sheet.rootVariables != null) { + scopes.push(applyLayerValuesToRootVariables(layer.sheet.rootVariables, layer.values)) + } + const themeRootVariables = getThemeVariables(layer.sheet, activeTheme) + if (themeRootVariables != null) { + scopes.push(applyLayerValuesToRootVariables(themeRootVariables, layer.values)) + } + } + + return scopes.length > 0 ? scopes : undefined +} + +function collectCustomMedia ( + base: Record | undefined, + layers: readonly NormalizedLayer[] +): Record | undefined { + let customMedia: Record | undefined = base == null + ? undefined + : { ...base } + + for (const layer of layers) { + if (layer.sheet.customMedia == null) continue + customMedia ??= {} + Object.assign(customMedia, applyLayerValuesToRootVariables(layer.sheet.customMedia, layer.values)) + } + + return customMedia +} + +function getThemeVariables ( + sheet: CompiledCssSheet, + theme: string +): Record | undefined { + if (sheet.themeVariables == null) return undefined + if (theme === 'light') return sheet.themeVariables.light ?? sheet.themeVariables.default + if (theme === 'default') return sheet.themeVariables.default + return sheet.themeVariables[theme] +} + +function applyLayerValuesToRootVariables ( + rootVariables: Record, + values: readonly unknown[] +): Record { + if (values.length === 0) return rootVariables + + const output: Record = {} + for (const name of Object.keys(rootVariables)) { + const value = rootVariables[name] + let valid = true + const next = value.replace(DYNAMIC_ROOT_SLOT_RE, (_match, rawIndex: string) => { + const interpolation = values[Number(rawIndex)] + if (typeof interpolation === 'string') return interpolation + if (typeof interpolation === 'number') return String(interpolation) + valid = false + return '' + }) + if (valid) output[name] = next + } + return output +} + +function sameValues ( + left: readonly unknown[], + right: readonly unknown[] +): boolean { + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index++) { + if (!Object.is(left[index], right[index])) return false + } + return true +} + +function remember ( + cache: CssxCache, + key: string, + entry: ResolveCacheEntry +): void { + cache.entries.delete(key) + cache.entries.set(key, entry) + + while (cache.entries.size > cache.maxEntries) { + const oldestKey = cache.entries.keys().next().value + if (oldestKey == null) break + cache.entries.delete(oldestKey) + } +} + +function identityFor (value: unknown): string { + if (value && (typeof value === 'object' || typeof value === 'function')) { + const object = value as object + const existing = unknownObjectIds.get(object) + if (existing != null) return `o:${existing}` + const id = ++unknownIdentityCounter + unknownObjectIds.set(object, id) + return `o:${id}` + } + + const existing = unknownPrimitiveIds.get(value) + if (existing != null) return `p:${existing}` + const id = ++unknownIdentityCounter + unknownPrimitiveIds.set(value, id) + return `p:${id}` +} + +function createDependencies (): MutableDependencies { + return { + vars: new Set(), + dimensions: false, + media: new Map(), + sheets: new Set() + } +} + +function serializeDependencies ( + dependencies: MutableDependencies +): ResolveCssxDependencies { + return { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions, + media: Array.from(dependencies.media.keys()).sort(), + mediaMatches: Object.fromEntries(Array.from(dependencies.media.entries()).sort(([left], [right]) => left.localeCompare(right))), + sheets: Array.from(dependencies.sheets).sort() + } +} + +function toCssxDiagnostic (item: { + code: CssxDiagnostic['code'] + message: string +}): CssxDiagnostic { + return diagnostic(item.code, item.message, 'warning') +} + +function valueFromRecord (record: Record | undefined, key: string): unknown { + if (!record || !Object.prototype.hasOwnProperty.call(record, key)) return undefined + return record[key] +} + +function valueFromScopedRecords ( + records: readonly Record[] | undefined, + key: string +): unknown { + if (!records) return undefined + + for (let index = records.length - 1; index >= 0; index--) { + const value = valueFromRecord(records[index], key) + if (value !== undefined) return value + } + + return undefined +} diff --git a/packages/css-to-rn/src/selectors.ts b/packages/css-to-rn/src/selectors.ts new file mode 100644 index 0000000..3a12d5e --- /dev/null +++ b/packages/css-to-rn/src/selectors.ts @@ -0,0 +1,90 @@ +import { diagnostic } from './diagnostics.ts' +import type { CssxDiagnostic, SelectorParseResult } from './types.ts' + +const PART_RE = /::?part\(([^)]+)\)/ +const CLASS_RE = /\.([_a-zA-Z][-_a-zA-Z0-9]*)/g +const TAG_RE = /^[_a-zA-Z][-_a-zA-Z0-9]*/ +const PSEUDO_PARTS: Record = { + ':hover': 'hover', + ':active': 'active' +} + +export function parseSelector (selector: string, position?: { line?: number, column?: number }): { + result?: SelectorParseResult + diagnostic?: CssxDiagnostic +} { + const original = selector.trim() + let current = original + let part: string | null = null + + const partMatch = current.match(PART_RE) + if (partMatch) { + part = partMatch[1].trim() + current = ( + current.slice(0, partMatch.index) + + current.slice((partMatch.index ?? 0) + partMatch[0].length) + ).trim() + } else { + for (const pseudo of Object.keys(PSEUDO_PARTS)) { + if (current.endsWith(pseudo)) { + part = PSEUDO_PARTS[pseudo] + current = current.slice(0, -pseudo.length).trim() + break + } + } + } + + if ( + current.includes(' ') || + current.includes('>') || + current.includes('+') || + current.includes('~') || + current.includes('[') || + current.includes('#') || + current.includes(':') + ) { + return unsupported(original, position) + } + + const tagMatch = current.startsWith('.') ? null : current.match(TAG_RE) + const tag = tagMatch?.[0] ?? null + const classPart = tag == null ? current : current.slice(tag.length) + + if (classPart && !classPart.startsWith('.')) { + return unsupported(original, position) + } + + const classes: string[] = [] + CLASS_RE.lastIndex = 0 + let consumed = '' + let match: RegExpExecArray | null + while ((match = CLASS_RE.exec(classPart)) != null) { + classes.push(match[1]) + consumed += match[0] + } + + if (consumed !== classPart || (tag == null && classes.length === 0)) { + return unsupported(original, position) + } + + return { + result: { + selector: original, + tag, + classes, + part, + specificity: classes.length + } + } +} + +function unsupported (selector: string, position?: { line?: number, column?: number }) { + return { + diagnostic: diagnostic( + 'UNSUPPORTED_SELECTOR', + `Unsupported selector "${selector}" ignored. CSSX supports class selectors, component tag selectors, and :part()/:hover/:active only.`, + 'warning', + position + ) + } +} diff --git a/packages/css-to-rn/src/transform/index.ts b/packages/css-to-rn/src/transform/index.ts new file mode 100644 index 0000000..b2448d3 --- /dev/null +++ b/packages/css-to-rn/src/transform/index.ts @@ -0,0 +1,1608 @@ +export type CssPlatform = 'react-native' | 'web' + +export type TransformStyleValue = + | string + | number + | boolean + | null + | undefined + | TransformStyle + | TransformStyleValue[] + +export interface TransformStyle { + [property: string]: TransformStyleValue +} + +export interface CssDeclaration { + property: string + raw?: string + value?: string + order?: number +} + +export interface TransformDeclarationOptions { + platform?: CssPlatform + keyframes?: Record + onInvalid?: 'diagnose' | 'throw' + shorthandBlacklist?: readonly string[] +} + +export type TransformDiagnosticCode = + | 'INVALID_DECLARATION' + | 'UNSUPPORTED_BACKGROUND_IMAGE' + | 'UNSUPPORTED_BACKGROUND_SHORTHAND' + +export interface TransformDiagnostic { + code: TransformDiagnosticCode + property: string + value: string + message: string + order?: number +} + +export interface TransformDeclarationResult { + style: TransformStyle + diagnostics: TransformDiagnostic[] +} + +interface PropertyTransformContext { + platform: CssPlatform + keyframes: Record +} + +interface PropertyTransformResult { + style: TransformStyle + diagnostics?: TransformDiagnostic[] +} + +type PropertyTransform = ( + property: string, + value: string, + declaration: CssDeclaration, + context: PropertyTransformContext +) => PropertyTransformResult + +const numberPattern = + '[+-]?(?:(?:\\d+\\.\\d+)|(?:\\d+\\.)|(?:\\.\\d+)|(?:\\d+))(?:e[+-]?\\d+)?' +const numberRe = new RegExp(`^${numberPattern}$`, 'i') +const numberOrLengthRe = new RegExp(`^(${numberPattern})([a-z%]*)$`, 'i') +const timeRe = new RegExp(`^${numberPattern}(?:ms|s)$`, 'i') +const angleRe = new RegExp(`^${numberPattern}(?:deg|rad|grad|turn)$`, 'i') +const hexColorRe = /^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))$/i +const colorFunctionRe = + /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|gray|color)\(/i +const supportedLengthUnits = new Set([ + 'ch', + 'cm', + 'em', + 'ex', + 'in', + 'mm', + 'pc', + 'pt', + 'rem', + 'vh', + 'vmax', + 'vmin', + 'vw', +]) +const borderStyles = new Set([ + 'solid', + 'dashed', + 'dotted', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', +]) +const timingFunctionKeywords = new Set([ + 'ease', + 'linear', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'step-start', + 'step-end', +]) +const animationDirectionKeywords = new Set([ + 'normal', + 'reverse', + 'alternate', + 'alternate-reverse', +]) +const animationFillModeKeywords = new Set([ + 'none', + 'forwards', + 'backwards', + 'both', +]) +const animationPlayStateKeywords = new Set(['running', 'paused']) +const cssColorKeywords = new Set([ + 'aliceblue', + 'antiquewhite', + 'aqua', + 'aquamarine', + 'azure', + 'beige', + 'bisque', + 'black', + 'blanchedalmond', + 'blue', + 'blueviolet', + 'brown', + 'burlywood', + 'cadetblue', + 'chartreuse', + 'chocolate', + 'coral', + 'cornflowerblue', + 'cornsilk', + 'crimson', + 'cyan', + 'darkblue', + 'darkcyan', + 'darkgoldenrod', + 'darkgray', + 'darkgreen', + 'darkgrey', + 'darkkhaki', + 'darkmagenta', + 'darkolivegreen', + 'darkorange', + 'darkorchid', + 'darkred', + 'darksalmon', + 'darkseagreen', + 'darkslateblue', + 'darkslategray', + 'darkslategrey', + 'darkturquoise', + 'darkviolet', + 'deeppink', + 'deepskyblue', + 'dimgray', + 'dimgrey', + 'dodgerblue', + 'firebrick', + 'floralwhite', + 'forestgreen', + 'fuchsia', + 'gainsboro', + 'ghostwhite', + 'gold', + 'goldenrod', + 'gray', + 'green', + 'greenyellow', + 'grey', + 'honeydew', + 'hotpink', + 'indianred', + 'indigo', + 'ivory', + 'khaki', + 'lavender', + 'lavenderblush', + 'lawngreen', + 'lemonchiffon', + 'lightblue', + 'lightcoral', + 'lightcyan', + 'lightgoldenrodyellow', + 'lightgray', + 'lightgreen', + 'lightgrey', + 'lightpink', + 'lightsalmon', + 'lightseagreen', + 'lightskyblue', + 'lightslategray', + 'lightslategrey', + 'lightsteelblue', + 'lightyellow', + 'lime', + 'limegreen', + 'linen', + 'magenta', + 'maroon', + 'mediumaquamarine', + 'mediumblue', + 'mediumorchid', + 'mediumpurple', + 'mediumseagreen', + 'mediumslateblue', + 'mediumspringgreen', + 'mediumturquoise', + 'mediumvioletred', + 'midnightblue', + 'mintcream', + 'mistyrose', + 'moccasin', + 'navajowhite', + 'navy', + 'oldlace', + 'olive', + 'olivedrab', + 'orange', + 'orangered', + 'orchid', + 'palegoldenrod', + 'palegreen', + 'paleturquoise', + 'palevioletred', + 'papayawhip', + 'peachpuff', + 'peru', + 'pink', + 'plum', + 'powderblue', + 'purple', + 'rebeccapurple', + 'red', + 'rosybrown', + 'royalblue', + 'saddlebrown', + 'salmon', + 'sandybrown', + 'seagreen', + 'seashell', + 'sienna', + 'silver', + 'skyblue', + 'slateblue', + 'slategray', + 'slategrey', + 'snow', + 'springgreen', + 'steelblue', + 'tan', + 'teal', + 'thistle', + 'tomato', + 'transparent', + 'turquoise', + 'violet', + 'wheat', + 'white', + 'whitesmoke', + 'yellow', + 'yellowgreen', +]) + +const shorthandTransforms: Record = { + animation: transformAnimation, + animationDelay: transformAnimationLonghand, + animationDirection: transformAnimationLonghand, + animationDuration: transformAnimationLonghand, + animationFillMode: transformAnimationLonghand, + animationIterationCount: transformAnimationLonghand, + animationName: transformAnimationLonghand, + animationPlayState: transformAnimationLonghand, + animationTimingFunction: transformAnimationLonghand, + background: transformBackground, + backgroundImage: transformBackgroundImage, + border: transformBorder, + borderColor: transformDirectionalColor, + borderRadius: transformBorderRadius, + borderStyle: transformDirectionalBorderStyle, + borderWidth: transformDirectionalWidth, + boxShadow: passthroughString, + filter: passthroughString, + lineHeight: transformLineHeight, + margin: transformMargin, + padding: transformPadding, + textShadow: transformTextShadow, + transform: transformTransform, + transition: transformTransition, + transitionDelay: transformTransitionLonghand, + transitionDuration: transformTransitionLonghand, + transitionProperty: transformTransitionLonghand, + transitionTimingFunction: transformTransitionLonghand, +} + +export function transformDeclarations ( + declarations: readonly CssDeclaration[], + options: TransformDeclarationOptions = {} +): TransformDeclarationResult { + const style: TransformStyle = {} + const diagnostics: TransformDiagnostic[] = [] + const shorthandBlacklist = new Set(options.shorthandBlacklist ?? []) + const context: PropertyTransformContext = { + platform: options.platform ?? 'react-native', + keyframes: options.keyframes ?? {}, + } + + const orderedDeclarations = declarations + .map((declaration, index) => ({ declaration, index })) + .sort((left, right) => { + const leftOrder = left.declaration.order ?? left.index + const rightOrder = right.declaration.order ?? right.index + return leftOrder - rightOrder || left.index - right.index + }) + + for (const { declaration } of orderedDeclarations) { + const property = getPropertyName(declaration.property) + const value = getDeclarationValue(declaration) + + if (property.startsWith('--')) continue + if (value.length === 0) continue + + try { + const transformer = shorthandBlacklist.has(property) + ? undefined + : shorthandTransforms[property] + const result = + transformer == null + ? transformRawProperty(property, value) + : transformer(property, value, declaration, context) + + Object.assign(style, result.style) + if (result.diagnostics != null) diagnostics.push(...result.diagnostics) + } catch (error) { + if (options.onInvalid === 'throw') throw error + diagnostics.push({ + code: 'INVALID_DECLARATION', + property: declaration.property, + value, + message: + error instanceof Error + ? error.message + : `Failed to parse declaration "${declaration.property}: ${value}"`, + order: declaration.order, + }) + } + } + + inlineAnimationKeyframes(style, context.keyframes) + + return { style, diagnostics } +} + +export function getPropertyName (property: string): string { + const trimmed = property.trim() + if (trimmed.startsWith('--')) return trimmed + + return trimmed.replace(/-([a-z])/g, (_, character: string) => + character.toUpperCase() + ) +} + +export function transformRawValue (value: string): TransformStyleValue { + const trimmed = value.trim() + const numberMatch = trimmed.match(numberOrLengthRe) + + if (numberMatch != null) { + const number = Number(numberMatch[1]) + const unit = numberMatch[2].toLowerCase() + + if (unit === '' || unit === 'px') return number + if (unit === 'u') return number * 8 + } + + if (/^(?:true|false)$/i.test(trimmed)) { + return trimmed.toLowerCase() === 'true' + } + if (/^null$/i.test(trimmed)) return null + if (/^undefined$/i.test(trimmed)) return undefined + + return trimmed +} + +function getDeclarationValue (declaration: CssDeclaration): string { + if (typeof declaration.value === 'string') return declaration.value.trim() + if (typeof declaration.raw === 'string') { + const raw = declaration.raw.trim() + const colonIndex = raw.indexOf(':') + if (colonIndex === -1) return raw + return raw.slice(colonIndex + 1).replace(/;$/, '').trim() + } + return '' +} + +function transformRawProperty ( + property: string, + value: string +): PropertyTransformResult { + return { style: { [property]: transformRawValue(value) } } +} + +function passthroughString ( + property: string, + value: string +): PropertyTransformResult { + return { style: { [property]: value.trim() } } +} + +function transformLineHeight ( + property: string, + value: string, + _declaration: CssDeclaration, + context: PropertyTransformContext +): PropertyTransformResult { + const trimmed = value.trim() + const match = trimmed.match(numberOrLengthRe) + + if (match == null) return transformRawProperty(property, value) + + const number = Number(match[1]) + const unit = match[2].toLowerCase() + + if (unit === '') return { style: { [property]: number } } + + if (unit === 'px') { + return { + style: { + [property]: context.platform === 'web' + ? `${number}px` + : number + } + } + } + + if (unit === 'u') { + const pixels = number * 8 + return { + style: { + [property]: context.platform === 'web' + ? `${pixels}px` + : pixels + } + } + } + + return { style: { [property]: trimmed } } +} + +function transformMargin ( + property: string, + value: string +): PropertyTransformResult { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: property, + values: parseDirectionalValues(value, valueToken => + parseLength(valueToken, { allowAuto: true, allowPercent: true }) + ), + }), + } +} + +function transformPadding ( + property: string, + value: string +): PropertyTransformResult { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: property, + values: parseDirectionalValues(value, valueToken => + parseLength(valueToken, { allowPercent: true }) + ), + }), + } +} + +function transformDirectionalWidth ( + property: string, + value: string +): PropertyTransformResult { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: 'border', + suffix: 'Width', + values: parseDirectionalValues(value, valueToken => + parseLength(valueToken, { allowPercent: false }) + ), + }), + } +} + +function transformDirectionalColor ( + property: string, + value: string +): PropertyTransformResult { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: 'border', + suffix: 'Color', + values: parseDirectionalValues(value, parseColor), + }), + } +} + +function transformDirectionalBorderStyle ( + property: string, + value: string +): PropertyTransformResult { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: 'border', + suffix: 'Style', + values: parseDirectionalValues(value, parseBorderStyle), + }), + } +} + +function transformBorderRadius ( + property: string, + value: string +): PropertyTransformResult { + if (value.includes('/')) { + throw new Error(`Unsupported elliptical border-radius "${value}"`) + } + + return { + style: expandDirectionalValues({ + directions: ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'], + prefix: 'border', + suffix: 'Radius', + values: parseDirectionalValues(value, valueToken => + parseLength(valueToken, { allowPercent: false }) + ), + }), + } +} + +function transformBorder ( + property: string, + value: string +): PropertyTransformResult { + const trimmed = value.trim() + if (trimmed.toLowerCase() === 'none') { + return { + style: { + borderWidth: 0, + borderColor: 'black', + borderStyle: 'solid', + }, + } + } + + const tokens = splitByWhitespace(trimmed) + if (tokens.length === 0 || tokens.length > 3) { + throw new Error(`Unsupported border shorthand "${value}"`) + } + + let borderWidth: TransformStyleValue | undefined + let borderColor: string | undefined + let borderStyle: string | undefined + + for (const token of tokens) { + if (borderWidth === undefined && isLength(token, false)) { + borderWidth = parseLength(token, { allowPercent: false }) + } else if (borderColor === undefined && isColor(token)) { + borderColor = token + } else if ( + borderStyle === undefined && + borderStyles.has(token.toLowerCase()) + ) { + borderStyle = token.toLowerCase() + } else { + throw new Error(`Unsupported border shorthand "${value}"`) + } + } + + return { + style: { + borderWidth: borderWidth ?? 1, + borderColor: borderColor ?? 'black', + borderStyle: borderStyle ?? 'solid', + }, + } +} + +function transformTransform ( + property: string, + value: string +): PropertyTransformResult { + if (value.trim().toLowerCase() === 'none') { + return { style: { transform: [] } } + } + + const parts = parseFunctionSequence(value) + const transforms: TransformStyleValue[] = [] + + for (const part of parts) { + const args = parseFunctionArguments(part.arguments) + const transformed = transformTransformFunction(part.name, args) + transforms.unshift(...transformed) + } + + return { style: { transform: transforms } } +} + +function transformTransformFunction ( + name: string, + args: readonly string[] +): TransformStyle[] { + if (name === 'perspective') { + expectArgumentCount(name, args, 1, 1) + return [{ perspective: parseNumber(args[0]) }] + } + + if (name === 'scale') { + expectArgumentCount(name, args, 1, 2) + const x = parseNumber(args[0]) + if (args.length === 1) return [{ scale: x }] + return [{ scaleY: parseNumber(args[1]) }, { scaleX: x }] + } + + if (name === 'scaleX' || name === 'scaleY') { + expectArgumentCount(name, args, 1, 1) + return [{ [name]: parseNumber(args[0]) }] + } + + if (name === 'translate') { + expectArgumentCount(name, args, 1, 2) + const x = parseLength(args[0], { allowPercent: true }) + const y = + args.length === 2 ? parseLength(args[1], { allowPercent: true }) : 0 + return [{ translateY: y }, { translateX: x }] + } + + if (name === 'translateX' || name === 'translateY') { + expectArgumentCount(name, args, 1, 1) + return [{ [name]: parseLength(args[0], { allowPercent: true }) }] + } + + if ( + name === 'rotate' || + name === 'rotateX' || + name === 'rotateY' || + name === 'rotateZ' || + name === 'skewX' || + name === 'skewY' + ) { + expectArgumentCount(name, args, 1, 1) + return [{ [name]: parseAngle(args[0]) }] + } + + if (name === 'skew') { + expectArgumentCount(name, args, 1, 2) + return [ + { skewY: args.length === 2 ? parseAngle(args[1]) : '0deg' }, + { skewX: parseAngle(args[0]) }, + ] + } + + throw new Error(`Unsupported transform function "${name}"`) +} + +function transformTextShadow ( + property: string, + value: string +): PropertyTransformResult { + const trimmed = value.trim() + if (trimmed.toLowerCase() === 'none') { + return { + style: { + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 0, + textShadowColor: 'black', + }, + } + } + + const tokens = splitByWhitespace(trimmed) + let color: string | undefined + const lengths: TransformStyleValue[] = [] + + for (const token of tokens) { + if (color === undefined && isColor(token)) { + color = token + } else if (isLength(token, false)) { + lengths.push(parseLength(token, { allowPercent: false })) + } else { + throw new Error(`Unsupported text-shadow "${value}"`) + } + } + + if (lengths.length < 2 || lengths.length > 3) { + throw new Error(`Unsupported text-shadow "${value}"`) + } + + return { + style: { + textShadowOffset: { width: lengths[0], height: lengths[1] }, + textShadowRadius: lengths[2] ?? 0, + textShadowColor: color ?? 'black', + }, + } +} + +function transformAnimation ( + property: string, + value: string +): PropertyTransformResult { + const trimmed = value.trim() + if (trimmed.toLowerCase() === 'none') { + return { + style: { + animationName: 'none', + animationDuration: '0s', + animationTimingFunction: 'ease', + animationDelay: '0s', + animationIterationCount: 1, + animationDirection: 'normal', + animationFillMode: 'none', + animationPlayState: 'running', + }, + } + } + + const animations = splitTopLevel(trimmed, ',').map(parseSingleAnimation) + const isSingle = animations.length === 1 + + return { + style: { + animationName: singleOrArray( + animations.map(animation => animation.name), + isSingle + ), + animationDuration: singleOrArray( + animations.map(animation => animation.duration), + isSingle + ), + animationTimingFunction: singleOrArray( + animations.map(animation => animation.timingFunction), + isSingle + ), + animationDelay: singleOrArray( + animations.map(animation => animation.delay), + isSingle + ), + animationIterationCount: singleOrArray( + animations.map(animation => animation.iterationCount), + isSingle + ), + animationDirection: singleOrArray( + animations.map(animation => animation.direction), + isSingle + ), + animationFillMode: singleOrArray( + animations.map(animation => animation.fillMode), + isSingle + ), + animationPlayState: singleOrArray( + animations.map(animation => animation.playState), + isSingle + ), + }, + } +} + +function transformAnimationLonghand ( + property: string, + value: string +): PropertyTransformResult { + if (property === 'animationName') { + return { + style: { animationName: parseCommaSeparated(value, parseIdentifier) }, + } + } + if (property === 'animationDuration') { + return { + style: { animationDuration: parseCommaSeparated(value, parseTime) }, + } + } + if (property === 'animationTimingFunction') { + return { + style: { + animationTimingFunction: parseCommaSeparated( + value, + parseTimingFunction + ), + }, + } + } + if (property === 'animationDelay') { + return { style: { animationDelay: parseCommaSeparated(value, parseTime) } } + } + if (property === 'animationIterationCount') { + return { + style: { + animationIterationCount: parseCommaSeparated( + value, + parseIterationCount + ), + }, + } + } + if (property === 'animationDirection') { + return { + style: { + animationDirection: parseCommaSeparated(value, valueToken => + parseKeyword(valueToken, animationDirectionKeywords) + ), + }, + } + } + if (property === 'animationFillMode') { + return { + style: { + animationFillMode: parseCommaSeparated(value, valueToken => + parseKeyword(valueToken, animationFillModeKeywords) + ), + }, + } + } + if (property === 'animationPlayState') { + return { + style: { + animationPlayState: parseCommaSeparated(value, valueToken => + parseKeyword(valueToken, animationPlayStateKeywords) + ), + }, + } + } + + throw new Error(`Unsupported animation property "${property}"`) +} + +function transformTransition ( + property: string, + value: string +): PropertyTransformResult { + const trimmed = value.trim() + if (trimmed.toLowerCase() === 'none') { + return { + style: { + transitionProperty: 'none', + transitionDuration: '0s', + transitionTimingFunction: 'ease', + transitionDelay: '0s', + }, + } + } + + const transitions = splitTopLevel(trimmed, ',').map(parseSingleTransition) + const isSingle = transitions.length === 1 + + return { + style: { + transitionProperty: singleOrArray( + transitions.map(transition => transition.property), + isSingle + ), + transitionDuration: singleOrArray( + transitions.map(transition => transition.duration), + isSingle + ), + transitionTimingFunction: singleOrArray( + transitions.map(transition => transition.timingFunction), + isSingle + ), + transitionDelay: singleOrArray( + transitions.map(transition => transition.delay), + isSingle + ), + }, + } +} + +function transformTransitionLonghand ( + property: string, + value: string +): PropertyTransformResult { + if (property === 'transitionProperty') { + return { + style: { + transitionProperty: parseCommaSeparated( + value, + parseTransitionProperty + ), + }, + } + } + if (property === 'transitionDuration') { + return { + style: { transitionDuration: parseCommaSeparated(value, parseTime) }, + } + } + if (property === 'transitionTimingFunction') { + return { + style: { + transitionTimingFunction: parseCommaSeparated( + value, + parseTimingFunction + ), + }, + } + } + if (property === 'transitionDelay') { + return { style: { transitionDelay: parseCommaSeparated(value, parseTime) } } + } + + throw new Error(`Unsupported transition property "${property}"`) +} + +function transformBackgroundImage ( + property: string, + value: string, + declaration: CssDeclaration, + context: PropertyTransformContext +): PropertyTransformResult { + const trimmed = value.trim() + if (!isSupportedBackgroundImageValue(trimmed)) { + return { + style: {}, + diagnostics: [ + createDiagnostic( + 'UNSUPPORTED_BACKGROUND_IMAGE', + property, + value, + `Unsupported background image "${value}"`, + declaration + ), + ], + } + } + + return { + style: { + [backgroundImageProperty(context.platform)]: trimmed, + }, + } +} + +function transformBackground ( + property: string, + value: string, + declaration: CssDeclaration, + context: PropertyTransformContext +): PropertyTransformResult { + const trimmed = value.trim() + + if (isColor(trimmed)) { + return { style: { backgroundColor: trimmed } } + } + + if (isSupportedBackgroundImageValue(trimmed)) { + return { + style: { [backgroundImageProperty(context.platform)]: trimmed }, + } + } + + if (containsUnsupportedBackgroundImage(trimmed)) { + return { + style: {}, + diagnostics: [ + createDiagnostic( + 'UNSUPPORTED_BACKGROUND_IMAGE', + property, + value, + `Unsupported background image "${value}"`, + declaration + ), + ], + } + } + + const tokens = splitByWhitespace(trimmed) + if (tokens.length === 2) { + const firstIsColor = isColor(tokens[0]) + const secondIsColor = isColor(tokens[1]) + const firstIsImage = isSupportedBackgroundImageValue(tokens[0]) + const secondIsImage = isSupportedBackgroundImageValue(tokens[1]) + + if (firstIsColor && secondIsImage) { + return { + style: { + backgroundColor: tokens[0], + [backgroundImageProperty(context.platform)]: tokens[1], + }, + } + } + + if (firstIsImage && secondIsColor) { + return { + style: { + backgroundColor: tokens[1], + [backgroundImageProperty(context.platform)]: tokens[0], + }, + } + } + } + + return { + style: {}, + diagnostics: [ + createDiagnostic( + 'UNSUPPORTED_BACKGROUND_SHORTHAND', + property, + value, + `Unsupported background shorthand "${value}"`, + declaration + ), + ], + } +} + +function parseSingleAnimation (value: string): { + name: string + duration: string + timingFunction: string + delay: string + iterationCount: string | number + direction: string + fillMode: string + playState: string +} { + const tokens = splitByWhitespace(value) + let name: string | undefined + let duration: string | undefined + let timingFunction: string | undefined + let delay: string | undefined + let iterationCount: string | number | undefined + let direction: string | undefined + let fillMode: string | undefined + let playState: string | undefined + + for (const token of tokens) { + const lower = token.toLowerCase() + + if (isTime(token)) { + if (duration == null) duration = token + else if (delay == null) delay = token + else throw new Error(`Unsupported animation "${value}"`) + } else if (isTimingFunction(token)) { + timingFunction = token + } else if (animationDirectionKeywords.has(lower)) { + direction = lower + } else if (animationFillModeKeywords.has(lower)) { + fillMode = lower + } else if (animationPlayStateKeywords.has(lower)) { + playState = lower + } else if (lower === 'infinite') { + iterationCount = 'infinite' + } else if (numberRe.test(token)) { + iterationCount = Number(token) + } else { + name = token + } + } + + return { + name: name ?? 'none', + duration: duration ?? '0s', + timingFunction: timingFunction ?? 'ease', + delay: delay ?? '0s', + iterationCount: iterationCount ?? 1, + direction: direction ?? 'normal', + fillMode: fillMode ?? 'none', + playState: playState ?? 'running', + } +} + +function parseSingleTransition (value: string): { + property: string + duration: string + timingFunction: string + delay: string +} { + const tokens = splitByWhitespace(value) + let property: string | undefined + let duration: string | undefined + let timingFunction: string | undefined + let delay: string | undefined + + for (const token of tokens) { + if (isTime(token)) { + if (duration == null) duration = token + else if (delay == null) delay = token + else throw new Error(`Unsupported transition "${value}"`) + } else if (isTimingFunction(token)) { + timingFunction = token + } else { + property = token + } + } + + return { + property: parseTransitionProperty(property ?? 'all'), + duration: duration ?? '0s', + timingFunction: timingFunction ?? 'ease', + delay: delay ?? '0s', + } +} + +function parseDirectionalValues ( + value: string, + parseValue: (value: string) => TransformStyleValue +): TransformStyleValue[] { + const tokens = splitByWhitespace(value) + if (tokens.length < 1 || tokens.length > 4) { + throw new Error(`Expected 1 to 4 values, got "${value}"`) + } + return tokens.map(parseValue) +} + +function expandDirectionalValues (options: { + directions: readonly string[] + prefix: string + suffix?: string + values: readonly TransformStyleValue[] +}): TransformStyle { + const [top, right = top, bottom = top, left = right] = options.values + const suffix = options.suffix ?? '' + const values = [top, right, bottom, left] + const style: TransformStyle = {} + + for (let index = 0; index < options.directions.length; index += 1) { + style[`${options.prefix}${options.directions[index]}${suffix}`] = + values[index] + } + + return style +} + +function parseLength ( + value: string, + options: { allowAuto?: boolean; allowPercent?: boolean } = {} +): TransformStyleValue { + const trimmed = value.trim() + const lower = trimmed.toLowerCase() + + if (options.allowAuto === true && lower === 'auto') return 'auto' + if (isCalc(trimmed)) return trimmed + + const match = trimmed.match(numberOrLengthRe) + if (match == null) { + throw new Error(`Expected length value, got "${value}"`) + } + + const number = Number(match[1]) + const unit = match[2].toLowerCase() + + if (unit === '') { + if (number === 0) return 0 + throw new Error(`Expected length unit in "${value}"`) + } + if (unit === 'px') return number + if (unit === 'u') return number * 8 + if (unit === '%') { + if (options.allowPercent === true) return `${match[1]}%` + throw new Error(`Percentage is not supported in "${value}"`) + } + if (supportedLengthUnits.has(unit)) return trimmed + + throw new Error(`Unsupported length unit in "${value}"`) +} + +function parseNumber (value: string): number { + const trimmed = value.trim() + if (!numberRe.test(trimmed)) { + throw new Error(`Expected number value, got "${value}"`) + } + return Number(trimmed) +} + +function parseAngle (value: string): string { + const trimmed = value.trim() + if (!angleRe.test(trimmed)) { + throw new Error(`Expected angle value, got "${value}"`) + } + return trimmed.toLowerCase() +} + +function parseColor (value: string): string { + const trimmed = value.trim() + if (!isColor(trimmed)) throw new Error(`Expected color value, got "${value}"`) + return trimmed +} + +function parseBorderStyle (value: string): string { + const lower = value.trim().toLowerCase() + if (!borderStyles.has(lower)) { + throw new Error(`Expected border style value, got "${value}"`) + } + return lower +} + +function parseTime (value: string): string { + const trimmed = value.trim() + if (!isTime(trimmed)) throw new Error(`Expected time value, got "${value}"`) + return trimmed +} + +function parseTimingFunction (value: string): string { + const trimmed = value.trim() + if (!isTimingFunction(trimmed)) { + throw new Error(`Expected timing function value, got "${value}"`) + } + return trimmed +} + +function parseIterationCount (value: string): string | number { + const trimmed = value.trim() + if (trimmed.toLowerCase() === 'infinite') return 'infinite' + if (numberRe.test(trimmed)) return Number(trimmed) + throw new Error(`Expected iteration count value, got "${value}"`) +} + +function parseIdentifier (value: string): string { + const trimmed = value.trim() + if (!/^[-_a-z][-_a-z0-9]*$/i.test(trimmed) && trimmed !== 'none') { + throw new Error(`Expected identifier value, got "${value}"`) + } + return trimmed +} + +function parseKeyword (value: string, keywords: ReadonlySet): string { + const lower = value.trim().toLowerCase() + if (!keywords.has(lower)) { + throw new Error(`Expected one of ${Array.from(keywords).join(', ')}`) + } + return lower +} + +function parseTransitionProperty (value: string): string { + const trimmed = value.trim() + if (trimmed === 'all' || trimmed === 'none') return trimmed + return getPropertyName(trimmed) +} + +function parseCommaSeparated ( + value: string, + parseValue: (value: string) => T +): T | T[] { + const values = splitTopLevel(value, ',').map(parseValue) + return values.length === 1 ? values[0] : values +} + +function singleOrArray (values: T[], isSingle: boolean): T | T[] { + return isSingle ? values[0] : values +} + +function inlineAnimationKeyframes ( + style: TransformStyle, + keyframes: Record +): void { + if (style.animationName == null) return + + if (Array.isArray(style.animationName)) { + style.animationName = style.animationName.map(value => + typeof value === 'string' && value !== 'none' && keyframes[value] != null + ? keyframes[value] + : value + ) + return + } + + if ( + typeof style.animationName === 'string' && + style.animationName !== 'none' && + keyframes[style.animationName] != null + ) { + style.animationName = keyframes[style.animationName] + } +} + +function isLength (value: string, allowPercent: boolean): boolean { + try { + parseLength(value, { allowPercent }) + return true + } catch { + return false + } +} + +function isColor (value: string): boolean { + const trimmed = value.trim() + const lower = trimmed.toLowerCase() + return ( + hexColorRe.test(trimmed) || + colorFunctionRe.test(trimmed) || + cssColorKeywords.has(lower) || + lower === 'currentcolor' + ) +} + +function isTime (value: string): boolean { + return timeRe.test(value.trim()) +} + +function isTimingFunction (value: string): boolean { + const trimmed = value.trim() + const lower = trimmed.toLowerCase() + + return ( + timingFunctionKeywords.has(lower) || + isFunctionToken(trimmed, 'cubic-bezier') || + isFunctionToken(trimmed, 'steps') || + isFunctionToken(trimmed, 'linear') + ) +} + +function isCalc (value: string): boolean { + return isFunctionToken(value.trim(), 'calc') +} + +function isSupportedBackgroundImageValue (value: string): boolean { + const trimmed = value.trim() + if (trimmed.toLowerCase() === 'none') return true + + const layers = splitTopLevel(trimmed, ',') + return ( + layers.length > 0 && + layers.every( + layer => + isFunctionToken(layer, 'linear-gradient') || + isFunctionToken(layer, 'radial-gradient') + ) + ) +} + +function containsUnsupportedBackgroundImage (value: string): boolean { + return /\b(?:url|image-set|cross-fade|element|paint)\s*\(/i.test(value) +} + +function backgroundImageProperty (platform: CssPlatform): string { + return platform === 'web' ? 'backgroundImage' : 'experimental_backgroundImage' +} + +function isFunctionToken (value: string, functionName: string): boolean { + const trimmed = value.trim() + if (!trimmed.toLowerCase().startsWith(`${functionName.toLowerCase()}(`)) { + return false + } + const openIndex = trimmed.indexOf('(') + return findMatchingParen(trimmed, openIndex) === trimmed.length - 1 +} + +function parseFunctionSequence ( + value: string +): Array<{ name: string; arguments: string }> { + const functions: Array<{ name: string; arguments: string }> = [] + let index = 0 + const source = value.trim() + + while (index < source.length) { + while (/\s/.test(source[index] ?? '')) index += 1 + if (index >= source.length) break + + const nameMatch = source.slice(index).match(/^[-_a-z][-_a-z0-9]*/i) + if (nameMatch == null) { + throw new Error(`Expected transform function in "${value}"`) + } + + const name = nameMatch[0] + index += name.length + if (source[index] !== '(') { + throw new Error(`Expected "(" after transform function "${name}"`) + } + + const closeIndex = findMatchingParen(source, index) + if (closeIndex === -1) { + throw new Error(`Unclosed transform function "${name}"`) + } + + functions.push({ + name, + arguments: source.slice(index + 1, closeIndex), + }) + index = closeIndex + 1 + } + + if (functions.length === 0) { + throw new Error(`Expected transform value, got "${value}"`) + } + + return functions +} + +function parseFunctionArguments (value: string): string[] { + const commaParts = splitTopLevel(value, ',') + if (commaParts.length > 1) return commaParts + return splitByWhitespace(value) +} + +function expectArgumentCount ( + functionName: string, + args: readonly string[], + min: number, + max: number +): void { + if (args.length < min || args.length > max) { + throw new Error( + `Expected ${functionName}() to have ${min === max ? min : `${min}-${max}`} arguments` + ) + } +} + +function splitByWhitespace (value: string): string[] { + const parts: string[] = [] + let current = '' + let depth = 0 + let quote: string | null = null + let escaped = false + + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + + if (escaped) { + current += character + escaped = false + continue + } + + if (character === '\\') { + current += character + escaped = true + continue + } + + if (quote != null) { + current += character + if (character === quote) quote = null + continue + } + + if (character === '"' || character === "'") { + current += character + quote = character + continue + } + + if (character === '(') { + depth += 1 + current += character + continue + } + + if (character === ')') { + depth -= 1 + if (depth < 0) throw new Error(`Unexpected ")" in "${value}"`) + current += character + continue + } + + if (depth === 0 && /\s/.test(character)) { + if (current.length > 0) { + parts.push(current) + current = '' + } + continue + } + + current += character + } + + if (quote != null) throw new Error(`Unclosed string in "${value}"`) + if (depth !== 0) throw new Error(`Unclosed function in "${value}"`) + if (current.length > 0) parts.push(current) + + return parts +} + +function splitTopLevel (value: string, separator: string): string[] { + const parts: string[] = [] + let current = '' + let depth = 0 + let quote: string | null = null + let escaped = false + + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + + if (escaped) { + current += character + escaped = false + continue + } + + if (character === '\\') { + current += character + escaped = true + continue + } + + if (quote != null) { + current += character + if (character === quote) quote = null + continue + } + + if (character === '"' || character === "'") { + current += character + quote = character + continue + } + + if (character === '(') { + depth += 1 + current += character + continue + } + + if (character === ')') { + depth -= 1 + if (depth < 0) throw new Error(`Unexpected ")" in "${value}"`) + current += character + continue + } + + if (depth === 0 && character === separator) { + const part = current.trim() + if (part.length === 0) throw new Error(`Empty value in "${value}"`) + parts.push(part) + current = '' + continue + } + + current += character + } + + if (quote != null) throw new Error(`Unclosed string in "${value}"`) + if (depth !== 0) throw new Error(`Unclosed function in "${value}"`) + + const part = current.trim() + if (part.length === 0) throw new Error(`Empty value in "${value}"`) + parts.push(part) + + return parts +} + +function findMatchingParen (value: string, openIndex: number): number { + let depth = 0 + let quote: string | null = null + let escaped = false + + for (let index = openIndex; index < value.length; index += 1) { + const character = value[index] + + if (escaped) { + escaped = false + continue + } + + if (character === '\\') { + escaped = true + continue + } + + if (quote != null) { + if (character === quote) quote = null + continue + } + + if (character === '"' || character === "'") { + quote = character + continue + } + + if (character === '(') { + depth += 1 + continue + } + + if (character === ')') { + depth -= 1 + if (depth === 0) return index + if (depth < 0) return -1 + } + } + + return -1 +} + +function createDiagnostic ( + code: TransformDiagnosticCode, + property: string, + value: string, + message: string, + declaration: CssDeclaration +): TransformDiagnostic { + return { + code, + property, + value, + message, + order: declaration.order, + } +} diff --git a/packages/css-to-rn/src/types.ts b/packages/css-to-rn/src/types.ts new file mode 100644 index 0000000..54bc03d --- /dev/null +++ b/packages/css-to-rn/src/types.ts @@ -0,0 +1,112 @@ +export type CompileMode = 'runtime' | 'build' + +export type CssxDiagnosticLevel = 'warning' | 'error' + +export type CssxDiagnosticCode = + | 'CSS_SYNTAX_ERROR' + | 'UNSUPPORTED_SELECTOR' + | 'UNSUPPORTED_AT_RULE' + | 'INVALID_DECLARATION' + | 'UNRESOLVED_VARIABLE' + | 'VARIABLE_CYCLE' + | 'VARIABLE_DEPTH_LIMIT' + | 'UNSUPPORTED_INTERPOLATION_POSITION' + | 'INVALID_INTERPOLATION_VALUE' + | 'UNSUPPORTED_CALC' + | 'UNSUPPORTED_BACKGROUND_IMAGE' + | 'UNSUPPORTED_BACKGROUND_SHORTHAND' + | 'INVALID_THEME_BLOCK' + | 'INVALID_CUSTOM_MEDIA' + | 'DEPRECATED_UNIT' + +export interface CssxDiagnostic { + level: CssxDiagnosticLevel + code: CssxDiagnosticCode + message: string + line?: number + column?: number +} + +export interface CompileCssOptions { + mode?: CompileMode + id?: string + sourceId?: string + contentHash?: string + sourceIdentity?: string + target?: CssxTarget +} + +export interface CompileCssTemplateOptions extends CompileCssOptions { + dynamicSlotPrefix?: string +} + +export type CssxTarget = 'react-native' | 'web' + +export interface CssxMetadata { + hasVars: boolean + vars: string[] + hasMedia: boolean + hasViewportUnits: boolean + hasInterpolations: boolean + hasDynamicRuntimeDependencies: boolean + hasAnimations: boolean + hasTransitions: boolean + hasThemes: boolean + hasCustomMedia: boolean +} + +export interface CompiledCssSheet { + version: 1 + id: string + sourceId?: string + contentHash: string + rules: CssxRule[] + keyframes: Record + rootVariables?: Record + themeVariables?: Record> + customMedia?: Record + exports?: Record + metadata: CssxMetadata + diagnostics: CssxDiagnostic[] + error?: CssxDiagnostic +} + +export interface CssxRule { + selector: string + tag: string | null + classes: string[] + part: string | null + specificity: number + order: number + media: string | null + declarations: CssxDeclaration[] +} + +export interface CssxDeclaration { + property: string + value: string + raw: string + order: number + dynamicSlots?: number[] + line?: number + column?: number +} + +export interface CssxKeyframe { + selector: string + declarations: CssxDeclaration[] + order: number +} + +export interface SelectorParseResult { + selector: string + tag: string | null + classes: string[] + part: string | null + specificity: number +} + +export interface CompileState { + diagnostics: CssxDiagnostic[] + mode: CompileMode +} diff --git a/packages/css-to-rn/src/units.ts b/packages/css-to-rn/src/units.ts new file mode 100644 index 0000000..f5e3bbe --- /dev/null +++ b/packages/css-to-rn/src/units.ts @@ -0,0 +1,14 @@ +let warnedAboutU = false + +export function u (value: number): number { + if (!warnedAboutU && process.env.NODE_ENV !== 'production') { + warnedAboutU = true + console.warn('[cssx] u() is deprecated. Use rem, var(--spacing), or CSS instead. 1u equals 0.5rem or 8px.') + } + + return value * 8 +} + +export function resetUWarningForTests (): void { + warnedAboutU = false +} diff --git a/packages/css-to-rn/src/values.ts b/packages/css-to-rn/src/values.ts new file mode 100644 index 0000000..76a678d --- /dev/null +++ b/packages/css-to-rn/src/values.ts @@ -0,0 +1,460 @@ +import { diagnostic } from './diagnostics.ts' +import { evaluateCssColors } from './colors.ts' +import type { CssxDiagnostic } from './types.ts' + +export type InterpolationValue = string | number | null | undefined | false + +export interface ResolveCssValueOptions { + values?: readonly unknown[] + variables?: Record + scopedVariables?: readonly Record[] + defaultVariables?: Record + dimensions?: { + width?: number + height?: number + } + maxVarDepth?: number + deprecateUUnits?: boolean +} + +export interface ResolveCssValueResult { + value?: string + valid: boolean + dependencies: { + vars: string[] + dimensions: boolean + } + diagnostics: CssxDiagnostic[] +} + +const DYNAMIC_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g +const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/ +const REM_UNIT_RE = /(^|[^\w.-])([+-]?(?:\d*\.)?\d+)rem\b/g +const VIEWPORT_UNIT_RE = /(^|[^\w.-])([+-]?(?:\d*\.)?\d+)(vh|vw|vmin|vmax)\b/g +const U_UNIT_RE = /(^|[^\w.-])([+-]?(?:\d*\.)?\d+)u\b/g +const CALC_RE = /calc\(/g + +export function resolveCssValue ( + input: string, + options: ResolveCssValueOptions = {} +): ResolveCssValueResult { + const diagnostics: CssxDiagnostic[] = [] + const dependencies = { + vars: new Set(), + dimensions: false + } + const maxVarDepth = options.maxVarDepth ?? 20 + + const interpolation = replaceDynamicSlots(input, options.values ?? [], diagnostics) + if (!interpolation.valid) { + return invalid(diagnostics, dependencies) + } + + const variableResolution = resolveVars( + interpolation.value, + options, + dependencies.vars, + diagnostics, + [], + maxVarDepth + ) + if (!variableResolution.valid) { + return invalid(diagnostics, dependencies) + } + + const units = resolveUnits(variableResolution.value, options, dependencies, diagnostics) + const calc = resolveCalcs(units.value, diagnostics) + if (!calc.valid) { + return invalid(diagnostics, dependencies) + } + const colors = evaluateCssColors(calc.value) + + return { + value: colors.trim(), + valid: true, + dependencies: serializeDependencies(dependencies), + diagnostics + } +} + +export function coerceCssValue (input: unknown): unknown { + if (typeof input !== 'string') return input + + const value = evaluateCssColors(input.trim()) + const number = Number(value) + if (value !== '' && Number.isFinite(number) && /^[-+]?(?:\d*\.)?\d+$/.test(value)) { + return number + } + + const px = value.match(/^([-+]?(?:\d*\.)?\d+)px$/) + if (px) return Number(px[1]) + + return value +} + +function replaceDynamicSlots ( + input: string, + values: readonly unknown[], + diagnostics: CssxDiagnostic[] +): { valid: true, value: string } | { valid: false } { + DYNAMIC_SLOT_RE.lastIndex = 0 + let valid = true + const value = input.replace(DYNAMIC_SLOT_RE, (_match, rawIndex: string) => { + const index = Number(rawIndex) + const interpolation = values[index] + if (typeof interpolation === 'string') return interpolation + if (typeof interpolation === 'number') return String(interpolation) + if (interpolation === null || interpolation === undefined || interpolation === false) { + diagnostics.push(diagnostic( + 'INVALID_INTERPOLATION_VALUE', + `Interpolation slot ${index} resolved to an omitted value, so the declaration is invalid.`, + 'warning' + )) + valid = false + return '' + } + + diagnostics.push(diagnostic( + 'INVALID_INTERPOLATION_VALUE', + `Interpolation slot ${index} resolved to unsupported value type "${typeof interpolation}".`, + 'warning' + )) + valid = false + return '' + }) + + return valid ? { valid: true, value } : { valid: false } +} + +function resolveVars ( + input: string, + options: ResolveCssValueOptions, + deps: Set, + diagnostics: CssxDiagnostic[], + stack: string[], + maxDepth: number +): { valid: true, value: string } | { valid: false } { + if (stack.length > maxDepth) { + diagnostics.push(diagnostic( + 'VARIABLE_DEPTH_LIMIT', + `CSS variable resolution exceeded max depth ${maxDepth}.`, + 'warning' + )) + return { valid: false } + } + + let output = input + + while (true) { + const start = output.indexOf('var(') + if (start === -1) return { valid: true, value: output } + + const open = start + 3 + const close = findMatchingParen(output, open) + if (close === -1) { + diagnostics.push(diagnostic( + 'UNRESOLVED_VARIABLE', + 'Malformed var() expression.', + 'warning' + )) + return { valid: false } + } + + const body = output.slice(open + 1, close) + const parts = splitTopLevelComma(body) + const name = parts[0]?.trim() + if (!name || !VAR_NAME_RE.test(name)) { + diagnostics.push(diagnostic( + 'UNRESOLVED_VARIABLE', + `Invalid CSS variable name "${name ?? ''}".`, + 'warning' + )) + return { valid: false } + } + + deps.add(name) + if (stack.includes(name)) { + diagnostics.push(diagnostic( + 'VARIABLE_CYCLE', + `CSS variable cycle detected: ${stack.concat(name).join(' -> ')}.`, + 'warning' + )) + return { valid: false } + } + + const fallback = parts.length > 1 ? parts.slice(1).join(',').trim() : undefined + const rawReplacement = + valueFromRecord(options.variables, name) ?? + valueFromScopedRecords(options.scopedVariables, name) ?? + valueFromRecord(options.defaultVariables, name) ?? + fallback + + if (rawReplacement === undefined) { + diagnostics.push(diagnostic( + 'UNRESOLVED_VARIABLE', + `CSS variable "${name}" is not defined and has no fallback.`, + 'warning' + )) + return { valid: false } + } + + const nested = resolveVars( + String(rawReplacement), + options, + deps, + diagnostics, + stack.concat(name), + maxDepth + ) + if (!nested.valid) return { valid: false } + + output = output.slice(0, start) + nested.value + output.slice(close + 1) + } +} + +function resolveUnits ( + input: string, + options: ResolveCssValueOptions, + dependencies: { vars: Set, dimensions: boolean }, + diagnostics: CssxDiagnostic[] +): { value: string } { + const warnUUnits = options.deprecateUUnits && U_UNIT_RE.test(input) + U_UNIT_RE.lastIndex = 0 + if (warnUUnits) { + diagnostics.push(diagnostic( + 'DEPRECATED_UNIT', + 'The CSSX "u" unit is deprecated. Use rem, var(--spacing), or calc(var(--spacing) * n).', + 'warning' + )) + } + + let value = input.replace(U_UNIT_RE, (_match, prefix: string, rawNumber: string) => { + return `${prefix}${Number(rawNumber) * 8}px` + }) + + value = value.replace(REM_UNIT_RE, (_match, prefix: string, rawNumber: string) => { + return `${prefix}${Number(rawNumber) * 16}px` + }) + + const width = options.dimensions?.width ?? 0 + const height = options.dimensions?.height ?? 0 + + value = value.replace(VIEWPORT_UNIT_RE, (_match, prefix: string, rawNumber: string, unit: string) => { + dependencies.dimensions = true + const number = Number(rawNumber) + const basis = + unit === 'vw' + ? width + : unit === 'vh' + ? height + : unit === 'vmin' + ? Math.min(width, height) + : Math.max(width, height) + return `${prefix}${number * basis / 100}px` + }) + + return { value } +} + +function resolveCalcs ( + input: string, + diagnostics: CssxDiagnostic[] +): { valid: true, value: string } | { valid: false } { + let output = input + CALC_RE.lastIndex = 0 + + while (true) { + const start = output.indexOf('calc(') + if (start === -1) return { valid: true, value: output } + const open = start + 4 + const close = findMatchingParen(output, open) + if (close === -1) { + diagnostics.push(diagnostic('UNSUPPORTED_CALC', 'Malformed calc() expression.', 'warning')) + return { valid: false } + } + + const expression = output.slice(open + 1, close).trim() + const result = evaluateCalc(expression) + if (result == null) { + diagnostics.push(diagnostic( + 'UNSUPPORTED_CALC', + `Unsupported calc() expression "${expression}".`, + 'warning' + )) + return { valid: false } + } + + output = output.slice(0, start) + String(result) + output.slice(close + 1) + } +} + +function evaluateCalc (expression: string): string | null { + const unit = getCalcUnit(expression) + if (unit === false) return null + + const normalized = expression.replace(/([+-]?(?:\d*\.)?\d+)(px\b|%)/g, (_match, number: string) => number) + if (!/^[0-9+\-*/().\s]+$/.test(normalized)) return null + + let index = 0 + + const skipWhitespace = () => { + while (/\s/.test(normalized[index] ?? '')) index++ + } + + const parseNumber = (): number | null => { + skipWhitespace() + const match = normalized.slice(index).match(/^(?:(?:\d+\.\d+)|(?:\d+\.)|(?:\.\d+)|(?:\d+))/) + if (match == null) return null + index += match[0].length + return Number(match[0]) + } + + const parseFactor = (): number | null => { + skipWhitespace() + + if (normalized[index] === '+') { + index++ + return parseFactor() + } + + if (normalized[index] === '-') { + index++ + const value = parseFactor() + return value == null ? null : -value + } + + if (normalized[index] === '(') { + index++ + const value = parseAdditive() + skipWhitespace() + if (normalized[index] !== ')') return null + index++ + return value + } + + return parseNumber() + } + + const parseMultiplicative = (): number | null => { + let value = parseFactor() + if (value == null) return null + + while (true) { + skipWhitespace() + const operator = normalized[index] + if (operator !== '*' && operator !== '/') return value + index++ + + const right = parseFactor() + if (right == null) return null + value = operator === '*' ? value * right : value / right + } + } + + function parseAdditive (): number | null { + let value = parseMultiplicative() + if (value == null) return null + + while (true) { + skipWhitespace() + const operator = normalized[index] + if (operator !== '+' && operator !== '-') return value + index++ + + const right = parseMultiplicative() + if (right == null) return null + value = operator === '+' ? value + right : value - right + } + } + + const result = parseAdditive() + skipWhitespace() + + return result != null && index === normalized.length && Number.isFinite(result) + ? unit ? `${roundCalc(result)}${unit}` : String(roundCalc(result)) + : null +} + +function getCalcUnit (expression: string): 'px' | '%' | '' | false { + const units = new Set() + expression.replace(/(?:^|[^\w.-])[+-]?(?:\d*\.)?\d+(px\b|%)/g, (_match, unit: string) => { + units.add(unit === '%' ? '%' : 'px') + return '' + }) + + if (units.size > 1) return false + return (units.values().next().value ?? '') as 'px' | '%' | '' +} + +function roundCalc (value: number): number { + return Math.round(value * 1000000) / 1000000 +} + +function findMatchingParen (input: string, openIndex: number): number { + let depth = 0 + for (let index = openIndex; index < input.length; index++) { + const char = input[index] + if (char === '(') depth++ + if (char === ')') { + depth-- + if (depth === 0) return index + } + } + return -1 +} + +function splitTopLevelComma (input: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + + for (let index = 0; index < input.length; index++) { + const char = input[index] + if (char === '(') depth++ + if (char === ')') depth-- + if (char === ',' && depth === 0) { + parts.push(input.slice(start, index)) + start = index + 1 + } + } + + parts.push(input.slice(start)) + return parts +} + +function valueFromRecord (record: Record | undefined, key: string): unknown { + if (!record || !Object.prototype.hasOwnProperty.call(record, key)) return undefined + return record[key] +} + +function valueFromScopedRecords ( + records: readonly Record[] | undefined, + key: string +): unknown { + if (!records) return undefined + + for (let index = records.length - 1; index >= 0; index--) { + const value = valueFromRecord(records[index], key) + if (value !== undefined) return value + } + + return undefined +} + +function serializeDependencies (dependencies: { vars: Set, dimensions: boolean }) { + return { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions + } +} + +function invalid ( + diagnostics: CssxDiagnostic[], + dependencies: { vars: Set, dimensions: boolean } +): ResolveCssValueResult { + return { + valid: false, + dependencies: serializeDependencies(dependencies), + diagnostics + } +} diff --git a/packages/css-to-rn/src/vendor.d.ts b/packages/css-to-rn/src/vendor.d.ts new file mode 100644 index 0000000..93edb1f --- /dev/null +++ b/packages/css-to-rn/src/vendor.d.ts @@ -0,0 +1,33 @@ +declare module 'css/lib/parse/index.js' { + export default function parseCss (css: string, options?: unknown): unknown +} + +declare module 'css-mediaquery' { + interface MediaQueryExpression { + modifier?: string + feature: string + value?: string + } + + interface MediaQuery { + inverse: boolean + type: string + expressions: MediaQueryExpression[] + } + + const mediaQuery: { + parse(query: string): MediaQuery[] + match(query: string, values: Record): boolean + } + + export default mediaQuery +} + +declare module '@react-native-async-storage/async-storage' { + const AsyncStorage: { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise + } + + export default AsyncStorage +} diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts new file mode 100644 index 0000000..718677e --- /dev/null +++ b/packages/css-to-rn/src/web.ts @@ -0,0 +1,189 @@ +/// + +export { + compileCss, + compileCssTemplate +} from './compiler.ts' +export { + resolveCssValue +} from './values.ts' +export { + u +} from './units.ts' +import { + resetUWarningForTests +} from './units.ts' +import { + cssx as baseCssx, + clearRawCssCacheForTests +} from './react/cssx.ts' +import { + useCssxLayer as baseUseCssxLayer, + useRuntimeCss as baseUseRuntimeCss, + useCssxSheet as baseUseCssxSheet, + useCssxTemplate as baseUseCssxTemplate +} from './react/hooks.ts' +import { + createTrackedCssxSheet +} from './react/tracker.ts' +import { + configureColorSchemeAdapter, + configureDimensionsAdapter, + configureMediaQueryAdapter, + configureThemeStorageAdapter, + defaultVariables, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setColorSchemeForTests, + setDefaultVariables, + setDimensionsForTests, + subscribeVariablesForTests, + variables +} from './react/store.ts' + +export type { + CompileCssOptions, + CompileCssTemplateOptions, + CompiledCssSheet +} from './types.ts' +export type { + CssxResolvedProps, + CssxRuntimeOptions, + CssxStyleName +} from './react/cssx.ts' +export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, + CssxProviderProps, + CssxReactConfig, + CssxRuntimeContextValue, + CssxThemeHookResult, + CssxThemeSetter +} from './react/config.ts' +export type { + TrackedCssxSheetOptions +} from './react/tracker.ts' +export type { + CssxColorSchemeAdapter, + CssxThemeStorageAdapter, + CssxVariableStore +} from './react/store.ts' + +export { + CssxProvider, + configureCssx, + themed, + useTheme, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext +} from './react/config.ts' +export { + getCssColor, + getCssVariable, + getCssVariableRaw, + useMedia, + useCssColor, + useCssVariable, + useCssVariableRaw +} from './react/hooks.ts' +export type { + CssColorMixInput +} from './react/hooks.ts' +export { + TrackedCssxSheet, + isTrackedCssxSheet +} from './react/tracker.ts' +export { + defaultVariables, + setDefaultVariables, + variables +} + +installWebThemeStorageAdapter() + +export function cssx ( + ...args: Parameters +): ReturnType { + const [styleName, sheet, inlineStyleProps, options] = args + return baseCssx(styleName, sheet, inlineStyleProps, { + target: 'web', + ...(options ?? {}) + }) +} + +export function useRuntimeCss ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseRuntimeCss(input, { + target: 'web', + ...(options ?? {}) + }) +} + +export function useCssxLayer ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseCssxLayer(input, { + target: 'web', + ...(options ?? {}) + }) +} + +export function useCssxSheet ( + ...args: Parameters +): ReturnType { + const [sheet, options] = args + return baseUseCssxSheet(sheet, { + target: 'web', + ...(options ?? {}) + }) +} + +export function useCssxTemplate ( + ...args: Parameters +): ReturnType { + const [sheet, values, options] = args + return baseUseCssxTemplate(sheet, values, { + target: 'web', + ...(options ?? {}) + }) +} + +export const __cssxInternals = { + clearRawCssCacheForTests, + configureColorSchemeAdapterForTests: configureColorSchemeAdapter, + configureDimensionsAdapterForTests: configureDimensionsAdapter, + configureMediaQueryAdapterForTests: configureMediaQueryAdapter, + configureThemeStorageAdapterForTests: configureThemeStorageAdapter, + createTrackedCssxSheet, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + resetUWarningForTests, + setColorSchemeForTests, + setDimensionsForTests, + subscribeVariablesForTests +} + +function installWebThemeStorageAdapter (): void { + configureThemeStorageAdapter({ + get: () => { + try { + if (typeof window === 'undefined') return null + return window.localStorage?.getItem('cssx-theme') ?? null + } catch { + return null + } + }, + set: theme => { + try { + if (typeof window === 'undefined') return + window.localStorage?.setItem('cssx-theme', theme) + } catch {} + } + }) +} diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts new file mode 100644 index 0000000..41653c4 --- /dev/null +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { compileCss, compileCssTemplate } from '../../src/index.ts' + +describe('@cssxjs/css-to-rn compiler IR', () => { + it('compiles class selectors into canonical rules', () => { + const sheet = compileCss(` + .root { + color: red; + padding: 8px 16px; + } + .root.active:part(label) { + color: var(--label-color, blue); + } + `, { mode: 'build', sourceIdentity: 'Button.tsx:0' }) + + assert.equal(sheet.version, 1) + assert.equal(sheet.rules.length, 2) + assert.deepEqual(sheet.rules[0].classes, ['root']) + assert.equal(sheet.rules[0].tag, null) + assert.equal(sheet.rules[0].part, null) + assert.equal(sheet.rules[0].specificity, 1) + assert.equal(sheet.rules[0].declarations[0].property, 'color') + assert.deepEqual(sheet.rules[1].classes, ['root', 'active']) + assert.equal(sheet.rules[1].part, 'label') + assert.deepEqual(sheet.metadata.vars, ['--label-color']) + assert.equal(sheet.metadata.hasDynamicRuntimeDependencies, true) + assert.match(sheet.id, /^cssx_/) + assert.match(sheet.sourceId ?? '', /^cssx_/) + }) + + it('compiles component tag selectors and scoped root variables', () => { + const sheet = compileCss(` + :root { + --button-color: oklch(62% 0.18 250 / 0.5); + } + Button { + color: var(--button-color); + } + Button.primary::part(label) { + color: white; + } + Button:part(icon).large { + opacity: 0.5; + } + `, { mode: 'build' }) + + assert.deepEqual(sheet.rootVariables, { + '--button-color': 'oklch(62% 0.18 250 / 0.5)' + }) + assert.equal(sheet.rules[0].tag, 'Button') + assert.deepEqual(sheet.rules[0].classes, []) + assert.equal(sheet.rules[1].tag, 'Button') + assert.deepEqual(sheet.rules[1].classes, ['primary']) + assert.equal(sheet.rules[1].part, 'label') + assert.equal(sheet.rules[2].tag, 'Button') + assert.deepEqual(sheet.rules[2].classes, ['large']) + assert.equal(sheet.rules[2].part, 'icon') + assert.deepEqual(sheet.metadata.vars, ['--button-color']) + }) + + it('compiles named theme root variables', () => { + const sheet = compileCss(` + :root { + --surface: white; + } + :root.dark { + --surface: black; + color: white; + } + .root { + color: var(--surface); + } + `, { mode: 'build' }) + + assert.deepEqual(sheet.rootVariables, { + '--surface': 'white' + }) + assert.deepEqual(sheet.themeVariables, { + dark: { + '--surface': 'black' + } + }) + assert.equal(sheet.metadata.hasThemes, true) + assert.deepEqual(sheet.metadata.vars, ['--surface']) + assert.equal(sheet.diagnostics[0].code, 'INVALID_THEME_BLOCK') + }) + + it('stores custom media aliases and rejects theme alias collisions', () => { + const sheet = compileCss(` + @custom-media --breakpoint-tablet (width >= 48rem); + @custom-media --theme-dark (prefers-color-scheme: dark); + @media (--breakpoint-tablet) { + .root { color: red; } + } + `, { mode: 'build' }) + + assert.deepEqual(sheet.customMedia, { + '--breakpoint-tablet': '(width >= 48rem)' + }) + assert.equal(sheet.metadata.hasCustomMedia, true) + assert.equal(sheet.diagnostics[0].code, 'INVALID_CUSTOM_MEDIA') + }) + + it('maps hover and active pseudos to logical part aliases', () => { + const sheet = compileCss(` + .root:hover { color: red; } + .root.active:active { color: blue; } + `, { mode: 'build' }) + + assert.equal(sheet.rules[0].part, 'hover') + assert.equal(sheet.rules[1].part, 'active') + }) + + it('keeps media conditions on matching rules', () => { + const sheet = compileCss(` + @media (min-width: 600px) { + .root { width: 50vw; } + } + `, { mode: 'build' }) + + assert.equal(sheet.rules.length, 1) + assert.equal(sheet.rules[0].media, '@media (min-width: 600px)') + assert.equal(sheet.metadata.hasMedia, true) + assert.equal(sheet.metadata.hasViewportUnits, true) + }) + + it('stores keyframes as declaration IR and marks animation metadata', () => { + const sheet = compileCss(` + .root { animation: fade 200ms ease; } + @keyframes fade { + from { opacity: 0; } + to { opacity: var(--target-opacity, 1); } + } + `, { mode: 'build' }) + + assert.equal(sheet.metadata.hasAnimations, true) + assert.deepEqual(sheet.metadata.vars, ['--target-opacity']) + assert.equal(sheet.keyframes.fade.length, 2) + assert.equal(sheet.keyframes.fade[0].selector, 'from') + assert.equal(sheet.keyframes.fade[1].declarations[0].property, 'opacity') + }) + + it('returns structured diagnostics instead of throwing in runtime mode', () => { + const sheet = compileCss('.root { color red; }') + + assert.equal(sheet.rules.length, 0) + assert.equal(sheet.error?.code, 'CSS_SYNTAX_ERROR') + assert.equal(sheet.diagnostics[0].level, 'error') + }) + + it('throws syntax diagnostics in build mode', () => { + assert.throws( + () => compileCss('.root { color red; }', { mode: 'build' }), + /CSS_SYNTAX_ERROR/ + ) + }) + + it('throws unsupported static declaration diagnostics in build mode', () => { + assert.throws( + () => compileCss('.root { width: calc(100% - 16px); }', { mode: 'build' }), + /UNSUPPORTED_CALC/ + ) + assert.throws( + () => compileCss('.root { transform: translate3d(1px, 2px, 3px); }', { mode: 'build' }), + /INVALID_DECLARATION/ + ) + assert.throws( + () => compileCss('.root { background-image: url(hero.png); }', { mode: 'build' }), + /UNSUPPORTED_BACKGROUND_IMAGE/ + ) + }) + + it('defers dynamic declarations to runtime validation in build mode', () => { + const sheet = compileCssTemplate(` + .root { + width: var(--width); + transform: var(--__cssx_dynamic_0); + } + `, { mode: 'build' }) + + assert.equal(sheet.rules.length, 1) + assert.equal(sheet.error, undefined) + }) + + it('warns about deprecated u units in build mode', () => { + const sheet = compileCss('.root { padding: 1u; }', { mode: 'build' }) + + assert.equal(sheet.error, undefined) + assert.equal(sheet.diagnostics[0].code, 'DEPRECATED_UNIT') + assert.equal(sheet.diagnostics[0].level, 'warning') + }) + + it('warns and ignores unsupported selectors in runtime mode', () => { + const sheet = compileCss(` + .root .child { color: red; } + .root { color: blue; } + `) + + assert.equal(sheet.rules.length, 1) + assert.equal(sheet.diagnostics[0].code, 'UNSUPPORTED_SELECTOR') + }) + + it('records interpolation slots in template mode', () => { + const sheet = compileCssTemplate(` + .root { + color: var(--__cssx_dynamic_0); + padding: var(--__cssx_dynamic_1) 2u; + } + `, { mode: 'build' }) + + assert.equal(sheet.metadata.hasInterpolations, true) + assert.deepEqual(sheet.rules[0].declarations[0].dynamicSlots, [0]) + assert.deepEqual(sheet.rules[0].declarations[1].dynamicSlots, [1]) + }) + + it('rejects interpolation inside media queries in build mode', () => { + assert.throws( + () => compileCssTemplate(` + @media (min-width: var(--__cssx_dynamic_0)) { + .root { color: red; } + } + `, { mode: 'build' }), + /UNSUPPORTED_INTERPOLATION_POSITION/ + ) + }) + + it('keeps :export static-only', () => { + const sheet = compileCss(` + :export { + color: red; + } + `, { mode: 'build' }) + + assert.deepEqual(sheet.exports, { color: 'red' }) + }) +}) diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts new file mode 100644 index 0000000..3a744d9 --- /dev/null +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -0,0 +1,680 @@ +import assert from 'node:assert/strict' + +import { + compileCss, + compileCssTemplate, + createCssxCache, + resolveCssx +} from '../../src/index.ts' + +describe('@cssxjs/css-to-rn resolver', () => { + it('resolves matched root and part styles with specificity and inline overrides', () => { + const sheet = compileCss(` + .button { color: red; padding: 1u; } + .button.primary { color: blue; } + .button:part(root) { background-color: yellow; } + .button:part(label) { color: white; } + .button:hover { opacity: 0.5; } + `) + + const result = resolveCssx({ + styleName: ['button', { primary: true }], + layers: sheet, + inlineStyleProps: { color: 'green' } + }) + + assert.deepEqual(result.props, { + style: { + color: 'green', + backgroundColor: 'yellow', + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + }, + labelStyle: { color: 'white' }, + hoverStyle: { opacity: 0.5 } + }) + }) + + it('applies later layers after earlier layers', () => { + const base = compileCss('.button { color: red; padding: 8px; }') + const local = compileCss('.button { color: blue; }') + + const result = resolveCssx({ + styleName: 'button', + layers: [base, local] + }) + + assert.deepEqual(result.props, { + style: { + color: 'blue', + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + } + }) + }) + + it('returns compile diagnostics from runtime sheet inputs', () => { + const result = resolveCssx({ + styleName: 'button', + layers: ` + #ignored { color: red; } + .button { color: blue; } + ` + }) + + assert.deepEqual(result.props, { style: { color: 'blue' } }) + assert.equal(result.diagnostics[0].code, 'UNSUPPORTED_SELECTOR') + }) + + it('drops only invalid dynamic declarations and keeps fallback declarations', () => { + const sheet = compileCss(` + .button { + color: red; + color: var(--button-color); + border: var(--border-width, 2px) solid var(--border-color, blue); + } + `) + + const result = resolveCssx({ + styleName: 'button', + layers: sheet, + variables: { '--border-color': 'green' } + }) + + assert.deepEqual(result.props, { + style: { + color: 'red', + borderWidth: 2, + borderColor: 'green', + borderStyle: 'solid' + } + }) + assert.deepEqual(result.dependencies.vars, [ + '--border-color', + '--border-width', + '--button-color' + ]) + assert.equal(result.diagnostics[0].code, 'UNRESOLVED_VARIABLE') + }) + + it('does not subscribe to variables in inactive media rules', () => { + const sheet = compileCss(` + .button { color: red; } + @media (min-width: 600px) { + .button { color: var(--wide-color); } + } + `) + + const result = resolveCssx({ + styleName: 'button', + layers: sheet, + variables: { '--wide-color': 'blue' }, + dimensions: { width: 320, height: 640 } + }) + + assert.deepEqual(result.props, { style: { color: 'red' } }) + assert.deepEqual(result.dependencies.vars, []) + assert.deepEqual(result.dependencies.media, ['(min-width: 600px)']) + }) + + it('activates media rules and resolves viewport units from dimensions', () => { + const sheet = compileCss(` + .button { width: 10vw; } + @media (min-width: 600px) { + .button { width: calc(20vw + 1u); } + } + `) + + const result = resolveCssx({ + styleName: 'button', + layers: sheet, + dimensions: { width: 800, height: 600 } + }) + + assert.deepEqual(result.props, { style: { width: 168 } }) + assert.equal(result.dependencies.dimensions, true) + assert.deepEqual(result.dependencies.media, ['(min-width: 600px)']) + }) + + it('resolves active theme variables and invalidates cache by theme', () => { + const sheet = compileCss(` + :root { --surface: white; } + :root.dark { --surface: black; } + .button { color: var(--surface); } + `) + const cache = createCssxCache() + + const light = resolveCssx({ + styleName: 'button', + layers: sheet, + theme: 'default', + cache + }) + const dark = resolveCssx({ + styleName: 'button', + layers: sheet, + theme: 'dark', + cache + }) + const darkAgain = resolveCssx({ + styleName: 'button', + layers: sheet, + theme: 'dark', + cache + }) + + assert.deepEqual(light.props, { style: { color: 'white' } }) + assert.deepEqual(dark.props, { style: { color: 'black' } }) + assert.notEqual(dark.props, light.props) + assert.equal(darkAgain.cacheHit, true) + assert.equal(darkAgain.props, dark.props) + }) + + it('matches built-in theme media aliases', () => { + const sheet = compileCss(` + .button { color: red; } + @media (--theme-dark) { + .button { color: white; } + } + @media (--theme-dark) and (min-width: 600px) { + .button { padding: 2u; } + } + `) + + const light = resolveCssx({ + styleName: 'button', + layers: sheet, + theme: 'default', + dimensions: { width: 800, height: 600 } + }) + const darkNarrow = resolveCssx({ + styleName: 'button', + layers: sheet, + theme: 'dark', + dimensions: { width: 320, height: 600 } + }) + const darkWide = resolveCssx({ + styleName: 'button', + layers: sheet, + theme: 'dark', + dimensions: { width: 800, height: 600 } + }) + + assert.deepEqual(light.props, { style: { color: 'red' } }) + assert.deepEqual(darkNarrow.props, { style: { color: 'white' } }) + assert.deepEqual(darkWide.props, { + style: { + color: 'white', + paddingTop: 16, + paddingRight: 16, + paddingBottom: 16, + paddingLeft: 16 + } + }) + assert.deepEqual(darkWide.dependencies.media, ['(min-width: 600px)']) + }) + + it('expands custom media aliases with provider variables', () => { + const sheet = compileCss(` + :root { --tablet: 40rem; } + @custom-media --breakpoint-tablet (width >= var(--tablet)); + .button { color: red; } + @media (--breakpoint-tablet) { + .button { color: blue; } + } + `) + + const narrow = resolveCssx({ + styleName: 'button', + layers: sheet, + dimensions: { width: 600, height: 800 } + }) + const wide = resolveCssx({ + styleName: 'button', + layers: sheet, + dimensions: { width: 700, height: 800 } + }) + + assert.deepEqual(narrow.props, { style: { color: 'red' } }) + assert.deepEqual(wide.props, { style: { color: 'blue' } }) + assert.deepEqual(wide.dependencies.vars, ['--tablet']) + assert.equal(wide.dependencies.dimensions, true) + assert.deepEqual(wide.dependencies.media, []) + assert.deepEqual(wide.dependencies.mediaMatches, {}) + }) + + it('evaluates width and height range media syntax', () => { + const sheet = compileCss(` + .button { color: red; } + @media (width >= 48rem) { + .button { color: blue; } + } + @media (height < 40rem) { + .button { opacity: 0.5; } + } + `) + + const small = resolveCssx({ + styleName: 'button', + layers: sheet, + dimensions: { width: 767, height: 640 } + }) + const large = resolveCssx({ + styleName: 'button', + layers: sheet, + dimensions: { width: 768, height: 639 } + }) + + assert.deepEqual(small.props, { style: { color: 'red' } }) + assert.deepEqual(large.props, { style: { color: 'blue', opacity: 0.5 } }) + }) + + it('resolves template interpolation values through one cache slot', () => { + const sheet = compileCssTemplate('.button { color: var(--__cssx_dynamic_0); }') + const cache = createCssxCache() + + const red = resolveCssx({ + styleName: 'button', + layers: { sheet, values: ['red'] }, + cache + }) + const redAgain = resolveCssx({ + styleName: 'button', + layers: { sheet, values: ['red'] }, + cache + }) + const green = resolveCssx({ + styleName: 'button', + layers: { sheet, values: ['green'] }, + cache + }) + const greenAgain = resolveCssx({ + styleName: 'button', + layers: { sheet, values: ['green'] }, + cache + }) + const redAfterGreen = resolveCssx({ + styleName: 'button', + layers: { sheet, values: ['red'] }, + cache + }) + + assert.equal(redAgain.cacheHit, true) + assert.equal(redAgain.props, red.props) + assert.notEqual(green.props, red.props) + assert.equal(greenAgain.cacheHit, true) + assert.equal(greenAgain.props, green.props) + assert.notEqual(redAfterGreen.props, red.props) + assert.equal(cache.entries.size, 1) + }) + + it('reuses cached references for equal inline style values', () => { + const sheet = compileCss('.button { color: red; }') + const cache = createCssxCache() + + const first = resolveCssx({ + styleName: 'button', + layers: sheet, + inlineStyleProps: { opacity: 0.5 }, + cache + }) + const second = resolveCssx({ + styleName: 'button', + layers: sheet, + inlineStyleProps: { opacity: 0.5 }, + cache + }) + + assert.equal(second.cacheHit, true) + assert.equal(second.props, first.props) + assert.equal(second.props.style, first.props.style) + }) + + it('does not invalidate cache when unused variables change', () => { + const sheet = compileCss('.button { color: var(--text); }') + const cache = createCssxCache() + + const first = resolveCssx({ + styleName: 'button', + layers: sheet, + variables: { '--text': 'red', '--unused': 1 }, + cache + }) + const second = resolveCssx({ + styleName: 'button', + layers: sheet, + variables: { '--text': 'red', '--unused': 2 }, + cache + }) + const changed = resolveCssx({ + styleName: 'button', + layers: sheet, + variables: { '--text': 'green', '--unused': 2 }, + cache + }) + + assert.equal(second.cacheHit, true) + assert.equal(second.props, first.props) + assert.notEqual(changed.props, first.props) + assert.deepEqual(changed.props, { style: { color: 'green' } }) + }) + + it('keeps separate cache entries for different elements', () => { + const sheet = compileCss(` + .button { color: red; } + .label { color: blue; } + `) + const cache = createCssxCache() + + const button = resolveCssx({ styleName: 'button', layers: sheet, cache }) + const label = resolveCssx({ styleName: 'label', layers: sheet, cache }) + const buttonAgain = resolveCssx({ styleName: 'button', layers: sheet, cache }) + const labelAgain = resolveCssx({ styleName: 'label', layers: sheet, cache }) + + assert.equal(buttonAgain.props, button.props) + assert.equal(labelAgain.props, label.props) + assert.notEqual(button.props, label.props) + assert.equal(cache.entries.size, 2) + }) + + it('matches component tag selectors and resolves sheet root variables', () => { + const sheet = compileCss(` + :root { + --button-color: oklch(62% 0.18 250 / 0.5); + } + Button { color: var(--button-color); } + Button.primary:part(label) { color: white; } + Link { color: green; } + .utility { padding: 1u; } + `) + + const result = resolveCssx({ + componentTag: 'Button', + styleName: ['primary', 'utility'], + layers: sheet + }) + + assert.deepEqual(result.props, { + style: { + color: 'rgba(0, 137, 237, 0.5)', + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + }, + labelStyle: { color: 'white' } + }) + assert.deepEqual(result.dependencies.vars, ['--button-color']) + }) + + it('keeps component tag and scoped variables in cache invalidation', () => { + const sheet = compileCss(` + Button { color: var(--color); } + Link { color: var(--color); } + `) + const cache = createCssxCache() + const button = resolveCssx({ + componentTag: 'Button', + styleName: '', + layers: sheet, + scopedVariables: [{ '--color': 'red' }], + cache + }) + const link = resolveCssx({ + componentTag: 'Link', + styleName: '', + layers: sheet, + scopedVariables: [{ '--color': 'red' }], + cache + }) + const buttonAgain = resolveCssx({ + componentTag: 'Button', + styleName: '', + layers: sheet, + scopedVariables: [{ '--color': 'red' }], + cache + }) + const buttonChanged = resolveCssx({ + componentTag: 'Button', + styleName: '', + layers: sheet, + scopedVariables: [{ '--color': 'blue' }], + cache + }) + + assert.equal(buttonAgain.cacheHit, true) + assert.equal(buttonAgain.props, button.props) + assert.notEqual(link.props, button.props) + assert.notEqual(buttonChanged.props, button.props) + assert.deepEqual(buttonChanged.props, { style: { color: 'blue' } }) + }) + + it('resolves variables in inline style props', () => { + const sheet = compileCss('.button { color: red; }') + const result = resolveCssx({ + styleName: 'button', + layers: sheet, + inlineStyleProps: { + style: { + color: 'var(--inline-color)', + paddingTop: 'var(--inline-space)' + } + }, + variables: { + '--inline-color': 'oklch(62% 0.18 250 / 0.5)', + '--inline-space': '2u' + } + }) + + assert.deepEqual(result.props, { + style: { + color: 'rgba(0, 137, 237, 0.5)', + paddingTop: 16 + } + }) + assert.deepEqual(result.dependencies.vars, ['--inline-color', '--inline-space']) + }) + + it('preserves non-plain inline style objects such as animated values', () => { + class AnimatedValue { + current: number + + constructor (current: number) { + this.current = current + } + } + + const animatedTranslateX = new AnimatedValue(1) + const sheet = compileCss('.button { color: red; }') + const result = resolveCssx({ + styleName: 'button', + layers: sheet, + inlineStyleProps: { + style: { + opacity: 'var(--opacity)', + transform: [{ translateX: animatedTranslateX }] + } + } as any, + variables: { + '--opacity': 0.5 + } + }) + + assert.equal( + (result.props.style as any).transform[0].translateX, + animatedTranslateX + ) + assert.deepEqual(result.props, { + style: { + color: 'red', + opacity: 0.5, + transform: [{ translateX: animatedTranslateX }] + } + }) + }) + + it('resolves partial variables inside complex property values', () => { + const sheet = compileCss(` + .button { + box-shadow: var(--shadow-x, 0) 2px var(--shadow-blur, 4px) var(--shadow-color); + filter: blur(var(--blur, 2px)) brightness(var(--brightness, 0.8)); + text-shadow: var(--text-x, 1px) 2px 3px var(--text-color, red); + transform: translateX(var(--tx, 4px)) scale(var(--scale, 2)); + background: var(--bg-color, red) var(--bg-image, radial-gradient(circle, white, black)); + } + `) + + const result = resolveCssx({ + styleName: 'button', + layers: sheet, + variables: { + '--shadow-x': '1px', + '--shadow-blur': '8px', + '--shadow-color': 'rgba(0,0,0,.2)', + '--blur': '4px', + '--brightness': 0.9, + '--text-x': '5px', + '--text-color': 'blue', + '--tx': '10px', + '--scale': 1.5, + '--bg-color': 'green', + '--bg-image': 'linear-gradient(90deg, white, black)' + } + }) + + assert.deepEqual(result.dependencies.vars, [ + '--bg-color', + '--bg-image', + '--blur', + '--brightness', + '--scale', + '--shadow-blur', + '--shadow-color', + '--shadow-x', + '--text-color', + '--text-x', + '--tx' + ]) + assert.deepEqual(result.props, { + style: { + boxShadow: '1px 2px 8px rgba(0,0,0,.2)', + filter: 'blur(4px) brightness(0.9)', + textShadowOffset: { width: 5, height: 2 }, + textShadowRadius: 3, + textShadowColor: 'blue', + transform: [ + { scale: 1.5 }, + { translateX: 10 } + ], + backgroundColor: 'green', + experimental_backgroundImage: 'linear-gradient(90deg, white, black)' + } + }) + }) + + it('evicts raw CSS resolved cache entries when a caller requests a single cache slot', () => { + const cache = createCssxCache({ maxEntries: 1 }) + const redCss = '.root { color: red; }' + const greenCss = '.root { color: green; }' + + const red = resolveCssx({ styleName: 'root', layers: redCss, cache }) + const redAgain = resolveCssx({ styleName: 'root', layers: redCss, cache }) + const green = resolveCssx({ styleName: 'root', layers: greenCss, cache }) + const redAfterGreen = resolveCssx({ styleName: 'root', layers: redCss, cache }) + + assert.equal(redAgain.cacheHit, true) + assert.equal(redAgain.props, red.props) + assert.equal(green.cacheHit, false) + assert.equal(redAfterGreen.cacheHit, false) + assert.notEqual(redAfterGreen.props, red.props) + assert.equal(cache.entries.size, 1) + }) + + it('inlines only keyframes used by matched animation styles', () => { + const sheet = compileCss(` + @keyframes fade { + from { opacity: var(--from-opacity, 0); } + to { opacity: 1; } + } + @keyframes unused { + from { color: var(--unused-color); } + to { color: black; } + } + .button { animation: fade 200ms ease; } + `) + + const result = resolveCssx({ + styleName: 'button', + layers: sheet + }) + + assert.deepEqual(result.dependencies.vars, ['--from-opacity']) + assert.deepEqual(result.props.style, { + animationName: { + from: { opacity: 0 }, + to: { opacity: 1 } + }, + animationDuration: '200ms', + animationTimingFunction: 'ease', + animationDelay: '0s', + animationIterationCount: 1, + animationDirection: 'normal', + animationFillMode: 'none', + animationPlayState: 'running' + }) + }) + + it('resolves variables and interpolation inside animation and transition values', () => { + const sheet = compileCssTemplate(` + @keyframes fade { + from { opacity: var(--from-opacity, 0); } + to { opacity: var(--target-opacity, 1); } + } + .button { + animation: var(--animation-name, fade) var(--__cssx_dynamic_0) ease; + transition: opacity var(--transition-duration, 150ms); + } + `) + + const result = resolveCssx({ + styleName: 'button', + layers: { + sheet, + values: ['300ms'] + }, + variables: { + '--from-opacity': 0.25, + '--target-opacity': 0.75, + '--transition-duration': '250ms' + } + }) + + assert.deepEqual(result.dependencies.vars, [ + '--animation-name', + '--from-opacity', + '--target-opacity', + '--transition-duration' + ]) + assert.deepEqual(result.props.style, { + animationName: { + from: { opacity: 0.25 }, + to: { opacity: 0.75 } + }, + animationDuration: '300ms', + animationTimingFunction: 'ease', + animationDelay: '0s', + animationIterationCount: 1, + animationDirection: 'normal', + animationFillMode: 'none', + animationPlayState: 'running', + transitionProperty: 'opacity', + transitionDuration: '250ms', + transitionTimingFunction: 'ease', + transitionDelay: '0s' + }) + }) +}) diff --git a/packages/css-to-rn/test/engine/transform.test.ts b/packages/css-to-rn/test/engine/transform.test.ts new file mode 100644 index 0000000..4d50050 --- /dev/null +++ b/packages/css-to-rn/test/engine/transform.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict' + +import { transformDeclarations } from '../../src/transform/index.ts' +import type { + CssDeclaration, + TransformDeclarationOptions, +} from '../../src/transform/index.ts' + +function declarations ( + input: ReadonlyArray +): CssDeclaration[] { + return input.map(([property, value], order) => ({ + property, + value, + raw: `${property}: ${value}`, + order, + })) +} + +function transform ( + input: ReadonlyArray, + options?: TransformDeclarationOptions +) { + return transformDeclarations(declarations(input), options) +} + +describe('@cssxjs/css-to-rn declaration transformer', () => { + it('normalizes raw declarations and expands margin, padding, and border shorthands', () => { + const result = transform([ + ['opacity', '0.5'], + ['display', 'flex'], + ['margin', '1px 2px auto 4px'], + ['padding', '2u 8px'], + ['border', '2px dashed #f00'], + ['border-radius', '4px 8px 12px 16px'], + ['border-width', '1px 2px 3px 4px'], + ['border-color', 'red green blue black'], + ]) + + assert.deepEqual(result.diagnostics, []) + assert.deepEqual(result.style, { + opacity: 0.5, + display: 'flex', + marginTop: 1, + marginRight: 2, + marginBottom: 'auto', + marginLeft: 4, + paddingTop: 16, + paddingRight: 8, + paddingBottom: 16, + paddingLeft: 8, + borderWidth: 2, + borderColor: '#f00', + borderStyle: 'dashed', + borderTopLeftRadius: 4, + borderTopRightRadius: 8, + borderBottomRightRadius: 12, + borderBottomLeftRadius: 16, + borderTopWidth: 1, + borderRightWidth: 2, + borderBottomWidth: 3, + borderLeftWidth: 4, + borderTopColor: 'red', + borderRightColor: 'green', + borderBottomColor: 'blue', + borderLeftColor: 'black', + }) + }) + + it('transforms transform and text-shadow values', () => { + const result = transform([ + ['transform', 'scale(2, 3) translate(4px, 50%) rotate(5deg)'], + ['text-shadow', '10px 20px 30px rgba(0, 0, 0, 0.4)'], + ]) + + assert.deepEqual(result.diagnostics, []) + assert.deepEqual(result.style, { + transform: [ + { rotate: '5deg' }, + { translateY: '50%' }, + { translateX: 4 }, + { scaleY: 3 }, + { scaleX: 2 }, + ], + textShadowOffset: { width: 10, height: 20 }, + textShadowRadius: 30, + textShadowColor: 'rgba(0, 0, 0, 0.4)', + }) + }) + + it('transforms none into an empty transform list', () => { + const result = transform([ + ['transform', 'none'], + ]) + + assert.deepEqual(result.diagnostics, []) + assert.deepEqual(result.style, { + transform: [], + }) + }) + + it('passes through box-shadow and filter strings', () => { + const result = transform([ + ['box-shadow', '0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333'], + ['filter', 'blur(4px) brightness(0.8)'], + ]) + + assert.deepEqual(result.diagnostics, []) + assert.deepEqual(result.style, { + boxShadow: '0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333', + filter: 'blur(4px) brightness(0.8)', + }) + }) + + it('keeps line-height length values web-safe while preserving native numbers', () => { + const nativeResult = transform([ + ['line-height', '20px'], + ]) + const nativeUnitlessResult = transform([ + ['line-height', '1.25'], + ]) + const webResult = transform( + [['line-height', '20px']], + { platform: 'web' } + ) + const webUnitlessResult = transform( + [['line-height', '1.25']], + { platform: 'web' } + ) + + assert.deepEqual(nativeResult.style, { lineHeight: 20 }) + assert.deepEqual(nativeUnitlessResult.style, { lineHeight: 1.25 }) + assert.deepEqual(webResult.style, { lineHeight: '20px' }) + assert.deepEqual(webUnitlessResult.style, { lineHeight: 1.25 }) + }) + + it('maps background-image by platform and supports limited background shorthand', () => { + const nativeResult = transform([ + ['background-image', 'linear-gradient(90deg, red, blue)'], + ['background', 'red radial-gradient(circle, white, black)'], + ]) + const webResult = transform( + [['background-image', 'linear-gradient(90deg, red, blue)']], + { platform: 'web' } + ) + + assert.deepEqual(nativeResult.diagnostics, []) + assert.deepEqual(nativeResult.style, { + experimental_backgroundImage: 'radial-gradient(circle, white, black)', + backgroundColor: 'red', + }) + assert.deepEqual(webResult.style, { + backgroundImage: 'linear-gradient(90deg, red, blue)', + }) + }) + + it('diagnoses unsupported background images without emitting style', () => { + const result = transform([ + ['background-image', 'url(foo.png)'], + ['background', 'no-repeat center/cover red'], + ]) + + assert.deepEqual(result.style, {}) + assert.deepEqual( + result.diagnostics.map(diagnostic => diagnostic.code), + ['UNSUPPORTED_BACKGROUND_IMAGE', 'UNSUPPORTED_BACKGROUND_SHORTHAND'] + ) + }) + + it('transforms animations, transitions, and animation keyframe names', () => { + const result = transform( + [ + ['animation', 'fadeIn 300ms ease, slideIn 500ms ease-out 100ms'], + [ + 'transition', + 'background-color 200ms linear, opacity 1s ease-in 50ms', + ], + ], + { + keyframes: { + fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } }, + }, + } + ) + + assert.deepEqual(result.diagnostics, []) + assert.deepEqual(result.style, { + animationName: [ + { from: { opacity: 0 }, to: { opacity: 1 } }, + 'slideIn', + ], + animationDuration: ['300ms', '500ms'], + animationTimingFunction: ['ease', 'ease-out'], + animationDelay: ['0s', '100ms'], + animationIterationCount: [1, 1], + animationDirection: ['normal', 'normal'], + animationFillMode: ['none', 'none'], + animationPlayState: ['running', 'running'], + transitionProperty: ['backgroundColor', 'opacity'], + transitionDuration: ['200ms', '1s'], + transitionTimingFunction: ['linear', 'ease-in'], + transitionDelay: ['0s', '50ms'], + }) + }) +}) diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts new file mode 100644 index 0000000..8cab53f --- /dev/null +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict' +import { resolveCssValue, u } from '../../src/index.ts' +import { resetUWarningForTests } from '../../src/units.ts' + +describe('@cssxjs/css-to-rn value resolver', () => { + it('resolves runtime variables, defaults, and inline fallbacks by priority', () => { + assert.equal(resolveCssValue('var(--color, red)', { + defaultVariables: { '--color': 'blue' }, + variables: { '--color': 'green' } + }).value, 'green') + + assert.equal(resolveCssValue('var(--color, red)', { + defaultVariables: { '--color': 'blue' } + }).value, 'blue') + + assert.equal(resolveCssValue('var(--color, red)').value, 'red') + }) + + it('resolves nested var fallbacks and records dependencies', () => { + const result = resolveCssValue('var(--a, var(--b, red))', { + defaultVariables: { '--b': 'blue' } + }) + + assert.equal(result.valid, true) + assert.equal(result.value, 'blue') + assert.deepEqual(result.dependencies.vars, ['--a', '--b']) + }) + + it('invalidates unresolved variables', () => { + const result = resolveCssValue('1px solid var(--missing)') + + assert.equal(result.valid, false) + assert.equal(result.diagnostics[0].code, 'UNRESOLVED_VARIABLE') + assert.deepEqual(result.dependencies.vars, ['--missing']) + }) + + it('detects variable cycles', () => { + const result = resolveCssValue('var(--a)', { + defaultVariables: { + '--a': 'var(--b)', + '--b': 'var(--a)' + } + }) + + assert.equal(result.valid, false) + assert.equal(result.diagnostics[0].code, 'VARIABLE_CYCLE') + }) + + it('replaces interpolation slots before resolving variables', () => { + const result = resolveCssValue('color-mix(in srgb, var(--__cssx_dynamic_0), white)', { + values: ['var(--color, red)'], + variables: { '--color': 'green' } + }) + + assert.equal(result.valid, true) + assert.equal(result.value, 'rgba(128, 192, 128, 1)') + assert.deepEqual(result.dependencies.vars, ['--color']) + }) + + it('resolves scoped variables before defaults', () => { + const result = resolveCssValue('var(--color)', { + scopedVariables: [ + { '--color': 'red' }, + { '--color': 'oklch(62% 0.18 250 / 0.5)' } + ], + defaultVariables: { '--color': 'blue' } + }) + + assert.equal(result.valid, true) + assert.equal(result.value, 'rgba(0, 137, 237, 0.5)') + assert.deepEqual(result.dependencies.vars, ['--color']) + }) + + it('invalidates omitted interpolation values', () => { + const result = resolveCssValue('var(--__cssx_dynamic_0)', { + values: [false] + }) + + assert.equal(result.valid, false) + assert.equal(result.diagnostics[0].code, 'INVALID_INTERPOLATION_VALUE') + }) + + it('resolves u, rem, and viewport units', () => { + const result = resolveCssValue('calc(10vw + 2u + 0.25rem)', { + dimensions: { width: 200, height: 100 } + }) + + assert.equal(result.valid, true) + assert.equal(result.value, '40px') + assert.equal(result.dependencies.dimensions, true) + }) + + it('resolves rem variables inside calc expressions', () => { + const result = resolveCssValue('calc(var(--spacing) * 2)', { + variables: { '--spacing': '0.25rem' } + }) + + assert.equal(result.valid, true) + assert.equal(result.value, '8px') + assert.deepEqual(result.dependencies.vars, ['--spacing']) + }) + + it('keeps deprecated JS u helper with one warning', () => { + resetUWarningForTests() + const originalWarn = console.warn + const warnings: unknown[][] = [] + console.warn = (...args: unknown[]) => { + warnings.push(args) + } + + try { + assert.equal(u(1), 8) + assert.equal(u(2.5), 20) + } finally { + console.warn = originalWarn + resetUWarningForTests() + } + + assert.equal(warnings.length, 1) + assert.match(String(warnings[0][0]), /u\(\) is deprecated/) + }) + + it('resolves percentage and unitless calc expressions for color channels', () => { + assert.equal(resolveCssValue('calc(50% + 10%)').value, '60%') + assert.equal(resolveCssValue('calc(0.2 * 0.5)').value, '0.1') + assert.equal(resolveCssValue('oklch(calc(50% + 10%) calc(0.2 * 0.5) 250)').value, 'rgba(79, 132, 186, 1)') + }) + + it('mixes srgb colors with transparent using premultiplied alpha', () => { + assert.equal( + resolveCssValue('color-mix(in srgb, rgb(24 107 236) 5%, transparent)').value, + 'rgba(24, 107, 236, 0.05)' + ) + }) + + it('rejects unsupported calc expressions', () => { + const result = resolveCssValue('calc(100% - 16px)') + + assert.equal(result.valid, false) + assert.equal(result.diagnostics[0].code, 'UNSUPPORTED_CALC') + }) +}) diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts new file mode 100644 index 0000000..5ff4c53 --- /dev/null +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -0,0 +1,1457 @@ +import assert from 'node:assert/strict' +import { JSDOM } from 'jsdom' +import React, { Suspense, act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { + __cssxInternals, + compileCss, + compileCssTemplate, + CssxProvider, + cssx, + defaultVariables, + getCssColor, + getCssVariable, + getCssVariableRaw, + setDefaultVariables, + themed, + useTheme, + useCssColor, + useCssVariable, + useCssVariableRaw, + useCssxLayer, + useCssxTemplate, + useMedia, + variables +} from '../../src/web.ts' + +(globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +}).IS_REACT_ACT_ENVIRONMENT = true + +const dom = new JSDOM('') +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Node: dom.window.Node +}) + +describe('@cssxjs/css-to-rn React tracking prototype', () => { + function reset (): void { + __cssxInternals.resetStoreForTests() + __cssxInternals.clearRawCssCacheForTests() + } + + it('batches variable notifications in one microtask', async () => { + reset() + const calls: string[][] = [] + const unsubscribe = __cssxInternals.subscribeVariablesForTests( + ['--bg', '--text'], + names => calls.push([...names].sort()) + ) + + variables['--bg'] = 'black' + Object.assign(variables, { + '--text': 'white' + }) + + assert.equal(calls.length, 0) + await __cssxInternals.flushMicrotasksForTests() + + assert.deepEqual(calls, [['--bg', '--text']]) + unsubscribe() + reset() + }) + + it('records dependencies only for matched active selectors', () => { + reset() + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .label { color: var(--label-color, blue); } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + + tracked.startRender() + const props = cssx('root', tracked) + const dependencies = tracked.getPendingDependenciesForTests() + + assert.deepEqual(props, { + style: { + color: 'red' + } + }) + assert.deepEqual( + Array.from(dependencies?.vars.keys() ?? []), + ['--root-color'] + ) + reset() + }) + + it('notifies tracked wrappers only for committed variable dependencies', async () => { + reset() + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .root.active { background-color: var(--active-bg, blue); } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + tracked.startRender() + cssx('root', tracked) + tracked.commitRender() + + variables['--active-bg'] = 'green' + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 0) + + variables['--root-color'] = 'black' + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + unsubscribe() + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + reset() + }) + + it('unions dependencies from multiple cssx calls in one render', () => { + reset() + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .label { color: var(--label-color, blue); } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + + tracked.startRender() + cssx('root', tracked) + cssx('label', tracked) + + assert.deepEqual( + Array.from(tracked.getPendingDependenciesForTests()?.vars.keys() ?? []), + ['--root-color', '--label-color'] + ) + reset() + }) + + it('does not subscribe to dependencies collected by an aborted render', async () => { + reset() + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .root.active { background-color: var(--active-bg, blue); } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + tracked.startRender() + cssx('root', tracked) + tracked.commitRender() + + tracked.startRender() + cssx(['root', 'active'], tracked) + + variables['--active-bg'] = 'green' + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 0) + + variables['--root-color'] = 'black' + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + unsubscribe() + reset() + }) + + it('commits the dependency snapshot captured for that render', async () => { + reset() + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .root.active { background-color: var(--active-bg, blue); } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + const rootRender = tracked.startRender() + cssx('root', tracked) + + tracked.startRender() + cssx(['root', 'active'], tracked) + + tracked.commitRender(rootRender) + + variables['--active-bg'] = 'green' + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 0) + + variables['--root-color'] = 'black' + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + unsubscribe() + reset() + }) + + it('reuses tracked cache references for identical render inputs', () => { + reset() + const sheet = compileCss('.root { color: var(--root-color, red); }') + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + + tracked.startRender() + const first = cssx('root', tracked, { style: { opacity: 0.5 } }) + tracked.commitRender() + + tracked.startRender() + const second = cssx('root', tracked, { style: { opacity: 0.5 } }) + tracked.commitRender() + + assert.equal(second, first) + assert.equal(second.style, first.style) + reset() + }) + + it('passes tracked template values into the shared resolver', () => { + reset() + const sheet = compileCssTemplate('.root { color: var(--__cssx_dynamic_0); }') + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { + target: 'web', + values: ['red'] + }) + + tracked.startRender() + const red = cssx('root', tracked) + tracked.commitRender() + + tracked.update(sheet, { + target: 'web', + values: ['green'] + }) + tracked.startRender() + const green = cssx('root', tracked) + tracked.commitRender() + + assert.deepEqual(red, { style: { color: 'red' } }) + assert.deepEqual(green, { style: { color: 'green' } }) + assert.notEqual(green, red) + reset() + }) + + it('notifies default variable replacements and removed defaults', async () => { + reset() + const sheet = compileCss('.root { color: var(--root-color, red); }') + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + setDefaultVariables({ '--root-color': 'blue' }) + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { style: { color: 'blue' } }) + tracked.commitRender() + + setDefaultVariables({ '--other': 'green' }) + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { style: { color: 'red' } }) + tracked.commitRender() + + unsubscribe() + reset() + }) + + it('supports variable store bulk methods and validation', () => { + reset() + + variables.assign({ + '--text': 'red', + '--space': '2u' + }) + assert.equal(variables['--text'], 'red') + assert.equal(getCssVariable('--space'), 16) + + variables.set({ + '--text': 'blue' + }) + assert.equal(variables['--text'], 'blue') + assert.equal(variables['--space'], undefined) + + variables.clear() + assert.equal(variables['--text'], undefined) + + defaultVariables.set({ '--fallback': 'oklch(62% 0.18 250 / 0.5)' }) + assert.equal(getCssVariableRaw('--fallback'), 'rgba(0, 137, 237, 0.5)') + + assert.throws(() => { + variables.assign({ color: 'red' }) + }, /Invalid CSS custom property name/) + assert.throws(() => { + variables.color = 'red' + }, /Invalid CSS custom property name/) + + reset() + }) + + it('resolves CSS colors from semantic tokens, var() expressions, and mixes', () => { + reset() + + variables.set({ + '--color-primary': 'red', + '--color-secondary': 'blue', + '--custom': 'oklch(62% 0.18 250 / 0.5)' + }) + + assert.equal(getCssColor('primary'), 'red') + assert.equal(getCssColor('var(--custom)'), 'rgba(0, 137, 237, 0.5)') + assert.equal(getCssColor('primary', 0.5), 'rgba(255, 0, 0, 0.5)') + assert.equal( + getCssColor('primary', { mix: '25%', with: 'secondary' }), + 'rgba(64, 0, 191, 1)' + ) + assert.equal(getCssColor('white'), 'white') + + assert.throws(() => { + getCssColor('--primary') + }, /Ambiguous CSS color token/) + assert.throws(() => { + getCssColor('primary', 2) + }, /Expected a number from 0 to 1/) + + reset() + }) + + it('resolves provider styles and themed component tag selectors', async () => { + reset() + let latest: unknown + let latestVar: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + const Button = themed('Button', function Button (): React.ReactNode { + latest = cssx(['primary', 'utility'], []) + latestVar = useCssVariable('--brand') + return createElement('div') + }) + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + style: ` + :root { --brand: oklch(62% 0.18 250 / 0.5); } + Button { color: var(--brand); } + Button.primary:part(label) { color: white; } + Link { color: green; } + .utility { padding: 1u; } + ` + }, + createElement(Button) + )) + }) + + assert.deepEqual(latest, { + style: { + color: 'rgba(0, 137, 237, 0.5)', + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + }, + labelStyle: { color: 'white' } + }) + assert.equal(latestVar, 'rgba(0, 137, 237, 0.5)') + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('resolves useCssColor through provider variables', async () => { + reset() + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + latest = useCssColor('primary') + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + style: ` + :root { + --primary: oklch(62% 0.18 250 / 0.5); + --color-primary: var(--primary); + } + ` + }, + createElement(Component) + )) + }) + + assert.equal(latest, 'rgba(0, 137, 237, 0.5)') + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('uses nearest provider root variables over outer provider roots', async () => { + reset() + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + latest = useCssVariable('--space') + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { style: ':root { --space: 1u; }' }, + createElement( + CssxProvider, + { style: ':root { --space: 3u; }' }, + createElement(Component) + ) + )) + }) + + assert.equal(latest, 24) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('uses the default root theme unless auto is explicitly requested', async () => { + reset() + __cssxInternals.setColorSchemeForTests('dark') + let latest: unknown + let latestVar: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + latest = cssx('root', []) + latestVar = useCssVariable('--surface') + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + style: ` + :root { --surface: white; } + :root.dark { --surface: black; } + .root { color: var(--surface); } + ` + }, + createElement(Component) + )) + }) + + assert.deepEqual(latest, { style: { color: 'white' } }) + assert.equal(latestVar, 'white') + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('updates auto provider theme from color scheme changes', async () => { + reset() + __cssxInternals.setColorSchemeForTests('light') + let latest: unknown + let latestVar: unknown + let latestTheme: string | undefined + let setTheme: ((theme: string) => void) | undefined + let renders = 0 + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + renders += 1 + const themeHook = useTheme() + latestTheme = themeHook[0] + setTheme = themeHook[1] + latest = cssx('root', []) + latestVar = useCssVariable('--surface') + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + theme: 'auto', + style: ` + :root { --surface: white; } + :root.dark { --surface: black; } + .root { color: var(--surface); } + ` + }, + createElement(Component) + )) + }) + + assert.deepEqual(latest, { style: { color: 'white' } }) + assert.equal(latestVar, 'white') + assert.equal(latestTheme, 'default') + assert.equal(renders, 1) + + await act(async () => { + __cssxInternals.setColorSchemeForTests('dark') + }) + + assert.deepEqual(latest, { style: { color: 'black' } }) + assert.equal(latestVar, 'black') + assert.equal(latestTheme, 'dark') + assert.equal(renders, 2) + + await act(async () => { + setTheme?.('light') + }) + + assert.deepEqual(latest, { style: { color: 'white' } }) + assert.equal(latestVar, 'white') + assert.equal(latestTheme, 'default') + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('uses persisted theme preference and lets controlled providers win', async () => { + reset() + let savedTheme: string | undefined + __cssxInternals.configureThemeStorageAdapterForTests({ + get: () => 'dark', + set: theme => { + savedTheme = theme + } + }) + await __cssxInternals.flushMicrotasksForTests() + + let latest: unknown + let latestTheme: string | undefined + let setTheme: ((theme: string) => void) | undefined + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + const themeHook = useTheme() + latestTheme = themeHook[0] + setTheme = themeHook[1] + latest = cssx('root', []) + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + style: ` + :root { --surface: white; } + :root.dark { --surface: black; } + .root { color: var(--surface); } + ` + }, + createElement(Component) + )) + }) + + assert.equal(latestTheme, 'dark') + assert.deepEqual(latest, { style: { color: 'black' } }) + + await act(async () => { + setTheme?.('light') + }) + + assert.equal(savedTheme, 'light') + assert.equal(latestTheme, 'default') + assert.deepEqual(latest, { style: { color: 'white' } }) + + await act(async () => { + root?.render(createElement( + CssxProvider, + { + theme: 'dark', + style: ` + :root { --surface: white; } + :root.dark { --surface: black; } + .root { color: var(--surface); } + ` + }, + createElement(Component) + )) + }) + + assert.equal(latestTheme, 'dark') + assert.deepEqual(latest, { style: { color: 'black' } }) + + await act(async () => { + setTheme?.('light') + }) + + assert.equal(savedTheme, 'light') + assert.equal(latestTheme, 'dark') + assert.deepEqual(latest, { style: { color: 'black' } }) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('resolves provider custom media aliases in useMedia', async () => { + reset() + let dimensions = { width: 600, height: 800 } + const listeners = new Set<() => void>() + __cssxInternals.configureDimensionsAdapterForTests({ + get: () => dimensions, + subscribe: listener => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }) + let latest: Record | undefined + let renders = 0 + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + renders += 1 + latest = useMedia() + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + style: ` + :root { --compact-width: 40rem; } + @custom-media --compact (width < var(--compact-width)); + ` + }, + createElement(Component) + )) + }) + + assert.equal(latest?.compact, true) + assert.equal(latest?.tablet, false) + assert.equal(renders, 1) + + await act(async () => { + dimensions = { width: 800, height: 800 } + for (const listener of Array.from(listeners)) listener() + await __cssxInternals.flushMicrotasksForTests() + }) + + assert.equal(latest?.compact, false) + assert.equal(latest?.tablet, true) + assert.equal(renders, 2) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('resolves provider root variables from compiled layers and template values', async () => { + reset() + const providerSheet = compileCss(':root { --tone: blue; }') + const providerTemplate = compileCssTemplate(':root { --space: var(--__cssx_dynamic_0); }') + let renders = 0 + let latestTone: unknown + let latestSpace: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + setDefaultVariables({ + '--tone': 'red', + '--space': '1u' + }) + + function Component (): React.ReactNode { + renders += 1 + latestTone = useCssVariable('--tone') + latestSpace = useCssVariable('--space') + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { + style: [ + providerSheet, + { + sheet: providerTemplate, + values: ['2u'] + } + ] + }, + createElement(Component) + )) + }) + + assert.equal(renders, 1) + assert.equal(latestTone, 'blue') + assert.equal(latestSpace, 16) + + variables['--tone'] = 'green' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + + assert.equal(renders, 2) + assert.equal(latestTone, 'green') + assert.equal(latestSpace, 16) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('tracks provider style dependencies from themed components without local sheets', async () => { + reset() + let renders = 0 + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + variables['--brand'] = 'red' + + const Button = themed('Button', function Button (): React.ReactNode { + renders += 1 + latest = cssx('', []) + return createElement('div') + }) + + await act(async () => { + root = createRoot(container) + root.render(createElement( + CssxProvider, + { style: 'Button { color: var(--brand); }' }, + createElement(Button) + )) + }) + + assert.equal(renders, 1) + assert.deepEqual(latest, { style: { color: 'red' } }) + + variables['--brand'] = 'blue' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + + assert.equal(renders, 2) + assert.deepEqual(latest, { style: { color: 'blue' } }) + + await act(async () => { + root?.unmount() + }) + container.remove() + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + reset() + }) + + it('subscribes useCssVariable only to variables it resolves', async () => { + reset() + let renders = 0 + let latest: unknown + let latestRaw: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + variables.set({ + '--space': '2u', + '--tone': 'oklch(62% 0.18 250 / 0.5)', + '--unused': 'red' + }) + + function Component (): React.ReactNode { + renders += 1 + latest = useCssVariable('--space') + latestRaw = useCssVariableRaw('--tone') + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement(Component)) + }) + + assert.equal(renders, 1) + assert.equal(latest, 16) + assert.equal(latestRaw, 'rgba(0, 137, 237, 0.5)') + + variables['--unused'] = 'blue' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, 1) + + variables['--space'] = '3u' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, 2) + assert.equal(latest, 24) + + await act(async () => { + root?.unmount() + }) + container.remove() + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + reset() + }) + + it('subscribes useCssColor only to variables it resolves', async () => { + reset() + let renders = 0 + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + variables.set({ + '--color-primary': 'red', + '--color-secondary': 'blue', + '--unused': 'black' + }) + + function Component (): React.ReactNode { + renders += 1 + latest = useCssColor('primary', { mix: '25%', with: 'secondary' }) + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement(Component)) + }) + + assert.equal(renders, 1) + assert.equal(latest, 'rgba(64, 0, 191, 1)') + + variables['--unused'] = 'white' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, 1) + + variables['--color-secondary'] = 'white' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, 2) + assert.equal(latest, 'rgba(255, 191, 191, 1)') + + await act(async () => { + root?.unmount() + }) + container.remove() + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + reset() + }) + + it('uses dimension adapter values for media queries and viewport units', async () => { + reset() + let dimensions = { width: 320, height: 640 } + const listeners = new Set<() => void>() + + __cssxInternals.configureDimensionsAdapterForTests({ + get: () => dimensions, + subscribe: listener => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }) + + const sheet = compileCss(` + .root { + width: 100vw; + height: 50vh; + } + @media (max-width: 480px) { + .root { color: red; } + } + @media (orientation: portrait) { + .root { background-color: blue; } + } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + width: 320, + height: 320, + color: 'red', + backgroundColor: 'blue' + } + }) + tracked.commitRender() + + dimensions = { width: 800, height: 400 } + for (const listener of Array.from(listeners)) listener() + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + width: 800, + height: 200 + } + }) + tracked.commitRender() + + unsubscribe() + reset() + }) + + it('resolves default custom media aliases in cssx style media rules', () => { + reset() + __cssxInternals.setDimensionsForTests({ width: 767, height: 640 }) + + const sheet = compileCss(` + .root { color: red; } + @media (--breakpoint-tablet) { + .root { color: blue; } + } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'red' + } + }) + tracked.commitRender() + + __cssxInternals.setDimensionsForTests({ width: 768, height: 640 }) + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'blue' + } + }) + reset() + }) + + it('invalidates media dependencies using the same dimensions as resolution', async () => { + reset() + let dimensions = { width: 320, height: 640 } + const listeners = new Set<() => void>() + + __cssxInternals.configureDimensionsAdapterForTests({ + get: () => dimensions, + subscribe: listener => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }) + + const sheet = compileCss(` + .root { color: black; } + @media (orientation: portrait) { + .root { color: red; } + } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'red' + } + }) + tracked.commitRender() + + dimensions = { width: 800, height: 400 } + for (const listener of Array.from(listeners)) listener() + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'black' + } + }) + tracked.commitRender() + + unsubscribe() + reset() + }) + + it('invalidates matchMedia-only dependencies through the media adapter', async () => { + reset() + let scheme = 'light' + const listeners = new Map void>>() + + __cssxInternals.configureMediaQueryAdapterForTests({ + evaluate: query => query === '(prefers-color-scheme: dark)' && scheme === 'dark', + subscribe: (query, listener) => { + let queryListeners = listeners.get(query) + if (queryListeners == null) { + queryListeners = new Set() + listeners.set(query, queryListeners) + } + queryListeners.add(listener) + return () => { + queryListeners?.delete(listener) + if (queryListeners?.size === 0) listeners.delete(query) + } + } + }) + + const sheet = compileCss(` + .root { color: black; } + @media (prefers-color-scheme: dark) { + .root { color: white; } + } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'black' + } + }) + tracked.commitRender() + assert.equal(listeners.get('(prefers-color-scheme: dark)')?.size, 1) + + scheme = 'dark' + for (const listener of Array.from(listeners.get('(prefers-color-scheme: dark)') ?? [])) { + listener() + } + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'white' + } + }) + tracked.commitRender() + + unsubscribe() + assert.equal(listeners.size, 0) + reset() + }) + + it('invalidates custom media aliases through expanded matchMedia dependencies', async () => { + reset() + let canHover = false + const listeners = new Map void>>() + + __cssxInternals.configureMediaQueryAdapterForTests({ + evaluate: query => query === '(hover: hover)' && canHover, + subscribe: (query, listener) => { + let queryListeners = listeners.get(query) + if (queryListeners == null) { + queryListeners = new Set() + listeners.set(query, queryListeners) + } + queryListeners.add(listener) + return () => { + queryListeners?.delete(listener) + if (queryListeners?.size === 0) listeners.delete(query) + } + } + }) + + const sheet = compileCss(` + @custom-media --can-hover (hover: hover); + .root { color: black; } + @media (--can-hover) { + .root { color: red; } + } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + let calls = 0 + const unsubscribe = tracked.subscribe(() => { + calls += 1 + }) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'black' + } + }) + tracked.commitRender() + assert.equal(listeners.get('(hover: hover)')?.size, 1) + assert.equal(listeners.has('(--can-hover)'), false) + + canHover = true + for (const listener of Array.from(listeners.get('(hover: hover)') ?? [])) { + listener() + } + await __cssxInternals.flushMicrotasksForTests() + assert.equal(calls, 1) + + tracked.startRender() + assert.deepEqual(cssx('root', tracked), { + style: { + color: 'red' + } + }) + tracked.commitRender() + + unsubscribe() + assert.equal(listeners.size, 0) + reset() + }) + + it('does not retain media query listeners from aborted renders', () => { + reset() + const listeners = new Map void>>() + + __cssxInternals.configureMediaQueryAdapterForTests({ + evaluate: () => true, + subscribe: (query, listener) => { + let queryListeners = listeners.get(query) + if (queryListeners == null) { + queryListeners = new Set() + listeners.set(query, queryListeners) + } + queryListeners.add(listener) + return () => { + queryListeners?.delete(listener) + if (queryListeners?.size === 0) listeners.delete(query) + } + } + }) + + const sheet = compileCss(` + @media (hover: hover) { + .root { color: red; } + } + `) + const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' }) + const unsubscribe = tracked.subscribe(() => {}) + + tracked.startRender() + cssx('root', tracked) + + assert.equal(listeners.size, 0) + + unsubscribe() + reset() + }) + + it('subscribes React hook users only to committed dependencies', async () => { + reset() + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .root.active { background-color: var(--active-bg, blue); } + `) + let renders = 0 + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (props: { active?: boolean }): React.ReactNode { + renders += 1 + const layer = useCssxLayer(sheet, { target: 'web' }) + latest = cssx(['root', { active: props.active }], layer as Parameters[1]) + return createElement('div', latest as Record) + } + + await act(async () => { + root = createRoot(container) + root.render(createElement(Component)) + }) + + assert.deepEqual(latest, { + style: { + color: 'red' + } + }) + + variables['--active-bg'] = 'green' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, 1) + + variables['--root-color'] = 'black' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, 2) + assert.deepEqual(latest, { + style: { + color: 'black' + } + }) + + await act(async () => { + root?.unmount() + }) + container.remove() + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + reset() + }) + + it('does not subscribe React hook dependencies from a Suspense-aborted initial render', async () => { + reset() + const pending = new Promise(() => {}) + const sheet = compileCss(` + .root { color: var(--root-color, red); } + .root.active { background-color: var(--active-bg, blue); } + `) + let renders = 0 + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Suspender (): React.ReactNode { + renders += 1 + const layer = useCssxLayer(sheet, { target: 'web' }) + cssx(['root', 'active'], layer as Parameters[1]) + throw pending + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + Suspense, + { fallback: createElement('span', null, 'loading') }, + createElement(Suspender) + )) + }) + + assert.equal(container.textContent, 'loading') + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + const rendersAfterFallback = renders + + variables['--active-bg'] = 'green' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, rendersAfterFallback) + assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('does not promote template values from a Suspense-aborted update', async () => { + reset() + const pending = new Promise(() => {}) + const sheet = compileCssTemplate('.root { color: var(--__cssx_dynamic_0); }') + let latest: unknown + let committedLayer: Parameters[1] | undefined + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (props: { color: string, suspend?: boolean }): React.ReactNode { + const layer = useCssxTemplate(sheet, [props.color], { target: 'web' }) + latest = cssx('root', layer) + React.useLayoutEffect(() => { + committedLayer = layer + }, [layer]) + if (props.suspend) throw pending + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + Suspense, + { fallback: createElement('span', null, 'loading') }, + createElement(Component, { color: 'red' }) + )) + }) + + assert.deepEqual(latest, { style: { color: 'red' } }) + assert.deepEqual(cssx('root', committedLayer!), { style: { color: 'red' } }) + + await act(async () => { + root?.render(createElement( + Suspense, + { fallback: createElement('span', null, 'loading') }, + createElement(Component, { color: 'green', suspend: true }) + )) + }) + + assert.deepEqual(latest, { style: { color: 'green' } }) + assert.deepEqual(cssx('root', committedLayer!), { style: { color: 'red' } }) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('keeps useCssVariable dependencies from a Suspense-aborted update uncommitted', async () => { + reset() + const pending = new Promise(() => {}) + let renders = 0 + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + variables.set({ + '--root': 'red', + '--active': 'blue' + }) + + function Component (props: { name: string, suspend?: boolean }): React.ReactNode { + renders += 1 + latest = useCssVariable(props.name) + if (props.suspend) throw pending + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement( + Suspense, + { fallback: createElement('span', null, 'loading') }, + createElement(Component, { name: '--root' }) + )) + }) + + assert.equal(latest, 'red') + + await act(async () => { + root?.render(createElement( + Suspense, + { fallback: createElement('span', null, 'loading') }, + createElement(Component, { name: '--active', suspend: true }) + )) + }) + + const rendersAfterAbortedUpdate = renders + + variables['--active'] = 'green' + await act(async () => { + await __cssxInternals.flushMicrotasksForTests() + }) + assert.equal(renders, rendersAfterAbortedUpdate) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) + + it('keeps useCssxLayer hook order stable when disabled input toggles', async () => { + reset() + const sheet = compileCss('.root { color: red; }') + let latest: unknown + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (props: { enabled: boolean }): React.ReactNode { + const layer = useCssxLayer(props.enabled ? sheet : false, { target: 'web' }) + latest = props.enabled ? cssx('root', layer as Parameters[1]) : null + return createElement('div') + } + + await act(async () => { + root = createRoot(container) + root.render(createElement(Component, { enabled: false })) + }) + assert.equal(latest, null) + + await act(async () => { + root?.render(createElement(Component, { enabled: true })) + }) + assert.deepEqual(latest, { style: { color: 'red' } }) + + await act(async () => { + root?.render(createElement(Component, { enabled: false })) + }) + assert.equal(latest, null) + + await act(async () => { + root?.unmount() + }) + container.remove() + reset() + }) +}) diff --git a/packages/css-to-rn/test/types.d.ts b/packages/css-to-rn/test/types.d.ts new file mode 100644 index 0000000..038853c --- /dev/null +++ b/packages/css-to-rn/test/types.d.ts @@ -0,0 +1,2 @@ +declare function describe (name: string, fn: () => void): void +declare function it (name: string, fn: () => void): void diff --git a/packages/css-to-rn/test/types/react-api.test.ts b/packages/css-to-rn/test/types/react-api.test.ts new file mode 100644 index 0000000..c0f8cc5 --- /dev/null +++ b/packages/css-to-rn/test/types/react-api.test.ts @@ -0,0 +1,19 @@ +import type { ComponentType, ReactNode } from 'react' +import { themed } from '../../src/react/config.ts' +import { cssx, type CssxSheetInput } from '../../src/react/cssx.ts' + +interface ButtonProps { + label: string +} + +function Button (props: ButtonProps): ReactNode { + return props.label +} + +const ThemedButton = themed('Button', Button) +export const typedButton: ComponentType = ThemedButton + +const importedSheet: Record = {} +const cssxSheet: CssxSheetInput = importedSheet + +cssx('root', cssxSheet) diff --git a/packages/css-to-rn/tsconfig.build.json b/packages/css-to-rn/tsconfig.build.json new file mode 100644 index 0000000..297b615 --- /dev/null +++ b/packages/css-to-rn/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "noEmit": false, + "allowImportingTsExtensions": false + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/css-to-rn/tsconfig.json b/packages/css-to-rn/tsconfig.json new file mode 100644 index 0000000..ad929e1 --- /dev/null +++ b/packages/css-to-rn/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "customConditions": [ + "cssx-ts" + ], + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/packages/cssxjs/CHANGELOG.md b/packages/cssxjs/CHANGELOG.md index dd6a0bb..d9ae633 100644 --- a/packages/cssxjs/CHANGELOG.md +++ b/packages/cssxjs/CHANGELOG.md @@ -3,6 +3,65 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + +**Note:** Version bump only for package cssxjs + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package cssxjs + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package cssxjs + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package cssxjs + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package cssxjs + + + + + +# [0.4.0-alpha.1](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.0...v0.4.0-alpha.1) (2026-06-24) + +**Note:** Version bump only for package cssxjs + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/cssx/issues/5)) ([cd205cf](https://github.com/startupjs/cssx/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 06b60fb..10f6502 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -1,4 +1,46 @@ import type React from 'react' +export { + CssxProvider, + TrackedCssxSheet, + configureCssx, + cssx, + defaultVariables, + getCssColor, + getCssVariable, + getCssVariableRaw, + isTrackedCssxSheet, + setDefaultVariables, + themed, + u, + useCssColor, + useCssVariable, + useCssVariableRaw, + useCssxLayer, + useRuntimeCss, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext, + useCssxSheet, + useCssxTemplate, + useMedia, + useTheme, + variables +} from '@cssxjs/css-to-rn/react' +export type { + CssColorMixInput, + CssxProviderStyleInput, + CssxProviderStyleLayer, + CssxProviderProps, + CssxReactConfig, + CssxResolvedProps, + CssxRuntimeContextValue, + CssxRuntimeOptions, + CssxStyleName, + CssxThemeHookResult, + CssxThemeSetter, + CssxVariableStore, + TrackedCssxSheetOptions +} from '@cssxjs/css-to-rn/react' export type CssxjsSimpleValue = | string @@ -27,3 +69,10 @@ export function styl ( inlineStyleProps?: Record ): any export function pug (pug: TemplateStringsArray): React.ReactNode +export function matcher ( + styleName: StyleNameValue, + fileStyles?: Record, + globalStyles?: Record, + localStyles?: Record, + inlineStyleProps?: Record +): any diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js index 38ae353..e6c4f19 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -1,7 +1,32 @@ -export { default as variables } from '@cssxjs/runtime/variables' -export { defaultVariables, setDefaultVariables } from '@cssxjs/runtime/variables' -export { default as dimensions } from '@cssxjs/runtime/dimensions' -export { default as matcher } from '@cssxjs/runtime/matcher' +export { + CssxProvider, + TrackedCssxSheet, + configureCssx, + cssx, + defaultVariables, + getCssColor, + getCssVariable, + getCssVariableRaw, + isTrackedCssxSheet, + setDefaultVariables, + themed, + u, + useCssColor, + useCssVariable, + useCssVariableRaw, + useTheme, + useCssxLayer, + useRuntimeCss, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext, + useCssxSheet, + useCssxTemplate, + useMedia, + variables +} from '@cssxjs/css-to-rn/react' + +export { default as matcher } from './matcher.js' export function css (cssString) { throw Error('[cssxjs] Unprocessed \'css\' template string. Bundler (Babel / Metro) did not process this file correctly.') diff --git a/packages/cssxjs/matcher.js b/packages/cssxjs/matcher.js new file mode 100644 index 0000000..82c481e --- /dev/null +++ b/packages/cssxjs/matcher.js @@ -0,0 +1,97 @@ +const ROOT_STYLE_PROP_NAME = 'style' +const PART_REGEX = /::?part\(([^)]+)\)/ +const isArray = Array.isArray + +// Backward-compatibility export for libraries built against the old cssxjs +// runtime surface. New code should use cssx()/useRuntimeCss(). +export default function matcher ( + styleName, + fileStyles, + globalStyles, + localStyles, + inlineStyleProps +) { + const legacy = !inlineStyleProps + const classNames = toClassName(styleName).split(' ').filter(Boolean) + const result = getStyleProps(classNames, fileStyles, legacy) + + if (legacy) return result[ROOT_STYLE_PROP_NAME] + + appendStyleProps(result, getStyleProps(classNames, globalStyles)) + appendStyleProps(result, getStyleProps(classNames, localStyles)) + appendStyleProps(result, inlineStyleProps) + return result +} + +function appendStyleProps (target, appendProps) { + if (!appendProps) return + + for (const propName in appendProps) { + if (target[propName]) { + if (isArray(appendProps[propName])) { + target[propName] = target[propName].concat(appendProps[propName]) + } else { + target[propName].push(appendProps[propName]) + } + } else { + target[propName] = appendProps[propName] + } + } +} + +function getStyleProps (classNames, styles, legacyRootOnly) { + const result = {} + if (!styles) return result + + for (const selector in styles) { + const match = selector.match(PART_REGEX) + const propName = match ? getPropName(match[1]) : ROOT_STYLE_PROP_NAME + if (legacyRootOnly && propName !== ROOT_STYLE_PROP_NAME) continue + + const pureSelector = selector.replace(PART_REGEX, '') + const cssClasses = pureSelector.split('.') + if (!classesContainedInClasses(cssClasses, classNames)) continue + + const specificity = cssClasses.length - 1 + result[propName] ??= [] + result[propName][specificity] ??= [] + result[propName][specificity].push(styles[selector]) + } + + return result +} + +function getPropName (name) { + return `${name}Style` +} + +function classesContainedInClasses (cssClasses, classNames) { + for (let i = 0; i < cssClasses.length; i++) { + if (classNames.indexOf(cssClasses[i]) === -1) return false + } + return true +} + +function toClassName (names) { + let i + let tmp + let output = '' + + tmp = typeof names + if (tmp === 'string' || tmp === 'number') return names || '' + + if (isArray(names) && names.length > 0) { + for (i = 0; i < names.length; i++) { + tmp = toClassName(names[i]) + if (tmp !== '') output += (output && ' ') + tmp + } + } else if (names && typeof names === 'object') { + for (i in names) { + if (Object.prototype.hasOwnProperty.call(names, i) && names[i]) { + output += (output && ' ') + i + } + } + } + + return output +} diff --git a/packages/cssxjs/package.json b/packages/cssxjs/package.json index b72c121..6881094 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -1,6 +1,6 @@ { "name": "cssxjs", - "version": "0.3.0", + "version": "0.4.0-alpha.6", "description": "CSS-in-JS with actual CSS syntax", "type": "module", "main": "index.js", @@ -27,26 +27,34 @@ "./runtime/web": "./runtime/web.js", "./runtime/react-native": "./runtime/react-native.js", "./runtime/web-teamplay": "./runtime/web-teamplay.js", - "./runtime/react-native-teamplay": "./runtime/react-native-teamplay.js" + "./runtime/react-native-teamplay": "./runtime/react-native-teamplay.js", + "./themes/tailwind": "./themes/tailwind.js", + "./themes/shadcn": "./themes/shadcn.js" }, "publishConfig": { "access": "public" }, "scripts": { - "test": "echo 'No tests yet' && exit 0" + "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node test/smoke.mjs" }, "dependencies": { - "@cssxjs/babel-plugin-rn-stylename-inline": "^0.3.0", - "@cssxjs/babel-plugin-rn-stylename-to-style": "^0.3.0", - "@cssxjs/bundler": "^0.3.0", - "@cssxjs/loaders": "^0.3.0", - "@cssxjs/runtime": "^0.3.0", + "@cssxjs/babel-plugin-rn-stylename-inline": "^0.4.0-alpha.6", + "@cssxjs/babel-plugin-rn-stylename-to-style": "^0.4.0-alpha.2", + "@cssxjs/bundler": "^0.4.0-alpha.6", + "@cssxjs/css-to-rn": "^0.4.0-alpha.6", + "@cssxjs/loaders": "^0.4.0-alpha.6", "@react-pug/babel-plugin-react-pug": "^0.1.18", "@react-pug/check-types": "^0.1.18", - "babel-preset-cssxjs": "^0.3.0" + "babel-preset-cssxjs": "^0.4.0-alpha.6" }, "peerDependencies": { + "@react-native-async-storage/async-storage": "*", "react": "*" }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + }, "license": "MIT" } diff --git a/packages/cssxjs/runtime/react-native-teamplay.js b/packages/cssxjs/runtime/react-native-teamplay.js index 61f15cc..7f689f8 100644 --- a/packages/cssxjs/runtime/react-native-teamplay.js +++ b/packages/cssxjs/runtime/react-native-teamplay.js @@ -1,2 +1,10 @@ -export { default } from '@cssxjs/runtime/entrypoints/react-native-teamplay' -export { default as runtime } from '@cssxjs/runtime/entrypoints/react-native-teamplay' +// Backward-compatibility entrypoint for older Babel configs that selected +// `cache: 'teamplay'`. Runtime caching/subscriptions are now implemented by +// @cssxjs/css-to-rn; this file intentionally just re-exports the normal React +// Native runtime and does not import Teamplay. +export { + default, + runtime +} from './react-native.js' + +export * from './react-native.js' diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js index d3fc080..f91882d 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -1,2 +1,66 @@ -export { default } from '@cssxjs/runtime/entrypoints/react-native' -export { default as runtime } from '@cssxjs/runtime/entrypoints/react-native' +import { + cssx +} from '@cssxjs/css-to-rn/react-native' + +export { + CssxProvider, + TrackedCssxSheet, + configureCssx, + cssx, + defaultVariables, + getCssColor, + getCssVariable, + getCssVariableRaw, + isTrackedCssxSheet, + setDefaultVariables, + themed, + useCssColor, + useCssVariable, + useCssVariableRaw, + useTheme, + useCssxLayer, + useRuntimeCss, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext, + useCssxSheet, + useCssxTemplate, + useMedia, + variables +} from '@cssxjs/css-to-rn/react-native' + +export { default as matcher } from '../matcher.js' + +export function runtime ( + styleName, + fileStyles, + globalStyles, + localStyles, + inlineStyleProps +) { + return cssx( + styleName, + collectLayers(fileStyles, globalStyles, localStyles), + inlineStyleProps + ) +} + +export default runtime + +function collectLayers (...layers) { + return layers.filter(isLayer) +} + +function isLayer (layer) { + return Boolean( + typeof layer === 'string' || + ( + layer && + typeof layer === 'object' && + ( + layer.version === 1 || + Object.prototype.hasOwnProperty.call(layer, 'sheet') + ) + ) + ) +} diff --git a/packages/cssxjs/runtime/web-teamplay.js b/packages/cssxjs/runtime/web-teamplay.js index b1956ea..4911329 100644 --- a/packages/cssxjs/runtime/web-teamplay.js +++ b/packages/cssxjs/runtime/web-teamplay.js @@ -1,2 +1,10 @@ -export { default } from '@cssxjs/runtime/entrypoints/web-teamplay' -export { default as runtime } from '@cssxjs/runtime/entrypoints/web-teamplay' +// Backward-compatibility entrypoint for older Babel configs that selected +// `cache: 'teamplay'`. Runtime caching/subscriptions are now implemented by +// @cssxjs/css-to-rn; this file intentionally just re-exports the normal web +// runtime and does not import Teamplay. +export { + default, + runtime +} from './web.js' + +export * from './web.js' diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js index 081b11e..00e6679 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -1,2 +1,66 @@ -export { default } from '@cssxjs/runtime/entrypoints/web' -export { default as runtime } from '@cssxjs/runtime/entrypoints/web' +import { + cssx +} from '@cssxjs/css-to-rn/web' + +export { + CssxProvider, + TrackedCssxSheet, + configureCssx, + cssx, + defaultVariables, + getCssColor, + getCssVariable, + getCssVariableRaw, + isTrackedCssxSheet, + setDefaultVariables, + themed, + useCssColor, + useCssVariable, + useCssVariableRaw, + useTheme, + useCssxLayer, + useRuntimeCss, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext, + useCssxSheet, + useCssxTemplate, + useMedia, + variables +} from '@cssxjs/css-to-rn/web' + +export { default as matcher } from '../matcher.js' + +export function runtime ( + styleName, + fileStyles, + globalStyles, + localStyles, + inlineStyleProps +) { + return cssx( + styleName, + collectLayers(fileStyles, globalStyles, localStyles), + inlineStyleProps + ) +} + +export default runtime + +function collectLayers (...layers) { + return layers.filter(isLayer) +} + +function isLayer (layer) { + return Boolean( + typeof layer === 'string' || + ( + layer && + typeof layer === 'object' && + ( + layer.version === 1 || + Object.prototype.hasOwnProperty.call(layer, 'sheet') + ) + ) + ) +} diff --git a/packages/cssxjs/test/smoke.mjs b/packages/cssxjs/test/smoke.mjs new file mode 100644 index 0000000..82edd21 --- /dev/null +++ b/packages/cssxjs/test/smoke.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { compileCss } from '@cssxjs/css-to-rn' +import { + CssxProvider, + cssx, + getCssColor, + getCssVariable, + matcher, + themed, + u, + useCssColor, + useCssVariable, + useCssxLayer, + useRuntimeCss +} from 'cssxjs' +import { + cssx as runtimeCssx, + getCssColor as runtimeGetCssColor, + useCssColor as runtimeUseCssColor +} from 'cssxjs/runtime/web' +import shadcnTheme from 'cssxjs/themes/shadcn' +import tailwindTheme from 'cssxjs/themes/tailwind' + +assert.equal(typeof CssxProvider, 'function') +assert.equal(typeof cssx, 'function') +assert.equal(typeof getCssColor, 'function') +assert.equal(typeof getCssVariable, 'function') +assert.equal(typeof matcher, 'function') +assert.equal(typeof themed, 'function') +assert.equal(typeof u, 'function') +assert.equal(typeof useCssColor, 'function') +assert.equal(typeof useCssVariable, 'function') +assert.equal(typeof useCssxLayer, 'function') +assert.equal(typeof useRuntimeCss, 'function') +assert.equal(typeof runtimeCssx, 'function') +assert.equal(typeof runtimeGetCssColor, 'function') +assert.equal(typeof runtimeUseCssColor, 'function') + +assert.deepEqual( + matcher('root active', { + root: { color: 'red' }, + active: { opacity: 0.5 } + }), + [[{ color: 'red' }, { opacity: 0.5 }]] +) + +assert.deepEqual( + matcher(['root', { active: true }], { + root: { color: 'red' }, + active: { opacity: 0.5 }, + 'root:part(icon)': { color: 'blue' } + }, undefined, undefined, { + style: { marginTop: 4 }, + iconStyle: { marginLeft: 8 } + }), + { + style: [[{ color: 'red' }, { opacity: 0.5 }], { marginTop: 4 }], + iconStyle: [[{ color: 'blue' }], { marginLeft: 8 }] + } +) + +for (const [name, source] of Object.entries({ + tailwind: tailwindTheme, + shadcn: shadcnTheme +})) { + assert.equal(source.includes('@theme'), false, `${name} theme must not use Tailwind @theme syntax`) + + const sheet = compileCss(source, { mode: 'build', sourceId: `cssxjs/themes/${name}` }) + assert.equal(sheet.error, undefined, `${name} theme should compile without fatal errors`) + assert.deepEqual( + sheet.diagnostics.filter(diagnostic => diagnostic.level === 'error'), + [], + `${name} theme should compile without errors` + ) + assert.equal(sheet.metadata.hasVars, true, `${name} theme should expose CSS variables`) +} diff --git a/packages/cssxjs/themes/shadcn.d.ts b/packages/cssxjs/themes/shadcn.d.ts new file mode 100644 index 0000000..02026ee --- /dev/null +++ b/packages/cssxjs/themes/shadcn.d.ts @@ -0,0 +1,2 @@ +declare const theme: string +export default theme diff --git a/packages/cssxjs/themes/shadcn.js b/packages/cssxjs/themes/shadcn.js new file mode 100644 index 0000000..15bbf97 --- /dev/null +++ b/packages/cssxjs/themes/shadcn.js @@ -0,0 +1,131 @@ +export default ` +/* + * shadcn/ui theme variables adapted for CSSX. + * Source: https://ui.shadcn.com/docs/theming + * + * The default theme is represented by :root. The dark variant is represented + * by :root.dark so CssxProvider theme='auto' can select it on dark systems. + */ + +:root { + --radius: 0.625rem; + + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.723 0.219 149.579); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.769 0.188 70.08); + --warning-foreground: oklch(0.205 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.696 0.17 162.48); + --success-foreground: oklch(0.145 0 0); + --warning: oklch(0.769 0.188 70.08); + --warning-foreground: oklch(0.145 0 0); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} +` diff --git a/packages/cssxjs/themes/tailwind.d.ts b/packages/cssxjs/themes/tailwind.d.ts new file mode 100644 index 0000000..02026ee --- /dev/null +++ b/packages/cssxjs/themes/tailwind.d.ts @@ -0,0 +1,2 @@ +declare const theme: string +export default theme diff --git a/packages/cssxjs/themes/tailwind.js b/packages/cssxjs/themes/tailwind.js new file mode 100644 index 0000000..b37aa6c --- /dev/null +++ b/packages/cssxjs/themes/tailwind.js @@ -0,0 +1,521 @@ +export default ` +/* + * Tailwind CSS theme variables adapted for CSSX. + * Source: https://raw.githubusercontent.com/tailwindlabs/tailwindcss/main/packages/tailwindcss/theme.css + * + * Tailwind's non-standard theme blocks are represented as plain :root + * custom-property blocks. Nested keyframes are lifted to top-level CSS. + */ + +:root { + --font-sans: + ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-red-950: oklch(25.8% 0.092 26.042); + + --color-orange-50: oklch(98% 0.016 73.684); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-200: oklch(90.1% 0.076 70.697); + --color-orange-300: oklch(83.7% 0.128 66.29); + --color-orange-400: oklch(75% 0.183 55.934); + --color-orange-500: oklch(70.5% 0.213 47.604); + --color-orange-600: oklch(64.6% 0.222 41.116); + --color-orange-700: oklch(55.3% 0.195 38.402); + --color-orange-800: oklch(47% 0.157 37.304); + --color-orange-900: oklch(40.8% 0.123 38.172); + --color-orange-950: oklch(26.6% 0.079 36.259); + + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-700: oklch(55.5% 0.163 48.998); + --color-amber-800: oklch(47.3% 0.137 46.201); + --color-amber-900: oklch(41.4% 0.112 45.904); + --color-amber-950: oklch(27.9% 0.077 45.635); + + --color-yellow-50: oklch(98.7% 0.026 102.212); + --color-yellow-100: oklch(97.3% 0.071 103.193); + --color-yellow-200: oklch(94.5% 0.129 101.54); + --color-yellow-300: oklch(90.5% 0.182 98.111); + --color-yellow-400: oklch(85.2% 0.199 91.936); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-yellow-600: oklch(68.1% 0.162 75.834); + --color-yellow-700: oklch(55.4% 0.135 66.442); + --color-yellow-800: oklch(47.6% 0.114 61.907); + --color-yellow-900: oklch(42.1% 0.095 57.708); + --color-yellow-950: oklch(28.6% 0.066 53.813); + + --color-lime-50: oklch(98.6% 0.031 120.757); + --color-lime-100: oklch(96.7% 0.067 122.328); + --color-lime-200: oklch(93.8% 0.127 124.321); + --color-lime-300: oklch(89.7% 0.196 126.665); + --color-lime-400: oklch(84.1% 0.238 128.85); + --color-lime-500: oklch(76.8% 0.233 130.85); + --color-lime-600: oklch(64.8% 0.2 131.684); + --color-lime-700: oklch(53.2% 0.157 131.589); + --color-lime-800: oklch(45.3% 0.124 130.933); + --color-lime-900: oklch(40.5% 0.101 131.063); + --color-lime-950: oklch(27.4% 0.072 132.109); + + --color-green-50: oklch(98.2% 0.018 155.826); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-300: oklch(87.1% 0.15 154.449); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-green-900: oklch(39.3% 0.095 152.535); + --color-green-950: oklch(26.6% 0.065 152.934); + + --color-emerald-50: oklch(97.9% 0.021 166.113); + --color-emerald-100: oklch(95% 0.052 163.051); + --color-emerald-200: oklch(90.5% 0.093 164.15); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-emerald-600: oklch(59.6% 0.145 163.225); + --color-emerald-700: oklch(50.8% 0.118 165.612); + --color-emerald-800: oklch(43.2% 0.095 166.913); + --color-emerald-900: oklch(37.8% 0.077 168.94); + --color-emerald-950: oklch(26.2% 0.051 172.552); + + --color-teal-50: oklch(98.4% 0.014 180.72); + --color-teal-100: oklch(95.3% 0.051 180.801); + --color-teal-200: oklch(91% 0.096 180.426); + --color-teal-300: oklch(85.5% 0.138 181.071); + --color-teal-400: oklch(77.7% 0.152 181.912); + --color-teal-500: oklch(70.4% 0.14 182.503); + --color-teal-600: oklch(60% 0.118 184.704); + --color-teal-700: oklch(51.1% 0.096 186.391); + --color-teal-800: oklch(43.7% 0.078 188.216); + --color-teal-900: oklch(38.6% 0.063 188.416); + --color-teal-950: oklch(27.7% 0.046 192.524); + + --color-cyan-50: oklch(98.4% 0.019 200.873); + --color-cyan-100: oklch(95.6% 0.045 203.388); + --color-cyan-200: oklch(91.7% 0.08 205.041); + --color-cyan-300: oklch(86.5% 0.127 207.078); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-cyan-600: oklch(60.9% 0.126 221.723); + --color-cyan-700: oklch(52% 0.105 223.128); + --color-cyan-800: oklch(45% 0.085 224.283); + --color-cyan-900: oklch(39.8% 0.07 227.392); + --color-cyan-950: oklch(30.2% 0.056 229.695); + + --color-sky-50: oklch(97.7% 0.013 236.62); + --color-sky-100: oklch(95.1% 0.026 236.824); + --color-sky-200: oklch(90.1% 0.058 230.902); + --color-sky-300: oklch(82.8% 0.111 230.318); + --color-sky-400: oklch(74.6% 0.16 232.661); + --color-sky-500: oklch(68.5% 0.169 237.323); + --color-sky-600: oklch(58.8% 0.158 241.966); + --color-sky-700: oklch(50% 0.134 242.749); + --color-sky-800: oklch(44.3% 0.11 240.79); + --color-sky-900: oklch(39.1% 0.09 240.876); + --color-sky-950: oklch(29.3% 0.066 243.157); + + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-100: oklch(93.2% 0.032 255.585); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-blue-900: oklch(37.9% 0.146 265.522); + --color-blue-950: oklch(28.2% 0.091 267.935); + + --color-indigo-50: oklch(96.2% 0.018 272.314); + --color-indigo-100: oklch(93% 0.034 272.788); + --color-indigo-200: oklch(87% 0.065 274.039); + --color-indigo-300: oklch(78.5% 0.115 274.713); + --color-indigo-400: oklch(67.3% 0.182 276.935); + --color-indigo-500: oklch(58.5% 0.233 277.117); + --color-indigo-600: oklch(51.1% 0.262 276.966); + --color-indigo-700: oklch(45.7% 0.24 277.023); + --color-indigo-800: oklch(39.8% 0.195 277.366); + --color-indigo-900: oklch(35.9% 0.144 278.697); + --color-indigo-950: oklch(25.7% 0.09 281.288); + + --color-violet-50: oklch(96.9% 0.016 293.756); + --color-violet-100: oklch(94.3% 0.029 294.588); + --color-violet-200: oklch(89.4% 0.057 293.283); + --color-violet-300: oklch(81.1% 0.111 293.571); + --color-violet-400: oklch(70.2% 0.183 293.541); + --color-violet-500: oklch(60.6% 0.25 292.717); + --color-violet-600: oklch(54.1% 0.281 293.009); + --color-violet-700: oklch(49.1% 0.27 292.581); + --color-violet-800: oklch(43.2% 0.232 292.759); + --color-violet-900: oklch(38% 0.189 293.745); + --color-violet-950: oklch(28.3% 0.141 291.089); + + --color-purple-50: oklch(97.7% 0.014 308.299); + --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-200: oklch(90.2% 0.063 306.703); + --color-purple-300: oklch(82.7% 0.119 306.383); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-700: oklch(49.6% 0.265 301.924); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-purple-900: oklch(38.1% 0.176 304.987); + --color-purple-950: oklch(29.1% 0.149 302.717); + + --color-fuchsia-50: oklch(97.7% 0.017 320.058); + --color-fuchsia-100: oklch(95.2% 0.037 318.852); + --color-fuchsia-200: oklch(90.3% 0.076 319.62); + --color-fuchsia-300: oklch(83.3% 0.145 321.434); + --color-fuchsia-400: oklch(74% 0.238 322.16); + --color-fuchsia-500: oklch(66.7% 0.295 322.15); + --color-fuchsia-600: oklch(59.1% 0.293 322.896); + --color-fuchsia-700: oklch(51.8% 0.253 323.949); + --color-fuchsia-800: oklch(45.2% 0.211 324.591); + --color-fuchsia-900: oklch(40.1% 0.17 325.612); + --color-fuchsia-950: oklch(29.3% 0.136 325.661); + + --color-pink-50: oklch(97.1% 0.014 343.198); + --color-pink-100: oklch(94.8% 0.028 342.258); + --color-pink-200: oklch(89.9% 0.061 343.231); + --color-pink-300: oklch(82.3% 0.12 346.018); + --color-pink-400: oklch(71.8% 0.202 349.761); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-pink-600: oklch(59.2% 0.249 0.584); + --color-pink-700: oklch(52.5% 0.223 3.958); + --color-pink-800: oklch(45.9% 0.187 3.815); + --color-pink-900: oklch(40.8% 0.153 2.432); + --color-pink-950: oklch(28.4% 0.109 3.907); + + --color-rose-50: oklch(96.9% 0.015 12.422); + --color-rose-100: oklch(94.1% 0.03 12.58); + --color-rose-200: oklch(89.2% 0.058 10.001); + --color-rose-300: oklch(81% 0.117 11.638); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-rose-600: oklch(58.6% 0.253 17.585); + --color-rose-700: oklch(51.4% 0.222 16.935); + --color-rose-800: oklch(45.5% 0.188 13.697); + --color-rose-900: oklch(41% 0.159 10.272); + --color-rose-950: oklch(27.1% 0.105 12.094); + + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-100: oklch(96.8% 0.007 247.896); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-gray-950: oklch(13% 0.028 261.692); + + --color-zinc-50: oklch(98.5% 0 0); + --color-zinc-100: oklch(96.7% 0.001 286.375); + --color-zinc-200: oklch(92% 0.004 286.32); + --color-zinc-300: oklch(87.1% 0.006 286.286); + --color-zinc-400: oklch(70.5% 0.015 286.067); + --color-zinc-500: oklch(55.2% 0.016 285.938); + --color-zinc-600: oklch(44.2% 0.017 285.786); + --color-zinc-700: oklch(37% 0.013 285.805); + --color-zinc-800: oklch(27.4% 0.006 286.033); + --color-zinc-900: oklch(21% 0.006 285.885); + --color-zinc-950: oklch(14.1% 0.005 285.823); + + --color-neutral-50: oklch(98.5% 0 0); + --color-neutral-100: oklch(97% 0 0); + --color-neutral-200: oklch(92.2% 0 0); + --color-neutral-300: oklch(87% 0 0); + --color-neutral-400: oklch(70.8% 0 0); + --color-neutral-500: oklch(55.6% 0 0); + --color-neutral-600: oklch(43.9% 0 0); + --color-neutral-700: oklch(37.1% 0 0); + --color-neutral-800: oklch(26.9% 0 0); + --color-neutral-900: oklch(20.5% 0 0); + --color-neutral-950: oklch(14.5% 0 0); + + --color-stone-50: oklch(98.5% 0.001 106.423); + --color-stone-100: oklch(97% 0.001 106.424); + --color-stone-200: oklch(92.3% 0.003 48.717); + --color-stone-300: oklch(86.9% 0.005 56.366); + --color-stone-400: oklch(70.9% 0.01 56.259); + --color-stone-500: oklch(55.3% 0.013 58.071); + --color-stone-600: oklch(44.4% 0.011 73.639); + --color-stone-700: oklch(37.4% 0.01 67.558); + --color-stone-800: oklch(26.8% 0.007 34.298); + --color-stone-900: oklch(21.6% 0.006 56.043); + --color-stone-950: oklch(14.7% 0.004 49.25); + + --color-mauve-50: oklch(98.5% 0 0); + --color-mauve-100: oklch(96% 0.003 325.6); + --color-mauve-200: oklch(92.2% 0.005 325.62); + --color-mauve-300: oklch(86.5% 0.012 325.68); + --color-mauve-400: oklch(71.1% 0.019 323.02); + --color-mauve-500: oklch(54.2% 0.034 322.5); + --color-mauve-600: oklch(43.5% 0.029 321.78); + --color-mauve-700: oklch(36.4% 0.029 323.89); + --color-mauve-800: oklch(26.3% 0.024 320.12); + --color-mauve-900: oklch(21.2% 0.019 322.12); + --color-mauve-950: oklch(14.5% 0.008 326); + + --color-olive-50: oklch(98.8% 0.003 106.5); + --color-olive-100: oklch(96.6% 0.005 106.5); + --color-olive-200: oklch(93% 0.007 106.5); + --color-olive-300: oklch(88% 0.011 106.6); + --color-olive-400: oklch(73.7% 0.021 106.9); + --color-olive-500: oklch(58% 0.031 107.3); + --color-olive-600: oklch(46.6% 0.025 107.3); + --color-olive-700: oklch(39.4% 0.023 107.4); + --color-olive-800: oklch(28.6% 0.016 107.4); + --color-olive-900: oklch(22.8% 0.013 107.4); + --color-olive-950: oklch(15.3% 0.006 107.1); + + --color-mist-50: oklch(98.7% 0.002 197.1); + --color-mist-100: oklch(96.3% 0.002 197.1); + --color-mist-200: oklch(92.5% 0.005 214.3); + --color-mist-300: oklch(87.2% 0.007 219.6); + --color-mist-400: oklch(72.3% 0.014 214.4); + --color-mist-500: oklch(56% 0.021 213.5); + --color-mist-600: oklch(45% 0.017 213.2); + --color-mist-700: oklch(37.8% 0.015 216); + --color-mist-800: oklch(27.5% 0.011 216.9); + --color-mist-900: oklch(21.8% 0.008 223.9); + --color-mist-950: oklch(14.8% 0.004 228.8); + + --color-taupe-50: oklch(98.6% 0.002 67.8); + --color-taupe-100: oklch(96% 0.002 17.2); + --color-taupe-200: oklch(92.2% 0.005 34.3); + --color-taupe-300: oklch(86.8% 0.007 39.5); + --color-taupe-400: oklch(71.4% 0.014 41.2); + --color-taupe-500: oklch(54.7% 0.021 43.1); + --color-taupe-600: oklch(43.8% 0.017 39.3); + --color-taupe-700: oklch(36.7% 0.016 35.7); + --color-taupe-800: oklch(26.8% 0.011 36.5); + --color-taupe-900: oklch(21.4% 0.009 43.1); + --color-taupe-950: oklch(14.7% 0.004 49.3); + + --color-black: #000; + --color-white: #fff; + + --spacing: 0.25rem; + + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --text-7xl: 4.5rem; + --text-7xl--line-height: 1; + --text-8xl: 6rem; + --text-8xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; + + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --font-weight-extrabold: 800; + --font-weight-black: 900; + + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; + + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; + + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --radius-4xl: 2rem; + + --shadow-2xs: 0 1px rgb(0 0 0 / 0.05); + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05); + --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); + --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); + + --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05); + --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15); + --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12); + --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15); + --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1); + --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15); + + --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15); + --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2); + --text-shadow-sm: + 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075); + --text-shadow-md: + 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1); + --text-shadow-lg: + 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1); + + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce: bounce 1s infinite; + + --blur-xs: 4px; + --blur-sm: 8px; + --blur-md: 12px; + --blur-lg: 16px; + --blur-xl: 24px; + --blur-2xl: 40px; + --blur-3xl: 64px; + + --perspective-dramatic: 100px; + --perspective-near: 300px; + --perspective-normal: 500px; + --perspective-midrange: 800px; + --perspective-distant: 1200px; + + --aspect-video: 16 / 9; + + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans, initial); + --default-font-feature-settings: var(--font-sans--font-feature-settings, initial); + --default-font-variation-settings: var(--font-sans--font-variation-settings, initial); + --default-mono-font-family: var(--font-mono, initial); + --default-mono-font-feature-settings: var(--font-mono--font-feature-settings, initial); + --default-mono-font-variation-settings: var(--font-mono--font-variation-settings, initial); +} + +/* Deprecated */ + +:root { + --blur: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06); + --radius: 0.25rem; + --max-width-prose: 65ch; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes ping { + 75%, + 100% { + transform: scale(2); + opacity: 0; + } +} + +@keyframes pulse { + 50% { + opacity: 0.5; + } +} + +@keyframes bounce { + 0%, + 100% { + transform: translateY(-25%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: none; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} +` diff --git a/packages/eslint-plugin-cssxjs/CHANGELOG.md b/packages/eslint-plugin-cssxjs/CHANGELOG.md index c0c7611..2ae7cf6 100644 --- a/packages/eslint-plugin-cssxjs/CHANGELOG.md +++ b/packages/eslint-plugin-cssxjs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + +**Note:** Version bump only for package eslint-plugin-cssxjs + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/eslint-plugin-cssxjs/package.json b/packages/eslint-plugin-cssxjs/package.json index 553d460..5fa39d6 100644 --- a/packages/eslint-plugin-cssxjs/package.json +++ b/packages/eslint-plugin-cssxjs/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-cssxjs", - "version": "0.3.0", + "version": "0.4.0-alpha.0", "description": "ESLint plugin for CSSX projects", "main": "index.js", "publishConfig": { diff --git a/packages/loaders/CHANGELOG.md b/packages/loaders/CHANGELOG.md index 32d9cff..7c9cd15 100644 --- a/packages/loaders/CHANGELOG.md +++ b/packages/loaders/CHANGELOG.md @@ -3,6 +3,57 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.4.0-alpha.6](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.5...v0.4.0-alpha.6) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/loaders + + + + + +# [0.4.0-alpha.5](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.4...v0.4.0-alpha.5) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/loaders + + + + + +# [0.4.0-alpha.4](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.3...v0.4.0-alpha.4) (2026-06-25) + +**Note:** Version bump only for package @cssxjs/loaders + + + + + +# [0.4.0-alpha.3](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.2...v0.4.0-alpha.3) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/loaders + + + + + +# [0.4.0-alpha.2](https://github.com/startupjs/cssx/compare/v0.4.0-alpha.1...v0.4.0-alpha.2) (2026-06-24) + +**Note:** Version bump only for package @cssxjs/loaders + + + + + +# [0.4.0-alpha.0](https://github.com/startupjs/cssx/compare/v0.3.0...v0.4.0-alpha.0) (2026-06-24) + + +### Features + +* Unify CSS-to-RN compiler and runtime pipeline ([#5](https://github.com/startupjs/cssx/issues/5)) ([cd205cf](https://github.com/startupjs/cssx/commit/cd205cfcf0e7772f79263a47d6ca5c7b802edc31)), closes [startupjs/startupjs#1327](https://github.com/startupjs/startupjs/issues/1327) [startupjs/startupjs-ui#41](https://github.com/startupjs/startupjs-ui/issues/41) + + + + + # [0.3.0](https://github.com/startupjs/cssx/compare/v0.2.33...v0.3.0) (2026-05-03) diff --git a/packages/loaders/compilers/css.js b/packages/loaders/compilers/css.js index e430135..78963e1 100644 --- a/packages/loaders/compilers/css.js +++ b/packages/loaders/compilers/css.js @@ -3,11 +3,13 @@ const cssLoader = require('../cssToReactNativeLoader.js') const callLoader = require('../callLoader.js') const { stripExport } = require('./helpers') -module.exports = function compileCss (src) { +module.exports = function compileCss (src, filename, options) { return stripExport( callLoader( cssLoader, - src + src, + filename, + options ) ) } diff --git a/packages/loaders/compilers/styl.js b/packages/loaders/compilers/styl.js index a92dd13..fe4c1a5 100644 --- a/packages/loaders/compilers/styl.js +++ b/packages/loaders/compilers/styl.js @@ -10,5 +10,5 @@ module.exports = function compileStyl (src, filename, options) { filename, options ) - return compileCss(src) + return compileCss(src, filename, options) } diff --git a/packages/loaders/cssToReactNativeLoader.js b/packages/loaders/cssToReactNativeLoader.js index d2b0589..275a717 100644 --- a/packages/loaders/cssToReactNativeLoader.js +++ b/packages/loaders/cssToReactNativeLoader.js @@ -1,33 +1,130 @@ -// ref: https://github.com/kristerkari/react-native-css-transformer -const css2rn = require('@startupjs/css-to-react-native-transform').default +const { spawnSync } = require('child_process') +const { existsSync } = require('fs') +const { createRequire } = require('module') +const { join } = require('path') +const { pathToFileURL } = require('url') +const cssToRn = requireCssToRn() +const { compileCss, compileCssTemplate } = cssToRn +const resolveCssx = cssToRn.resolveCssx +const hashCssObject = cssToRn.simpleNumericHash ?? simpleNumericHash const EXPORT_REGEX = /:export\s*\{/ -// Match var() anywhere in a string value (not just at the start) -const VAR_NAMES_REGEX = /var\(\s*(--[A-Za-z0-9_-]+)/g module.exports = function cssToReactNative (source) { source = escapeExport(source) - const cssObject = css2rn(source, { - parseMediaQueries: true, - parsePartSelectors: true, - parseKeyframes: true + const compile = this.query?.template ? compileCssTemplate : compileCss + const cssObject = compile(source, { + mode: 'build', + target: this.query?.platform, + sourceIdentity: this.query?.sourceIdentity ?? this.resourcePath }) - for (const key in cssObject.__exportProps || {}) { - cssObject[key] = parseStylValue(cssObject.__exportProps[key]) + for (const key in cssObject.exports || {}) { + cssObject[key] = parseStylValue(cssObject.exports[key]) } + addLegacyStaticStyles(cssObject, this.query?.platform) const stringifiedCss = JSON.stringify(cssObject) - // save hash to use with the caching system of @startupjs/cache - cssObject.__hash__ = simpleNumericHash(stringifiedCss) - // OPTIMIZATION: save vars used in the styles for later replacement in runtime - // and also to determine whether we need to listen for variable changes - const vars = getVariableNames(stringifiedCss) - if (vars) cssObject.__vars = vars - // OPTIMIZATION: indicate whether @media queries are used. - // This is later used in runtime to determine whether we need to listen for dimension changes - if (hasMedia(cssObject)) cssObject.__hasMedia = true + // save hash to keep compatibility with existing generated code and tests + cssObject.__hash__ = hashCssObject(stringifiedCss) return 'module.exports = ' + JSON.stringify(cssObject) } +function addLegacyStaticStyles (cssObject, target) { + if (typeof resolveCssx !== 'function') return + + for (const className of getLegacyStaticClassNames(cssObject)) { + if (Object.prototype.hasOwnProperty.call(cssObject, className)) continue + + const style = resolveCssx({ + styleName: className, + layers: cssObject, + target, + cache: false + }).props.style + + if (style && typeof style === 'object' && Object.keys(style).length > 0) { + cssObject[className] = style + } + } +} + +function getLegacyStaticClassNames (cssObject) { + const classNames = new Set() + + for (const rule of cssObject.rules || []) { + if (rule.part || rule.media || rule.classes?.length !== 1) continue + classNames.add(rule.classes[0]) + } + + return classNames +} + +function requireCssToRn () { + const nativeRequire = createRequire(__filename) + try { + return nativeRequire('@cssxjs/css-to-rn') + } catch (error) { + const sourceEntrypoint = join(__dirname, '../css-to-rn/src/index.ts') + if ( + existsSync(sourceEntrypoint) && + ( + error.code === 'MODULE_NOT_FOUND' || + error instanceof SyntaxError || + /Must use import to load ES Module/.test(error.message) + ) + ) { + return createChildCompiler(sourceEntrypoint) + } + throw error + } +} + +function createChildCompiler (sourceEntrypoint) { + return { + compileCss: (source, options) => + compileInChildProcess('compileCss', sourceEntrypoint, source, options), + compileCssTemplate: (source, options) => + compileInChildProcess('compileCssTemplate', sourceEntrypoint, source, options), + resolveCssx: (options) => + compileInChildProcess('resolveCssx', sourceEntrypoint, options), + simpleNumericHash + } +} + +function compileInChildProcess (method, sourceEntrypoint, source, options) { + const script = ` + import { ${method} } from ${JSON.stringify(pathToFileURL(sourceEntrypoint).href)} + let input = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) input += chunk + const payload = JSON.parse(input) + const args = Array.isArray(payload.args) ? payload.args : [payload.source, payload.options] + process.stdout.write(JSON.stringify(${method}(...args))) + ` + const result = spawnSync(process.execPath, [ + '-C', + 'cssx-ts', + '--input-type=module', + '--eval', + script + ], { + input: JSON.stringify({ args: options === undefined ? [source] : [source, options] }), + encoding: 'utf8' + }) + + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout) + } + + return JSON.parse(result.stdout) +} + +// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2694461#gistcomment-269461 +function simpleNumericHash (s) { + let i, h + for (i = 0, h = 0; i < s.length; i++) h = Math.imul(31, h) + s.charCodeAt(i) | 0 + return h +} + function parseStylValue (value) { if (typeof value !== 'string') return value // strip single quotes (stylus adds it for the topmost value) @@ -92,25 +189,3 @@ function escapeExport (source) { return source } - -// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2694461#gistcomment-2694461 -function simpleNumericHash (s) { - let i, h - for (i = 0, h = 0; i < s.length; i++) h = Math.imul(31, h) + s.charCodeAt(i) | 0 - return h -} - -function getVariableNames (cssString) { - const matches = [...cssString.matchAll(VAR_NAMES_REGEX)] - if (!matches.length) return - const res = matches.map(m => m[1]) // extract capture group (variable name) - return [...new Set(res)].sort() // remove duplicates and sort -} - -function hasMedia (styles = {}) { - for (const selector in styles) { - if (/^@media/.test(selector)) { - return true - } - } -} diff --git a/packages/loaders/package.json b/packages/loaders/package.json index 39cd918..758a4b9 100644 --- a/packages/loaders/package.json +++ b/packages/loaders/package.json @@ -1,6 +1,6 @@ { "name": "@cssxjs/loaders", - "version": "0.3.0", + "version": "0.4.0-alpha.6", "description": "Webpack-compatible loaders for CSSX styles in React Native and Web bundlers", "exports": { "./callLoader": "./callLoader.js", @@ -12,7 +12,7 @@ "access": "public" }, "scripts": { - "test": "echo 'No tests yet' && exit 0" + "test": "node test/cssToReactNativeLoader.test.cjs" }, "author": { "name": "Pavel Zhukov", @@ -20,7 +20,7 @@ }, "license": "MIT", "dependencies": { - "@startupjs/css-to-react-native-transform": "2.1.0-3", + "@cssxjs/css-to-rn": "^0.4.0-alpha.6", "stylus": "0.64.0" } } diff --git a/packages/loaders/test/cssToReactNativeLoader.test.cjs b/packages/loaders/test/cssToReactNativeLoader.test.cjs new file mode 100644 index 0000000..85c3dbc --- /dev/null +++ b/packages/loaders/test/cssToReactNativeLoader.test.cjs @@ -0,0 +1,29 @@ +const assert = require('assert') +const loader = require('../cssToReactNativeLoader.js') + +const output = loader.call( + { query: { platform: 'web' }, resourcePath: 'smoke.css' }, + ` + .root { color: red; } + .years-item { height: 36px; padding: 8px; } + .root.active { opacity: 0.5; } + .root:part(icon) { color: blue; } + :export { spacing: 2u; } + ` +) + +assert(output.startsWith('module.exports = '), 'loader must emit a CommonJS export') + +const sheet = JSON.parse(output.replace(/^module\.exports = /, '')) + +assert.equal(sheet.version, 1) +assert.equal(typeof sheet.__hash__, 'number') +assert.equal(sheet.spacing, 2) +assert.equal(sheet.root.color, 'red') +assert.equal(sheet['years-item'].height, 36) +assert.equal(sheet['years-item'].paddingTop, 8) +assert.equal(sheet['years-item'].paddingRight, 8) +assert.equal(sheet['years-item'].paddingBottom, 8) +assert.equal(sheet['years-item'].paddingLeft, 8) +assert.equal(sheet.active, undefined, 'multi-class selectors should stay rule-only') +assert.equal(sheet.icon, undefined, 'part selectors should stay rule-only') diff --git a/packages/runtime/.npmignore b/packages/runtime/.npmignore deleted file mode 100644 index 0f8eb33..0000000 --- a/packages/runtime/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -__tests__/ -test/ diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md deleted file mode 100644 index 4f38e84..0000000 --- a/packages/runtime/CHANGELOG.md +++ /dev/null @@ -1,159 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [0.3.0](https://github.com/startupjs/startupjs/compare/v0.2.33...v0.3.0) (2026-05-03) - - -### Features - -* [BREAKING] [v0.3] Allow writing styles inside pug's `style(lang='styl')` tag; move to new `react-pug` compilation pipeline and linting (fully TS-compatible) ([#4](https://github.com/startupjs/startupjs/issues/4)) ([fca2e90](https://github.com/startupjs/startupjs/commit/fca2e908f2d94ea966bb88f36308677f20709f58)) - - - - - -# [0.3.0-alpha.0](https://github.com/startupjs/startupjs/compare/v0.2.33...v0.3.0-alpha.0) (2026-03-25) - -**Note:** Version bump only for package @cssxjs/runtime - - - - - -## [0.2.32](https://github.com/startupjs/startupjs/compare/v0.2.31...v0.2.32) (2026-01-25) - - -### Bug Fixes - -* **runtime:** support var() in shorthand values and in various complex cases ([4483f54](https://github.com/startupjs/startupjs/commit/4483f54d9507ebb38eb5f056de3fcac39862cb30)) - - - - - -## [0.2.31](https://github.com/startupjs/startupjs/compare/v0.2.30...v0.2.31) (2026-01-23) - - -### Bug Fixes - -* **runtime:** improve performance of substituting var() in css ([282cb46](https://github.com/startupjs/startupjs/commit/282cb461369cdb951cc873973a2d0da97a682b9b)) - - - - - -## [0.2.30](https://github.com/startupjs/startupjs/compare/v0.2.29...v0.2.30) (2026-01-18) - - -### Features - -* support animation and transition (the way it's expected by Reanimated v4) ([44a1f77](https://github.com/startupjs/startupjs/commit/44a1f778074f1f65a8ccd76994a6bf1a3eb5e4a7)) - - - - - -## [0.2.29](https://github.com/startupjs/startupjs/compare/v0.2.28...v0.2.29) (2025-12-26) - - -### Bug Fixes - -* **runtime:** show warning about missing window just once ([b2f07d7](https://github.com/startupjs/startupjs/commit/b2f07d7a6b4f203477057db61c8a2456660d9e87)) - - - - - -## [0.2.27](https://github.com/startupjs/startupjs/compare/v0.2.26...v0.2.27) (2025-12-16) - -**Note:** Version bump only for package @cssxjs/runtime - - - - - -# v0.2.11 (Fri Nov 07 2025) - -#### 🐛 Bug Fix - -- fix: make pug reconstruct bindings; add extra options to babel preset; implement reactive update of @media for web and RN ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) - ---- - -# v0.2.10 (Wed Nov 05 2025) - -#### 🐛 Bug Fix - -- fix: export matcher, variables, dimensions from @cssxjs/runtime and from the main cssxjs ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) - ---- - -# v0.2.9 (Wed Nov 05 2025) - -#### 🐛 Bug Fix - -- fix(runtime): don't process styles when undefined, fix mediaQuery call ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) - ---- - -# v0.2.5 (Wed Nov 05 2025) - -#### 🐛 Bug Fix - -- fix: force 'px' unit for lineHegiht in pure React on web ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) - ---- - -# v0.2.4 (Wed Nov 05 2025) - -#### 🚀 Enhancement - -- feat: add 'u' unit support to the 'style' prop: 1u = 8px ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) - ---- - -# v0.2.2 (Tue Nov 04 2025) - -#### 🐛 Bug Fix - -- fix: support dynamic css var() for colors ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) - ---- - -# v0.2.0 (Tue Nov 04 2025) - -#### 🚀 Enhancement - -- feat: add TypeScript support, write a more comprehensive example in TSX ([@cray0000](https://github.com/cray0000)) -- feat(runtime): implement support for both React Native and pure Web ([@cray0000](https://github.com/cray0000)) -- feat: make it work for pure web through a babel plugin [#2](https://github.com/startupjs/cssx/pull/2) ([@cray0000](https://github.com/cray0000)) - -#### Authors: 1 - -- Pavel Zhukov ([@cray0000](https://github.com/cray0000)) diff --git a/packages/runtime/constants.cjs b/packages/runtime/constants.cjs deleted file mode 100644 index de9f7dd..0000000 --- a/packages/runtime/constants.cjs +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - GLOBAL_NAME: '__CSS_GLOBAL__', - LOCAL_NAME: '__CSS_LOCAL__' -} diff --git a/packages/runtime/dimensions.js b/packages/runtime/dimensions.js deleted file mode 100644 index c745153..0000000 --- a/packages/runtime/dimensions.js +++ /dev/null @@ -1,15 +0,0 @@ -import { observable } from '@nx-js/observer-util' - -let dimensionsInitialized = false - -export function setDimensionsInitialized (value) { - dimensionsInitialized = value -} - -export function getDimensionsInitialized () { - return dimensionsInitialized -} - -export default observable({ - width: 0 -}) diff --git a/packages/runtime/entrypoints/react-native-teamplay.js b/packages/runtime/entrypoints/react-native-teamplay.js deleted file mode 100644 index 6fe4e75..0000000 --- a/packages/runtime/entrypoints/react-native-teamplay.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as platformHelpers from '../platformHelpers/react-native.js' -import { setPlatformHelpers } from '../platformHelpers/index.js' -import { process } from '../processCached.js' - -setPlatformHelpers(platformHelpers) -platformHelpers.initDimensionsUpdater() - -export default process diff --git a/packages/runtime/entrypoints/react-native.js b/packages/runtime/entrypoints/react-native.js deleted file mode 100644 index b1c9bf7..0000000 --- a/packages/runtime/entrypoints/react-native.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as platformHelpers from '../platformHelpers/react-native.js' -import { setPlatformHelpers } from '../platformHelpers/index.js' -import { process } from '../process.js' - -setPlatformHelpers(platformHelpers) -platformHelpers.initDimensionsUpdater() - -export default process diff --git a/packages/runtime/entrypoints/web-teamplay.js b/packages/runtime/entrypoints/web-teamplay.js deleted file mode 100644 index cae627d..0000000 --- a/packages/runtime/entrypoints/web-teamplay.js +++ /dev/null @@ -1,8 +0,0 @@ -import { setPlatformHelpers } from '../platformHelpers/index.js' -import * as platformHelpers from '../platformHelpers/web.js' -import { process } from '../processCached.js' - -setPlatformHelpers(platformHelpers) -platformHelpers.initDimensionsUpdater() - -export default process diff --git a/packages/runtime/entrypoints/web.js b/packages/runtime/entrypoints/web.js deleted file mode 100644 index 3e721e6..0000000 --- a/packages/runtime/entrypoints/web.js +++ /dev/null @@ -1,8 +0,0 @@ -import { setPlatformHelpers } from '../platformHelpers/index.js' -import * as platformHelpers from '../platformHelpers/web.js' -import { process } from '../process.js' - -setPlatformHelpers(platformHelpers) -platformHelpers.initDimensionsUpdater() - -export default process diff --git a/packages/runtime/matcher.js b/packages/runtime/matcher.js deleted file mode 100644 index e7c7aaf..0000000 --- a/packages/runtime/matcher.js +++ /dev/null @@ -1,127 +0,0 @@ -const ROOT_STYLE_PROP_NAME = 'style' -const PART_REGEX = /::?part\(([^)]+)\)/ - -const isArray = Array.isArray || function (arg) { - return Object.prototype.toString.call(arg) === '[object Array]' -} - -export default function matcher ( - styleName, - fileStyles, - globalStyles, - localStyles, - inlineStyleProps -) { - // inlineStyleProps is used as an implicit indication of: - // w/ inlineStyleProps -- process all styles and return an object with style props - // w/o inlineStyleProps -- default inline styles addition is done externally, - // return styles object directly - const legacy = !inlineStyleProps - - // Process styleName through the `classnames`-like function. - // This allows to specify styleName as an array or an object, - // not just the string. - styleName = cc(styleName) - - const htmlClasses = (styleName || '').split(' ').filter(Boolean) - const resProps = getStyleProps(htmlClasses, fileStyles, legacy) - - // In the legacy mode, return root styles right away - if (legacy) return resProps[ROOT_STYLE_PROP_NAME] - - // 1. Add global styles - appendStyleProps(resProps, getStyleProps(htmlClasses, globalStyles)) - - // 2. Add local styles - appendStyleProps(resProps, getStyleProps(htmlClasses, localStyles)) - - // 3. Add inline styles - appendStyleProps(resProps, inlineStyleProps) - return resProps -} - -function appendStyleProps (target, appendProps) { - for (const propName in appendProps) { - if (target[propName]) { - if (isArray(appendProps[propName])) { - target[propName] = target[propName].concat(appendProps[propName]) - } else { - target[propName].push(appendProps[propName]) - } - } else { - target[propName] = appendProps[propName] - } - } -} - -// Process all styles, including the ::part() ones. -function getStyleProps (htmlClasses, styles, legacyRootOnly) { - const res = {} - for (const selector in styles) { - // Find out which part (or root) this selector is targeting - const match = selector.match(PART_REGEX) - const attr = match ? getPropName(match[1]) : ROOT_STYLE_PROP_NAME - - // Don't process part if legacyRootOnly is specified - if (legacyRootOnly && attr !== ROOT_STYLE_PROP_NAME) continue - - // Strip ::part() if it exists - const pureSelector = selector.replace(PART_REGEX, '') - - // Check if the selector is matching our list of existing classes - const cssClasses = pureSelector.split('.') - if (!arrayContainedInArray(cssClasses, htmlClasses)) continue - - // Push selector's style to the according part's array of styles. - // We have a nested array structure here to account for the selector specificity. - // This way styles for selector with 3 classes take priority - // over selectors with 2 classes, etc. - - // Note: Specificity here does not strictly equal the standard - // since we only use classes to increase the specificity. - // In future this might change when we add support for tags, but for now - // it is a single digit increment starting from 0 and equalling the amount - // of classes in the selector. - const specificity = cssClasses.length - 1 - if (!res[attr]) res[attr] = [] - if (!res[attr][specificity]) res[attr][specificity] = [] - res[attr][specificity].push(styles[selector]) - } - return res -} - -function getPropName (name) { - return name + 'Style' -} - -function arrayContainedInArray (cssClasses, htmlClasses) { - for (let i = 0; i < cssClasses.length; i++) { - if (htmlClasses.indexOf(cssClasses[i]) === -1) return false - } - return true -}; - -// classcat 4.0.2 -// https://github.com/jorgebucaran/classcat - -function cc (names) { - let i - let len - let tmp = typeof names - let out = '' - - if (tmp === 'string' || tmp === 'number') return names || '' - - if (isArray(names) && names.length > 0) { - for (i = 0, len = names.length; i < len; i++) { - if ((tmp = cc(names[i])) !== '') out += (out && ' ') + tmp - } - } else { - for (i in names) { - // eslint-disable-next-line no-prototype-builtins - if (names.hasOwnProperty(i) && names[i]) out += (out && ' ') + i - } - } - - return out -} diff --git a/packages/runtime/package.json b/packages/runtime/package.json deleted file mode 100644 index cfe9d31..0000000 --- a/packages/runtime/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@cssxjs/runtime", - "version": "0.3.0", - "publishConfig": { - "access": "public" - }, - "description": "Dynamically resolve styleName in RN with support for multi-class selectors (for easier modifiers)", - "keywords": [ - "babel", - "babel-plugin", - "react-native", - "stylename", - "style" - ], - "exports": { - "./entrypoints/web": "./entrypoints/web.js", - "./entrypoints/react-native": "./entrypoints/react-native.js", - "./entrypoints/web-teamplay": "./entrypoints/web-teamplay.js", - "./entrypoints/react-native-teamplay": "./entrypoints/react-native-teamplay.js", - "./constants": "./constants.cjs", - "./dimensions": "./dimensions.js", - "./variables": "./variables.js", - "./matcher": "./matcher.js" - }, - "type": "module", - "scripts": { - "test": "mocha" - }, - "author": "Pavel Zhukov", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/startupjs/startupjs" - }, - "dependencies": { - "@nx-js/observer-util": "^4.1.3", - "css-viewport-units-transform": "^0.10.2", - "deepmerge": "^3.2.0", - "micro-memoize": "^3.0.1" - }, - "devDependencies": { - "@cssxjs/loaders": "^0.3.0", - "@startupjs/css-to-react-native-transform": "2.1.0-3", - "mocha": "^8.1.1" - }, - "peerDependencies": { - "react-native": "*", - "teamplay": "*" - }, - "peerDependenciesMeta": { - "react-native": { - "optional": true - }, - "teamplay": { - "optional": true - } - } -} diff --git a/packages/runtime/platformHelpers/index.js b/packages/runtime/platformHelpers/index.js deleted file mode 100644 index 9f5f814..0000000 --- a/packages/runtime/platformHelpers/index.js +++ /dev/null @@ -1,50 +0,0 @@ -// injection of platformHelpers - -let platformHelpers - -export function setPlatformHelpers (newPlatformHelpers) { - if (platformHelpers === newPlatformHelpers) return - platformHelpers = newPlatformHelpers -} - -export function getPlatformHelpers () { - return platformHelpers -} - -// facades to call the currently injected platform helper functions - -export function getDimensions (...args) { - try { - return platformHelpers.getDimensions(...args) - } catch (err) { - console.error('[cssxjs] platform helpers \'getDimensions\' is not specified. Babel is probably misconfigured') - throw err - } -} - -export function getPlatform (...args) { - try { - return platformHelpers.getPlatform(...args) - } catch (err) { - console.error('[cssxjs] platform helpers \'getPlatform\' is not specified. Babel is probably misconfigured') - throw err - } -} - -export function isPureReact (...args) { - try { - return platformHelpers.isPureReact(...args) - } catch (err) { - console.error('[cssxjs] platform helpers \'isPureReact\' is not specified. Babel is probably misconfigured') - throw err - } -} - -export function initDimensionsUpdater (...args) { - try { - return platformHelpers.initDimensionsUpdater(...args) - } catch (err) { - console.error('[cssxjs] platform helpers \'initDimensionsUpdater\' is not specified. Babel is probably misconfigured') - throw err - } -} diff --git a/packages/runtime/platformHelpers/react-native.js b/packages/runtime/platformHelpers/react-native.js deleted file mode 100644 index 64d5e59..0000000 --- a/packages/runtime/platformHelpers/react-native.js +++ /dev/null @@ -1,35 +0,0 @@ -import { Dimensions, Platform } from 'react-native' -import dimensions, { getDimensionsInitialized, setDimensionsInitialized } from '../dimensions.js' - -export function getDimensions () { - return Dimensions.get('window') -} - -export function getPlatform () { - return Platform.OS -} - -export function isPureReact () { - return false -} - -// this is needed to trigger components rerendering to update @media queries -export function initDimensionsUpdater () { - if (getDimensionsInitialized()) return - setDimensionsInitialized(true) - dimensions.width = Dimensions.get('window').width - console.log('> Init dimensions updater for React Native. Initial width:', dimensions.width) - - // debounce by 200ms to avoid too many updates in a short time - let timeoutId - Dimensions.addEventListener('change', ({ window }) => { - if (timeoutId) clearTimeout(timeoutId) - timeoutId = setTimeout(() => { - if (dimensions.width !== window.width) { - console.log('> update window width:', window.width) - dimensions.width = window.width - } - timeoutId = undefined - }, 200) - }) -} diff --git a/packages/runtime/platformHelpers/web.js b/packages/runtime/platformHelpers/web.js deleted file mode 100644 index 3e6a282..0000000 --- a/packages/runtime/platformHelpers/web.js +++ /dev/null @@ -1,55 +0,0 @@ -import dimensions, { getDimensionsInitialized, setDimensionsInitialized } from '../dimensions.js' - -let shownWarningGetDimensions = false -let shownWarningInitDimensionsUpdater = false - -export function getDimensions () { - if (typeof window === 'undefined' || !window.innerWidth || !window.innerHeight) { - if (!shownWarningGetDimensions) { - console.warn('[cssx] No "window" global variable. Falling back to constant window width and height of 1024x768') - shownWarningGetDimensions = true - } - return { width: 1024, height: 768 } - } - return { - width: window.innerWidth, - height: window.innerHeight - } -} - -export function getPlatform () { - return 'web' -} - -export function isPureReact () { - return true -} - -// this is needed to trigger components rerendering to update @media queries -export function initDimensionsUpdater () { - if (getDimensionsInitialized()) return - setDimensionsInitialized(true) - if (typeof window === 'undefined' || !window.innerWidth || !window.addEventListener) { - if (!shownWarningInitDimensionsUpdater) { - console.warn('[cssx] No "window" global variable. Setting default window width to 1024 and skipping updater.') - shownWarningInitDimensionsUpdater = true - } - dimensions.width = 1024 - return - } - dimensions.width = window.innerWidth - console.log('> Init dimensions updater for Web. Initial width:', dimensions.width) - - // debounce by 200ms to avoid too many updates in a short time - let timeoutId - window.addEventListener('resize', () => { - if (timeoutId) clearTimeout(timeoutId) - timeoutId = setTimeout(() => { - if (dimensions.width !== window.innerWidth) { - console.log('> update window width:', window.innerWidth) - dimensions.width = window.innerWidth - } - timeoutId = undefined - }, 200) - }) -} diff --git a/packages/runtime/process.js b/packages/runtime/process.js deleted file mode 100644 index 5ee689e..0000000 --- a/packages/runtime/process.js +++ /dev/null @@ -1,137 +0,0 @@ -import { process as dynamicProcess } from './vendor/react-native-dynamic-style-processor/index.js' -import dimensions from './dimensions.js' -import singletonVariables, { defaultVariables } from './variables.js' -import matcher from './matcher.js' -import { isPureReact } from './platformHelpers/index.js' - -// Regex to match var() anywhere within a string value (handles both full and partial) -const VARS_REGEX = /var\(\s*(--[A-Za-z0-9_-]+)\s*,?\s*([^)]*)\s*\)/g -const SUPPORT_UNIT = true - -export function process ( - styleName, - fileStyles, - globalStyles, - localStyles, - inlineStyleProps -) { - fileStyles = transformStyles(fileStyles) - globalStyles = transformStyles(globalStyles) - localStyles = transformStyles(localStyles) - - const res = matcher( - styleName, fileStyles, globalStyles, localStyles, inlineStyleProps - ) - for (const propName in res) { - // flatten styles into single objects - if (Array.isArray(res[propName])) { - res[propName] = res[propName].flat(10) - res[propName] = Object.assign({}, ...res[propName]) - } - if (typeof res[propName] !== 'object') continue - // force transform to 'px' some units in pure React environment - if (isPureReact()) { - // atm it's only 'lineHeight' property - if (typeof res[propName].lineHeight === 'number') { - res[propName].lineHeight = `${res[propName].lineHeight}px` - } - } - // add 'u' unit support (1u = 8px) - // replace in string values `{NUMBER}u` with the `{NUMBER*8}` - // (pure number without any units - which will be treated as 'px' by React Native and pure React) - if (SUPPORT_UNIT) { - for (const property in res[propName]) { - if (typeof res[propName][property] !== 'string') continue - if (!/\du/.test(res[propName][property])) continue // quick check for potential presence of 'u' unit - while (true) { - const match = res[propName][property].match(/(\(|,| |^)([+-]?(?:\d*\.)?\d+)u(\)|,| |$)/) - if (!match) break - const fullMatch = match[0] - const number = parseFloat(match[2]) - const replacedValue = number * 8 - // if left and right don't exist (pure value), then assign the pure number - if (!match[1] && !match[3]) { - res[propName][property] = replacedValue - break - } - res[propName][property] = res[propName][property].replace(fullMatch, `${match[1]}${replacedValue}${match[3]}`) - } - } - } - } - return res -} - -function replaceVariablesInObject (obj) { - if (obj === null || obj === undefined) return obj - if (Array.isArray(obj)) { - return obj.map(item => replaceVariablesInObject(item)) - } - if (typeof obj === 'object') { - const result = {} - for (const key of Object.keys(obj)) { - result[key] = replaceVariablesInObject(obj[key]) - } - return result - } - if (typeof obj === 'string' && obj.includes('var(')) { - return replaceVariablesInString(obj) - } - return obj -} - -function replaceVariablesInString (str) { - // Replace all var() occurrences in the string - const result = str.replace(VARS_REGEX, (match, varName, varDefault) => { - let res = singletonVariables[varName] ?? defaultVariables[varName] ?? varDefault - if (typeof res === 'string') { - res = res.trim() - // sometimes compiler returns wrapped brackets. Remove them - const bracketsCount = res.match(/^\(+/)?.[0]?.length || 0 - res = res.substring(bracketsCount, res.length - bracketsCount) - } - return res - }) - - // After all replacements, check if the result is a pure numeric value - // If so, convert it to a number (stripping 'px' suffix if present) - const trimmed = result.trim() - const withoutPx = trimmed.replace(/px$/, '') - if (isNumeric(withoutPx)) { - return parseFloat(withoutPx) - } - - return result -} - -function transformStyles (styles) { - if (!styles) return {} - - // Dynamically process css variables. - // This will also auto-trigger rerendering on variable change when cache is not used - if (styles.__vars) { - styles = replaceVariablesInObject(styles) - } - - // trigger rerender when cache is NOT used - if (styles.__hasMedia) listenForDimensionsChange() - - // dynamically process @media queries and vh/vw units - styles = dynamicProcess(styles) - - return styles -} - -// If @media is used, force trigger access to the observable value. -// `dimensions` is an observed Proxy so -// whenever its value changes the according components will -// automatically rerender. -// The change is triggered globally in startupjs/plugins/cssMediaUpdater.plugin.js -export function listenForDimensionsChange () { - // eslint-disable-next-line no-unused-expressions - if (dimensions.width) true -} - -function isNumeric (num) { - return (typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) && !isNaN(num) -} diff --git a/packages/runtime/processCached.js b/packages/runtime/processCached.js deleted file mode 100644 index 41cd573..0000000 --- a/packages/runtime/processCached.js +++ /dev/null @@ -1,68 +0,0 @@ -import { singletonMemoize } from 'teamplay/cache' -import dimensions from './dimensions.js' -import singletonVariables from './variables.js' -import { process as _process, listenForDimensionsChange } from './process.js' - -export const process = singletonMemoize(_process, { - cacheName: 'styles', - // IMPORTANT: This should be the same as the ones which go into the singletonMemoize function - normalizer: (styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) => simpleNumericHash(JSON.stringify([ - styleName, - fileStyles?.__hash__ || fileStyles, - globalStyles?.__hash__ || globalStyles, - localStyles?.__hash__ || localStyles, - inlineStyleProps - ])), - // IMPORTANT: This should be the same as the ones which go into the singletonMemoize function - forceUpdateWhenChanged: (styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) => { - const args = {} - const watchWidthChange = fileStyles?.__hasMedia || globalStyles?.__hasMedia || localStyles?.__hasMedia - if (watchWidthChange) { - // trigger rerender when cache is used - listenForDimensionsChange() - // Return the dimensionsWidth value itself to force - // the affected cache to recalculate - args.dimensionsWidth = dimensions.width - } - if (fileStyles?.__vars || globalStyles?.__vars || localStyles?.__vars) { - const variableNames = getVariableNames(fileStyles, globalStyles, localStyles) - // trigger rerender when cache is used - listenForVariablesChange(variableNames) - // Return the variable values themselves to force - // the affected cache to recalculate - for (const variableName of variableNames) { - args['VAR_' + variableName] = singletonVariables[variableName] - } - } - return simpleNumericHash(JSON.stringify(args)) - } -}) - -function getVariableNames (...styleObjects) { - const vars = [] - for (const styleObject of styleObjects) { - if (!styleObject?.__vars) continue - for (const varName of styleObject.__vars) { - if (!vars.includes(varName)) vars.push(varName) - } - } - return vars.sort() -} - -// If var() is used, force trigger access to the observable value. -// `singletonVariables` is an observed Proxy so -// whenever its value changes the according components will -// automatically rerender. -function listenForVariablesChange (variables = []) { - for (const variable of variables) { - // eslint-disable-next-line no-unused-expressions - if (singletonVariables[variable]) true - } -} - -// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2694461#gistcomment-2694461 -function simpleNumericHash (s) { - let i, h - for (i = 0, h = 0; i < s.length; i++) h = Math.imul(31, h) + s.charCodeAt(i) | 0 - return h -} diff --git a/packages/runtime/test/matcher.mjs b/packages/runtime/test/matcher.mjs deleted file mode 100644 index 57f24d2..0000000 --- a/packages/runtime/test/matcher.mjs +++ /dev/null @@ -1,485 +0,0 @@ -/* global describe, it */ -import css2rn from '@startupjs/css-to-react-native-transform' -import assert from 'assert' -import matcher from '../matcher.js' - -function p ({ styleName, fileStyles, globalStyles, localStyles, inlineStyleProps, legacy }) { - if (!legacy) inlineStyleProps = inlineStyleProps || {} - return matcher( - styleName, - fileStyles && css2rn.default(fileStyles, { parseMediaQueries: true, parsePartSelectors: true, parseKeyframes: true }), - globalStyles && css2rn.default(globalStyles, { parseMediaQueries: true, parsePartSelectors: true, parseKeyframes: true }), - localStyles && css2rn.default(localStyles, { parseMediaQueries: true, parsePartSelectors: true, parseKeyframes: true }), - inlineStyleProps - ) -} - -describe('Pure usage without attributes', () => { - it('simple', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: /* css */` - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .dummy { - color: green; - } - .root.dummy { - color: red; - } - `, - legacy: true - }), [ - [{ // specificity 0 selectors (same as specificity 10 in CSS) - color: 'red', - fontWeight: 'bold', - paddingLeft: 10 - }] - ]) - }) -}) - -describe('Root styles only', () => { - it('simple', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: /* css */` - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .dummy { - color: green; - } - .root.dummy { - color: red; - } - ` - }), { - style: [ - [{ // specificity 0 selectors (same as specificity 10 in CSS) - color: 'red', - fontWeight: 'bold', - paddingLeft: 10 - }] - ] - }) - }) - it('with inline styles', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: /* css */` - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .dummy { - color: green; - } - .root.dummy { - color: red; - } - `, - inlineStyleProps: { - style: { - marginLeft: 10 - } - } - }), { - style: [ - [{ // specificity 0 - color: 'red', - fontWeight: 'bold', - paddingLeft: 10 - }], - { // inline styles - marginLeft: 10 - } - ] - }) - }) - it('empty root. Pipe inline styles only', () => { - assert.deepStrictEqual(p({ - styleName: '', - fileStyles: /* css */` - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .dummy { - color: green; - } - .root.dummy { - color: red; - } - `, - inlineStyleProps: { - style: [ - { - marginLeft: 10 - }, { - marginRight: 20 - } - ], - cardStyle: { - marginRight: 10 - } - } - }), { - style: [ - // inline styles - { - marginLeft: 10 - }, { - marginRight: 20 - } - ], - cardStyle: { - marginRight: 10 - } - }) - }) - it('empty everything. Pipe inline styles only', () => { - assert.deepStrictEqual(p({ - styleName: '', - inlineStyleProps: { - style: { - marginLeft: 10 - } - } - }), { - style: { - marginLeft: 10 - } - }) - }) - it('pass inline styles as is if it\'s a string', () => { - assert.deepStrictEqual(p({ - styleName: '', - inlineStyleProps: { - style: 'my-magic-style', - barStyle: 'magic-bar-style' - } - }), { - style: 'my-magic-style', - barStyle: 'magic-bar-style' - }) - }) - it('multiple classes', () => { - assert.deepStrictEqual(p({ - styleName: 'root active card', - fileStyles: /* css */` - .active { - opacity: 0.8; - } - .card { - border-radius: 8px; - } - .card.active { - opacity: 0.9; - } - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .root.active { - opacity: 1; - } - .root.card.active { - color: green; - } - .dummy { - color: green; - } - .root.dummy { - color: red; - } - .root.card.dummy { - color: red; - } - `, - inlineStyleProps: { - style: { - marginLeft: 10 - } - } - }), { - style: [ - [{ // specificity 0 (1 class) - opacity: 0.8 - }, { - borderRadius: 8 - }, { - color: 'red', - fontWeight: 'bold', - paddingLeft: 10 - }], - [{ // specificity 1 (2 classes) - opacity: 0.9 - }, { - opacity: 1 - }], - [{ // specificity 2 (3 classes) - color: 'green' - }], - { // inline styles - marginLeft: 10 - } - ] - }) - }) -}) - -describe('Parts', () => { - it('simple', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: /* css */` - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .root::part(input) { - background-color: black; - color: blue; - } - ` - }), { - style: [ - [{ - color: 'red', - fontWeight: 'bold', - paddingLeft: 10 - }] - ], - inputStyle: [ - [{ - backgroundColor: 'black', - color: 'blue' - }] - ] - }) - }) - it('multiple classes', () => { - assert.deepStrictEqual(p({ - styleName: 'root active card', - fileStyles: /* css */` - .active { - opacity: 0.8; - } - .card { - border-radius: 8px; - } - .card.active { - opacity: 0.9; - } - .card::part(header) { - background-color: green; - } - .card.active::part(header) { - background-color: red; - } - .card.active::part(footer) { - color: orange; - } - .root { - color: red; - font-weight: bold; - padding-left: 10px; - } - .root::part(header) { - font-size: 20px; - } - .root::part(footer) { - font-size: 22px; - } - .root.active { - opacity: 1; - } - .root.active::part(footer) { - background-color: pink; - } - .root.card.active { - color: green; - } - .root.card.active::part(footer) { - background-color: violet; - } - .dummy { - color: green; - } - .dummy::part(header) { - color: magenta; - } - .root.dummy { - color: red; - } - .root.card.dummy { - color: red; - } - `, - inlineStyleProps: { - style: { - marginLeft: 10 - }, - headerStyle: { - marginLeft: 12 - }, - footerStyle: { - marginLeft: 14 - }, - dummyStyle: { - marginLeft: 16 - } - } - }), { - style: [ - [{ // specificity 0 (1 class) - opacity: 0.8 - }, { - borderRadius: 8 - }, { - color: 'red', - fontWeight: 'bold', - paddingLeft: 10 - }], - [{ // specificity 1 (2 classes) - opacity: 0.9 - }, { - opacity: 1 - }], - [{ // specificity 2 (3 classes) - color: 'green' - }], - { // inline styles - marginLeft: 10 - } - ], - headerStyle: [ - [{ // specificity 0 - backgroundColor: 'green' - }, { - fontSize: 20 - }], - [{ // specificity 1 - backgroundColor: 'red' - }], - { // inline styles - marginLeft: 12 - } - ], - footerStyle: [ - [{ // specificity 0 - fontSize: 22 - }], - [{ // specificity 1 - color: 'orange' - }, { - backgroundColor: 'pink' - }], - [{ // specificity 2 - backgroundColor: 'violet' - }], - { // inline styles - marginLeft: 14 - } - ], - dummyStyle: { - marginLeft: 16 - } - }) - }) -}) - -describe('External and global and local styles', () => { - it('inline > local > global > external. No matter the specificity', () => { - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: /* css */` - .root { - color: red; - font-weight: bold; - padding-left: 10px; - padding-right: 10px; - } - .root.active { - color: yellow; - padding-right: 20px; - } - .dummy { - color: green; - } - .root.dummy { - color: red; - } - `, - globalStyles: /* css */` - .root { - color: blue; - padding-left: 15px; - padding-right: 15px; - } - .root.active { - color: white; - } - .dummy { - padding-left: 50px; - } - `, - localStyles: /* css */` - .root { - color: violet; - } - .root.active { - padding-right: 20px; - } - .dummy { - padding-top: 10px; - } - `, - inlineStyleProps: { - style: { - marginLeft: 10 - } - } - }), { - style: [ - [{ // external specificity 0 - color: 'red', - fontWeight: 'bold', - paddingLeft: 10, - paddingRight: 10 - }], - [{ // external specificity 1 - color: 'yellow', - paddingRight: 20 - }], - [{ // global specificity 0 - color: 'blue', - paddingLeft: 15, - paddingRight: 15 - }], - [{ // global specificity 1 - color: 'white' - }], - [{ // local specificity 0 - color: 'violet' - }], - [{ // local specificity 1 - paddingRight: 20 - }], - { // inline styles - marginLeft: 10 - } - ] - }) - }) -}) diff --git a/packages/runtime/test/process.mjs b/packages/runtime/test/process.mjs deleted file mode 100644 index 20c686a..0000000 --- a/packages/runtime/test/process.mjs +++ /dev/null @@ -1,1180 +0,0 @@ -/* global describe, it, before, beforeEach */ -import assert from 'assert' -import { createRequire } from 'module' -import { process } from '../process.js' -import { setPlatformHelpers } from '../platformHelpers/index.js' -import singletonVariables, { setDefaultVariables } from '../variables.js' - -const require = createRequire(import.meta.url) -const { styl } = require('@cssxjs/loaders/compilers') - -// Configure platform helpers for test environment -before(() => { - setPlatformHelpers({ - getDimensions: () => ({ width: 1024, height: 768 }), - getPlatform: () => 'web', - isPureReact: () => false, - initDimensionsUpdater: () => {} - }) -}) - -// Helper function to compile stylus to a style object -// The styl() compiler returns a JSON string, so we need to parse it -function compileStyl (source) { - if (!source) return undefined - const jsonString = styl(source, 'test.styl') - return JSON.parse(jsonString) -} - -// Helper function to compile stylus and process it through the full pipeline -function p ({ styleName, fileStyles, globalStyles, localStyles, inlineStyleProps }) { - return process( - styleName, - compileStyl(fileStyles), - compileStyl(globalStyles), - compileStyl(localStyles), - inlineStyleProps || {} - ) -} - -// Reset variables before each test -beforeEach(() => { - // Clear singleton variables - for (const key of Object.keys(singletonVariables)) { - delete singletonVariables[key] - } - // Reset default variables - setDefaultVariables({}) -}) - -// ============================================================================ -// LEVEL 1: Simple tests - no var(), no @media, single selector -// Note: Stylus converts color names to hex codes (red -> #f00, blue -> #00f) -// ============================================================================ -describe('Level 1: Simple styles - single selector, no variables', () => { - it('single class with one property', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - ` - }), { - style: { color: '#f00' } // Stylus converts 'red' to '#f00' - }) - }) - - it('single class with multiple properties', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - font-size 16px - padding 10px - ` - }), { - style: { - color: '#f00', - fontSize: 16, - paddingTop: 10, - paddingRight: 10, - paddingBottom: 10, - paddingLeft: 10 - } - }) - }) - - it('single class with camelCase CSS properties', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - background-color blue - border-radius 8px - font-weight bold - ` - }), { - style: { - backgroundColor: '#00f', // Stylus converts 'blue' to '#00f' - borderRadius: 8, - fontWeight: 'bold' - } - }) - }) - - it('non-matching selector is ignored', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - .other - color blue - ` - }), { - style: { color: '#f00' } - }) - }) - - it('empty styleName returns only inline styles', () => { - assert.deepStrictEqual(p({ - styleName: '', - fileStyles: ` - .root - color red - `, - inlineStyleProps: { - style: { marginLeft: 10 } - } - }), { - style: { marginLeft: 10 } - }) - }) -}) - -// ============================================================================ -// LEVEL 2: Multiple classes without variables -// ============================================================================ -describe('Level 2: Multiple classes - specificity handling', () => { - it('two classes matching single-class selectors', () => { - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: ` - .root - color red - .active - opacity 0.8 - ` - }), { - style: { - color: '#f00', - opacity: 0.8 - } - }) - }) - - it('compound selector has higher specificity', () => { - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: ` - .root - color red - .active - color blue - .root.active - color green - ` - }), { - style: { color: '#008000' } // Stylus converts 'green' to '#008000' - }) - }) - - it('three classes with varying specificity', () => { - assert.deepStrictEqual(p({ - styleName: 'root active card', - fileStyles: ` - .root - color red - .active - opacity 0.5 - .card - border-radius 8px - .root.active - opacity 0.8 - .root.card.active - opacity 1 - ` - }), { - style: { - color: '#f00', - borderRadius: 8, - opacity: 1 - } - }) - }) -}) - -// ============================================================================ -// LEVEL 3: Part selectors (::part) -// ============================================================================ -describe('Level 3: Part selectors', () => { - it('simple part selector', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - .root::part(input) - background-color white - ` - }), { - style: { color: '#f00' }, - inputStyle: { backgroundColor: '#fff' } - }) - }) - - it('multiple part selectors', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - .root::part(header) - font-size 20px - .root::part(footer) - font-size 14px - ` - }), { - style: { color: '#f00' }, - headerStyle: { fontSize: 20 }, - footerStyle: { fontSize: 14 } - }) - }) - - it('part selector with compound class', () => { - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: ` - .root::part(header) - color red - .root.active::part(header) - color blue - ` - }), { - headerStyle: { color: '#00f' } - }) - }) -}) - -// ============================================================================ -// LEVEL 4: Single var() usage -// ============================================================================ -describe('Level 4: Single var() usage', () => { - it('var() with default value for color', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--primary-color, #f00) - ` - }), { - style: { color: '#f00' } - }) - }) - - it('var() with default numeric value for font-size', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - font-size var(--font-size, 16px) - ` - }), { - style: { fontSize: 16 } - }) - }) - - it('var() overridden by default variables', () => { - setDefaultVariables({ '--primary-color': '#00f' }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--primary-color, #f00) - ` - }), { - style: { color: '#00f' } - }) - }) - - it('var() overridden by singleton variables', () => { - setDefaultVariables({ '--primary-color': '#00f' }) - singletonVariables['--primary-color'] = '#0f0' - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--primary-color, #f00) - ` - }), { - style: { color: '#0f0' } - }) - }) - - it('singleton takes precedence over default', () => { - setDefaultVariables({ '--color': '#00f' }) - singletonVariables['--color'] = '#800080' // purple - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--color, #f00) - ` - }), { - style: { color: '#800080' } - }) - }) -}) - -// ============================================================================ -// LEVEL 5: Multiple var() usages in same selector -// ============================================================================ -describe('Level 5: Multiple var() in same selector', () => { - it('two var() in different properties', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--text-color, #000) - background-color var(--bg-color, #fff) - ` - }), { - style: { - color: '#000', - backgroundColor: '#fff' - } - }) - }) - - it('multiple var() with mixed overrides', () => { - setDefaultVariables({ '--text-color': '#00f' }) - singletonVariables['--bg-color'] = '#ff0' - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--text-color, #000) - background-color var(--bg-color, #fff) - border-color var(--border-color, #808080) - ` - }), { - style: { - color: '#00f', - backgroundColor: '#ff0', - borderColor: '#808080' - } - }) - }) - - it('three var() with numeric values', () => { - setDefaultVariables({ - '--padding-top': '20px', - '--margin-left': '10px' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - padding-top var(--padding-top, 8px) - margin-left var(--margin-left, 4px) - font-size var(--font-size, 4px) - ` - }), { - style: { - paddingTop: 20, - marginLeft: 10, - fontSize: 4 - } - }) - }) -}) - -// ============================================================================ -// LEVEL 6: var() in different selectors and parts -// ============================================================================ -describe('Level 6: var() across selectors and parts', () => { - it('var() in different class selectors', () => { - setDefaultVariables({ '--active-opacity': '0.5' }) - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: ` - .root - color var(--color, #f00) - .active - opacity var(--active-opacity, 1) - ` - }), { - style: { - color: '#f00', - opacity: 0.5 - } - }) - }) - - it('var() in part selectors', () => { - setDefaultVariables({ '--header-bg': '#00f' }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--text-color, #000) - .root::part(header) - background-color var(--header-bg, #808080) - .root::part(footer) - padding-left var(--footer-padding, 10px) - ` - }), { - style: { color: '#000' }, - headerStyle: { backgroundColor: '#00f' }, - footerStyle: { paddingLeft: 10 } - }) - }) - - it('var() in compound selectors with parts', () => { - singletonVariables['--active-header-bg'] = '#0f0' - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: ` - .root::part(header) - background-color var(--header-bg, #808080) - .root.active::part(header) - background-color var(--active-header-bg, #f00) - ` - }), { - headerStyle: { backgroundColor: '#0f0' } - }) - }) -}) - -// ============================================================================ -// LEVEL 7: @media queries -// ============================================================================ -describe('Level 7: @media queries', () => { - it('simple @media query', () => { - const result = p({ - styleName: 'root', - fileStyles: ` - .root - width 100px - @media (min-width: 768px) - .root - width 200px - ` - }) - // The style should be present (either 100px or 200px depending on current screen) - // With our test dimensions of 1024x768, min-width: 768px should match - assert.ok(result.style) - assert.strictEqual(result.style.width, 200) - }) - - it('@media query not matching', () => { - const result = p({ - styleName: 'root', - fileStyles: ` - .root - width 100px - @media (min-width: 1200px) - .root - width 200px - ` - }) - // With our test dimensions of 1024x768, min-width: 1200px should NOT match - assert.strictEqual(result.style.width, 100) - }) - - it('@media with var()', () => { - setDefaultVariables({ '--desktop-width': '500px' }) - const result = p({ - styleName: 'root', - fileStyles: ` - .root - width var(--mobile-width, 100px) - @media (min-width: 768px) - .root - width var(--desktop-width, 200px) - ` - }) - // With test dimensions 1024x768, the media query matches - assert.strictEqual(result.style.width, 500) - }) -}) - -// ============================================================================ -// LEVEL 8: External, global, and local styles hierarchy -// ============================================================================ -describe('Level 8: Style hierarchy (external > global > local)', () => { - it('local overrides global overrides external', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - font-size 14px - `, - globalStyles: ` - .root - color blue - padding-left 10px - `, - localStyles: ` - .root - color green - ` - }), { - style: { - color: '#008000', // green - fontSize: 14, - paddingLeft: 10 - } - }) - }) - - it('var() in all style levels', () => { - setDefaultVariables({ - '--file-color': '#f00', - '--global-padding': '20px', - '--local-margin': '15px' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--file-color, #000) - `, - globalStyles: ` - .root - padding-left var(--global-padding, 10px) - `, - localStyles: ` - .root - margin-left var(--local-margin, 5px) - ` - }), { - style: { - color: '#f00', - paddingLeft: 20, - marginLeft: 15 - } - }) - }) - - it('inline styles override all', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - `, - globalStyles: ` - .root - color blue - `, - localStyles: ` - .root - color green - `, - inlineStyleProps: { - style: { color: 'purple' } - } - }), { - style: { color: 'purple' } - }) - }) -}) - -// ============================================================================ -// LEVEL 9: Complex combinations -// ============================================================================ -describe('Level 9: Complex combinations', () => { - it('multiple classes, parts, var(), and hierarchy', () => { - setDefaultVariables({ - '--primary': '#00f', - '--header-size': '24px' - }) - singletonVariables['--active-opacity'] = '0.9' - - assert.deepStrictEqual(p({ - styleName: 'root active', - fileStyles: ` - .root - color var(--primary, #f00) - .active - opacity var(--base-opacity, 0.5) - .root.active - opacity var(--active-opacity, 0.8) - .root::part(header) - font-size var(--header-size, 16px) - `, - globalStyles: ` - .root - padding-left var(--padding, 10px) - `, - localStyles: ` - .root - margin-left var(--margin, 5px) - `, - inlineStyleProps: { - headerStyle: { fontWeight: 'bold' } - } - }), { - style: { - color: '#00f', - opacity: 0.9, - paddingLeft: 10, - marginLeft: 5 - }, - headerStyle: { - fontSize: 24, - fontWeight: 'bold' - } - }) - }) - - it('var() with rgba color value', () => { - setDefaultVariables({ - '--string-color': 'rgba(255, 0, 0, 0.5)', - '--numeric-size': '32px' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--string-color, #000) - font-size var(--numeric-size, 16px) - ` - }), { - style: { - color: 'rgba(255, 0, 0, 0.5)', - fontSize: 32 - } - }) - }) - - it('deeply nested specificity with vars', () => { - setDefaultVariables({ - '--level1-color': '#f00', - '--level2-color': '#00f', - '--level3-color': '#0f0' - }) - assert.deepStrictEqual(p({ - styleName: 'root active card', - fileStyles: ` - .root - color var(--level1-color, #000) - .root.active - color var(--level2-color, #808080) - .root.active.card - color var(--level3-color, #fff) - ` - }), { - style: { color: '#0f0' } - }) - }) - - it('parts with multiple classes and vars', () => { - setDefaultVariables({ - '--header-bg': '#00f', - '--active-header-bg': '#0f0', - '--card-header-bg': '#800080' - }) - singletonVariables['--full-header-bg'] = '#ffa500' // orange - - assert.deepStrictEqual(p({ - styleName: 'root active card', - fileStyles: ` - .root::part(header) - background-color var(--header-bg, #808080) - .root.active::part(header) - background-color var(--active-header-bg, #f00) - .root.card::part(header) - background-color var(--card-header-bg, #00f) - .root.active.card::part(header) - background-color var(--full-header-bg, #000) - ` - }), { - headerStyle: { backgroundColor: '#ffa500' } - }) - }) -}) - -// ============================================================================ -// LEVEL 10: Edge cases and special values -// ============================================================================ -describe('Level 10: Edge cases', () => { - it('var() with hyphenated variable names', () => { - setDefaultVariables({ '--my-very-long-variable-name': '#f00' }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--my-very-long-variable-name, #00f) - ` - }), { - style: { color: '#f00' } - }) - }) - - it('var() with numeric variable names', () => { - setDefaultVariables({ '--color-100': '#d3d3d3' }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--color-100, #fff) - ` - }), { - style: { color: '#d3d3d3' } - }) - }) - - it('empty default in var()', () => { - singletonVariables['--color'] = '#f00' - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--color) - ` - }), { - style: { color: '#f00' } - }) - }) - - it('u unit support (1u = 8px)', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - padding-left 2u - margin-left 1.5u - ` - }), { - style: { - paddingLeft: 16, - marginLeft: 12 - } - }) - }) - - it('multiple inline style props', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color red - .root::part(header) - font-size 20px - `, - inlineStyleProps: { - style: { marginLeft: 10 }, - headerStyle: { marginTop: 5 }, - customStyle: { padding: 15 } - } - }), { - style: { - color: '#f00', - marginLeft: 10 - }, - headerStyle: { - fontSize: 20, - marginTop: 5 - }, - customStyle: { - padding: 15 - } - }) - }) - - it('var() fallback chain - singleton > default > inline default', () => { - // Test 1: Only inline default - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--test-color, #f00) - ` - }), { - style: { color: '#f00' } - }) - - // Test 2: Default variable overrides inline default - setDefaultVariables({ '--test-color': '#00f' }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--test-color, #f00) - ` - }), { - style: { color: '#00f' } - }) - - // Test 3: Singleton overrides default - singletonVariables['--test-color'] = '#0f0' - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - color var(--test-color, #f00) - ` - }), { - style: { color: '#0f0' } - }) - }) -}) - -// ============================================================================ -// LEVEL 11: var() as part of compound values (not the whole value) -// ============================================================================ -describe('Level 11: var() in compound values', () => { - it('multiple var() in box-shadow', () => { - setDefaultVariables({ - '--shadow-x': '2px', - '--shadow-y': '4px', - '--shadow-blur': '8px', - '--shadow-color': 'rgba(0, 0, 0, 0.2)' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - box-shadow var(--shadow-x, 0) var(--shadow-y, 0) var(--shadow-blur, 0) var(--shadow-color, #000) - ` - }), { - style: { - // RN New Architecture supports boxShadow as a string natively - boxShadow: '2px 4px 8px rgba(0, 0, 0, 0.2)' - } - }) - }) - - it('var() mixed with static values in box-shadow', () => { - setDefaultVariables({ - '--shadow-color': '#f00' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - box-shadow 2px 4px 8px var(--shadow-color, #000) - ` - }), { - style: { - // RN New Architecture supports boxShadow as a string natively - boxShadow: '2px 4px 8px #f00' - } - }) - }) - - it('var() in transform with multiple functions', () => { - setDefaultVariables({ - '--translate-x': '10px', - '--scale': '1.5' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - transform translateX(var(--translate-x, 0)) scale(var(--scale, 1)) - ` - }), { - style: { - // RN applies transforms in reverse order, so scale comes first - transform: [ - { scale: 1.5 }, - { translateX: 10 } - ] - } - }) - }) - - it('var() in border longhand properties', () => { - setDefaultVariables({ - '--border-width': '2px', - '--border-color': '#00f' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - border-width var(--border-width, 1px) - border-style solid - border-color var(--border-color, #000) - ` - }), { - style: { - borderWidth: 2, - borderStyle: 'solid', - borderColor: '#00f' - } - }) - }) - - // border shorthand syntax: width style color (all optional, any order for style/color) - // Common patterns: "1px solid red", "1px solid", "solid red", "1px", etc. - - it('border shorthand: width style color (no var)', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - border 2px solid red - ` - }), { - style: { - borderWidth: 2, - borderStyle: 'solid', - borderColor: '#f00' - } - }) - }) - - it('border shorthand: width style (no color, no var)', () => { - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - border 2px solid - ` - }), { - style: { - borderWidth: 2, - borderStyle: 'solid', - borderColor: 'black' // css-to-react-native defaults to black - } - }) - }) - - it('border shorthand: width style var(color)', () => { - setDefaultVariables({ - '--border-color': '#0f0' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - border 2px solid var(--border-color, #000) - ` - }), { - style: { - borderWidth: 2, - borderStyle: 'solid', - borderColor: '#0f0' - } - }) - }) - - // NOTE: var() in border width position is not currently supported by css-to-react-native - // Use separate border-width property with var() instead: - it('border with var(width) using longhand', () => { - setDefaultVariables({ - '--border-width': '3px', - '--border-color': '#00f' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - border-width var(--border-width, 1px) - border-style solid - border-color var(--border-color, #000) - ` - }), { - style: { - borderWidth: 3, - borderStyle: 'solid', - borderColor: '#00f' - } - }) - }) - - it('multiple var() with some overridden by singleton', () => { - setDefaultVariables({ - '--x': '5px', - '--y': '10px' - }) - singletonVariables['--y'] = '20px' - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - box-shadow var(--x, 0) var(--y, 0) 0 #000 - ` - }), { - style: { - // RN New Architecture supports boxShadow as a string natively - boxShadow: '5px 20px 0 #000' - } - }) - }) - - // text-shadow syntax: [color] offset-x offset-y [blur-radius] [color] - // color can be at start or end, blur-radius is optional - - it('var() in text-shadow: offset-x offset-y var(color)', () => { - setDefaultVariables({ - '--text-shadow-color': 'rgba(0, 0, 0, 0.5)' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - text-shadow 1px 2px var(--text-shadow-color, #000) - ` - }), { - style: { - textShadowOffset: { width: 1, height: 2 }, - textShadowRadius: 0, - textShadowColor: 'rgba(0, 0, 0, 0.5)' - } - }) - }) - - it('var() in text-shadow: offset-x offset-y blur var(color)', () => { - setDefaultVariables({ - '--text-shadow-color': 'rgba(0, 0, 0, 0.5)' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - text-shadow 1px 2px 3px var(--text-shadow-color, #000) - ` - }), { - style: { - textShadowOffset: { width: 1, height: 2 }, - textShadowRadius: 3, - textShadowColor: 'rgba(0, 0, 0, 0.5)' - } - }) - }) - - it('var() in text-shadow: var(color) offset-x offset-y', () => { - setDefaultVariables({ - '--text-shadow-color': 'rgba(0, 0, 0, 0.5)' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - text-shadow var(--text-shadow-color, #000) 1px 2px - ` - }), { - style: { - textShadowOffset: { width: 1, height: 2 }, - textShadowRadius: 0, - textShadowColor: 'rgba(0, 0, 0, 0.5)' - } - }) - }) - - it('var() in text-shadow: var(color) offset-x offset-y blur', () => { - setDefaultVariables({ - '--text-shadow-color': 'rgba(0, 0, 0, 0.5)' - }) - assert.deepStrictEqual(p({ - styleName: 'root', - fileStyles: ` - .root - text-shadow var(--text-shadow-color, #000) 1px 2px 3px - ` - }), { - style: { - textShadowOffset: { width: 1, height: 2 }, - textShadowRadius: 3, - textShadowColor: 'rgba(0, 0, 0, 0.5)' - } - }) - }) -}) - -// ============================================================================ -// LEVEL 12: Comprehensive integration test -// ============================================================================ -describe('Level 12: Full integration test', () => { - it('kitchen sink test', () => { - setDefaultVariables({ - '--primary-color': '#00f', - '--secondary-color': '#808080', - '--spacing-md': '16px', - '--font-size-lg': '24px' - }) - singletonVariables['--primary-color'] = '#4b0082' // indigo - singletonVariables['--active-bg'] = 'rgba(0, 0, 255, 0.1)' - - assert.deepStrictEqual(p({ - styleName: 'button primary active', - fileStyles: ` - .button - padding-top var(--spacing-md, 12px) - padding-bottom var(--spacing-md, 12px) - padding-left var(--spacing-md, 12px) - padding-right var(--spacing-md, 12px) - border-radius 8px - background-color var(--secondary-color, #d3d3d3) - - .primary - background-color var(--primary-color, #00f) - color white - - .active - opacity 0.9 - - .button.primary - font-weight bold - - .button.active - background-color var(--active-bg, transparent) - - .button.primary.active - border-width 2px - - .button::part(icon) - width var(--spacing-md, 16px) - height var(--spacing-md, 16px) - - .button.primary::part(icon) - opacity 1 - - .button::part(label) - font-size var(--font-size-lg, 16px) - `, - globalStyles: ` - .button - cursor pointer - - .button::part(label) - text-transform uppercase - `, - localStyles: ` - .button - min-width 100px - - .button.primary - min-height 40px - `, - inlineStyleProps: { - style: { marginRight: 10 }, - iconStyle: { marginRight: 5 } - } - }), { - style: { - paddingTop: 16, - paddingBottom: 16, - paddingLeft: 16, - paddingRight: 16, - borderRadius: 8, - backgroundColor: 'rgba(0, 0, 255, 0.1)', - color: '#fff', - opacity: 0.9, - fontWeight: 'bold', - borderWidth: 2, - cursor: 'pointer', - minWidth: 100, - minHeight: 40, - marginRight: 10 - }, - iconStyle: { - width: 16, - height: 16, - opacity: 1, - marginRight: 5 - }, - labelStyle: { - fontSize: 24, - textTransform: 'uppercase' - } - }) - }) -}) diff --git a/packages/runtime/variables.js b/packages/runtime/variables.js deleted file mode 100644 index 73abc80..0000000 --- a/packages/runtime/variables.js +++ /dev/null @@ -1,9 +0,0 @@ -import { observable } from '@nx-js/observer-util' - -export let defaultVariables = {} - -export default observable({}) - -export function setDefaultVariables (variables = {}) { - defaultVariables = { ...variables } -} diff --git a/packages/runtime/vendor/react-native-css-media-query-processor/README.md b/packages/runtime/vendor/react-native-css-media-query-processor/README.md deleted file mode 100644 index 45c6b79..0000000 --- a/packages/runtime/vendor/react-native-css-media-query-processor/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Credits - -[kristerkary](https://github.com/kristerkari) - -Original code taken from: -https://github.com/kristerkari/react-native-css-media-query-processor - -Original version: 0.21.3 diff --git a/packages/runtime/vendor/react-native-css-media-query-processor/index.js b/packages/runtime/vendor/react-native-css-media-query-processor/index.js deleted file mode 100644 index 214cb3c..0000000 --- a/packages/runtime/vendor/react-native-css-media-query-processor/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import merge from 'deepmerge' -import memoize from 'micro-memoize' -import mediaQuery from './mediaquery.js' -import { getPlatform } from '../../platformHelpers/index.js' - -const PREFIX = '@media' - -function isMediaQuery (str) { - return typeof str === 'string' && str.indexOf(PREFIX) === 0 -} - -function filterMq (obj) { - return Object.keys(obj).filter(key => isMediaQuery(key)) -} - -function filterNonMq (obj) { - return Object.keys(obj).reduce((out, key) => { - if (!isMediaQuery(key) && key !== '__mediaQueries') { - out[key] = obj[key] - } - return out - }, {}) -} - -const mFilterMq = memoize(filterMq) -const mFilterNonMq = memoize(filterNonMq) - -export function process (obj, matchObject) { - const mqKeys = mFilterMq(obj) - let res = mFilterNonMq(obj) - - mqKeys.forEach(key => { - if (/^@media\s+(not\s+)?(ios|android|dom|macos|web|windows)/i.test(key)) { - matchObject.type = getPlatform() - } else { - matchObject.type = 'screen' - } - - const isMatch = mediaQuery(obj.__mediaQueries[key], matchObject) - if (isMatch) { - res = merge(res, obj[key]) - } - }) - - return res -} diff --git a/packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js b/packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js deleted file mode 100644 index 32ad3b8..0000000 --- a/packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js +++ /dev/null @@ -1,152 +0,0 @@ -/* -Copyright (c) 2014, Yahoo! Inc. All rights reserved. -Copyrights licensed under the New BSD License. -See the accompanying LICENSE file for terms. -*/ - -export default match - -// ----------------------------------------------------------------------------- - -const RE_LENGTH_UNIT = /(em|rem|px|cm|mm|in|pt|pc)?\s*$/ -const RE_RESOLUTION_UNIT = /(dpi|dpcm|dppx)?\s*$/ - -function match (parsed, values) { - if (!parsed) { - return false - } - if (parsed.length === 1) { - return matchQuery(parsed[0], values) - } - return parsed.some(mq => matchQuery(mq, values)) -} - -function matchQuery (query, values) { - const inverse = query.inverse - - // Either the parsed or specified `type` is "all", or the types must be - // equal for a match. - const typeMatch = query.type === 'all' || values.type === query.type - - if (query.expressions.length === 0) { - // Quit early when `type` doesn't match, but take "not" into account. - if ((typeMatch && inverse) || !(typeMatch || inverse)) { - return false - } - } - - const expressionsMatch = query.expressions.every(function (expression) { - const feature = expression.feature - const modifier = expression.modifier - let expValue = expression.value - let value = values[feature] - - // Missing or falsy values don't match. - if (!value) { - return false - } - - switch (feature) { - case 'orientation': - case 'scan': - return value.toLowerCase() === expValue.toLowerCase() - - case 'width': - case 'height': - case 'device-width': - case 'device-height': - expValue = toPx(expValue) - value = toPx(value) - break - - case 'resolution': - expValue = toDpi(expValue) - value = toDpi(value) - break - - case 'aspect-ratio': - case 'device-aspect-ratio': - case /* Deprecated */ 'device-pixel-ratio': - expValue = toDecimal(expValue) - value = toDecimal(value) - break - - case 'grid': - case 'color': - case 'color-index': - case 'monochrome': - expValue = parseInt(expValue, 10) || 1 - value = parseInt(value, 10) || 0 - break - } - - switch (modifier) { - case 'min': - return value >= expValue - case 'max': - return value <= expValue - default: - return value === expValue - } - }) - - const isMatch = typeMatch && expressionsMatch - - if (inverse) { - return !isMatch - } - - return isMatch -} - -// -- Utilities ---------------------------------------------------------------- - -function toDecimal (ratio) { - let decimal = Number(ratio) - let numbers - - if (!decimal) { - numbers = ratio.match(/^(\d+)\s*\/\s*(\d+)$/) - decimal = numbers[1] / numbers[2] - } - - return decimal -} - -function toDpi (resolution) { - const value = parseFloat(resolution) - const units = String(resolution).match(RE_RESOLUTION_UNIT)[1] - - switch (units) { - case 'dpcm': - return value / 2.54 - case 'dppx': - return value * 96 - default: - return value - } -} - -function toPx (length) { - const value = parseFloat(length) - const units = String(length).match(RE_LENGTH_UNIT)[1] - - switch (units) { - case 'em': - return value * 16 - case 'rem': - return value * 16 - case 'cm': - return (value * 96) / 2.54 - case 'mm': - return (value * 96) / 2.54 / 10 - case 'in': - return value * 96 - case 'pt': - return value * 72 - case 'pc': - return (value * 72) / 12 - default: - return value - } -} diff --git a/packages/runtime/vendor/react-native-dynamic-style-processor/README.md b/packages/runtime/vendor/react-native-dynamic-style-processor/README.md deleted file mode 100644 index cd0de16..0000000 --- a/packages/runtime/vendor/react-native-dynamic-style-processor/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Credits - -[kristerkary](https://github.com/kristerkari) - -Original code taken from: -https://github.com/kristerkari/react-native-dynamic-style-processor - -Original version: 0.21.0 diff --git a/packages/runtime/vendor/react-native-dynamic-style-processor/index.js b/packages/runtime/vendor/react-native-dynamic-style-processor/index.js deleted file mode 100644 index 127d17c..0000000 --- a/packages/runtime/vendor/react-native-dynamic-style-processor/index.js +++ /dev/null @@ -1,52 +0,0 @@ -import { process as mediaQueriesProcess } from '../react-native-css-media-query-processor/index.js' -import { transform } from 'css-viewport-units-transform' -import memoize from 'micro-memoize' -import { getDimensions } from '../../platformHelpers/index.js' - -function omit (obj, omitKey) { - return Object.keys(obj).reduce((result, key) => { - if (key !== omitKey) { - result[key] = obj[key] - } - return result - }, {}) -} - -const omitMemoized = memoize(omit) - -function viewportUnitsTransform (obj, matchObject) { - const hasViewportUnits = '__viewportUnits' in obj - - if (!hasViewportUnits) { - return obj - } - return transform(omitMemoized(obj, '__viewportUnits'), matchObject) -} - -function mediaQueriesTransform (obj, matchObject) { - const hasParsedMQs = '__mediaQueries' in obj - - if (!hasParsedMQs) { - return obj - } - return mediaQueriesProcess(obj, matchObject) -} - -export function process (obj) { - const matchObject = getMatchObject() - return viewportUnitsTransform( - mediaQueriesTransform(obj, matchObject), - matchObject - ) -} - -function getMatchObject () { - const win = getDimensions() - return { - width: win.width, - height: win.height, - orientation: win.width > win.height ? 'landscape' : 'portrait', - 'aspect-ratio': win.width / win.height, - type: 'screen' - } -} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..505cd7d --- /dev/null +++ b/plan.md @@ -0,0 +1,45 @@ +# Backlog + +Completed design notes from the CSSX unification and theming work now live in +`architecture.md`. Keep this file only for future work that is not yet part of +the implemented architecture. + +## Tailwind Runtime Utilities + +CSSX ships Tailwind-compatible theme variables through +`cssxjs/themes/tailwind`, but it does not yet ship a full Tailwind utility +runtime. + +Future work: + +- Add an optional export, likely `cssxjs/tailwind`, that can generate or resolve + Tailwind-style utility classes without bundling it for users who never import + it. +- Keep StartupJS UI independent from the Tailwind utility runtime. StartupJS UI + should keep using CSS variables and semantic component CSS, not utility class + internals. +- Reuse a proven small implementation where possible, or isolate the custom + class parser behind a narrow adapter so CSSX does not become responsible for + the whole Tailwind language surface by accident. +- Ensure generated utilities interoperate with CSSX variables, especially + `--color-*`, `--spacing`, radii, font sizes, breakpoints, and arbitrary + bracket values such as `w-[15px]`. +- Add tests for cache identity, dynamic variables, arbitrary values, web and + React Native output parity, and package tree-shaking. + +## Legacy Cleanup + +- Remove compatibility-only `cache: 'teamplay'` behavior after downstream + projects no longer pass it. +- Remove legacy runtime wrappers or import aliases only after the StartupJS and + StartupJS UI breaking migrations are complete. +- Keep separate `.cssx.css` and `.styl` support working while projects migrate + toward inline `css`/`styl` templates. + +## CSS Surface Expansion + +- Add more CSS functions only when a real component or app migration needs them. + Current color work centers on CSS variables, `calc()`, `oklch()`, and + `color-mix()`. +- Broaden transform tests before adding large parser features. CSSX should stay + lightweight enough for runtime compilation on the client. diff --git a/rspress.config.ts b/rspress.config.ts index c63447d..f705a7b 100644 --- a/rspress.config.ts +++ b/rspress.config.ts @@ -87,6 +87,7 @@ export default defineConfig({ { text: 'styl() Function', link: '/api/styl-function' }, { text: 'CSS Variables', link: '/api/variables' }, { text: 'JSX Props', link: '/api/jsx-props' }, + { text: 'Runtime Compilation', link: '/api/runtime' }, { text: 'Babel Config', link: '/api/babel' } ] }, @@ -108,6 +109,7 @@ export default defineConfig({ text: 'Migration Guides', collapsed: true, items: [ + { text: '0.4', link: '/migration-guides/0.4' }, { text: '0.3', link: '/migration-guides/0.3' } ] } diff --git a/yarn.lock b/yarn.lock index 6a7b8fc..6a6bc03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,6 +22,46 @@ __metadata: languageName: node linkType: hard +"@asamuzakjp/css-color@npm:^5.1.11": + version: 5.1.11 + resolution: "@asamuzakjp/css-color@npm:5.1.11" + dependencies: + "@asamuzakjp/generational-cache": "npm:^1.0.1" + "@csstools/css-calc": "npm:^3.2.0" + "@csstools/css-color-parser": "npm:^4.1.0" + "@csstools/css-parser-algorithms": "npm:^4.0.0" + "@csstools/css-tokenizer": "npm:^4.0.0" + checksum: 10c0/32720bdff8daea6a8847aba6cdfae55baa3b4a2690b51d21db7f0382bbd183f3d9f2d5126df50afd889062635684b2819e47113629ee2e80c99389e75f48d060 + languageName: node + linkType: hard + +"@asamuzakjp/dom-selector@npm:^7.1.1": + version: 7.1.1 + resolution: "@asamuzakjp/dom-selector@npm:7.1.1" + dependencies: + "@asamuzakjp/generational-cache": "npm:^1.0.1" + "@asamuzakjp/nwsapi": "npm:^2.3.9" + bidi-js: "npm:^1.0.3" + css-tree: "npm:^3.2.1" + is-potential-custom-element-name: "npm:^1.0.1" + checksum: 10c0/8cec1c618781c94de5836a215bbe5aafb4d8b835b18c51faf8547f4574afa39f92def3951e40123860062467613dd825f1e1600ff32e8045cc099a91796dcfb8 + languageName: node + linkType: hard + +"@asamuzakjp/generational-cache@npm:^1.0.1": + version: 1.0.1 + resolution: "@asamuzakjp/generational-cache@npm:1.0.1" + checksum: 10c0/1de62de43764e13fca3b9a31b7ea9b1bf0780fe053d266e40378a19ff8c66b543e011e6a0df02d410cd59bf981126706f176cdbb938985165202c4a079fe1057 + languageName: node + linkType: hard + +"@asamuzakjp/nwsapi@npm:^2.3.9": + version: 2.3.9 + resolution: "@asamuzakjp/nwsapi@npm:2.3.9" + checksum: 10c0/869b81382e775499c96c45c6dbe0d0766a6da04bcf0abb79f5333535c4e19946851acaa43398f896e2ecc5a1de9cf3db7cf8c4b1afac1ee3d15e21584546d74d + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0": version: 7.26.0 resolution: "@babel/code-frame@npm:7.26.0" @@ -709,6 +749,24 @@ __metadata: languageName: node linkType: hard +"@bramus/specificity@npm:^2.4.2": + version: 2.4.2 + resolution: "@bramus/specificity@npm:2.4.2" + dependencies: + css-tree: "npm:^3.0.0" + bin: + specificity: bin/cli.js + checksum: 10c0/c5f4e04e0bca0d2202598207a5eb0733c8109d12a68a329caa26373bec598d99db5bb785b8865fefa00fc01b08c6068138807ceb11a948fe15e904ed6cf4ba72 + languageName: node + linkType: hard + +"@colordx/core@npm:5.4.3": + version: 5.4.3 + resolution: "@colordx/core@npm:5.4.3" + checksum: 10c0/9fa1888b8794d6e80c9fb346bf18dc6de0a79834099559baab4854ed09c5684f1ec26f1d745c2702774dbb47ce936eeb0645da0f64cce43b45df68f7e8fa9ff2 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -718,7 +776,65 @@ __metadata: languageName: node linkType: hard -"@cssxjs/babel-plugin-rn-stylename-inline@npm:^0.3.0, @cssxjs/babel-plugin-rn-stylename-inline@workspace:packages/babel-plugin-rn-stylename-inline": +"@csstools/color-helpers@npm:^6.0.2": + version: 6.0.2 + resolution: "@csstools/color-helpers@npm:6.0.2" + checksum: 10c0/4c66574563d7c960010c11e41c2673675baff07c427cca6e8dddffa5777de45770d13ff3efce1c0642798089ad55de52870d9d8141f78db3fa5bba012f2d3789 + languageName: node + linkType: hard + +"@csstools/css-calc@npm:^3.2.0, @csstools/css-calc@npm:^3.2.1": + version: 3.2.1 + resolution: "@csstools/css-calc@npm:3.2.1" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/0191c8d1cd4dffa0d3b6bfd1e78a721934b1d7a6c972966e4fdaa72208c6789e8ff443ee81764a32f1e6107825695b5524ef2b4dc1681b5b29230f2a1277e5df + languageName: node + linkType: hard + +"@csstools/css-color-parser@npm:^4.1.0": + version: 4.1.8 + resolution: "@csstools/css-color-parser@npm:4.1.8" + dependencies: + "@csstools/color-helpers": "npm:^6.0.2" + "@csstools/css-calc": "npm:^3.2.1" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/7a5ed5cca6ee2d33e6f9710eb00616658efc09df5ed0cf1619f572986180e36c70728bde42a0cc29bd59c6dc4469c04edd4d7f3e52129c3ec9e56a56a85d2d85 + languageName: node + linkType: hard + +"@csstools/css-parser-algorithms@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-parser-algorithms@npm:4.0.0" + peerDependencies: + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10c0/94558c2428d6ef0ddef542e86e0a8376aa1263a12a59770abb13ba50d7b83086822c75433f32aa2e7fef00555e1cc88292f9ca5bce79aed232bb3fed73b1528d + languageName: node + linkType: hard + +"@csstools/css-syntax-patches-for-csstree@npm:^1.1.3": + version: 1.1.5 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.5" + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + checksum: 10c0/a31f0cfb74e2b5ce8a283c47969a202fc3b23c3ee05c6b6beab7f5c14d89c50b82533e446df74f7df0bf88bf23810ed59431353db26e00d5b013995c1ebf07a2 + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-tokenizer@npm:4.0.0" + checksum: 10c0/669cf3d0f9c8e1ffdf8c9955ad8beba0c8cfe03197fe29a4fcbd9ee6f7a18856cfa42c62670021a75183d9ab37f5d14a866e6a9df753a6c07f59e36797a9ea9f + languageName: node + linkType: hard + +"@cssxjs/babel-plugin-rn-stylename-inline@npm:^0.4.0-alpha.6, @cssxjs/babel-plugin-rn-stylename-inline@workspace:packages/babel-plugin-rn-stylename-inline": version: 0.0.0-use.local resolution: "@cssxjs/babel-plugin-rn-stylename-inline@workspace:packages/babel-plugin-rn-stylename-inline" dependencies: @@ -726,14 +842,13 @@ __metadata: "@babel/plugin-syntax-jsx": "npm:^7.0.0" "@babel/template": "npm:^7.4.0" "@babel/types": "npm:^7.0.0" - "@cssxjs/loaders": "npm:^0.3.0" - "@cssxjs/runtime": "npm:^0.3.0" + "@cssxjs/loaders": "npm:^0.4.0-alpha.6" babel-plugin-tester: "npm:^9.1.0" jest: "npm:^30.0.4" languageName: unknown linkType: soft -"@cssxjs/babel-plugin-rn-stylename-to-style@npm:^0.3.0, @cssxjs/babel-plugin-rn-stylename-to-style@workspace:packages/babel-plugin-rn-stylename-to-style": +"@cssxjs/babel-plugin-rn-stylename-to-style@npm:^0.4.0-alpha.2, @cssxjs/babel-plugin-rn-stylename-to-style@workspace:packages/babel-plugin-rn-stylename-to-style": version: 0.0.0-use.local resolution: "@cssxjs/babel-plugin-rn-stylename-to-style@workspace:packages/babel-plugin-rn-stylename-to-style" dependencies: @@ -742,63 +857,60 @@ __metadata: "@babel/plugin-syntax-jsx": "npm:^7.0.0" "@babel/template": "npm:^7.4.0" "@babel/types": "npm:^7.0.0" - "@cssxjs/runtime": "npm:^0.3.0" "@react-pug/babel-plugin-react-pug": "npm:^0.1.18" babel-plugin-tester: "npm:^9.1.0" jest: "npm:^30.0.4" languageName: unknown linkType: soft -"@cssxjs/bundler@npm:^0.3.0, @cssxjs/bundler@workspace:packages/bundler": +"@cssxjs/bundler@npm:^0.4.0-alpha.6, @cssxjs/bundler@workspace:packages/bundler": version: 0.0.0-use.local resolution: "@cssxjs/bundler@workspace:packages/bundler" dependencies: - "@cssxjs/loaders": "npm:^0.3.0" + "@cssxjs/loaders": "npm:^0.4.0-alpha.6" languageName: unknown linkType: soft -"@cssxjs/css-to-react-native@npm:3.2.0-2": - version: 3.2.0-2 - resolution: "@cssxjs/css-to-react-native@npm:3.2.0-2" - dependencies: - camelize: "npm:^1.0.0" - css-color-keywords: "npm:^1.0.0" - postcss-value-parser: "npm:^4.0.2" - checksum: 10c0/54d5990946c164089be1ca2203ff360ab49528df79290b8fd9df1bcdb07780335c03c3a6668a045786ff1b5808c6e101bdfe3b9b62a9fcc3b460d3513f56287f - languageName: node - linkType: hard - -"@cssxjs/loaders@npm:^0.3.0, @cssxjs/loaders@workspace:packages/loaders": +"@cssxjs/css-to-rn@npm:^0.4.0-alpha.6, @cssxjs/css-to-rn@workspace:packages/css-to-rn": version: 0.0.0-use.local - resolution: "@cssxjs/loaders@workspace:packages/loaders" + resolution: "@cssxjs/css-to-rn@workspace:packages/css-to-rn" dependencies: - "@startupjs/css-to-react-native-transform": "npm:2.1.0-3" - stylus: "npm:0.64.0" - languageName: unknown - linkType: soft - -"@cssxjs/runtime@npm:^0.3.0, @cssxjs/runtime@workspace:packages/runtime": - version: 0.0.0-use.local - resolution: "@cssxjs/runtime@workspace:packages/runtime" - dependencies: - "@cssxjs/loaders": "npm:^0.3.0" - "@nx-js/observer-util": "npm:^4.1.3" - "@startupjs/css-to-react-native-transform": "npm:2.1.0-3" - css-viewport-units-transform: "npm:^0.10.2" - deepmerge: "npm:^3.2.0" - micro-memoize: "npm:^3.0.1" - mocha: "npm:^8.1.1" + "@colordx/core": "npm:5.4.3" + "@types/jsdom": "npm:^28.0.3" + "@types/node": "npm:^22.8.1" + "@types/react": "npm:19.2.17" + "@types/react-dom": "npm:19.2.3" + css: "npm:^3.0.0" + css-mediaquery: "npm:^0.1.2" + jsdom: "npm:^29.1.1" + mocha: "npm:^8.4.0" + postcss-value-parser: "npm:^4.2.0" + react: "npm:19.2.7" + react-dom: "npm:19.2.7" + typescript: "npm:^6.0.3" peerDependencies: + "@react-native-async-storage/async-storage": "*" + react: "*" react-native: "*" - teamplay: "*" peerDependenciesMeta: - react-native: + "@react-native-async-storage/async-storage": + optional: true + react: optional: true - teamplay: + react-native: optional: true languageName: unknown linkType: soft +"@cssxjs/loaders@npm:^0.4.0-alpha.6, @cssxjs/loaders@workspace:packages/loaders": + version: 0.0.0-use.local + resolution: "@cssxjs/loaders@workspace:packages/loaders" + dependencies: + "@cssxjs/css-to-rn": "npm:^0.4.0-alpha.6" + stylus: "npm:0.64.0" + languageName: unknown + linkType: soft + "@emnapi/core@npm:^1.1.0": version: 1.3.1 resolution: "@emnapi/core@npm:1.3.1" @@ -1150,6 +1262,18 @@ __metadata: languageName: node linkType: hard +"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0": + version: 1.15.1 + resolution: "@exodus/bytes@npm:1.15.1" + peerDependencies: + "@noble/hashes": ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + "@noble/hashes": + optional: true + checksum: 10c0/333056a6953bbf875d9f3b86c32314de29458d842e5f56f6ef8034b18c2d9660184550093d1bae5de0064043d5e23f54cc03148798d9d29cf5167ac03f2e9f8c + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -2314,13 +2438,6 @@ __metadata: languageName: node linkType: hard -"@nx-js/observer-util@npm:^4.1.3": - version: 4.2.2 - resolution: "@nx-js/observer-util@npm:4.2.2" - checksum: 10c0/2b9953f598be95cc87fa1d02a59e73206f8a46d52f1ab20183e525d0f8273f470fa5fd27e176006db9adf2ec3f9e6e7e203a8844fc46998dacd28e9d5f704bd3 - languageName: node - linkType: hard - "@nx/devkit@npm:>=21.5.2 < 23.0.0": version: 22.2.6 resolution: "@nx/devkit@npm:22.2.6" @@ -3067,17 +3184,6 @@ __metadata: languageName: node linkType: hard -"@startupjs/css-to-react-native-transform@npm:2.1.0-3": - version: 2.1.0-3 - resolution: "@startupjs/css-to-react-native-transform@npm:2.1.0-3" - dependencies: - "@cssxjs/css-to-react-native": "npm:3.2.0-2" - css: "npm:^3.0.0" - css-mediaquery: "npm:^0.1.2" - checksum: 10c0/e0adfce66b6afb6f5a8e2e164d017c07bd5b810fdde92302d3c28d2c62159a21d79da42a377fb19a211ba17b2175b577b621acec5e9d10f44ccd0a6e4e7a4516 - languageName: node - linkType: hard - "@stylistic/eslint-plugin@npm:2.11.0": version: 2.11.0 resolution: "@stylistic/eslint-plugin@npm:2.11.0" @@ -3298,6 +3404,18 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^28.0.3": + version: 28.0.3 + resolution: "@types/jsdom@npm:28.0.3" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^8.0.0" + undici-types: "npm:^7.21.0" + checksum: 10c0/08b1cd61ee3e9610676be3c68a782a94667b86a5f73b8a262095d05f84c9e864fc11b25ae53450cd519a0abd46c202906a735bd61aa176257a981964bc5b1166 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -3351,6 +3469,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.8.1": + version: 22.20.0 + resolution: "@types/node@npm:22.20.0" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/55d78223205bd5f81f043d71b7a5c8d8854b9ef44ef81291680943adb27fa5ba1f092658c87183d5bc8cf6baf6a57b81dad966eb3afa452cc301a615b6d9b20e + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -3372,29 +3499,21 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*": - version: 15.7.13 - resolution: "@types/prop-types@npm:15.7.13" - checksum: 10c0/1b20fc67281902c6743379960247bc161f3f0406ffc0df8e7058745a85ea1538612109db0406290512947f9632fe9e10e7337bf0ce6338a91d6c948df16a7c61 - languageName: node - linkType: hard - -"@types/react-dom@npm:^18.3.1": - version: 18.3.7 - resolution: "@types/react-dom@npm:18.3.7" +"@types/react-dom@npm:19.2.3": + version: 19.2.3 + resolution: "@types/react-dom@npm:19.2.3" peerDependencies: - "@types/react": ^18.0.0 - checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e + "@types/react": ^19.2.0 + checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1 languageName: node linkType: hard -"@types/react@npm:~18.2.45": - version: 18.2.79 - resolution: "@types/react@npm:18.2.79" +"@types/react@npm:19.2.17": + version: 19.2.17 + resolution: "@types/react@npm:19.2.17" dependencies: - "@types/prop-types": "npm:*" - csstype: "npm:^3.0.2" - checksum: 10c0/c8a8a005d8830a48cc1ef93c3510c4935a2a03e5557dbecaa8f1038450cbfcb18eb206fa7fba7077d54b8da21faeb25577e897a333392770a7797f625b62c78a + csstype: "npm:^3.2.2" + checksum: 10c0/bc2c4af96b3e480604424de70d5ebda90c5f4b485df471858c0bc2d7d70364b606ec3c4d8579f94f01aa0c6c0591f56bcf14cba5689f5eea4b74250ccdc3a232 languageName: node linkType: hard @@ -3405,6 +3524,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0, @types/unist@npm:^3.0.3": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" @@ -4260,14 +4386,14 @@ __metadata: languageName: node linkType: hard -"babel-preset-cssxjs@npm:^0.3.0, babel-preset-cssxjs@workspace:packages/babel-preset-cssxjs": +"babel-preset-cssxjs@npm:^0.4.0-alpha.6, babel-preset-cssxjs@workspace:packages/babel-preset-cssxjs": version: 0.0.0-use.local resolution: "babel-preset-cssxjs@workspace:packages/babel-preset-cssxjs" dependencies: "@babel/plugin-syntax-jsx": "npm:^7.0.0" "@babel/plugin-syntax-typescript": "npm:^7.23.3" - "@cssxjs/babel-plugin-rn-stylename-inline": "npm:^0.3.0" - "@cssxjs/babel-plugin-rn-stylename-to-style": "npm:^0.3.0" + "@cssxjs/babel-plugin-rn-stylename-inline": "npm:^0.4.0-alpha.6" + "@cssxjs/babel-plugin-rn-stylename-to-style": "npm:^0.4.0-alpha.2" "@react-pug/babel-plugin-react-pug": "npm:^0.1.18" languageName: unknown linkType: soft @@ -4344,6 +4470,15 @@ __metadata: languageName: node linkType: hard +"bidi-js@npm:^1.0.3": + version: 1.0.3 + resolution: "bidi-js@npm:1.0.3" + dependencies: + require-from-string: "npm:^2.0.2" + checksum: 10c0/fdddea4aa4120a34285486f2267526cd9298b6e8b773ad25e765d4f104b6d7437ab4ba542e6939e3ac834a7570bcf121ee2cf6d3ae7cd7082c4b5bedc8f271e1 + languageName: node + linkType: hard + "bin-links@npm:^5.0.0": version: 5.0.0 resolution: "bin-links@npm:5.0.0" @@ -4616,13 +4751,6 @@ __metadata: languageName: node linkType: hard -"camelize@npm:^1.0.0": - version: 1.0.1 - resolution: "camelize@npm:1.0.1" - checksum: 10c0/4c9ac55efd356d37ac483bad3093758236ab686192751d1c9daa43188cc5a07b09bd431eb7458a4efd9ca22424bba23253e7b353feb35d7c749ba040de2385fb - languageName: node - linkType: hard - "caniuse-lite@npm:^1.0.30001669": version: 1.0.30001672 resolution: "caniuse-lite@npm:1.0.30001672" @@ -5247,13 +5375,6 @@ __metadata: languageName: node linkType: hard -"css-color-keywords@npm:^1.0.0": - version: 1.0.0 - resolution: "css-color-keywords@npm:1.0.0" - checksum: 10c0/af205a86c68e0051846ed91eb3e30b4517e1904aac040013ff1d742019b3f9369ba5658ba40901dbbc121186fc4bf0e75a814321cc3e3182fbb2feb81c6d9cb7 - languageName: node - linkType: hard - "css-mediaquery@npm:^0.1.2": version: 0.1.2 resolution: "css-mediaquery@npm:0.1.2" @@ -5261,10 +5382,13 @@ __metadata: languageName: node linkType: hard -"css-viewport-units-transform@npm:^0.10.2": - version: 0.10.3 - resolution: "css-viewport-units-transform@npm:0.10.3" - checksum: 10c0/3133b0998de05340daee2cce6b3f3a03921b5bd481534be835788afef4b8ce981adefd9943a8098598a1a83bbc9ed668c3498f9b7f8d1382741db82449afb43d +"css-tree@npm:^3.0.0, css-tree@npm:^3.2.1": + version: 3.2.1 + resolution: "css-tree@npm:3.2.1" + dependencies: + mdn-data: "npm:2.27.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/1f65e9ccaa56112a4706d6f003dd43d777f0dbcf848e66fd320f823192533581f8dd58daa906cb80622658332d50284d6be13b87a6ab4556cbbfe9ef535bbf7e languageName: node linkType: hard @@ -5288,27 +5412,31 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.0.2": - version: 3.1.3 - resolution: "csstype@npm:3.1.3" - checksum: 10c0/80c089d6f7e0c5b2bd83cf0539ab41474198579584fa10d86d0cafe0642202343cbc119e076a0b1aece191989477081415d66c9fefbf3c957fc2fc4b7009f248 +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce languageName: node linkType: hard -"cssxjs@npm:^0.3.0, cssxjs@workspace:packages/cssxjs": +"cssxjs@npm:^0.4.0-alpha.6, cssxjs@workspace:packages/cssxjs": version: 0.0.0-use.local resolution: "cssxjs@workspace:packages/cssxjs" dependencies: - "@cssxjs/babel-plugin-rn-stylename-inline": "npm:^0.3.0" - "@cssxjs/babel-plugin-rn-stylename-to-style": "npm:^0.3.0" - "@cssxjs/bundler": "npm:^0.3.0" - "@cssxjs/loaders": "npm:^0.3.0" - "@cssxjs/runtime": "npm:^0.3.0" + "@cssxjs/babel-plugin-rn-stylename-inline": "npm:^0.4.0-alpha.6" + "@cssxjs/babel-plugin-rn-stylename-to-style": "npm:^0.4.0-alpha.2" + "@cssxjs/bundler": "npm:^0.4.0-alpha.6" + "@cssxjs/css-to-rn": "npm:^0.4.0-alpha.6" + "@cssxjs/loaders": "npm:^0.4.0-alpha.6" "@react-pug/babel-plugin-react-pug": "npm:^0.1.18" "@react-pug/check-types": "npm:^0.1.18" - babel-preset-cssxjs: "npm:^0.3.0" + babel-preset-cssxjs: "npm:^0.4.0-alpha.6" peerDependencies: + "@react-native-async-storage/async-storage": "*" react: "*" + peerDependenciesMeta: + "@react-native-async-storage/async-storage": + optional: true bin: cssxjs: ./cli.js languageName: unknown @@ -5321,6 +5449,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^7.0.0": + version: 7.0.0 + resolution: "data-urls@npm:7.0.0" + dependencies: + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.0" + checksum: 10c0/08d88ef50d8966a070ffdaa703e1e4b29f01bb2da364dfbc1612b1c2a4caa8045802c9532d81347b21781100132addb36a585071c8323b12cce97973961dee9f + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.1": version: 1.0.1 resolution: "data-view-buffer@npm:1.0.1" @@ -5454,6 +5592,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.6.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + "decode-named-character-reference@npm:^1.0.0": version: 1.0.2 resolution: "decode-named-character-reference@npm:1.0.2" @@ -5501,13 +5646,6 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^3.2.0": - version: 3.3.0 - resolution: "deepmerge@npm:3.3.0" - checksum: 10c0/143bc6b6cd8a1216565c61c0fe38bf43fe691fb6876fb3f5727c6e323defe4e947c68fbab9957e17e837c5594a56af885c5834d23dc6cf2c41bef97090005104 - languageName: node - linkType: hard - "deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" @@ -5753,6 +5891,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^8.0.0": + version: 8.0.0 + resolution: "entities@npm:8.0.0" + checksum: 10c0/938e631664c19451823344a351aeeafd74fae2d5fa51e4d5b6ff635afaefd4bacf0f609989888c04c42733f46ffdac15211608267ebb02488005891a4793e94d + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -6187,7 +6332,18 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-cssxjs@npm:^0.3.0-alpha.0, eslint-plugin-cssxjs@workspace:packages/eslint-plugin-cssxjs": +"eslint-plugin-cssxjs@npm:^0.3.0-alpha.0": + version: 0.3.0 + resolution: "eslint-plugin-cssxjs@npm:0.3.0" + dependencies: + "@react-pug/eslint-plugin-react-pug": "npm:^0.1.19" + peerDependencies: + eslint: "*" + checksum: 10c0/3f16a86a666a0e2f7fab9e9d2cf0e2dfa0536e5ad2bdabff75e24f38e640bb24f4f00d53f0fcb39e83a7420d12ce0fea8a3a31dc57a52e94bb5619bfd8773d7e + languageName: node + linkType: hard + +"eslint-plugin-cssxjs@workspace:packages/eslint-plugin-cssxjs": version: 0.0.0-use.local resolution: "eslint-plugin-cssxjs@workspace:packages/eslint-plugin-cssxjs" dependencies: @@ -6488,12 +6644,13 @@ __metadata: resolution: "example@workspace:example" dependencies: "@babel/core": "npm:^7.0.0" - "@types/react-dom": "npm:^18.3.1" + "@types/react": "npm:19.2.17" + "@types/react-dom": "npm:19.2.3" cli-highlight: "npm:^2.1.11" - cssxjs: "npm:^0.3.0" + cssxjs: "npm:^0.4.0-alpha.6" esbuild: "npm:^0.21.4" - react: "npm:^18.3.1" - react-dom: "npm:^18.3.1" + react: "npm:19.2.7" + react-dom: "npm:19.2.7" languageName: unknown linkType: soft @@ -7669,6 +7826,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^6.0.0": + version: 6.0.0 + resolution: "html-encoding-sniffer@npm:6.0.0" + dependencies: + "@exodus/bytes": "npm:^1.6.0" + checksum: 10c0/66dc3f6f5539cc3beb814fcbfae7eacf4ec38cf824d6e1425b72039b51a40f4456bd8541ba66f4f4fe09cdf885ab5cd5bae6ec6339d6895a930b2fdb83c53025 + languageName: node + linkType: hard + "html-entities@npm:^2.6.0": version: 2.6.0 resolution: "html-entities@npm:2.6.0" @@ -8310,6 +8476,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + "is-regex@npm:^1.1.4": version: 1.1.4 resolution: "is-regex@npm:1.1.4" @@ -9152,6 +9325,40 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^29.1.1": + version: 29.1.1 + resolution: "jsdom@npm:29.1.1" + dependencies: + "@asamuzakjp/css-color": "npm:^5.1.11" + "@asamuzakjp/dom-selector": "npm:^7.1.1" + "@bramus/specificity": "npm:^2.4.2" + "@csstools/css-syntax-patches-for-csstree": "npm:^1.1.3" + "@exodus/bytes": "npm:^1.15.0" + css-tree: "npm:^3.2.1" + data-urls: "npm:^7.0.0" + decimal.js: "npm:^10.6.0" + html-encoding-sniffer: "npm:^6.0.0" + is-potential-custom-element-name: "npm:^1.0.1" + lru-cache: "npm:^11.3.5" + parse5: "npm:^8.0.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^6.0.1" + undici: "npm:^7.25.0" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^8.0.1" + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.1" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/20e2174b09d9d06393cb48e1392b7a1cb7191d6656a6f7b3b8fbf9853b4ab0ef60b4a42c2c55f71b55ca5da50ffa75bcdc6986210963182e7993c6f9cd4f499b + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.0.2 resolution: "jsesc@npm:3.0.2" @@ -9615,7 +9822,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -9640,6 +9847,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.3.5": + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: 10c0/7b341cea79a8efe9c6a6f20c8757a77eca5b25d7ff983ccf4e11e547b81f6787824baa1c84705251dff84ab4ffac85717ac354b9d02e465f86a9f8b166409979 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -10016,6 +10230,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.27.1": + version: 2.27.1 + resolution: "mdn-data@npm:2.27.1" + checksum: 10c0/eb8abf5d22e4d1e090346f5e81b67d23cef14c83940e445da5c44541ad874dc8fb9f6ca236e8258c3a489d9fb5884188a4d7d58773adb9089ac2c0b966796393 + languageName: node + linkType: hard + "medium-zoom@npm:1.1.0": version: 1.1.0 resolution: "medium-zoom@npm:1.1.0" @@ -10049,13 +10270,6 @@ __metadata: languageName: node linkType: hard -"micro-memoize@npm:^3.0.1": - version: 3.0.2 - resolution: "micro-memoize@npm:3.0.2" - checksum: 10c0/215a9a10327c9e19f52099cd149d151cffadbdaf77d5ce6ff43aec4c7a2e13f026d3e286ebd2211023cdc27a80424925ff8c481fb3bfea03f2d9f00b1b9a9d4e - languageName: node - linkType: hard - "micromark-core-commonmark@npm:^2.0.0": version: 2.0.3 resolution: "micromark-core-commonmark@npm:2.0.3" @@ -10779,7 +10993,7 @@ __metadata: languageName: node linkType: hard -"mocha@npm:^8.1.1": +"mocha@npm:^8.4.0": version: 8.4.0 resolution: "mocha@npm:8.4.0" dependencies: @@ -11852,6 +12066,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^8.0.0, parse5@npm:^8.0.1": + version: 8.0.1 + resolution: "parse5@npm:8.0.1" + dependencies: + entities: "npm:^8.0.0" + checksum: 10c0/c3c1c5aab55f6e4be5245599790e56e64be7764a4a0edd7f98db4fe3bb380f63add752fa047dff0496446c25f4104f0c7c1967723de640bde92306a7bb67ed2f + languageName: node + linkType: hard + "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -12053,7 +12276,7 @@ __metadata: languageName: node linkType: hard -"postcss-value-parser@npm:^4.0.2": +"postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" checksum: 10c0/f4142a4f56565f77c1831168e04e3effd9ffcc5aebaf0f538eee4b2d465adfd4b85a44257bb48418202a63806a7da7fe9f56c330aebb3cac898e46b4cbf49161 @@ -12247,7 +12470,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 @@ -12277,15 +12500,14 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^18.0.0, react-dom@npm:^18.3.1": - version: 18.3.1 - resolution: "react-dom@npm:18.3.1" +"react-dom@npm:19.2.7": + version: 19.2.7 + resolution: "react-dom@npm:19.2.7" dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.2" + scheduler: "npm:^0.27.0" peerDependencies: - react: ^18.3.1 - checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85 + react: ^19.2.7 + checksum: 10c0/970ff600f6e80d47d39e2f226f12f226173b3cba3382efc97c5f0cd663de9af38c7a4c11c213fb936094faeac83060d660247accaa96b752180d5b951b9cfecb languageName: node linkType: hard @@ -12367,12 +12589,10 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.0.0, react@npm:^18.3.1": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3 +"react@npm:19.2.7": + version: 19.2.7 + resolution: "react@npm:19.2.7" + checksum: 10c0/0bd0e2f1bbd4ba97561c6597bf8a5fec05e6476fe61e165c1065598d16668efc6715205599c94d3ddd49d36cb0f21cbf1b9bcc18ee840b805ce222c3e8d558ac languageName: node linkType: hard @@ -12724,6 +12944,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -12863,15 +13090,16 @@ __metadata: resolution: "root-workspace-0b6124@workspace:." dependencies: "@rspress/core": "npm:^2.0.0" - "@types/react": "npm:~18.2.45" + "@types/react": "npm:19.2.17" + "@types/react-dom": "npm:19.2.3" eslint: "npm:^9.39.4" eslint-plugin-cssxjs: "npm:^0.3.0-alpha.0" husky: "npm:^4.3.0" lerna: "npm:^9.0.3" lint-staged: "npm:^15.2.2" neostandard: "npm:^0.13.0" - react: "npm:^18.0.0" - react-dom: "npm:^18.0.0" + react: "npm:19.2.7" + react-dom: "npm:19.2.7" ts-node: "npm:^10.9.2" typescript: "npm:^5.1.3" languageName: unknown @@ -12978,12 +13206,12 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.2": - version: 0.23.2 - resolution: "scheduler@npm:0.23.2" +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78 + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 languageName: node linkType: hard @@ -13325,6 +13553,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + "source-map-resolve@npm:^0.6.0": version: 0.6.0 resolution: "source-map-resolve@npm:0.6.0" @@ -13801,6 +14036,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "synckit@npm:^0.11.8": version: 0.11.8 resolution: "synckit@npm:0.11.8" @@ -13944,6 +14186,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^7.4.3": + version: 7.4.3 + resolution: "tldts-core@npm:7.4.3" + checksum: 10c0/866f9d46ef7ba80a560edaa0a659c32e0aa3b4e281694c96bcf7773f6530e107c5681c714f47d58ee1720dc5578bb168a1e8535c514de90b5907850dc1202cd8 + languageName: node + linkType: hard + +"tldts@npm:^7.0.5": + version: 7.4.3 + resolution: "tldts@npm:7.4.3" + dependencies: + tldts-core: "npm:^7.4.3" + bin: + tldts: bin/cli.js + checksum: 10c0/334c8d0d50fb0ac69453947460a6e51396f5c35bef6c70300b201832d86801ce54e6a26d03c1745cf801aa409780086e350a098c0a0afdf005c06de14e5e94c1 + languageName: node + linkType: hard + "tmp@npm:~0.2.1": version: 0.2.3 resolution: "tmp@npm:0.2.3" @@ -13981,6 +14241,24 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^6.0.1": + version: 6.0.1 + resolution: "tough-cookie@npm:6.0.1" + dependencies: + tldts: "npm:^7.0.5" + checksum: 10c0/ec70bd6b1215efe4ed31a158f0be3e4c9088fcbd8620edc23a5860d4f3d85c757b77e274baaa700f7b25e409f4181552ed189603c2b2e1a9f88104da3a61a37d + languageName: node + linkType: hard + +"tr46@npm:^6.0.0": + version: 6.0.0 + resolution: "tr46@npm:6.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/83130df2f649228aa91c17754b66248030a3af34911d713b5ea417066fa338aa4bc8668d06bd98aa21a2210f43fc0a3db8b9099e7747fb5830e40e39a6a1058e + languageName: node + linkType: hard + "tree-kill@npm:^1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" @@ -14300,6 +14578,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^6.0.3": + version: 6.0.3 + resolution: "typescript@npm:6.0.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A>=3 < 6#optional!builtin, typescript@patch:typescript@npm%3A^5.1.3#optional!builtin": version: 5.6.3 resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" @@ -14310,6 +14598,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": + version: 6.0.3 + resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -14343,6 +14641,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:^7.21.0": + version: 7.28.0 + resolution: "undici-types@npm:7.28.0" + checksum: 10c0/e1230791cfbaf7fc88a4ebb5282423886a2fb325572234437a3e9c9f7dff970bebe12d5672d6d23a3584119d6d43f8222d06531ed749d8ddeb3551f004fca55d + languageName: node + linkType: hard + "undici-types@npm:~6.19.8": version: 6.19.8 resolution: "undici-types@npm:6.19.8" @@ -14350,6 +14655,20 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 + languageName: node + linkType: hard + +"undici@npm:^7.25.0": + version: 7.28.0 + resolution: "undici@npm:7.28.0" + checksum: 10c0/fe781983a26098795e99bb1f64906cbb7d0bcaa029a26baade007b53ea67f2631d189b8f9671a31f4c8d0cb3773b7559608628ba54452fef51fec90e7c78bb0d + languageName: node + linkType: hard + "unhead@npm:2.1.2": version: 2.1.2 resolution: "unhead@npm:2.1.2" @@ -14686,6 +15005,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b + languageName: node + linkType: hard + "walk-up-path@npm:^4.0.0": version: 4.0.0 resolution: "walk-up-path@npm:4.0.0" @@ -14718,6 +15046,31 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^8.0.1": + version: 8.0.1 + resolution: "webidl-conversions@npm:8.0.1" + checksum: 10c0/3f6f327ca5fa0c065ed8ed0ef3b72f33623376e68f958e9b7bd0df49fdb0b908139ac2338d19fb45bd0e05595bda96cb6d1622222a8b413daa38a17aacc4dd46 + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-mimetype@npm:5.0.0" + checksum: 10c0/eead164fe73a00dd82f817af6fc0bd22e9c273e1d55bf4bc6bdf2da7ad8127fca82ef00ea6a37892f5f5641f8e34128e09508f92126086baba126b9e0d57feb4 + languageName: node + linkType: hard + +"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1": + version: 16.0.1 + resolution: "whatwg-url@npm:16.0.1" + dependencies: + "@exodus/bytes": "npm:^1.11.0" + tr46: "npm:^6.0.0" + webidl-conversions: "npm:^8.0.1" + checksum: 10c0/e75565566abf3a2cdbd9f06c965dbcccee6ec4e9f0d3728ad5e08ceb9944279848bcaa211d35a29cb6d2df1e467dd05cfb59fbddf8a0adcd7d0bce9ffb703fd2 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.0.2": version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2" @@ -14991,6 +15344,20 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2"