From cb0d5247b78e8ab31d11a25904fb70fffff39d33 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:11:44 +0300 Subject: [PATCH 01/37] Add CSS-to-RN unification plan --- plan.md | 2063 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2063 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..dab4266 --- /dev/null +++ b/plan.md @@ -0,0 +1,2063 @@ +# Unified CSS-to-RN Engine Plan + +This document is the implementation handoff for the CSSX CSS-to-React-Native +pipeline refactor. It captures the agreed architecture, public APIs, internal +IR, runtime tracking model, migration path, and test plan. A new agent should +be able to start from this file and implement the package end to end without +needing the design discussion history. + +## Goal + +Unify CSS-to-React-Native style transformation into one maintainable package in +this monorepo. + +The current implementation is split across: + +- `cssx` / this monorepo: + - Babel transforms + - Stylus preprocessing + - runtime selector matching + - runtime CSS variable substitution + - media and viewport unit processing + - teamplay-based caching +- `../css-to-react-native`: + - low-level CSS declaration to React Native style transformation + - forked support for `var()`, animations, transitions, keyframes +- `../css-to-react-native-transform`: + - full CSS parsing + - selector filtering + - media query parsing + - `:part()` selector support + - keyframe extraction + +The new package should replace that split with: + +- one canonical CSS compiler IR +- one resolver for static, dynamic, imported, inline, and runtime-generated CSS +- one runtime dependency tracker +- one caching model +- one public API surface re-exported from `cssxjs` + +## Non-Goals + +These are intentionally out of scope for the first implementation: + +- Runtime Stylus compilation. Runtime `compileCss()` accepts pure CSS only. +- Full browser selector support. CSSX remains a class-combination selector + system. +- Full browser CSS compatibility, prefixing, or old-browser normalization. +- Mandatory PostCSS. Client-side compiler size is important. +- A cssta/styled-components-like component factory API. +- Animation execution hooks/components. CSSX only emits Reanimated v4-compatible + style props. +- Provider-scoped CSS variables. Variables remain global singleton state for + now. +- CSS custom property declarations inside stylesheets, such as + `.root { --x: red }`. +- `:root` custom property defaults. +- Interpolation inside Pug `style` blocks. +- Dynamic `:export` values. + +## Research Summary + +### Current CSSX + +Important files: + +- `packages/loaders/cssToReactNativeLoader.js` +- `packages/loaders/stylusToCssLoader.js` +- `packages/loaders/compilers/css.js` +- `packages/loaders/compilers/styl.js` +- `packages/babel-plugin-rn-stylename-inline/index.js` +- `packages/babel-plugin-rn-stylename-to-style/index.js` +- `packages/runtime/process.js` +- `packages/runtime/processCached.js` +- `packages/runtime/matcher.js` +- `packages/runtime/variables.js` +- `packages/runtime/dimensions.js` + +Current behavior: + +- Inline `css``...`` and `styl``...`` templates are compiled by Babel to style + objects. +- External `.cssx.css` / `.cssx.styl` imports are compiled by loaders or Babel. +- JSX `styleName` is rewritten to runtime calls. +- Runtime currently handles selector matching, `var()` substitution, media query + processing, viewport units, `u` unit strings, and optional teamplay caching. +- Expression interpolation inside `css``...`` and `styl``...`` currently throws. + +### Forked `css-to-react-native` + +Useful pieces: + +- property transformers +- `TokenStream` +- animation and transition transforms +- keyframe object inlining behavior +- shorthand behavior and tests + +Do not preserve its architecture blindly. In the new engine, `var()` should be +resolved before property transformation, so the transformer should no longer +need unresolved `VARIABLE` tokens spread through every parser. + +### Forked `css-to-react-native-transform` + +Useful pieces: + +- current CSS parser usage +- media query validation +- selector filtering constraints +- existing legacy output shape +- tests for parts, media, keyframes, viewport units + +The new package should replace the old nested object output with canonical +rule/declaration IR. + +### cssta + +Useful inspiration: + +- template expression placeholder extraction +- preserving dynamic declarations as tuples until runtime +- splitting compile-time static work from runtime style tuple resolution + +Not reused: + +- component factory API +- React context variable model +- hook-based animation execution + +### Parser Size Decision + +PostCSS is not the default parser foundation because of client bundle size. + +Measured browser bundle baseline with esbuild: + +```text +current stack: +css/lib/parse + postcss-value-parser + css-mediaquery + helpers +15.8 KB minified, 6.2 KB gzip + +PostCSS stack: +postcss + postcss-selector-parser + postcss-value-parser + css-mediaquery + helpers +128.0 KB minified, 36.1 KB gzip +``` + +Use the lightweight stack: + +- `css/lib/parse` or an equivalent small stylesheet parser +- `postcss-value-parser` for values +- `css-mediaquery` or a small compatible evaluator/parser +- custom narrow selector parser/validator + +## Target Package + +Create: + +```text +packages/css-to-rn/ +``` + +Package name: + +```text +@cssxjs/css-to-rn +``` + +It is the unified engine package. The public `cssxjs` package re-exports the +user-facing APIs. + +### Package Boundaries + +`@cssxjs/css-to-rn` root export: + +- framework-independent compiler and resolver +- no React imports +- no React Native imports +- no Reanimated imports + +`@cssxjs/css-to-rn/react` and platform subpaths: + +- React hooks and tracked wrapper runtime +- optional peer dependency on `react` +- optional peer dependency on `react-native` +- conditional exports for web vs React Native + +`cssxjs`: + +- public facade used by users +- re-exports `css`, `styl`, `pug` +- re-exports `compileCss`, `cssx`, `useCompiledCss`, `CssxProvider`, + `configureCssx`, `variables`, `setDefaultVariables`, `defaultVariables` +- keeps conditional runtime entrypoints so Expo/RN picks the RN target + automatically and web picks the default target + +`@cssxjs/runtime`: + +- currently internal in practice +- can be collapsed, removed, or left as a compatibility wrapper after migration +- should not keep duplicate selector/var/media/cache implementation long term + +### TypeScript + +Write the new package in TypeScript from the start. + +Use Node-strip-friendly TypeScript, following the pattern in +`../teamplay/tsconfig.json`: + +- `type: "module"` +- `target: "esnext"` +- `module: "nodenext"` +- `moduleResolution: "nodenext"` +- `rewriteRelativeImportExtensions: true` +- `erasableSyntaxOnly: true` +- `verbatimModuleSyntax: true` +- `strict: true` +- `allowImportingTsExtensions: true` for source tests +- explicit `.ts` extensions in source imports +- no enums +- no parameter properties +- no namespaces +- no decorators +- no top-level await + +Scope the TS setup to `packages/css-to-rn` initially. Do not modernize the +root repo TS config as part of this plan unless needed later. + +Use a custom source condition: + +```text +cssx-ts +``` + +Package exports should follow the Teamplay source-test pattern: + +```json +{ + "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" + } + } +} +``` + +Adjust file names as implementation settles. The important constraints are: + +- root export is framework-independent +- React/RN/web entrypoints are explicit and conditionally resolvable +- source tests can import `.ts` through `-C cssx-ts` +- published package emits `.js` and `.d.ts` + +Peer dependencies: + +```json +{ + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { "optional": true }, + "react-native": { "optional": true } + } +} +``` + +## Public APIs + +### Pure Engine APIs + +These live at `@cssxjs/css-to-rn`. + +```ts +export function compileCss( + css: string, + options?: CompileCssOptions +): CompiledCssSheet + +export function compileCssTemplate( + cssWithDynamicSlots: string, + options?: CompileCssTemplateOptions +): CompiledCssSheet + +export function resolveCssx( + input: ResolveCssxInput +): ResolveCssxResult + +export function transformDeclarations( + declarations: readonly CssDeclaration[], + options?: TransformDeclarationOptions +): TransformDeclarationResult + +export function toLegacyStyleObject( + sheet: CompiledCssSheet, + options?: LegacyOutputOptions +): LegacyStyleObject +``` + +The exact function names can change, but the package needs these capabilities: + +- compile CSS string to canonical IR +- compile CSS string containing dynamic interpolation slots to canonical IR +- resolve style props from one or more compiled sheet layers +- transform resolved declaration values into RN/web style objects +- output the old object shape temporarily for incremental migration + +### Runtime React APIs + +These live at `@cssxjs/css-to-rn/react`, `@cssxjs/css-to-rn/web`, +`@cssxjs/css-to-rn/react-native`, and are re-exported by `cssxjs`. + +```ts +export function cssx( + styleName: StyleNameValue, + sheet: CompiledCssSheet | TrackedCssSheet | string | Array, + inlineStyleProps?: InlineStyleProps +): ResolvedStyleProps + +export function useCompiledCss( + css: string, + options?: CompileCssOptions +): TrackedCssSheet + +export function useCssxSheet( + sheet: CompiledCssSheet | CompiledCssSheet[], + options?: UseCssxSheetOptions +): TrackedCssSheet | TrackedCssSheet[] + +export function useCssxTemplate( + sheet: CompiledCssSheet, + values: readonly InterpolationValue[], + options?: UseCssxTemplateOptions +): TrackedCssSheet + +export function CssxProvider(props: { + value?: CssxRuntimeOptions + children: React.ReactNode +}): React.ReactNode + +export function configureCssx(options: CssxRuntimeOptions): void + +export const variables: Record +export let defaultVariables: Record +export function setDefaultVariables(vars: Record): void +``` + +Public manual runtime CSS usage: + +```tsx +import { compileCss, cssx, useCompiledCss } from 'cssxjs' + +const sheet = compileCss(generatedCss) + +function Button({ disabled, style }) { + const trackedSheet = useCompiledCss(generatedCss) + + return ( +
+ ) +} +``` + +Convenience raw string usage is allowed: + +```tsx +
+``` + +But documented React usage should prefer `useCompiledCss()` so subscriptions, +diagnostics, and parsing are controlled. + +### `cssx()` Ergonomics + +Do not require a `useCssx()` hook per element. The user should be able to write: + +```tsx +const sheet = useCompiledCss(generatedCss) + +return ( + <> +
+ + +) +``` + +The hook returns a tracked sheet wrapper. `cssx()` is a plain function that +resolves styles and records dependencies into the tracked wrapper during render. +The hook owns the actual React subscription lifecycle. + +### Compatibility APIs + +`css` and `styl` remain both: + +- tagged template markers transformed away by Babel +- spread helpers transformed by Babel when called as functions + +The existing user code shape remains: + +```tsx +import { css, styl } from 'cssxjs' + +function Button({ color }) { + return + + css` + .root { + color: ${color}; + } + ` +} +``` + +## Compiled Sheet IR + +The canonical compiler output must be plain JSON-serializable data: + +- no functions +- no Maps +- no Sets +- no Symbols +- no closures +- no runtime cache state + +Runtime cache/tracker state should live in WeakMaps or non-enumerable wrapper +objects, not inside the serialized IR. + +Approximate shape: + +```ts +export interface CompiledCssSheet { + version: 1 + id: string + sourceId?: string + contentHash: string + rules: CssRule[] + keyframes: Record + exports?: Record + metadata: CssxMetadata + diagnostics: CssxDiagnostic[] + error?: CssxDiagnostic +} + +export interface CssRule { + selector: string + classes: string[] + part: string | null + specificity: number + order: number + media: string | null + declarations: CssDeclaration[] +} + +export interface CssDeclaration { + property: string + value: CssValueAst + raw: string + order: number + dynamicSlots?: number[] +} + +export interface CssKeyframe { + selector: 'from' | 'to' | string + declarations: CssDeclaration[] + order: number +} + +export interface CssxMetadata { + hasVars: boolean + vars: string[] + hasMedia: boolean + hasViewportUnits: boolean + hasInterpolations: boolean + hasDynamicRuntimeDependencies: boolean + hasAnimations: boolean + hasTransitions: boolean +} +``` + +The exact TypeScript structure can evolve, but these semantic fields are needed. + +### IDs And Path Privacy + +Compiled sheets need stable hashes: + +- build templates/imports: + - use relative file path and per-file template/import order as hash input + - do not expose the path in emitted runtime objects +- runtime `compileCss(css)`: + - use CSS content as hash input + +Recommended build hash shape: + +```text +sourceId = hash(relativeFilePath + ':' + templateIndex) +contentHash = hash(staticCssContent) +id = hash(sourceId + ':' + contentHash) +``` + +Runtime objects may expose only hashed IDs: + +```ts +{ + id: 'cssx_abc123', + sourceId: 'cssx_src_def456' +} +``` + +Do not leak absolute or relative server paths into code delivered publicly. + +Build diagnostics can include actual filenames and code frames because those are +developer-only build outputs. + +Runtime diagnostics for AI-generated CSS should include sanitized line/column +but no source paths. + +## Compiler Behavior + +### Modes + +`compileCss()` should support separate modes: + +```ts +type CompileMode = 'runtime' | 'build' +``` + +Runtime-safe mode is the default for the public API: + +- CSS syntax errors return an empty sheet with structured diagnostics +- dev mode may warn +- production should not crash +- unsupported selectors/rules become diagnostics and are ignored +- invalid declarations become diagnostics and are ignored + +Build-strict mode is used by Babel/loaders: + +- syntax errors throw +- invalid static declarations throw +- unsupported critical constructs throw when they represent developer source bugs +- errors should include file-aware code frames where possible + +For parser syntax errors in runtime mode, return an empty sheet initially. Do not +attempt partial recovery until there is a good reason and tests. + +### Diagnostics + +Compiled sheets must expose structured diagnostics suitable for tooling and +AI feedback: + +```ts +export interface CssxDiagnostic { + level: 'warning' | 'error' + code: CssxDiagnosticCode + message: string + line?: number + column?: number +} +``` + +Use stable machine-readable codes. Initial codes: + +```text +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 +``` + +Deduplicate dev warnings per stylesheet/declaration/error kind/value pattern to +avoid console spam during repeated renders. + +### Source Locations + +Runtime IR should not include source maps or full source locations by default. + +Runtime diagnostics may include: + +- line +- column +- sanitized message + +Build tools can use parser locations immediately for code frames, but emitted +runtime objects should stay small and path-free. + +## Selector Model + +Keep the CSSX selector subset. + +Supported: + +```css +.root +.root.active +.root:part(label) +.root.active:part(icon) +.root:hover +.root:active +.root.active:hover +:export +``` + +Unsupported and ignored with dev diagnostics: + +```css +.root .child +.root > .child +#id +[type='x'] +:nth-child(2) +``` + +`:hover` and `:active` are aliases for part-style output: + +```css +.root:hover -> hoverStyle +.root:active -> activeStyle +``` + +They are equivalent targets to: + +```css +.root:part(hover) +.root:part(active) +``` + +If both forms target the same logical part, normal cascade decides the result. +No built-in hover/press state management is included. CSSX only emits +`hoverStyle` / `activeStyle` props for components that consume them. + +Specificity remains CSSX class specificity: + +- specificity is class count +- part/pseudo aliases do not add browser-style specificity +- within same specificity, later source order wins + +## Cascade And Layering + +Canonical IR preserves: + +- rule order +- declaration order +- selector specificity +- media condition per rule + +This is required for browser-like fallback behavior: + +```css +.button { + color: red; + color: var(--maybe-color); +} +``` + +If `--maybe-color` is unresolved or invalid at runtime, only the second +declaration is dropped and `color: red` still applies. + +Cross-source precedence stays as today: + +1. file/imported stylesheet +2. module-level global inline template +3. function-level local inline template +4. inline style props + +Model this as ordered sheet layers: + +```ts +resolveCssx({ + styleName, + layers: [fileSheet, globalSheet, localSheet], + inlineStyleProps +}) +``` + +Within each sheet: + +1. match selectors/classes/part +2. filter inactive media rules +3. sort/apply by specificity and source order +4. resolve declarations in order +5. drop invalid declarations + +Across sheets, later layers override earlier layers. + +Public `cssx()` should accept a single sheet or an array: + +```ts +cssx('root', sheet, inlineStyleProps) +cssx('root', [baseSheet, generatedSheet], inlineStyleProps) +``` + +## Interpolation + +Interpolation is supported only in JS tagged templates: + +```tsx +css` + .button { + color: ${buttonColor}; + } +` + +styl` + .button + color ${buttonColor} +` +``` + +It is not supported in: + +- external `.cssx.css` / `.cssx.styl` files +- module-level global templates +- Pug `style` blocks +- selectors +- property names +- media queries +- `:export` + +Interpolation is allowed only where CSS `var()` can legally appear in +declaration values. It can also interpolate a full `var(...)` string. + +### Lowering + +Babel lowers template expressions to synthetic `var()`-like tokens before CSS +or Stylus parsing: + +```tsx +css` + .root { + color: ${color}; + padding: ${pad} 2u; + } +` +``` + +becomes a static source equivalent to: + +```css +.root { + color: var(--__cssx_dynamic_0); + padding: var(--__cssx_dynamic_1) 2u; +} +``` + +The compiler validates that the synthetic slots appear only inside declaration +values. If a slot appears in a selector, property name, media query, `:export`, +or other unsupported position, build mode throws +`UNSUPPORTED_INTERPOLATION_POSITION`. + +For Stylus: + +```text +JS template -> synthetic dynamic var tokens -> Stylus -> CSS -> compileCssTemplate() +``` + +This keeps CSS and Stylus interpolation on one path. + +### Runtime Values + +Dynamic values are passed as an ordered array in template expression order: + +```ts +useCssxTemplate(__sheet, [color, pad]) +``` + +Accepted interpolation values: + +```ts +string | number | null | undefined | false +``` + +Semantics: + +- `string`: inserted as raw CSS value text +- `number`: inserted as raw numeric token +- `null`, `undefined`, `false`: invalidate only the containing declaration +- `true`: invalid +- objects, arrays, functions, symbols, bigint: invalid + +Invalid interpolation values drop only the containing declaration at runtime and +produce a deduped dev diagnostic. + +Interpolation cache equality uses `Object.is` over the primitive value array. +Do not stringify interpolation values. + +### Local Templates Only + +Interpolations are supported only in function-scoped local templates. This gives +the runtime a clear render lifecycle: + +```tsx +function Button({ color }) { + return + + css` + .root { color: ${color}; } + ` +} +``` + +Module-level templates with expressions remain unsupported because they would +require global mutable style state or one-time module initialization semantics. + +## Value Resolution + +Dynamic values must resolve at the CSS declaration-value layer before RN/web +property transformation. + +This is essential for: + +```css +box-shadow: var(--shadow); +box-shadow: var(--shadow-1), var(--shadow-2); +box-shadow: var(--x) 2px 8px rgba(0,0,0,var(--alpha)); +padding: var(--button-padding, 8px 16px); +border: var(--width) solid var(--color); +transform: translateX(var(--x)) scale(var(--scale)); +``` + +Resolution pipeline: + +1. replace interpolation slots +2. recursively resolve nested `var()` +3. resolve/evaluate supported `calc()` and viewport units at the value layer +4. apply `u` unit semantics +5. transform final declaration values into RN/web style props + +Implementation can combine steps 3 and 4 internally. The important invariant is +that RN property transformers receive final CSS value strings with no unresolved +`var()` or dynamic slots. + +### CSS Variables + +CSS variable priority stays: + +1. runtime `variables['--name']` +2. `defaultVariables['--name']` +3. inline fallback `var(--name, fallback)` + +Nested vars are supported: + +```css +color: var(--button-color, var(--theme-color, red)); +``` + +Cycles and runaway recursion are invalid: + +```css +var(--a) where --a -> var(--b) and --b -> var(--a) +``` + +Implement: + +- resolving-name stack for cycle detection +- explicit recursion depth limit, for example `20` +- invalid declaration on cycle/depth limit +- deduped dev warning + +Unresolved vars invalidate only the containing declaration. + +Do not support stylesheet custom property declarations initially: + +```css +.root { + --button-bg: red; + background: var(--button-bg); +} +``` + +Do not treat `:root { --x: ... }` as defaults. Ignore with dev warning. Use +`setDefaultVariables()` for defaults. + +### `calc()` + +Support limited `calc()` where the final expression can be reduced safely after +vars/interpolation/viewport units are resolved: + +```css +width: calc(100vw - 16px); +margin-left: calc(var(--spacing, 8px) * 2); +``` + +Do not attempt full browser layout math: + +```css +width: calc(100% - 16px); +``` + +Unsupported `calc()`: + +- throws in build mode if fully static +- drops declaration in runtime mode or if dynamic +- emits `UNSUPPORTED_CALC` + +### Viewport Units + +Support: + +- `vw` +- `vh` +- `vmin` +- `vmax` + +Resolve at the declaration-value layer before property transformation. Viewport +unit users depend on debounced dimension changes. + +### `u` Unit + +Preserve current CSSX semantics: + +```text +1u = 8px +``` + +The new resolver should handle `u` consistently before final RN/web output. + +## Media Queries + +Store media conditions on rules: + +```ts +{ + selector: '.button', + classes: ['button'], + part: null, + specificity: 1, + order: 4, + media: '@media (min-width: 600px)', + declarations: [...] +} +``` + +Do not use a separate nested style map in canonical IR. + +Rule filtering order: + +1. match selector/classes/part +2. evaluate media condition +3. resolve active declarations + +Inactive media rules must not contribute variable dependencies. + +Target optimization: + +- media subscribers rerender only when query match result changes +- viewport unit subscribers rerender when debounced dimension values change + +First milestone may use a simpler debounced dimension version for all media and +viewport dependencies if needed, but the target should be query-match-based +media invalidation. + +Web: + +- use `window.matchMedia(query).change` for media query subscriptions when + available +- use debounced `resize` for viewport units +- SSR/no-window falls back to configured defaults and no-op subscriptions + +React Native: + +- use `Dimensions` for width/height +- reevaluate query matches after dimension changes + +Dimension listener initialization: + +- platform entrypoint installs adapter automatically +- actual listeners start lazily on first dimension/media subscription +- listeners stop when last dimension subscriber unsubscribes, if possible + +Dimension notification debounce: + +- leading notification for immediate rotation/orientation response +- trailing notification after resize settles +- default around `100ms` +- configurable later through provider or singleton config + +## Keyframes, Animations, And Transitions + +CSSX should emit Reanimated v4-compatible style props only. It should not own +animation execution, hooks, or animated component wrappers. + +Users write: + +```tsx +import Animated from 'react-native-reanimated' + + +``` + +CSSX emits style props that Reanimated v4 understands. + +### Keyframe IR + +Store `@keyframes` separately: + +```ts +{ + keyframes: { + fade: [ + { selector: 'from', declarations: [...] }, + { selector: 'to', declarations: [...] } + ] + }, + rules: [...] +} +``` + +Keyframe declarations use the same dynamic value pipeline: + +- `var()` supported +- interpolation supported when the keyframes are inside a local interpolated + template +- invalid dynamic keyframe declarations are dropped at runtime + +Animation declarations resolve names to keyframe objects after dynamic value +resolution: + +```css +.button { + animation: fade var(--duration, 200ms) ease; +} +``` + +Result includes: + +```js +{ + animationName: { from: {...}, to: {...} }, + animationDuration: '200ms', + animationTimingFunction: 'ease' +} +``` + +Support comma-separated multi-values from the first implementation: + +```css +transition: background 0.2s, transform 0.1s, opacity 0.3s; +animation: fadeIn 300ms ease, slideIn 500ms ease-out; +``` + +## Property Transformation + +The property transformer should be designed around final resolved CSS values. +It can selectively reuse code from `../css-to-react-native`, but should not be +constrained by the old architecture. + +Keep or add support for: + +- all existing supported RN style props +- shorthand expansion +- border shorthand with dynamic width/color/style after var resolution +- padding/margin/border radius/width/color/style shorthands +- transform +- text-shadow +- box-shadow +- animation and transition shorthands/longhands +- keyframe object inlining +- `filter` +- `background-image` +- limited `background` shorthand + +### Box Shadow + +React Native now supports web-style `boxShadow` strings. Keep pass-through +string output after resolving vars/interpolation/calc/viewport units: + +```css +box-shadow: 0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333; +``` + +outputs: + +```js +{ boxShadow: '0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333' } +``` + +### Filter + +React Native supports CSS-like filter strings. Pass through as string after +value resolution: + +```css +filter: blur(4px) brightness(0.8); +``` + +outputs: + +```js +{ filter: 'blur(4px) brightness(0.8)' } +``` + +### Background Image + +React Native supports the style prop: + +```js +experimental_backgroundImage +``` + +for web-like `linear-gradient()` and `radial-gradient()` strings. + +CSS: + +```css +background-image: linear-gradient(90deg, red, blue); +``` + +React Native output: + +```js +{ experimental_backgroundImage: 'linear-gradient(90deg, red, blue)' } +``` + +Web output: + +```js +{ backgroundImage: 'linear-gradient(90deg, red, blue)' } +``` + +Use generic kebab-to-camelCase for properties, then special-case +`backgroundImage` to `experimental_backgroundImage` for React Native target. + +Supported background image functions: + +- `linear-gradient()` +- `radial-gradient()` + +Unsupported: + +- `url(...)` +- image-set +- other image functions + +Unsupported background images are dropped with `UNSUPPORTED_BACKGROUND_IMAGE`. + +Multiple gradients must be preserved as a comma-separated string: + +```css +background-image: + linear-gradient(0deg, white, rgba(238, 64, 53, 0.8), rgba(238, 64, 53, 0) 70%), + linear-gradient(45deg, white, rgba(243, 119, 54, 0.8), rgba(243, 119, 54, 0) 70%); +``` + +### Background Shorthand + +Support a limited useful subset: + +```css +background: red; +background: linear-gradient(90deg, red, blue); +background: red linear-gradient(90deg, red, blue); +background: linear-gradient(...), radial-gradient(...); +``` + +Output: + +- color-only -> `backgroundColor` +- gradient-only -> `backgroundImage` / `experimental_backgroundImage` +- color + gradient -> both + +Unsupported: + +```css +background: url(foo.png); +background: no-repeat center/cover red; +background: fixed border-box red; +``` + +Do not implement full browser background shorthand. + +## Runtime Store And React Tracking + +Replace the dependency on: + +- `teamplay` +- `teamplay/cache` +- `@nx-js/observer-util` + +with a small CSSX-owned store and React integration. + +### Variable Store + +Preserve current public API: + +```ts +variables['--text'] = '#111' +delete variables['--text'] +Object.assign(variables, theme) +setDefaultVariables({ '--text': '#111' }) +``` + +Implement `variables` as an internal `Proxy` over a plain object: + +- detects `set` +- detects `deleteProperty` +- records changed variable names +- batches notifications in a microtask +- notifies only subscribers interested in changed names + +Use microtask batching: + +```ts +Object.assign(variables, { + '--bg': 'black', + '--text': 'white' +}) +``` + +should notify once with `['--bg', '--text']`, not once per assignment. + +Variables remain global singleton state initially. Provider-scoped variables are +out of scope. + +### Runtime Options + +Defaults must work without any setup. + +Optional configuration paths: + +```tsx + + + +``` + +and: + +```ts +configureCssx({ dimensionsDebounceMs: 100 }) +``` + +Provider is for React tree options. Singleton config is for early app-wide setup. + +### Tracked Sheet Wrapper + +Manual runtime CSS should stay ergonomic: + +```tsx +const sheet = useCompiledCss(generatedCss) + +return ( + <> +
+ + +) +``` + +`useCompiledCss()` returns a tracked wrapper, not the plain JSON IR. The wrapper: + +- contains or references the compiled sheet +- holds a render-local dependency collector +- owns the React external-store subscription +- records dependencies from every `cssx()` call during render + +`cssx()` itself: + +- does not call hooks +- can be used inline in JSX spreads +- resolves style props +- records exact dependencies into the tracked wrapper if present + +### React Subscription Lifecycle + +Use `useSyncExternalStore` for external store subscriptions. + +Important constraints: + +- no global subscription mutation during render +- render-time dependency collection stays local to the tracked wrapper +- global subscriber registry is mutated only through hook subscribe/unsubscribe + lifecycle +- aborted/suspended renders must not leak subscriptions + +Algorithm target: + +1. Hook creates a tracker/wrapper. +2. Before each render, tracker starts a new dependency collection. +3. Each inline `cssx()` call resolves styles and records used dependencies: + - variable names and their versions + - media query IDs/match state + - viewport dimension dependency if used +4. After commit, an effect commits the collected dependency set. +5. `useSyncExternalStore` subscription listens only for changes intersecting the + committed dependency set. +6. If a dependency changed between render and effect commit, trigger one + corrective rerender. + +Race safeguard: + +- tracker records store version snapshot used during render +- commit effect compares against current versions +- if changed, force a rerender so no variable/media change is missed + +Memory safety: + +- suspended/aborted renders may collect dependencies locally, but never register + them globally +- previous committed subscription remains active until React commits a new one or + unmounts +- tests must cover promise-throwing Suspense renders where effects do not run + +### Babel-Compiled Usage + +Users still write: + +```tsx + +``` + +Babel hides the hook: + +```tsx +function Button() { + const __cssxSheet = useCssxSheet(__sheet) + return +} +``` + +Babel should inject the hook only when a component's styles can depend on +runtime state: + +- stylesheet uses `var()` +- stylesheet uses media queries +- stylesheet uses viewport units +- local template has interpolations +- dynamic interpolation could introduce `var(...)` + +Static-only styles should not pay a subscription cost. + +For any interpolation, always use the tracked runtime path, even if Babel sees a +literal expression. A string literal can still be `var(--x)`. + +Dependency tracking must happen after selector matching and active media +filtering, so unused selectors do not cause rerenders: + +```css +.root { color: var(--root-color); } +.label { color: var(--label-color); } +``` + +Resolving `styleName="root"` subscribes to `--root-color`, not `--label-color`. + +If an interpolation value introduces a variable: + +```tsx +css` + .root { color: ${color}; } +` +``` + +and `color === 'var(--button-color)'`, the component subscribes to +`--button-color`. If later `color === 'red'`, it stops depending on that +variable after commit. + +## Caching + +The new engine owns caching directly. Teamplay cache is not needed. + +### Static Sheet Result Cache + +Static/imported/runtime-generated sheets can be shared by many elements and +style names. Use a bounded per-sheet result cache. + +Target default: + +```text +max 100 resolved entries per sheet +``` + +Make the exact size internal initially or configurable later. + +Cache key includes only values that affect the resolved element: + +- normalized `styleName` +- sheet ID/hash +- active layer IDs +- relevant CSS variable values discovered while resolving matched declarations +- relevant media query match state +- dimension values only if viewport units are used +- inline style props hash + +Do not invalidate because an unrelated selector uses an unrelated variable. + +### Interpolated Template Cache + +Interpolated local templates must keep only one last-result slot: + +```ts +{ + lastValues, + lastResult +} +``` + +If values are the same by `Object.is` array equality, return the same result +reference. If values change, recompute and replace the previous slot. If values +later change back to an old value, recompute instead of keeping historical +variants. + +### Raw String Convenience Cache + +`cssx('root', generatedCss)` is allowed for convenience. It should internally +cache only the last raw CSS string and compiled sheet: + +```ts +lastCssString +lastCompiledSheet +``` + +Users who need stronger caching should use: + +```ts +const sheet = useCompiledCss(generatedCss) +``` + +or: + +```ts +const sheet = useMemo(() => compileCss(generatedCss), [generatedCss]) +``` + +### Inline Style Props Hash + +Use value hashing by default for inline style props, matching current CSSX +ergonomics. + +Current behavior uses: + +```ts +simpleNumericHash(JSON.stringify(inlineStyleProps)) +``` + +Continue this direction: + +- use `JSON.stringify` +- numeric hash is fine +- do not require users to memoize inline style objects +- fresh-but-equal inline object literals should hit cache + +If `JSON.stringify` throws on cycles, treat that inline input as uncacheable for +that render and warn in dev. + +### Output Shape + +Resolved style props should be flattened plain objects, like today: + +Input: + +```js +{ style: [{ color: 'red' }, { padding: 8 }] } +``` + +Output: + +```js +{ style: { color: 'red', padding: 8 } } +``` + +This maximizes stable object identity. + +## Part Props + +Preserve `part="root"` behavior: + +```tsx + +``` + +maps to the normal `style` prop. + +Other parts map to: + +```text +title -> titleStyle +icon -> iconStyle +hover -> hoverStyle +active -> activeStyle +``` + +The IR can represent: + +- normal root styles as `part: null` +- part styles as `part: 'title'` +- pseudo aliases as `part: 'hover'` / `part: 'active'` + +## Stylus + +Stylus remains outside `@cssxjs/css-to-rn`. + +Pipeline: + +```text +Stylus source -> CSS string -> compileCss() +``` + +Runtime compilation is CSS-only: + +```ts +compileCss(generatedCss) +``` + +Do not support: + +```ts +compileStyl(generatedStylus) +``` + +This keeps `stylus` out of client bundles. + +## Pug + +Pug style blocks continue to be transformed into local `css` or `styl` +templates by the existing Pug/Babel path. + +Supported: + +```pug +style(lang='styl') + .root + color var(--color, red) +``` + +Not supported initially: + +```pug +style(lang='styl') + .root + color ${color} +``` + +Pug interpolation syntax is a separate feature and is out of scope. + +## Babel And Loader Integration + +### Inline Template Plugin + +Update `packages/babel-plugin-rn-stylename-inline`: + +- stop rejecting all template expressions +- allow expressions only in function-scoped templates +- lower expressions to synthetic dynamic var tokens +- for CSS templates, compile tokenized CSS +- for Stylus templates, run tokenized Stylus through Stylus first, then compile + CSS +- validate slot positions during compilation +- hoist static compiled sheet IR after imports +- inject local runtime hook when needed +- pass ordered expression array to `useCssxTemplate()` + +Conceptual output: + +```tsx +const __sheet = { /* compiled IR */ } + +function Button({ color }) { + const __CSS_LOCAL__ = useCssxTemplate(__sheet, [color]) + return +} +``` + +The actual generated code may use different internal variable names, but should +preserve current user-facing behavior. + +Global/module-level templates: + +- remain static-only +- expressions are unsupported + +### StyleName Plugin + +Update `packages/babel-plugin-rn-stylename-to-style`: + +- use new resolver/runtime imports +- support canonical IR layers +- preserve file < global < local < inline precedence +- continue converting `styleName` and `*StyleName` +- continue handling `part` +- hide injected hooks from users +- no longer require `observer()` or teamplay detection for caching + +The existing `cache: 'teamplay'` option should become deprecated/no-op or be +removed in a breaking release path. + +### External Imports + +External `.cssx.css` and `.cssx.styl` imports should converge on canonical IR. + +Migration can use `toLegacyStyleObject()` temporarily, but long term: + +```tsx +import styles from './button.cssx.styl' + + +``` + +where `styles` is canonical compiled sheet IR. + +Build-time compilers must use strict mode: + +```ts +compileCss(css, { mode: 'build' }) +``` + +### Loaders + +Update `packages/loaders/cssToReactNativeLoader.js` to use +`@cssxjs/css-to-rn` instead of `@startupjs/css-to-react-native-transform`. + +Update compiler wrappers in `packages/loaders/compilers/` to emit either: + +- canonical IR, once runtime is migrated +- legacy object shape during the transition + +### Umbrella Package + +Update `packages/cssxjs`: + +- re-export new APIs +- update runtime conditional exports to point to new platform runtime +- preserve public import paths where possible + +## Legacy Adapter + +Include a legacy object-shape adapter for incremental migration: + +```ts +toLegacyStyleObject(sheet) +``` + +Output shape: + +```js +{ + root: { paddingTop: 8 }, + 'root::part(label)': { color: 'red' }, + __hash__: 123, + __vars: ['--color'], + __hasMedia: true +} +``` + +Use this only as a bridge. The canonical rule/declaration IR is the target. + +## Tests + +Use the same broad test setup pattern as `../teamplay/packages/teamplay`: + +- Mocha for source-level engine/isomorphic tests +- Jest + jsdom for React tests +- Node `-C cssx-ts` custom condition for direct TS source tests +- TypeScript type tests/build tests + +React integration tests should target React 19 only. Upgrade dev/test deps on +this branch as needed. + +### Test Scripts + +Approximate package scripts: + +```json +{ + "test": "npm run test-engine && npm run test-react && npm run test-types", + "test-engine": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/[!_]*.test.ts'", + "test-react": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --experimental-vm-modules -C cssx-ts\" jest --runInBand", + "test-types": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.build.json" +} +``` + +Exact paths can follow the package layout. + +### Pure Engine Tests + +Port and expand tests from: + +- `../css-to-react-native/src/__tests__` +- `../css-to-react-native-transform/src/index.spec.js` +- `packages/runtime/test/process.mjs` +- `packages/runtime/test/matcher.mjs` + +Cover: + +- property name normalization +- raw value transforms +- unit conversion +- shorthand expansion +- border shorthand including dynamic width/color/style after var resolution +- margin/padding/radius/width/color/style shorthands +- transform +- text-shadow +- box-shadow string pass-through +- filter string pass-through +- background-image platform mapping +- background shorthand limited support +- unsupported background images +- animations +- transitions +- comma-separated animation/transition values +- keyframes +- keyframes with vars +- keyframes with interpolation slots +- media query parsing and validation +- viewport units +- limited calc +- `u` unit +- CSS variables: + - runtime value + - default value + - inline fallback + - nested fallback + - unresolved + - cycles + - depth limit + - variable inside whole shorthand + - variable inside shorthand part + - variable inside comma chunk + - variable inside complex functions +- interpolation: + - CSS templates + - Stylus templates + - primitive values + - `null` / `undefined` / `false` + - invalid `true` + - invalid objects/arrays/functions/symbols/bigint + - interpolated `var(...)` + - unsupported selector/property/media/export positions +- selectors: + - class + - multi-class + - `:part()` + - `::part()` + - `:hover` + - `:active` + - unsupported descendants/IDs/attrs/pseudos +- cascade: + - specificity + - source order + - declaration fallback when dynamic declaration invalid + - file/global/local/inline precedence +- diagnostics: + - stable codes + - line/column + - empty sheet on runtime syntax error + - strict throw in build mode + - warning dedupe +- legacy adapter output + +### Cache Tests + +Add focused reference-stability tests: + +- same static `styleName` returns same result object +- fresh-but-equal inline object returns same result object due to JSON hash +- changed inline object invalidates +- unrelated variable change does not invalidate or rerender +- used variable change invalidates +- inactive media variable does not subscribe or invalidate +- media query match changes invalidate +- viewport unit dimension changes invalidate +- static sheet bounded cache evicts predictably +- interpolated sheet stores only one previous value set +- interpolation same primitive values returns same references +- interpolation changed values replace previous cache slot +- interpolation changed back recomputes rather than using historical cache +- raw CSS string convenience caches only one compiled string + +### React Integration Tests + +Use Jest/jsdom and React 19. + +Cover: + +- `useCompiledCss()` returns tracked wrapper +- inline `
` records dependencies +- multiple `cssx()` calls in one component union dependencies +- components rerender only for used variables +- components do not rerender for unused variables +- interpolation values that introduce `var()` dynamically update subscriptions +- interpolation values that stop using `var()` remove subscriptions after commit +- microtask batching of `variables` changes +- dimension leading/trailing debounce +- web `matchMedia` query subscriptions +- viewport unit resize subscriptions +- SSR/no-window fallback behavior +- unmount cleanup +- Suspense-aborted render does not leak subscriptions +- promise thrown during rerender where effect does not run destructor does not + leak new subscriptions +- stale-check rerenders if variable changes between render and effect commit +- subscriber counts are observable in tests through internal test-only helpers + +### Babel Tests + +Update snapshot tests for: + +- static inline templates +- interpolated local CSS templates +- interpolated local Stylus templates +- rejection of global template interpolation +- rejection of unsupported interpolation positions +- `styleName` transform with injected hook only when needed +- static template with no hook +- external imports with canonical IR or legacy bridge +- `:hover` / `:active` output +- `part="root"` behavior +- Pug style blocks still lowering to CSS/Stylus templates + +## Migration Milestones + +### Milestone 1: Package Scaffold And Test Harness + +- Create `packages/css-to-rn`. +- Add TS package config, build config, source condition, exports. +- Add Mocha source tests and Jest React test setup mirroring Teamplay. +- Add initial type declarations through TS source. +- Add copied/adapted test fixtures from forks and current runtime. +- Do not change existing production runtime yet. + +Exit criteria: + +- package tests run against TS source +- package builds `.js` and `.d.ts` +- test scaffold includes expected failing tests for new behavior + +### Milestone 2: Pure Compiler IR + +- Implement lightweight CSS parse path. +- Implement selector parser/validator. +- Implement rule/declaration/keyframe IR. +- Implement metadata and diagnostics. +- Implement build/runtime modes. +- Implement path-private hashes. +- Implement `:export` static-only. +- Implement unsupported selector diagnostics. +- Implement legacy adapter enough to compare with current output. + +Exit criteria: + +- static CSS fixtures compile to expected IR +- diagnostics work in runtime-safe and build-strict modes +- legacy adapter matches current static behavior for core cases + +### Milestone 3: Value Resolver And Property Transformer + +- Implement interpolation slot representation. +- Implement recursive `var()` resolver with cycles/depth. +- Implement declaration invalidation. +- Implement limited `calc()`. +- Implement viewport and `u` unit handling. +- Implement or adapt property transforms. +- Add new properties: + - `filter` + - `background-image` + - limited `background` +- Implement animations/transitions/keyframes. + +Exit criteria: + +- forked property tests pass or have intentional documented deltas +- complex var/shorthand tests pass +- Reanimated v4 animation style output matches docs + +### Milestone 4: Pure Resolver And Caching + +- Implement `resolveCssx()` over sheet layers. +- Implement specificity/source-order cascade. +- Implement media filtering. +- Implement dependency reporting from resolution. +- Implement per-sheet bounded cache. +- Implement single-entry interpolation cache. +- Implement inline style JSON hash. +- Implement raw string single-entry compile cache. +- Implement flattened output props. + +Exit criteria: + +- cache reference tests pass +- dependency-specific invalidation tests pass +- current matcher/process behavior is covered by new tests + +### Milestone 5: React Runtime Integration + +- Implement variable store proxy. +- Implement default variables. +- Implement microtask batching. +- Implement platform dimension adapters. +- Implement web `matchMedia` support. +- Implement React tracked sheet wrapper. +- Implement `useCompiledCss()`, `useCssxSheet()`, `useCssxTemplate()`. +- Implement `CssxProvider` and `configureCssx()`. +- Implement Suspense-safe subscription lifecycle. + +Exit criteria: + +- React tests pass +- no `observer()` needed +- no `teamplay` needed +- no `@nx-js/observer-util` needed + +### Milestone 6: Babel And Loader Migration + +- Update inline template plugin for interpolation lowering. +- Update styleName plugin for new resolver/hook path. +- Update loaders to call `@cssxjs/css-to-rn`. +- Keep legacy adapter bridge if needed. +- Update package dependencies. +- Update `cssxjs` public exports and conditional runtime exports. + +Exit criteria: + +- existing Babel snapshots updated +- example app works +- CSS variables/media no longer need `observer()` +- static style behavior remains compatible + +### Milestone 7: Runtime Package Cleanup + +- Remove duplicated logic from `packages/runtime`. +- Either: + - turn it into a compatibility wrapper around the new package, or + - remove it from internal generated imports and keep only if publishing + compatibility requires it +- Remove `teamplay` cache integration. +- Remove `@nx-js/observer-util` dependency. +- Update docs that currently mention teamplay caching. + +Exit criteria: + +- public `cssxjs` API works without teamplay +- docs no longer require `observer()` +- package dependency graph no longer includes removed runtime deps unless another + package still genuinely needs them + +### Milestone 8: Docs And Examples + +Update docs for: + +- interpolation +- runtime `compileCss()` +- `cssx()` and `useCompiledCss()` +- diagnostics for AI-generated CSS +- no-observer variable/media rerendering +- caching behavior +- `:hover` / `:active` part aliases +- `filter` +- `background-image` +- Reanimated v4 animation expectations + +Update examples to demonstrate: + +- local interpolation +- AI-generated CSS runtime use +- variables without `observer()` +- media query updates without teamplay + +## Implementation Notes + +### Avoiding The Old Split + +Do not recreate the old three-package architecture inside one package. Use the +old packages as: + +- test sources +- known-good code snippets +- behavior references + +Build the new architecture around: + +- canonical IR +- value-layer dynamic resolution +- dependency-aware resolver +- direct cache ownership +- React tracked wrapper integration + +### Build-Time Versus Runtime Behavior + +The same compiler powers both: + +- Babel/loaders +- runtime AI-generated CSS + +But options differ: + +```ts +compileCss(css, { mode: 'build' }) // strict, throw +compileCss(css, { mode: 'runtime' }) // default, graceful diagnostics +``` + +### React 19 + +Only support React 19 going forward for the new runtime integration. Use +`useSyncExternalStore` for external subscriptions. `use(context)` may be useful +for reading provider options, but it does not replace external store +subscription. + +### Platform Targets + +The engine should understand target platform: + +```ts +platform: 'ios' | 'android' | 'web' +reactType: 'react-native' | 'web' +``` + +This matters for: + +- `experimental_backgroundImage` vs `backgroundImage` +- pure web line-height string handling +- platform-specific future behavior + +### Current Public Behavior To Preserve + +- `styleName` accepts string, arrays, and object flags. +- `styleName` class matching supports multi-class selectors. +- `part` prop injects part style props into component props. +- `part="root"` maps to `style`. +- file/global/local/inline precedence stays the same. +- static styles continue to be build-time compiled. +- Stylus global imports/preprocessing stay outside pure CSS engine. + +## Open Implementation Choices + +These are left to implementation judgment: + +- exact internal names for hooks and generated variables +- exact cache size defaults +- exact hash function, as long as deterministic and small +- exact parser abstraction around `css/lib/parse` +- exact diagnostic message wording +- whether legacy adapter is used only in tests or also during migration + +Do not reopen the high-level decisions in this document unless implementation +reveals a concrete blocker. From bb558caa080474145ebc82c59bb03ea085ebe421 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:16:21 +0300 Subject: [PATCH 02/37] Scaffold unified CSS-to-RN package --- packages/css-to-rn/package.json | 72 +++ packages/css-to-rn/src/compiler.ts | 434 ++++++++++++++++++ packages/css-to-rn/src/diagnostics.ts | 24 + packages/css-to-rn/src/hash.ts | 11 + packages/css-to-rn/src/index.ts | 18 + packages/css-to-rn/src/react-native.ts | 10 + packages/css-to-rn/src/selectors.ts | 72 +++ packages/css-to-rn/src/types.ts | 102 ++++ packages/css-to-rn/src/vendor.d.ts | 24 + packages/css-to-rn/src/web.ts | 10 + .../css-to-rn/test/engine/compiler.test.ts | 116 +++++ packages/css-to-rn/test/types.d.ts | 2 + packages/css-to-rn/tsconfig.build.json | 15 + packages/css-to-rn/tsconfig.json | 25 + yarn.lock | 61 ++- 15 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 packages/css-to-rn/package.json create mode 100644 packages/css-to-rn/src/compiler.ts create mode 100644 packages/css-to-rn/src/diagnostics.ts create mode 100644 packages/css-to-rn/src/hash.ts create mode 100644 packages/css-to-rn/src/index.ts create mode 100644 packages/css-to-rn/src/react-native.ts create mode 100644 packages/css-to-rn/src/selectors.ts create mode 100644 packages/css-to-rn/src/types.ts create mode 100644 packages/css-to-rn/src/vendor.d.ts create mode 100644 packages/css-to-rn/src/web.ts create mode 100644 packages/css-to-rn/test/engine/compiler.test.ts create mode 100644 packages/css-to-rn/test/types.d.ts create mode 100644 packages/css-to-rn/tsconfig.build.json create mode 100644 packages/css-to-rn/tsconfig.json diff --git a/packages/css-to-rn/package.json b/packages/css-to-rn/package.json new file mode 100644 index 0000000..6a6ec50 --- /dev/null +++ b/packages/css-to-rn/package.json @@ -0,0 +1,72 @@ +{ + "name": "@cssxjs/css-to-rn", + "version": "0.3.0", + "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:types", + "test:engine": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/engine/**/*.test.ts'", + "test:types": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "css": "^3.0.0", + "css-mediaquery": "^0.1.2", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^22.8.1", + "mocha": "^8.4.0", + "typescript": "^6.0.3" + }, + "license": "MIT" +} diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts new file mode 100644 index 0000000..ac5071e --- /dev/null +++ b/packages/css-to-rn/src/compiler.ts @@ -0,0 +1,434 @@ +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 type { + CompileCssOptions, + CompileCssTemplateOptions, + CompileState, + CompiledCssSheet, + CssxDeclaration, + CssxDiagnostic, + CssxKeyframe, + CssxMetadata, + CssxRule +} from './types.ts' + +const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/ +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 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, state, orderRef(() => order++), isTemplate, exports) + continue + } + + if (rule.type === 'media') { + const mediaRule = rule as CssMediaAst + const media = `@media ${mediaRule.media ?? ''}`.trim() + validateMedia(mediaRule, state) + for (const child of mediaRule.rules ?? []) { + if (child.type !== 'rule') continue + compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, state, orderRef(() => order++), isTemplate, exports) + } + 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) + 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, isTemplate) + return createSheet({ + id, + sourceId, + contentHash, + rules, + keyframes, + 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[], + state: CompileState, + nextOrder: () => number, + isTemplate: boolean, + exports: Record +): void { + for (const selector of selectors) { + if (selector === ':export') { + compileExports(declarations, exports, state, isTemplate) + continue + } + + if (selector.trim().startsWith(':root')) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_SELECTOR', + `Unsupported selector "${selector}" ignored. Use setDefaultVariables() for CSS variable defaults.`, + 'warning' + )) + continue + } + + const parsed = parseSelector(selector, positionOfDeclarationList(declarations)) + if (parsed.diagnostic) { + addDiagnostic(state, parsed.diagnostic) + continue + } + if (!parsed.result) continue + + output.push({ + selector: parsed.result.selector, + classes: parsed.result.classes, + part: parsed.result.part, + specificity: parsed.result.specificity, + order: nextOrder(), + media, + declarations: compileDeclarations(declarations, state, isTemplate) + }) + } +} + +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 compileDeclarations ( + declarations: CssDeclarationAst[], + state: CompileState, + isTemplate: boolean +): 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 + output.push({ + property, + value, + raw: `${property}: ${value}`, + order: order++, + dynamicSlots, + line: declaration.position?.start?.line, + column: declaration.position?.start?.column + }) + } + + return output +} + +function compileKeyframes ( + rule: CssKeyframesAst, + state: CompileState, + nextOrder: () => number, + isTemplate: boolean +): CssxKeyframe[] { + const output: CssxKeyframe[] = [] + for (const frame of rule.keyframes ?? []) { + output.push({ + selector: (frame.values ?? []).join(', '), + declarations: compileDeclarations(frame.declarations ?? [], state, isTemplate), + order: nextOrder() + }) + } + return output +} + +function validateMedia (rule: CssMediaAst, state: CompileState): void { + try { + mediaQuery.parse(rule.media ?? '') + } catch (error) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_AT_RULE', + `Unsupported media query "${rule.media ?? ''}" ignored: ${(error as Error).message}`, + 'warning', + positionOf(rule) + )) + } +} + +function buildMetadata ( + rules: CssxRule[], + keyframes: 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) + } + + 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 + } +} + +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 ?? {}, + exports: input.exports, + metadata: input.metadata ?? { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: 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 | 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 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..01c0c0c --- /dev/null +++ b/packages/css-to-rn/src/index.ts @@ -0,0 +1,18 @@ +export { + compileCss, + compileCssTemplate +} from './compiler.ts' + +export type { + CompileCssOptions, + CompileCssTemplateOptions, + CompileMode, + CompiledCssSheet, + CssxDeclaration, + CssxDiagnostic, + CssxDiagnosticCode, + CssxKeyframe, + CssxMetadata, + CssxRule, + CssxTarget +} from './types.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..7cf5387 --- /dev/null +++ b/packages/css-to-rn/src/react-native.ts @@ -0,0 +1,10 @@ +export { + compileCss, + compileCssTemplate +} from './compiler.ts' + +export type { + CompileCssOptions, + CompileCssTemplateOptions, + CompiledCssSheet +} from './types.ts' diff --git a/packages/css-to-rn/src/selectors.ts b/packages/css-to-rn/src/selectors.ts new file mode 100644 index 0000000..29b6b12 --- /dev/null +++ b/packages/css-to-rn/src/selectors.ts @@ -0,0 +1,72 @@ +import { diagnostic } from './diagnostics.ts' +import type { CssxDiagnostic, SelectorParseResult } from './types.ts' + +const PART_RE = /::?part\(([^)]+)\)$/ +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).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.startsWith('.')) { + return unsupported(original, position) + } + + if ( + current.includes(' ') || + current.includes('>') || + current.includes('+') || + current.includes('~') || + current.includes('[') || + current.includes('#') || + current.includes(':') + ) { + return unsupported(original, position) + } + + const classes = current.split('.').filter(Boolean) + if (classes.length === 0 || classes.some(name => !/^[_a-zA-Z][-_a-zA-Z0-9]*$/.test(name))) { + return unsupported(original, position) + } + + return { + result: { + selector: original, + 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 combinations and :part()/:hover/:active only.`, + 'warning', + position + ) + } +} diff --git a/packages/css-to-rn/src/types.ts b/packages/css-to-rn/src/types.ts new file mode 100644 index 0000000..af70d51 --- /dev/null +++ b/packages/css-to-rn/src/types.ts @@ -0,0 +1,102 @@ +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' + +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 +} + +export interface CompiledCssSheet { + version: 1 + id: string + sourceId?: string + contentHash: string + rules: CssxRule[] + keyframes: Record + exports?: Record + metadata: CssxMetadata + diagnostics: CssxDiagnostic[] + error?: CssxDiagnostic +} + +export interface CssxRule { + selector: string + 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 + classes: string[] + part: string | null + specificity: number +} + +export interface CompileState { + diagnostics: CssxDiagnostic[] + mode: CompileMode +} 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..58bf215 --- /dev/null +++ b/packages/css-to-rn/src/vendor.d.ts @@ -0,0 +1,24 @@ +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 +} diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts new file mode 100644 index 0000000..7cf5387 --- /dev/null +++ b/packages/css-to-rn/src/web.ts @@ -0,0 +1,10 @@ +export { + compileCss, + compileCssTemplate +} from './compiler.ts' + +export type { + CompileCssOptions, + CompileCssTemplateOptions, + CompiledCssSheet +} from './types.ts' 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..b88f698 --- /dev/null +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -0,0 +1,116 @@ +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].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('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('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('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/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/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/yarn.lock b/yarn.lock index 6a7b8fc..12e663f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -768,6 +768,27 @@ __metadata: languageName: node linkType: hard +"@cssxjs/css-to-rn@workspace:packages/css-to-rn": + version: 0.0.0-use.local + resolution: "@cssxjs/css-to-rn@workspace:packages/css-to-rn" + dependencies: + "@types/node": "npm:^22.8.1" + css: "npm:^3.0.0" + css-mediaquery: "npm:^0.1.2" + mocha: "npm:^8.4.0" + postcss-value-parser: "npm:^4.2.0" + typescript: "npm:^6.0.3" + peerDependencies: + react: "*" + react-native: "*" + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + languageName: unknown + linkType: soft + "@cssxjs/loaders@npm:^0.3.0, @cssxjs/loaders@workspace:packages/loaders": version: 0.0.0-use.local resolution: "@cssxjs/loaders@workspace:packages/loaders" @@ -3351,6 +3372,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" @@ -10779,7 +10809,7 @@ __metadata: languageName: node linkType: hard -"mocha@npm:^8.1.1": +"mocha@npm:^8.1.1, mocha@npm:^8.4.0": version: 8.4.0 resolution: "mocha@npm:8.4.0" dependencies: @@ -12053,7 +12083,7 @@ __metadata: languageName: node linkType: hard -"postcss-value-parser@npm:^4.0.2": +"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 @@ -14300,6 +14330,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 +14350,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" @@ -14350,6 +14400,13 @@ __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 + "unhead@npm:2.1.2": version: 2.1.2 resolution: "unhead@npm:2.1.2" From 990f3f6ff6341f2ec4fe348b0a2ed6ef3718917b Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:20:36 +0300 Subject: [PATCH 03/37] Add CSS value and declaration engine --- packages/css-to-rn/src/index.ts | 8 + packages/css-to-rn/src/transform/index.ts | 1563 +++++++++++++++++ packages/css-to-rn/src/values.ts | 392 +++++ .../css-to-rn/test/engine/transform.test.ts | 172 ++ packages/css-to-rn/test/engine/values.test.ts | 84 + 5 files changed, 2219 insertions(+) create mode 100644 packages/css-to-rn/src/transform/index.ts create mode 100644 packages/css-to-rn/src/values.ts create mode 100644 packages/css-to-rn/test/engine/transform.test.ts create mode 100644 packages/css-to-rn/test/engine/values.test.ts diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts index 01c0c0c..bd3d00c 100644 --- a/packages/css-to-rn/src/index.ts +++ b/packages/css-to-rn/src/index.ts @@ -2,6 +2,9 @@ export { compileCss, compileCssTemplate } from './compiler.ts' +export { + resolveCssValue +} from './values.ts' export type { CompileCssOptions, @@ -16,3 +19,8 @@ export type { CssxRule, CssxTarget } from './types.ts' +export type { + InterpolationValue, + ResolveCssValueOptions, + ResolveCssValueResult +} from './values.ts' 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..500bf33 --- /dev/null +++ b/packages/css-to-rn/src/transform/index.ts @@ -0,0 +1,1563 @@ +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, + 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 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 { + 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/values.ts b/packages/css-to-rn/src/values.ts new file mode 100644 index 0000000..f81d6ae --- /dev/null +++ b/packages/css-to-rn/src/values.ts @@ -0,0 +1,392 @@ +import { diagnostic } from './diagnostics.ts' +import type { CssxDiagnostic } from './types.ts' + +export type InterpolationValue = string | number | null | undefined | false + +export interface ResolveCssValueOptions { + values?: readonly unknown[] + variables?: Record + defaultVariables?: Record + dimensions?: { + width?: number + height?: number + } + maxVarDepth?: number +} + +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 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) + const calc = resolveCalcs(units.value, diagnostics) + if (!calc.valid) { + return invalid(diagnostics, dependencies) + } + + return { + value: calc.value.trim(), + valid: true, + dependencies: serializeDependencies(dependencies), + diagnostics + } +} + +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) ?? + 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 } +): { value: string } { + let value = input.replace(U_UNIT_RE, (_match, prefix: string, rawNumber: string) => { + return `${prefix}${Number(rawNumber) * 8}` + }) + + 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}` + }) + + 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): number | null { + if (expression.includes('%')) return null + if (!/^[0-9+\-*/().\s]+$/.test(expression)) return null + + let index = 0 + + const skipWhitespace = () => { + while (/\s/.test(expression[index] ?? '')) index++ + } + + const parseNumber = (): number | null => { + skipWhitespace() + const match = expression.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 (expression[index] === '+') { + index++ + return parseFactor() + } + + if (expression[index] === '-') { + index++ + const value = parseFactor() + return value == null ? null : -value + } + + if (expression[index] === '(') { + index++ + const value = parseAdditive() + skipWhitespace() + if (expression[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 = expression[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 = expression[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 === expression.length && Number.isFinite(result) + ? result + : null +} + +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 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/test/engine/transform.test.ts b/packages/css-to-rn/test/engine/transform.test.ts new file mode 100644 index 0000000..e8db675 --- /dev/null +++ b/packages/css-to-rn/test/engine/transform.test.ts @@ -0,0 +1,172 @@ +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('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('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..2689864 --- /dev/null +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { resolveCssValue } from '../../src/index.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, 'color-mix(in srgb, green, white)') + 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 and viewport units', () => { + const result = resolveCssValue('calc(10vw + 2u)', { + dimensions: { width: 200, height: 100 } + }) + + assert.equal(result.valid, true) + assert.equal(result.value, '36') + assert.equal(result.dependencies.dimensions, true) + }) + + it('rejects unsupported calc expressions', () => { + const result = resolveCssValue('calc(100% - 16px)') + + assert.equal(result.valid, false) + assert.equal(result.diagnostics[0].code, 'UNSUPPORTED_CALC') + }) +}) From f0133346c47f7e549c015aa21e62bf31e4a49606 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:26:19 +0300 Subject: [PATCH 04/37] Add CSSX resolver cache layer --- packages/css-to-rn/src/index.ts | 17 + packages/css-to-rn/src/resolve.ts | 705 ++++++++++++++++++ packages/css-to-rn/src/values.ts | 30 +- .../css-to-rn/test/engine/resolve.test.ts | 269 +++++++ packages/css-to-rn/test/engine/values.test.ts | 2 +- 5 files changed, 1008 insertions(+), 15 deletions(-) create mode 100644 packages/css-to-rn/src/resolve.ts create mode 100644 packages/css-to-rn/test/engine/resolve.test.ts diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts index bd3d00c..93fb0e7 100644 --- a/packages/css-to-rn/src/index.ts +++ b/packages/css-to-rn/src/index.ts @@ -5,6 +5,11 @@ export { export { resolveCssValue } from './values.ts' +export { + createCssxCache, + cssx, + resolveCssx +} from './resolve.ts' export type { CompileCssOptions, @@ -24,3 +29,15 @@ export type { ResolveCssValueOptions, ResolveCssValueResult } from './values.ts' +export type { + CssxCache, + CssxDimensions, + CssxLayerInput, + InlineStyleInput, + ResolveCssxDependencies, + ResolveCssxLayer, + ResolveCssxOptions, + ResolveCssxResult, + ResolvedStyleProps, + StyleNameValue +} from './resolve.ts' diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts new file mode 100644 index 0000000..e4da177 --- /dev/null +++ b/packages/css-to-rn/src/resolve.ts @@ -0,0 +1,705 @@ +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 { 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 + defaultVariables?: Record + dimensions?: CssxDimensions + target?: CssxTarget + cache?: boolean | CssxCache + cacheMaxEntries?: number +} + +export interface CssxDimensions { + width?: number + height?: number + type?: string +} + +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[] + 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: Set + sheets: Set +} + +interface ResolutionContext { + target: CssxTarget + variables?: Record + defaultVariables?: Record + dimensions?: CssxDimensions + 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() + +export function createCssxCache (options: { maxEntries?: number } = {}): CssxCache { + return { + maxEntries: options.maxEntries ?? 100, + entries: new Map() + } +} + +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 + ) + if (currentSignature === cached.dynamicSignature) { + return { + ...cached.result, + cacheHit: true + } + } + } + + const result = resolveCssxUncached(options, layers, classNames) + const dynamicSignature = createDynamicSignature(result.dependencies, options) + + if (cache && stableKey) { + remember(cache, stableKey, { + dynamicSignature, + values, + result + }) + } + + return result +} + +function resolveCssxUncached ( + options: ResolveCssxOptions, + layers: readonly NormalizedLayer[], + classNames: readonly string[] +): ResolveCssxResult { + const context: ResolutionContext = { + target: options.target ?? 'react-native', + variables: options.variables, + defaultVariables: options.defaultVariables, + dimensions: options.dimensions, + dependencies: createDependencies(), + diagnostics: [], + } + const classSet = new Set(classNames) + const props: ResolvedStyleProps = {} + + for (const layer of layers) context.dependencies.sheets.add(layer.sheet.id) + + 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) + + 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 (!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, + 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 ruleMatchesMedia ( + rule: CssxRule, + context: ResolutionContext +): boolean { + if (!rule.media) return true + + const query = stripMediaPrefix(rule.media) + context.dependencies.media.add(query) + return matchesMediaQuery(query, context.dimensions) +} + +function matchesMediaQuery ( + query: string, + dimensions: CssxDimensions | undefined +): boolean { + try { + return mediaQuery.match(query, mediaValues(dimensions)) + } catch { + return false + } +} + +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 getPartPropName (part: string | null): string { + return part ? `${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 +): void { + if (!inlineStyleProps) return + + if (isStylePropsInput(inlineStyleProps)) { + for (const propName of Object.keys(inlineStyleProps)) { + mergeStyleProp(props, propName, inlineStyleProps[propName]) + } + return + } + + mergeStyleProp(props, 'style', inlineStyleProps) +} + +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 createStableKey ( + options: ResolveCssxOptions, + classNames: readonly string[], + layers: readonly NormalizedLayer[], + inlineHash: string +): string { + return JSON.stringify({ + target: options.target ?? 'react-native', + 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 +): string { + return JSON.stringify({ + vars: dependencies.vars.map(name => [ + name, + valueFromRecord(options.variables, 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) + ]) + }) +} + +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 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 Set(), + sheets: new Set() + } +} + +function serializeDependencies ( + dependencies: MutableDependencies +): ResolveCssxDependencies { + return { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions, + media: Array.from(dependencies.media).sort(), + 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] +} diff --git a/packages/css-to-rn/src/values.ts b/packages/css-to-rn/src/values.ts index f81d6ae..8e7aecd 100644 --- a/packages/css-to-rn/src/values.ts +++ b/packages/css-to-rn/src/values.ts @@ -197,7 +197,7 @@ function resolveUnits ( dependencies: { vars: Set, dimensions: boolean } ): { value: string } { let value = input.replace(U_UNIT_RE, (_match, prefix: string, rawNumber: string) => { - return `${prefix}${Number(rawNumber) * 8}` + return `${prefix}${Number(rawNumber) * 8}px` }) const width = options.dimensions?.width ?? 0 @@ -214,7 +214,7 @@ function resolveUnits ( : unit === 'vmin' ? Math.min(width, height) : Math.max(width, height) - return `${prefix}${number * basis / 100}` + return `${prefix}${number * basis / 100}px` }) return { value } @@ -252,19 +252,21 @@ function resolveCalcs ( } } -function evaluateCalc (expression: string): number | null { +function evaluateCalc (expression: string): string | null { if (expression.includes('%')) return null - if (!/^[0-9+\-*/().\s]+$/.test(expression)) return null + const hasPx = /(?:^|[^\w.-])[+-]?(?:\d*\.)?\d+px\b/.test(expression) + const normalized = expression.replace(/([+-]?(?:\d*\.)?\d+)px\b/g, '$1') + if (!/^[0-9+\-*/().\s]+$/.test(normalized)) return null let index = 0 const skipWhitespace = () => { - while (/\s/.test(expression[index] ?? '')) index++ + while (/\s/.test(normalized[index] ?? '')) index++ } const parseNumber = (): number | null => { skipWhitespace() - const match = expression.slice(index).match(/^(?:(?:\d+\.\d+)|(?:\d+\.)|(?:\.\d+)|(?:\d+))/) + const match = normalized.slice(index).match(/^(?:(?:\d+\.\d+)|(?:\d+\.)|(?:\.\d+)|(?:\d+))/) if (match == null) return null index += match[0].length return Number(match[0]) @@ -273,22 +275,22 @@ function evaluateCalc (expression: string): number | null { const parseFactor = (): number | null => { skipWhitespace() - if (expression[index] === '+') { + if (normalized[index] === '+') { index++ return parseFactor() } - if (expression[index] === '-') { + if (normalized[index] === '-') { index++ const value = parseFactor() return value == null ? null : -value } - if (expression[index] === '(') { + if (normalized[index] === '(') { index++ const value = parseAdditive() skipWhitespace() - if (expression[index] !== ')') return null + if (normalized[index] !== ')') return null index++ return value } @@ -302,7 +304,7 @@ function evaluateCalc (expression: string): number | null { while (true) { skipWhitespace() - const operator = expression[index] + const operator = normalized[index] if (operator !== '*' && operator !== '/') return value index++ @@ -318,7 +320,7 @@ function evaluateCalc (expression: string): number | null { while (true) { skipWhitespace() - const operator = expression[index] + const operator = normalized[index] if (operator !== '+' && operator !== '-') return value index++ @@ -331,8 +333,8 @@ function evaluateCalc (expression: string): number | null { const result = parseAdditive() skipWhitespace() - return result != null && index === expression.length && Number.isFinite(result) - ? result + return result != null && index === normalized.length && Number.isFinite(result) + ? hasPx ? `${result}px` : String(result) : null } 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..6beea92 --- /dev/null +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -0,0 +1,269 @@ +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(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', + 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('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 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('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' + }) + }) +}) diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts index 2689864..1dcab33 100644 --- a/packages/css-to-rn/test/engine/values.test.ts +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -71,7 +71,7 @@ describe('@cssxjs/css-to-rn value resolver', () => { }) assert.equal(result.valid, true) - assert.equal(result.value, '36') + assert.equal(result.value, '36px') assert.equal(result.dependencies.dimensions, true) }) From 9c8bcb47697832f86206590a6c833d2e3afd2a2d Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:31:29 +0300 Subject: [PATCH 05/37] Add React CSSX tracking runtime --- packages/css-to-rn/package.json | 3 +- packages/css-to-rn/src/react-native.ts | 102 +++++ packages/css-to-rn/src/react/config.ts | 45 +++ packages/css-to-rn/src/react/cssx.ts | 226 +++++++++++ packages/css-to-rn/src/react/hooks.ts | 73 ++++ packages/css-to-rn/src/react/index.ts | 46 +++ packages/css-to-rn/src/react/store.ts | 358 ++++++++++++++++++ packages/css-to-rn/src/react/tracker.ts | 163 ++++++++ packages/css-to-rn/src/resolve.ts | 7 + packages/css-to-rn/src/web.ts | 102 +++++ .../css-to-rn/test/react/tracking.test.ts | 210 ++++++++++ 11 files changed, 1334 insertions(+), 1 deletion(-) create mode 100644 packages/css-to-rn/src/react/config.ts create mode 100644 packages/css-to-rn/src/react/cssx.ts create mode 100644 packages/css-to-rn/src/react/hooks.ts create mode 100644 packages/css-to-rn/src/react/index.ts create mode 100644 packages/css-to-rn/src/react/store.ts create mode 100644 packages/css-to-rn/src/react/tracker.ts create mode 100644 packages/css-to-rn/test/react/tracking.test.ts diff --git a/packages/css-to-rn/package.json b/packages/css-to-rn/package.json index 6a6ec50..69c3e1d 100644 --- a/packages/css-to-rn/package.json +++ b/packages/css-to-rn/package.json @@ -36,8 +36,9 @@ "access": "public" }, "scripts": { - "test": "npm run test:engine && npm run test:types", + "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": "tsc -p tsconfig.build.json", "prepublishOnly": "npm run build" diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index 7cf5387..a09726f 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -2,9 +2,111 @@ export { compileCss, compileCssTemplate } from './compiler.ts' +export { + resolveCssValue +} from './values.ts' +import { + cssx as baseCssx, + clearRawCssCacheForTests +} from './react/cssx.ts' +import { + useCompiledCss as baseUseCompiledCss, + useCssxSheet as baseUseCssxSheet, + useCssxTemplate as baseUseCssxTemplate +} from './react/hooks.ts' +import { + createTrackedCssxSheet +} from './react/tracker.ts' +import { + defaultVariables, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + 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 { + CssxProviderProps, + CssxReactConfig +} from './react/config.ts' +export type { + TrackedCssxSheetOptions +} from './react/tracker.ts' + +export { + CssxProvider, + configureCssx, + useCssxConfig +} from './react/config.ts' +export { + TrackedCssxSheet, + isTrackedCssxSheet +} from './react/tracker.ts' +export { + defaultVariables, + setDefaultVariables, + variables +} + +export function cssx ( + ...args: Parameters +): ReturnType { + const [styleName, sheet, inlineStyleProps, options] = args + return baseCssx(styleName, sheet, inlineStyleProps, { + target: 'react-native', + ...(options ?? {}) + }) +} + +export function useCompiledCss ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseCompiledCss(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, + createTrackedCssxSheet, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setDimensionsForTests, + subscribeVariablesForTests +} 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..9b7c04d --- /dev/null +++ b/packages/css-to-rn/src/react/config.ts @@ -0,0 +1,45 @@ +import { + createContext, + createElement, + useContext, + useMemo, + type ReactNode +} from 'react' +import { + getRuntimeConfig, + setRuntimeConfig, + type CssxRuntimeConfig +} from './store.ts' +import type { TrackedCssxSheetOptions } from './tracker.ts' + +export interface CssxReactConfig extends CssxRuntimeConfig, TrackedCssxSheetOptions {} + +export interface CssxProviderProps { + value?: CssxReactConfig + children?: ReactNode +} + +const CssxConfigContext = createContext(null) + +export function configureCssx (config: CssxReactConfig): void { + setRuntimeConfig(config) +} + +export function CssxProvider (props: CssxProviderProps): ReactNode { + const parent = useContext(CssxConfigContext) + const value = useMemo( + () => ({ + ...(parent ?? getRuntimeConfig()), + ...(props.value ?? {}) + }), + [parent, props.value] + ) + + return createElement(CssxConfigContext.Provider, { + value + }, props.children) +} + +export function useCssxConfig (): CssxReactConfig { + return useContext(CssxConfigContext) ?? getRuntimeConfig() +} 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..f359fb7 --- /dev/null +++ b/packages/css-to-rn/src/react/cssx.ts @@ -0,0 +1,226 @@ +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 { + evaluateMediaQuery, + getDefaultVariableValues, + getDimensions, + getDimensionsVersion, + getVariableValues, + getVariableVersion, + type CssxDependencyCollector +} from './store.ts' +import { + isTrackedCssxSheet, + type TrackedCssxSheet +} from './tracker.ts' + +export type CssxStyleName = StyleNameValue +export type CssxResolvedProps = ResolvedStyleProps + +export interface CssxRuntimeOptions { + target?: CssxTarget + values?: readonly unknown[] + cache?: boolean | CssxCache +} + +export type CssxSheetInput = + | string + | CompiledCssSheet + | TrackedCssxSheet + | CssxReactLayer + | readonly CssxSheetInput[] + +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 normalized = normalizeSheetInput(sheetInput, options) + const result = resolveCssx({ + styleName, + layers: normalized.layers, + inlineStyleProps, + target: options.target ?? normalized.target ?? 'react-native', + variables: getVariableValues(), + defaultVariables: getDefaultVariableValues(), + dimensions: getDimensions(), + cache: options.cache ?? normalized.cache + }) + + for (const collector of normalized.collectors) { + recordDependencies(collector, result) + } + + return result.props +} + +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[] } } +): 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, evaluateMediaQuery(query)) + } +} 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..e7d6c16 --- /dev/null +++ b/packages/css-to-rn/src/react/hooks.ts @@ -0,0 +1,73 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useSyncExternalStore +} from 'react' +import { compileCss } from '../compiler.ts' +import type { CompiledCssSheet } from '../types.ts' +import { useCssxConfig, type CssxReactConfig } from './config.ts' +import { TrackedCssxSheet } from './tracker.ts' + +const useCommitEffect = typeof window === 'undefined' + ? useEffect + : useLayoutEffect + +export function useCssxSheet ( + sheet: CompiledCssSheet, + options: CssxReactConfig = {} +): TrackedCssxSheet { + const context = useCssxConfig() + const trackerRef = useRef(null) + const mergedOptions = { + ...context, + ...options + } + + if (trackerRef.current == null) { + trackerRef.current = new TrackedCssxSheet(sheet, mergedOptions) + } else { + trackerRef.current.update(sheet, mergedOptions) + } + + const tracker = trackerRef.current + tracker.startRender() + + useSyncExternalStore( + tracker.subscribe, + tracker.getSnapshot, + tracker.getServerSnapshot + ) + + useCommitEffect(() => { + tracker.commitRender() + }) + + return tracker +} + +export function useCompiledCss ( + 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 + }) +} 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..a791998 --- /dev/null +++ b/packages/css-to-rn/src/react/index.ts @@ -0,0 +1,46 @@ +export { + cssx, + clearRawCssCacheForTests +} from './cssx.ts' +export { + CssxProvider, + configureCssx, + useCssxConfig +} from './config.ts' +export { + useCompiledCss, + useCssxSheet, + useCssxTemplate +} from './hooks.ts' +export { + TrackedCssxSheet, + createTrackedCssxSheet, + isTrackedCssxSheet +} from './tracker.ts' +export { + defaultVariables, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setDefaultVariables, + setDimensionsForTests, + subscribeVariablesForTests, + variables +} from './store.ts' + +export type { + CssxResolvedProps, + CssxRuntimeOptions, + CssxStyleName +} from './cssx.ts' +export type { + CssxProviderProps, + CssxReactConfig +} from './config.ts' +export type { + CssxDependencySnapshot, + 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..da48b5f --- /dev/null +++ b/packages/css-to-rn/src/react/store.ts @@ -0,0 +1,358 @@ +import mediaQuery from 'css-mediaquery' + +export interface CssxRuntimeConfig { + dimensionsDebounceMs?: number +} + +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 +} + +type RuntimeSubscriber = { + listener: (change: RuntimeChangeSnapshot) => void + getDependencies: () => CssxDependencySnapshot +} + +const FALLBACK_DIMENSIONS = { width: 1024, height: 768 } + +const variableValues: Record = Object.create(null) +const defaultVariableValues: Record = Object.create(null) +const variableVersions = new Map() +const runtimeSubscribers = new Set() +const pendingVariableNames = new Set() + +let runtimeConfig: Required = { + dimensionsDebounceMs: 0 +} +let variableVersion = 0 +let dimensions = readWindowDimensions() +let dimensionsVersion = 0 +let pendingDimensionsChanged = 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 { + const changed = new Set() + for (const name of Object.keys(defaultVariableValues)) { + if (!Object.prototype.hasOwnProperty.call(next, name)) { + delete defaultVariableValues[name] + changed.add(name) + } + } + + for (const [name, value] of Object.entries(next)) { + if (Object.is(defaultVariableValues[name], value)) continue + defaultVariableValues[name] = value + changed.add(name) + } + + markVariablesChanged(Array.from(changed)) +} + +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 evaluateMediaQuery (query: string): boolean { + const normalized = stripMediaPrefix(query) + + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + return window.matchMedia(normalized).matches + } + + try { + return mediaQuery.match(normalized, { + type: 'screen', + width: `${dimensions.width}px`, + height: `${dimensions.height}px` + }) + } 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 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 + dimensions = FALLBACK_DIMENSIONS + dimensionsVersion = 0 + pendingDimensionsChanged = false + notifyScheduled = false + runtimeSubscribers.clear() + removeWindowResizeListener() +} + +function createVariableProxy (target: Record): Record { + return new Proxy(target, { + set (record, property, value) { + if (typeof property !== 'string') { + return Reflect.set(record, property, value) + } + 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) + } + if (!Object.prototype.hasOwnProperty.call(record, property)) return true + delete record[property] + markVariablesChanged([property]) + return true + } + }) +} + +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 scheduleNotification (): void { + if (notifyScheduled) return + notifyScheduled = true + + queueMicrotask(() => { + notifyScheduled = false + flushNotifications() + }) +} + +function flushNotifications (): void { + const vars = Array.from(pendingVariableNames) + const dimensionsChanged = pendingDimensionsChanged + + pendingVariableNames.clear() + pendingDimensionsChanged = false + + if (vars.length === 0 && !dimensionsChanged) return + + const change = { vars, dimensions: dimensionsChanged } + + 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) return false + if (dependencies.dimensionsVersion != null) return true + + for (const [query, matches] of dependencies.media) { + if (evaluateMediaQuery(query) !== matches) return true + } + + return false +} + +function ensureWindowResizeListener (): void { + 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 (resizeListener == null || typeof window === 'undefined') { + resizeListener = null + return + } + + window.removeEventListener('resize', resizeListener) + resizeListener = null +} + +function readWindowDimensions (): { width: number, height: number } { + if (typeof window === 'undefined') return FALLBACK_DIMENSIONS + + return { + width: window.innerWidth || FALLBACK_DIMENSIONS.width, + height: window.innerHeight || FALLBACK_DIMENSIONS.height + } +} + +function stripMediaPrefix (query: string): string { + return query.trim().replace(/^@media\s+/i, '').trim() +} + +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..3acd415 --- /dev/null +++ b/packages/css-to-rn/src/react/tracker.ts @@ -0,0 +1,163 @@ +import type { CompiledCssSheet } from '../types.ts' +import { + createCssxCache, + type CssxCache +} from '../resolve.ts' +import { + createDependencySnapshot, + hasStaleDependencies, + 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 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 + } + + 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 (): void { + this.pendingDependencies = createDependencySnapshot() + } + + commitRender (): void { + if (this.pendingDependencies == null) return + + const nextDependencies = this.pendingDependencies + this.pendingDependencies = null + this.committedDependencies = nextDependencies + + if (hasStaleDependencies(nextDependencies)) { + 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 + ) + } + + return () => { + this.listeners.delete(listener) + + if (this.listeners.size === 0 && this.unsubscribeRuntimeStore != null) { + this.unsubscribeRuntimeStore() + this.unsubscribeRuntimeStore = null + } + } + } + + 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() + } + } +} + +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 + } +} diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts index e4da177..65d52fb 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -134,6 +134,13 @@ export function createCssxCache (options: { maxEntries?: number } = {}): CssxCac } } +export function clearCssxRuntimeCachesForTests (): void { + lastRawCss = undefined + lastRawSheet = undefined + defaultCache.entries.clear() + unknownPrimitiveIds.clear() +} + export function cssx ( styleName: StyleNameValue, layers?: CssxLayerInput | readonly CssxLayerInput[], diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index 7cf5387..abd6bd4 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -2,9 +2,111 @@ export { compileCss, compileCssTemplate } from './compiler.ts' +export { + resolveCssValue +} from './values.ts' +import { + cssx as baseCssx, + clearRawCssCacheForTests +} from './react/cssx.ts' +import { + useCompiledCss as baseUseCompiledCss, + useCssxSheet as baseUseCssxSheet, + useCssxTemplate as baseUseCssxTemplate +} from './react/hooks.ts' +import { + createTrackedCssxSheet +} from './react/tracker.ts' +import { + defaultVariables, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + 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 { + CssxProviderProps, + CssxReactConfig +} from './react/config.ts' +export type { + TrackedCssxSheetOptions +} from './react/tracker.ts' + +export { + CssxProvider, + configureCssx, + useCssxConfig +} from './react/config.ts' +export { + TrackedCssxSheet, + isTrackedCssxSheet +} from './react/tracker.ts' +export { + defaultVariables, + setDefaultVariables, + variables +} + +export function cssx ( + ...args: Parameters +): ReturnType { + const [styleName, sheet, inlineStyleProps, options] = args + return baseCssx(styleName, sheet, inlineStyleProps, { + target: 'web', + ...(options ?? {}) + }) +} + +export function useCompiledCss ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseCompiledCss(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, + createTrackedCssxSheet, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setDimensionsForTests, + subscribeVariablesForTests +} 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..9e9b3fc --- /dev/null +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict' +import { + __cssxInternals, + compileCss, + compileCssTemplate, + cssx, + setDefaultVariables, + variables +} from '../../src/web.ts' + +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('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() + }) +}) From b0c5b0e4c1b9f3984e8a2cf92a53c6c558bf06e2 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:40:49 +0300 Subject: [PATCH 06/37] Wire unified CSS-to-RN runtime into cssx --- .../__snapshots__/index.spec.js.snap | 743 ++++++++++++++++-- .../__tests__/index.spec.js | 16 + .../babel-plugin-rn-stylename-inline/index.js | 56 +- .../package.json | 16 +- .../test/ts-transform.cjs | 17 + .../__snapshots__/index.spec.js.snap | 36 +- packages/css-to-rn/src/index.ts | 4 + packages/cssxjs/index.d.ts | 22 + packages/cssxjs/index.js | 18 +- packages/cssxjs/package.json | 1 + .../cssxjs/runtime/react-native-teamplay.js | 8 +- packages/cssxjs/runtime/react-native.js | 53 +- packages/cssxjs/runtime/web-teamplay.js | 8 +- packages/cssxjs/runtime/web.js | 53 +- packages/loaders/compilers/css.js | 6 +- packages/loaders/compilers/styl.js | 2 +- packages/loaders/cssToReactNativeLoader.js | 122 ++- packages/loaders/package.json | 2 +- yarn.lock | 5 +- 19 files changed, 1020 insertions(+), 168 deletions(-) create mode 100644 packages/babel-plugin-rn-stylename-inline/test/ts-transform.cjs 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..0cabf9a 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,88 @@ myCss\` import React from "react"; import { css as myCss, styl as myStyl, observer } from "cssxjs"; const __CSS_GLOBAL__ = { - card: { - color: "red", - backgroundColor: "green", + version: 1, + id: "cssx_d20fva", + contentHash: "cssx_9sdtvn", + rules: [ + { + selector: ".card", + 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, }, - __hash__: 1762191192, + diagnostics: [], + __hash__: -2145056715, }; const _localCssInstance = { - card: { - color: "#00f", + version: 1, + id: "cssx_6737ah", + contentHash: "cssx_bj97x3", + rules: [ + { + selector: ".card", + 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, }, - __hash__: -1529996446, + diagnostics: [], + __hash__: 1523967940, }; export default observer(function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -66,11 +137,50 @@ css\` import React from "react"; import { css, observer } from "startupjs"; const __CSS_GLOBAL__ = { - card: { - color: "red", - backgroundColor: "green", + version: 1, + id: "cssx_d20fva", + contentHash: "cssx_9sdtvn", + rules: [ + { + selector: ".card", + 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, }, - __hash__: 1762191192, + diagnostics: [], + __hash__: -2145056715, }; export default observer(function Card() { return ; @@ -116,26 +226,126 @@ import React from "react"; import { css, styl } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { - active: { - backgroundColor: "#f00", + version: 1, + id: "cssx_e4ok3b", + contentHash: "cssx_sae16k", + rules: [ + { + selector: ".active", + 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, }, - __hash__: -1767660834, + diagnostics: [], + __hash__: 1497110248, }; const _localCssInstance2 = { - root: { - marginTop: 16, - borderRadius: 8, + version: 1, + id: "cssx_8a2l4b", + contentHash: "cssx_fubghw", + rules: [ + { + selector: ".root", + 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, }, - __hash__: -1053412432, + diagnostics: [], + __hash__: 707783542, }; const _localCssInstance = { - root: { - paddingTop: 8, - paddingRight: 16, - paddingBottom: 8, - paddingLeft: 16, + version: 1, + id: "cssx_94aplp", + contentHash: "cssx_j0akch", + rules: [ + { + selector: ".root", + 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, }, - __hash__: -1823792365, + diagnostics: [], + __hash__: -1559627094, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -192,26 +402,126 @@ import React from "react"; import { css } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { - active: { - backgroundColor: "red", + version: 1, + id: "cssx_fgl1hb", + contentHash: "cssx_62w8qm", + rules: [ + { + selector: ".active", + 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, }, - __hash__: -1812576046, + diagnostics: [], + __hash__: -1215509807, }; const _localCssInstance2 = { - root: { - marginTop: 16, - borderRadius: 8, + version: 1, + id: "cssx_8a2l4b", + contentHash: "cssx_fubghw", + rules: [ + { + selector: ".root", + 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, }, - __hash__: -1053412432, + diagnostics: [], + __hash__: 707783542, }; const _localCssInstance = { - root: { - paddingTop: 8, - paddingRight: 16, - paddingBottom: 8, - paddingLeft: 16, + version: 1, + id: "cssx_e3vlw8", + contentHash: "cssx_7lhxx3", + rules: [ + { + selector: ".root", + 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, }, - __hash__: -1823792365, + diagnostics: [], + __hash__: -202231319, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -262,20 +572,86 @@ import React from "react"; import { css } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { - card: { - paddingTop: 8, - paddingRight: 16, - paddingBottom: 8, - paddingLeft: 16, + version: 1, + id: "cssx_5snslk", + contentHash: "cssx_c10gwr", + rules: [ + { + selector: ".card", + 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", + 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", + 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, }, - line: { - marginTop: 16, - borderRadius: 8, - }, - active: { - backgroundColor: "red", - }, - __hash__: 1310335761, + diagnostics: [], + __hash__: -1106277367, }; export default function Card() { return ( @@ -312,11 +688,50 @@ css\` import React from "react"; import { css, observer } from "cssxjs"; const __CSS_GLOBAL__ = { - card: { - color: "red", - backgroundColor: "green", + version: 1, + id: "cssx_d20fva", + contentHash: "cssx_9sdtvn", + rules: [ + { + selector: ".card", + 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, }, - __hash__: 1762191192, + diagnostics: [], + __hash__: -2145056715, }; export default observer(function Card() { return ; @@ -355,20 +770,86 @@ import React from "react"; import { styl } from "cssxjs"; import { View } from "react-native"; const __CSS_GLOBAL__ = { - card: { - paddingTop: 8, - paddingRight: 16, - paddingBottom: 8, - paddingLeft: 16, - }, - line: { - marginTop: 16, - borderRadius: 8, - }, - active: { - backgroundColor: "#f00", + version: 1, + id: "cssx_ccmade", + contentHash: "cssx_oj63s5", + rules: [ + { + selector: ".card", + 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", + 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", + 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, }, - __hash__: 553324671, + diagnostics: [], + __hash__: -156895245, }; export default function Card() { return ( @@ -404,17 +885,137 @@ styl\` import React from "react"; import { styl, observer } from "cssxjs"; const __CSS_GLOBAL__ = { - card: { - color: "#f00", - backgroundColor: "#008000", + version: 1, + id: "cssx_5vvl7n", + contentHash: "cssx_ask4pp", + rules: [ + { + selector: ".card", + 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, }, - __hash__: 772349652, + diagnostics: [], + __hash__: 1421483523, }; export default observer(function Card() { return ; }); +`; + +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' + +export default function Card ({ color, pad }) { + return + + css\` + .root { + color: \${color}; + padding: \${pad} 2u; + } + \` +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +import React from "react"; +import { css } from "cssxjs"; +import { View } from "react-native"; +const _localCssInstance = { + version: 1, + id: "cssx_fjh55d", + contentHash: "cssx_4m17p8", + rules: [ + { + selector: ".root", + 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, + }, + diagnostics: [], + __hash__: -1763352586, +}; +export default function Card({ color, pad }) { + 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..c1f6fd6 100644 --- a/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js +++ b/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js @@ -197,6 +197,22 @@ pluginTester({ background-color: green; } \` + `, + 'Local css interpolation': /* js */` + import React from 'react' + import { css } from 'cssxjs' + import { View } from 'react-native' + + export default function Card ({ color, pad }) { + return + + css\` + .root { + color: \${color}; + padding: \${pad} 2u; + } + \` + } ` } }) diff --git a/packages/babel-plugin-rn-stylename-inline/index.js b/packages/babel-plugin-rn-stylename-inline/index.js index 8be96ec..46e0d1e 100644 --- a/packages/babel-plugin-rn-stylename-inline/index.js +++ b/packages/babel-plugin-rn-stylename-inline/index.js @@ -29,22 +29,28 @@ 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() - - // 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 +60,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({ variable: t.identifier(LOCAL_NAME), - value: localIdentifier + value: localValue })) - // 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 +84,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 @@ -98,14 +111,19 @@ function shouldProcess ($template, usedCompilers) { 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..39942e8 100644 --- a/packages/babel-plugin-rn-stylename-inline/package.json +++ b/packages/babel-plugin-rn-stylename-inline/package.json @@ -14,7 +14,7 @@ ], "main": "index.js", "scripts": { - "test": "jest" + "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --experimental-vm-modules -C cssx-ts\" jest --runInBand" }, "author": "Pavel Zhukov", "license": "MIT", @@ -33,5 +33,19 @@ "@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/__tests__/__snapshots__/index.spec.js.snap b/packages/babel-plugin-rn-stylename-to-style/__tests__/__snapshots__/index.spec.js.snap index 0161a57..6354e7e 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 @@ -1482,12 +1482,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 +1505,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 +1533,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 | } `; diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts index 93fb0e7..d4f7776 100644 --- a/packages/css-to-rn/src/index.ts +++ b/packages/css-to-rn/src/index.ts @@ -2,6 +2,10 @@ export { compileCss, compileCssTemplate } from './compiler.ts' +export { + cssxHash, + simpleNumericHash +} from './hash.ts' export { resolveCssValue } from './values.ts' diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 06b60fb..0ee6c5f 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -1,4 +1,26 @@ import type React from 'react' +export { + CssxProvider, + TrackedCssxSheet, + configureCssx, + cssx, + defaultVariables, + isTrackedCssxSheet, + setDefaultVariables, + useCompiledCss, + useCssxConfig, + useCssxSheet, + useCssxTemplate, + variables +} from '@cssxjs/css-to-rn/react' +export type { + CssxProviderProps, + CssxReactConfig, + CssxResolvedProps, + CssxRuntimeOptions, + CssxStyleName, + TrackedCssxSheetOptions +} from '@cssxjs/css-to-rn/react' export type CssxjsSimpleValue = | string diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js index 38ae353..3fcd652 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -1,7 +1,17 @@ -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, + isTrackedCssxSheet, + setDefaultVariables, + useCompiledCss, + useCssxConfig, + useCssxSheet, + useCssxTemplate, + variables +} from '@cssxjs/css-to-rn/react' 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/package.json b/packages/cssxjs/package.json index b72c121..c06b5d8 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -39,6 +39,7 @@ "@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/css-to-rn": "^0.3.0", "@cssxjs/loaders": "^0.3.0", "@cssxjs/runtime": "^0.3.0", "@react-pug/babel-plugin-react-pug": "^0.1.18", diff --git a/packages/cssxjs/runtime/react-native-teamplay.js b/packages/cssxjs/runtime/react-native-teamplay.js index 61f15cc..571a068 100644 --- a/packages/cssxjs/runtime/react-native-teamplay.js +++ b/packages/cssxjs/runtime/react-native-teamplay.js @@ -1,2 +1,6 @@ -export { default } from '@cssxjs/runtime/entrypoints/react-native-teamplay' -export { default as runtime } from '@cssxjs/runtime/entrypoints/react-native-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..5809a65 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -1,2 +1,51 @@ -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, + defaultVariables, + isTrackedCssxSheet, + setDefaultVariables, + useCompiledCss, + useCssxConfig, + useCssxSheet, + useCssxTemplate, + variables +} from '@cssxjs/css-to-rn/react-native' + +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..def8db3 100644 --- a/packages/cssxjs/runtime/web-teamplay.js +++ b/packages/cssxjs/runtime/web-teamplay.js @@ -1,2 +1,6 @@ -export { default } from '@cssxjs/runtime/entrypoints/web-teamplay' -export { default as runtime } from '@cssxjs/runtime/entrypoints/web-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..4cc4ddd 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -1,2 +1,51 @@ -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, + defaultVariables, + isTrackedCssxSheet, + setDefaultVariables, + useCompiledCss, + useCssxConfig, + useCssxSheet, + useCssxTemplate, + variables +} from '@cssxjs/css-to-rn/web' + +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/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..f942781 100644 --- a/packages/loaders/cssToReactNativeLoader.js +++ b/packages/loaders/cssToReactNativeLoader.js @@ -1,33 +1,95 @@ -// 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 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.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]) } 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 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), + 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) + process.stdout.write(JSON.stringify(${method}(payload.source, payload.options))) + ` + const result = spawnSync(process.execPath, [ + '-C', + 'cssx-ts', + '--input-type=module', + '--eval', + script + ], { + input: JSON.stringify({ 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 +154,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..cecf50d 100644 --- a/packages/loaders/package.json +++ b/packages/loaders/package.json @@ -20,7 +20,7 @@ }, "license": "MIT", "dependencies": { - "@startupjs/css-to-react-native-transform": "2.1.0-3", + "@cssxjs/css-to-rn": "^0.3.0", "stylus": "0.64.0" } } diff --git a/yarn.lock b/yarn.lock index 12e663f..ce6ab79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -768,7 +768,7 @@ __metadata: languageName: node linkType: hard -"@cssxjs/css-to-rn@workspace:packages/css-to-rn": +"@cssxjs/css-to-rn@npm:^0.3.0, @cssxjs/css-to-rn@workspace:packages/css-to-rn": version: 0.0.0-use.local resolution: "@cssxjs/css-to-rn@workspace:packages/css-to-rn" dependencies: @@ -793,7 +793,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cssxjs/loaders@workspace:packages/loaders" dependencies: - "@startupjs/css-to-react-native-transform": "npm:2.1.0-3" + "@cssxjs/css-to-rn": "npm:^0.3.0" stylus: "npm:0.64.0" languageName: unknown linkType: soft @@ -5332,6 +5332,7 @@ __metadata: "@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/css-to-rn": "npm:^0.3.0" "@cssxjs/loaders": "npm:^0.3.0" "@cssxjs/runtime": "npm:^0.3.0" "@react-pug/babel-plugin-react-pug": "npm:^0.1.18" From bdee1ef49a563b3ebd2d2f23317fc584f9d8a348 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:42:44 +0300 Subject: [PATCH 07/37] Remove Babel runtime package coupling --- packages/babel-plugin-rn-stylename-inline/index.js | 4 ++-- packages/babel-plugin-rn-stylename-inline/package.json | 3 +-- packages/babel-plugin-rn-stylename-to-style/index.js | 3 ++- packages/babel-plugin-rn-stylename-to-style/package.json | 3 +-- packages/cssxjs/package.json | 1 - yarn.lock | 5 +---- 6 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/babel-plugin-rn-stylename-inline/index.js b/packages/babel-plugin-rn-stylename-inline/index.js index 46e0d1e..022ddd0 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%% diff --git a/packages/babel-plugin-rn-stylename-inline/package.json b/packages/babel-plugin-rn-stylename-inline/package.json index 39942e8..670bdad 100644 --- a/packages/babel-plugin-rn-stylename-inline/package.json +++ b/packages/babel-plugin-rn-stylename-inline/package.json @@ -26,8 +26,7 @@ "@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.3.0" }, "devDependencies": { "@babel/plugin-syntax-jsx": "^7.0.0", diff --git a/packages/babel-plugin-rn-stylename-to-style/index.js b/packages/babel-plugin-rn-stylename-to-style/index.js index 9ba95bc..5e51549 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') @@ -15,6 +14,8 @@ const STYLE_REGEX = /(?:^s|S)tyle$/ const ROOT_STYLE_PROP_NAME = 'style' const RUNTIME_IMPORT_NAME = 'runtime' const RUNTIME_FRIENDLY_NAME = 'cssx' +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'] diff --git a/packages/babel-plugin-rn-stylename-to-style/package.json b/packages/babel-plugin-rn-stylename-to-style/package.json index de3874e..94424a6 100644 --- a/packages/babel-plugin-rn-stylename-to-style/package.json +++ b/packages/babel-plugin-rn-stylename-to-style/package.json @@ -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/cssxjs/package.json b/packages/cssxjs/package.json index c06b5d8..304a255 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -41,7 +41,6 @@ "@cssxjs/bundler": "^0.3.0", "@cssxjs/css-to-rn": "^0.3.0", "@cssxjs/loaders": "^0.3.0", - "@cssxjs/runtime": "^0.3.0", "@react-pug/babel-plugin-react-pug": "^0.1.18", "@react-pug/check-types": "^0.1.18", "babel-preset-cssxjs": "^0.3.0" diff --git a/yarn.lock b/yarn.lock index ce6ab79..99f614b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -727,7 +727,6 @@ __metadata: "@babel/template": "npm:^7.4.0" "@babel/types": "npm:^7.0.0" "@cssxjs/loaders": "npm:^0.3.0" - "@cssxjs/runtime": "npm:^0.3.0" babel-plugin-tester: "npm:^9.1.0" jest: "npm:^30.0.4" languageName: unknown @@ -742,7 +741,6 @@ __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" @@ -798,7 +796,7 @@ __metadata: languageName: unknown linkType: soft -"@cssxjs/runtime@npm:^0.3.0, @cssxjs/runtime@workspace:packages/runtime": +"@cssxjs/runtime@workspace:packages/runtime": version: 0.0.0-use.local resolution: "@cssxjs/runtime@workspace:packages/runtime" dependencies: @@ -5334,7 +5332,6 @@ __metadata: "@cssxjs/bundler": "npm:^0.3.0" "@cssxjs/css-to-rn": "npm:^0.3.0" "@cssxjs/loaders": "npm:^0.3.0" - "@cssxjs/runtime": "npm:^0.3.0" "@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" From 9f74fc00c7a301cc2b63d00c119a6f562737c359 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:47:14 +0300 Subject: [PATCH 08/37] Remove legacy runtime package --- AGENTS.md | 34 +- architecture.md | 458 +++---- packages/runtime/.npmignore | 2 - packages/runtime/CHANGELOG.md | 159 --- packages/runtime/constants.cjs | 4 - packages/runtime/dimensions.js | 15 - .../entrypoints/react-native-teamplay.js | 8 - packages/runtime/entrypoints/react-native.js | 8 - packages/runtime/entrypoints/web-teamplay.js | 8 - packages/runtime/entrypoints/web.js | 8 - packages/runtime/matcher.js | 127 -- packages/runtime/package.json | 58 - packages/runtime/platformHelpers/index.js | 50 - .../runtime/platformHelpers/react-native.js | 35 - packages/runtime/platformHelpers/web.js | 55 - packages/runtime/process.js | 137 -- packages/runtime/processCached.js | 68 - packages/runtime/test/matcher.mjs | 485 ------- packages/runtime/test/process.mjs | 1180 ----------------- packages/runtime/variables.js | 9 - .../README.md | 8 - .../index.js | 46 - .../mediaquery.js | 152 --- .../README.md | 8 - .../index.js | 52 - yarn.lock | 90 +- 26 files changed, 211 insertions(+), 3053 deletions(-) delete mode 100644 packages/runtime/.npmignore delete mode 100644 packages/runtime/CHANGELOG.md delete mode 100644 packages/runtime/constants.cjs delete mode 100644 packages/runtime/dimensions.js delete mode 100644 packages/runtime/entrypoints/react-native-teamplay.js delete mode 100644 packages/runtime/entrypoints/react-native.js delete mode 100644 packages/runtime/entrypoints/web-teamplay.js delete mode 100644 packages/runtime/entrypoints/web.js delete mode 100644 packages/runtime/matcher.js delete mode 100644 packages/runtime/package.json delete mode 100644 packages/runtime/platformHelpers/index.js delete mode 100644 packages/runtime/platformHelpers/react-native.js delete mode 100644 packages/runtime/platformHelpers/web.js delete mode 100644 packages/runtime/process.js delete mode 100644 packages/runtime/processCached.js delete mode 100644 packages/runtime/test/matcher.mjs delete mode 100644 packages/runtime/test/process.mjs delete mode 100644 packages/runtime/variables.js delete mode 100644 packages/runtime/vendor/react-native-css-media-query-processor/README.md delete mode 100644 packages/runtime/vendor/react-native-css-media-query-processor/index.js delete mode 100644 packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js delete mode 100644 packages/runtime/vendor/react-native-dynamic-style-processor/README.md delete mode 100644 packages/runtime/vendor/react-native-dynamic-style-processor/index.js diff --git a/AGENTS.md b/AGENTS.md index bea3b03..2400c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ 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. +CSSX is a monorepo for a CSS-in-JS toolchain. Users write `css`, `styl`, or optional `pug` templates plus `styleName` and `part` props. Babel compiles that authoring syntax into compiled CSS sheet IR and runtime calls. The unified `@cssxjs/css-to-rn` package owns CSS parsing, CSS value resolution, React Native/web style transformation, runtime caching, variables, media/dimension tracking, and React subscriptions. ## Start Here @@ -12,11 +12,11 @@ CSSX is a monorepo for a CSS-in-JS toolchain. Users write `styl`, `css`, or opti ## 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, property transforms, caching, `cssx()`, `useCompiledCss()`, variables, and dimensions. +- `packages/cssxjs/`: public `cssxjs` facade, CLI, package exports, runtime compatibility wrappers, Babel preset wrapper, loader wrappers, and Metro wrappers. +- `packages/loaders/`: Stylus/CSS loaders and direct compiler wrappers. CSS compilation delegates to `@cssxjs/css-to-rn`. +- `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`, old `*StyleName`, 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. @@ -26,13 +26,14 @@ CSSX is a monorepo for a CSS-in-JS toolchain. Users write `styl`, `css`, or opti ## 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. +- Runtime calls generated by Babel keep the compatibility shape `runtime(styleName, fileStyles, globalStyles, localStyles, inlineStyleProps)`. +- `cssxjs/runtime/*` wrappers adapt that call shape to `@cssxjs/css-to-rn` platform entrypoints. +- Style priority is file/imported sheets, then global templates, then local templates, then inline props. +- Compiled sheets are JSON-serializable IR. Runtime cache/tracking state 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` and `:active` compile to `hoverStyle` and `activeStyle`. +- Local JS template interpolation is lowered to synthetic `var(--__cssx_dynamic_N)` slots and passed as `values`. +- `cache: 'teamplay'` remains accepted as a Babel option for compatibility, but runtime caching is owned by `@cssxjs/css-to-rn`, not Teamplay. ## Commands @@ -51,7 +52,7 @@ yarn test Run targeted tests: ```sh -cd packages/runtime && yarn test +cd packages/css-to-rn && npm test cd packages/babel-plugin-rn-stylename-inline && yarn test cd packages/babel-plugin-rn-stylename-to-style && yarn test ``` @@ -70,9 +71,10 @@ 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 CSS parsing, selector, value, transform, cache, variable, media, 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 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/architecture.md b/architecture.md index bc38792..ced6a40 100644 --- a/architecture.md +++ b/architecture.md @@ -1,44 +1,43 @@ # 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. +CSSX is a CSS-in-JS system for React Native, react-native-web, and pure React web targets. Users write `css`, `styl`, and optional `pug` templates, 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. +The current architecture centers on `@cssxjs/css-to-rn`. That package owns the unified CSS-to-style pipeline: CSS parsing, canonical sheet IR, selector matching, CSS variable/interpolation resolution, React Native/web property transformation, runtime caching, dimensions/media tracking, and React subscription helpers. The older separate runtime package has been removed from the active dependency graph. ## 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. +- `docs/`: public documentation served by Rspress. +- `packages/css-to-rn/`: unified compiler and runtime engine. +- `packages/cssxjs/`: umbrella package published as `cssxjs`; exports public APIs, runtime compatibility wrappers, Babel/Metro wrappers, CLI, and loader wrappers. +- `packages/loaders/`: webpack-compatible loaders plus compiler helpers used by Babel and Metro. Stylus still compiles to CSS here; CSS compilation delegates to `@cssxjs/css-to-rn`. +- `packages/babel-plugin-rn-stylename-inline/`: compiles inline `css` and `styl` tagged templates. +- `packages/babel-plugin-rn-stylename-to-style/`: rewrites JSX `styleName`, `part`, old `*StyleName`, and helper calls into runtime calls. +- `packages/babel-preset-cssxjs/`: composes syntax plugins, React Pug, inline style compilation, and `styleName`/`part` rewriting. - `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. +The repository uses Yarn workspaces and Lerna. Root `package.json` requires Node `>=22`. ## Public API Surface -The published `cssxjs` package exposes: +The `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. +- `css` and `styl`: Babel-processed template tags, plus helper-call forms after Babel rewriting. - `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. +- `cssx`: runtime helper from `@cssxjs/css-to-rn/react`. +- `variables`, `defaultVariables`, `setDefaultVariables`: runtime CSS variable registries. +- `useCompiledCss`, `useCssxSheet`, `useCssxTemplate`: React helpers for runtime-generated CSS and local template values. +- `CssxProvider`, `configureCssx`, `useCssxConfig`: optional runtime configuration. +- `cssxjs/runtime`, `cssxjs/runtime/web`, `cssxjs/runtime/react-native`, and `teamplay` compatibility runtime paths used by Babel-generated code. +- `cssxjs/babel`, `cssxjs/metro-config`, and `cssxjs/metro-babel-transformer`. - `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. +`packages/cssxjs/index.js` intentionally makes direct unprocessed `css`, `styl`, and `pug` calls throw. Seeing those errors means the file did not go through the Babel pipeline. -## End-to-End Build Flow +## End-To-End Flow -### 1. Authoring +### Authoring Users write components like: @@ -63,7 +62,7 @@ function Button ({ variant, children }) { } ``` -Parent components can target the exposed parts from outside: +Parent components can target exposed parts from outside: ```jsx function Toolbar () { @@ -78,92 +77,68 @@ function Toolbar () { } ``` -The core authoring constructs are: +Parts are only addressable from outside the component exposing them. Inside a component, style the inner element directly with its own class selector. -- 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. +### Babel Preset -### 2. Babel Preset +`packages/babel-preset-cssxjs/index.js` configures transforms in this order: -`packages/babel-preset-cssxjs/index.js` configures the transform stack: - -1. Syntax support for JSX, TypeScript, and TSX depending on filename. +1. JSX/TypeScript syntax plugins. 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. +This order matters. Pug must become JSX before CSSX rewrites JSX attributes, and inline CSS/Stylus templates must compile before `styleName` references are converted into runtime calls. -Preset options: +Important 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. +- `reactType`: chooses runtime target, `web` or `react-native`. +- `cache`: accepts `teamplay` for compatibility. It still affects generated import paths, but runtime caching is now internal to `@cssxjs/css-to-rn`. +- `transformPug` and `transformCss`: disable the corresponding transforms when false. -### 3. Pug Transform +### Inline Template Compilation -Pug support is provided by external `@react-pug/*` packages. CSSX wraps those packages through: +`packages/babel-plugin-rn-stylename-inline/index.js` handles `css` and `styl` tagged templates imported from magic imports. Defaults are `cssxjs` and `startupjs`. -- `cssxjs/babel/plugin-react-pug` -- `cssxjs check` -- `eslint-plugin-cssxjs` +Behavior: -Current CSSX docs recommend terminal embedded style blocks inside Pug templates: +- Imported aliases are supported. +- Module-level templates become `const __CSS_GLOBAL__ = compiledSheet`. +- Function-level templates become a top-level compiled sheet plus function-local `const __CSS_LOCAL__ = compiledSheet`. +- Local JS template interpolation is supported. Expressions are lowered to synthetic declaration-value variables: ```jsx -return pug` - View.card - Text.title= title - - style(lang='styl') - .card - padding 2u +css` + .root { + color: ${color}; + padding: ${pad} 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. - -### 4. Inline Style Compilation - -`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`. - -Important behavior: - -- 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`. +compiles as a sheet containing `var(--__cssx_dynamic_0)` and `var(--__cssx_dynamic_1)`, while the function receives: -The generated names come from `packages/runtime/constants.cjs`: - -- `GLOBAL_NAME`: `__CSS_GLOBAL__` -- `LOCAL_NAME`: `__CSS_LOCAL__` - -Those names are part of the transform/runtime contract. - -### 5. Style File Imports and JSX Rewriting +```js +const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad] +} +``` -`packages/babel-plugin-rn-stylename-to-style/index.js` is the main JSX transform. It has three jobs. +Interpolations are allowed only in function-scoped local `css`/`styl` templates and only in declaration values. Selectors, property names, media queries, exports, and module-level templates remain static. -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']`. +### JSX Rewriting -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. +`packages/babel-plugin-rn-stylename-to-style/index.js` handles JSX styling attributes and helper calls. -Second, it rewrites JSX styling attributes. A JSX opening element with `styleName`, `style`, or part style props becomes a spread call: +A JSX opening element with `styleName`, `style`, or part style props becomes a spread call: ```jsx ``` -becomes conceptually: +conceptually becomes: ```jsx ``` -The runtime call returns an object containing `style` and any `{part}Style` props. +The runtime call returns an object containing `style` and any `{part}Style`, `hoverStyle`, or `activeStyle` props. -Third, it rewrites function calls to imported `styl`/`css` identifiers. This supports the public spread helper form: +The same runtime call shape is used for public helper forms like: ```jsx ``` -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: +Runtime import paths are selected by plugin options: - default: `cssxjs/runtime` - `reactType: 'web'`: `cssxjs/runtime/web` @@ -195,166 +168,176 @@ Runtime import paths are chosen from plugin options and imports: - `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 `teamplay` paths are compatibility wrappers around the same new runtime. -The style compiler path is: +## `@cssxjs/css-to-rn` -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. +This package is TypeScript-first ESM and uses Node's strip-only TS support for tests via the custom export condition `cssx-ts`. -`packages/loaders/compilers/*` wrap the loaders for synchronous direct use from Babel and strip the generated `module.exports =` prefix. +### Exports -### Stylus Loader +- Root `@cssxjs/css-to-rn`: isomorphic compiler/resolver APIs. +- `@cssxjs/css-to-rn/react`: React runtime helpers with conditional web/native behavior. +- `@cssxjs/css-to-rn/web`: web-targeted helpers. +- `@cssxjs/css-to-rn/react-native`: React Native-targeted helpers. -`packages/loaders/stylusToCssLoader.js`: +`react` and `react-native` are optional peer dependencies. -- 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. +### Canonical Sheet IR -`patchStylusAddUnit()` monkey-patches Stylus units so `1u` is converted to `8px` during Stylus compilation. +`compileCss()` and `compileCssTemplate()` return JSON-serializable sheets: -### CSS-to-RN Loader - -`packages/loaders/cssToReactNativeLoader.js`: +```ts +interface CompiledCssSheet { + version: 1 + id: string + sourceId?: string + contentHash: string + rules: CssxRule[] + keyframes: Record + exports?: Record + metadata: CssxMetadata + diagnostics: CssxDiagnostic[] + error?: CssxDiagnostic +} +``` -- 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 = { ... }`. +Rules preserve: -The metadata fields are consumed by `packages/runtime/process.js` and `packages/runtime/processCached.js`; changing them requires coordinated runtime updates. +- selector text. +- class list. +- logical part target, or `null` for root. +- class-count specificity. +- source order. +- optional media condition. +- declaration order and source locations. -## Runtime +The sheet must remain serializable. Cache state, subscriptions, and runtime trackers live outside the sheet. -Runtime entrypoints live in `packages/runtime/entrypoints/*`. Each entrypoint: +### Compiler -1. injects platform helpers through `setPlatformHelpers()`. -2. initializes the dimensions updater. -3. exports either the normal `process` function or the teamplay-cached `process` function. +`src/compiler.ts` parses CSS with the lightweight `css` parser. Runtime mode returns an empty diagnostic sheet on syntax errors. Build mode throws for errors that should fail Babel/loader builds. -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 }`. +Supported selectors: -### Platform Helpers +- `.root` +- `.root.active` +- `.root:part(label)` +- `.root.active:part(icon)` +- `.root:hover` +- `.root:active` +- `:export` -`packages/runtime/platformHelpers/index.js` stores the active helper implementation. Helpers provide: +`:hover` maps to `hoverStyle`; `:active` maps to `activeStyle`. Unsupported selectors are ignored with diagnostics in runtime mode. -- `getDimensions()` -- `getPlatform()` -- `isPureReact()` -- `initDimensionsUpdater()` +`:root` custom-property declarations and declaration-level custom properties are intentionally not used as defaults. Use `setDefaultVariables()` for defaults. -`platformHelpers/web.js` uses `window.innerWidth`/`innerHeight`, falls back to `1024x768` without `window`, reports platform `web`, and marks pure React mode true. +### Value Resolution -`platformHelpers/react-native.js` uses React Native `Dimensions` and `Platform`, reports pure React mode false, and listens for dimension changes. +`src/values.ts` resolves declaration value strings before property transformation: -The runtime logs and throws if helpers are missing, which usually means Babel imported the wrong runtime entrypoint. +1. Replace interpolation slots from `values`. +2. Recursively resolve nested `var()`. +3. Resolve `u`, viewport units, and supported `calc()`. +4. Return dependencies for variables and dimensions. -### Variables and Dimensions +Variable priority is: -`packages/runtime/variables.js` exports: +1. runtime `variables['--name']` +2. `defaultVariables['--name']` +3. inline fallback `var(--name, fallback)` -- default observable `variables` object. -- mutable `defaultVariables`. -- `setDefaultVariables()`. +Unresolved variables, cycles, depth limits, invalid interpolations, and unsupported `calc()` invalidate only the containing declaration. Earlier fallback declarations in the same rule still apply. -Resolution order is: +`1u = 8px`. Viewport units resolve from current dimensions. `calc()` supports arithmetic that can reduce to a concrete numeric or pixel value; layout-dependent percentages are unsupported. -1. runtime `variables['--name']` -2. `defaultVariables['--name']` -3. inline fallback from `var(--name, fallback)` +### Property Transformation -`packages/runtime/dimensions.js` exports an observable `{ width: 0 }` singleton plus an initialization flag. +`src/transform/index.ts` turns final CSS declaration values into React Native/web style props. It supports: -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. +- raw camelCase property pass-through. +- margin/padding/border/border-radius/border-width/border-color shorthands. +- transform arrays. +- text-shadow. +- box-shadow string pass-through. +- `filter` string pass-through. +- animation and transition shorthands/longhands. +- keyframe object inlining for Reanimated v4 style props. +- `background-image` and limited `background` shorthand. -### `process()` +For React Native, `background-image` becomes `experimental_backgroundImage`. Only `linear-gradient()` and `radial-gradient()` are emitted; image URLs and other image functions are diagnosed and dropped. -`packages/runtime/process.js` is the main runtime function: +### Resolver And Caching -```js -process(styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) -``` +`src/resolve.ts` composes the compiler, value resolver, property transformer, cascade, and caching. -It: +Public functions: -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`. +- `resolveCssx(options)` +- `cssx(styleName, layers, inlineStyleProps?, options?)` +- `createCssxCache()` -### `matcher()` +Resolver order: -`packages/runtime/matcher.js` is intentionally simple and class-only. +1. Normalize `styleName` with classcat-like semantics. +2. Normalize one or more sheet layers. +3. Match selectors by class set. +4. Filter inactive media rules. +5. Group by output prop: `style`, `{part}Style`, `hoverStyle`, `activeStyle`. +6. Apply cascade by layer, specificity, and source order. +7. Resolve dynamic values declaration-by-declaration. +8. Transform final declarations. +9. Inline only keyframes referenced by active animation declarations. +10. Merge inline style props last. -Input `styleName` is normalized through an embedded classcat-style function. Supported shapes are strings, arrays, and object flags. +Runtime caches are bounded. Static cache keys include sheet identity, style names, and a JSON/hash of inline style props. Dynamic signatures include only used variables, used media matches, and dimensions when actually used. Interpolated local templates keep one effective cache entry per tracked sheet call shape; changing values replaces the same cache slot instead of growing historical variants. -For each selector in each style object: +`JSON.stringify()` is intentionally used for inline style value hashing. Cyclic inline style objects are treated as uncacheable. -- `: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. +### React Runtime -Application order is: +`src/react/**` adds React integration without making `cssx()` a hook. -1. file styles -2. global inline templates -3. local inline templates -4. inline style props +Key pieces: -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. +- `store.ts`: `variables`, `defaultVariables`, `setDefaultVariables()`, dimensions/media state, microtask-batched notifications. +- `tracker.ts`: `TrackedCssxSheet`, committed dependency snapshots, per-tracker cache. +- `cssx.ts`: ergonomic `cssx()` wrapper that delegates to `resolveCssx()` and records dependencies into tracked sheets during render. +- `hooks.ts`: `useCssxSheet()`, `useCompiledCss()`, `useCssxTemplate()`. +- `config.ts`: optional `CssxProvider`, `configureCssx()`, and `useCssxConfig()`. -There is also a legacy matcher mode when `inlineStyleProps` is omitted. It returns only root style arrays and exists for older `*StyleName` conversion behavior. +`useCssxSheet()` starts a render-local dependency collection before render and commits it in a layout/effect phase. If a render is aborted, for example because a component throws a promise into Suspense, the pending dependencies are not committed and do not leak global subscriptions. -### Cached Runtime +Variable writes and deletes notify subscribers once per microtask. Subscribers only rerender when a variable they actually used changes. Media and viewport-unit subscribers are tied to dimension changes. Web resize uses leading plus trailing debounced updates. -`packages/runtime/processCached.js` wraps `process()` with `teamplay/cache` `singletonMemoize`. +## Loaders And Separate Files -The cache normalizer hashes: +Stylus remains separate from CSS-to-RN transformation: -- `styleName` -- each style object's `__hash__` or full object -- `inlineStyleProps` +1. `stylusToCssLoader` compiles Stylus to CSS and preserves current project/UI auto-import behavior. +2. `cssToReactNativeLoader` calls `compileCss()` or `compileCssTemplate()` from `@cssxjs/css-to-rn`. +3. The loader emits `module.exports = `. -The cache invalidation hook watches: +`cssToReactNativeLoader` still handles `:export` compatibility by exposing exports as top-level properties on the emitted object. It also adds `__hash__` for old generated-code compatibility, but the new runtime uses sheet IDs and its own cache. -- `dimensions.width` when any style object has `__hasMedia`. -- specific variables listed in `__vars`. +The loader is CommonJS because Babel and webpack loader APIs are synchronous CommonJS. In normal Node >=22 usage it can require the ESM package directly. Jest's CommonJS runtime cannot, so plugin tests use the Teamplay-style TS/Jest setup and a test-only child-process fallback when Jest intercepts ESM loading. -The cached runtime depends on `teamplay` being installed. It is selected explicitly with `cache: 'teamplay'` or implicitly by importing `observer` from `teamplay` or `startupjs`. +Metro separate-file support lives in `packages/bundler`. Inline templates do not need Metro loader setup. ## Component Parts -Parts are a two-sided compile-time and runtime protocol. +Parts are a compile-time/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. - -On the parent side, a selector like: +On the parent side: ```stylus .card:part(title) color red ``` -is compiled as a selector that `matcher()` returns under `titleStyle` when the parent element has styleName `card`. +resolves under `titleStyle` when the parent element has `styleName='card'`. -On the child side, JSX like: +On the child side: ```jsx function Card ({ title }) { @@ -362,9 +345,9 @@ function Card ({ title }) { } ``` -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. +is rewritten so the closest likely React component accepts `titleStyle` and appends it to that element's `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='root'` maps to the normal `style` prop. Part names must be statically knowable. Supported `part` values are: @@ -372,33 +355,9 @@ Part names must be statically knowable. Supported `part` values are: - 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`. +Unsupported dynamic part names throw at build time. -## Pug, Type Checking, and Linting +## Pug, Type Checking, And Linting CSSX does not implement the Pug parser itself. It wraps React Pug tooling: @@ -414,79 +373,44 @@ npx cssxjs check [files...] [--project ] 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. - -## Metro and Separate Style Files - -Inline `css`/`styl` templates are handled by Babel and do not require Metro configuration. - -Separate `.cssx.styl` files need bundler support for hot reloading. `packages/bundler/metro-config.js`: - -- 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. - -`packages/bundler/metro-babel-transformer.js`: - -- compiles `.styl` through Stylus then CSS-to-RN. -- compiles `.css` through CSS-to-RN. -- passes resulting JS source to the upstream Metro Babel transformer. - -This path is primarily for imported style files and hot reloading. The preferred component-local path remains inline templates or Pug embedded style blocks. - -## Example App - -`example/` is a pure web demonstration: - -- `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. - -Run it with: - -```sh -yarn start -``` - -from the repository root. - ## Testing -Root script: +Run everything: ```sh yarn test ``` -This loops over every `packages/*` directory and runs each package's `yarn test`. - Useful targeted tests: ```sh -cd packages/runtime && yarn test +cd packages/css-to-rn && npm test cd packages/babel-plugin-rn-stylename-inline && yarn test cd packages/babel-plugin-rn-stylename-to-style && yarn test ``` -Runtime tests live in `packages/runtime/test/*.mjs`. +`@cssxjs/css-to-rn` tests: + +- `test/engine/**`: parser IR, value resolution, property transforms, resolver cascade, cache behavior. +- `test/react/**`: variable batching, dependency tracking, aborted-render safety, tracked cache references. Babel plugin tests use `babel-plugin-tester` and Jest snapshots in: - `packages/babel-plugin-rn-stylename-inline/__tests__/` - `packages/babel-plugin-rn-stylename-to-style/__tests__/` -Many packages currently have placeholder tests that print `No tests yet`. +The inline plugin test package uses a small TypeScript Jest transformer modeled after Teamplay because Jest cannot otherwise load TS/ESM workspace sources through custom export conditions. ## 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. +- Keep `__CSS_GLOBAL__`, `__CSS_LOCAL__`, and the Babel runtime call shape compatible unless both Babel plugins and runtime wrappers change together. +- Keep compiled sheet IR JSON-serializable. +- Keep `@cssxjs/css-to-rn` as the single owner of selector matching, value resolution, property transformation, caching, variables, and dimension/media dependency tracking. +- Do not reintroduce Teamplay or `@nx-js/observer-util` as runtime cache/subscription requirements. +- Keep Stylus-to-CSS separate from CSS-to-style transformation. +- For selector or cascade changes, update resolver tests and Babel snapshots as needed. +- For value syntax changes, update value resolver and transform tests together. +- For interpolation changes, update inline Babel snapshots and resolver cache tests. +- For part injection changes, update tests for destructured props, named props, nested render functions, `root`, and dynamic parts. +- For public API changes, update `docs/`, `AGENTS.md`, and this file. +- Be careful with historical READMEs and changelogs. Prefer current code, current docs, and this architecture document when they conflict. 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/yarn.lock b/yarn.lock index 99f614b..1ca05fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -755,17 +755,6 @@ __metadata: 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/css-to-rn@npm:^0.3.0, @cssxjs/css-to-rn@workspace:packages/css-to-rn": version: 0.0.0-use.local resolution: "@cssxjs/css-to-rn@workspace:packages/css-to-rn" @@ -796,28 +785,6 @@ __metadata: languageName: unknown linkType: soft -"@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" - peerDependencies: - react-native: "*" - teamplay: "*" - peerDependenciesMeta: - react-native: - optional: true - teamplay: - optional: true - languageName: unknown - linkType: soft - "@emnapi/core@npm:^1.1.0": version: 1.3.1 resolution: "@emnapi/core@npm:1.3.1" @@ -2333,13 +2300,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" @@ -3086,17 +3046,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" @@ -4644,13 +4593,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" @@ -5275,13 +5217,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" @@ -5289,13 +5224,6 @@ __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 - languageName: node - linkType: hard - "css@npm:^3.0.0": version: 3.0.0 resolution: "css@npm:3.0.0" @@ -5529,13 +5457,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" @@ -10077,13 +9998,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" @@ -10807,7 +10721,7 @@ __metadata: languageName: node linkType: hard -"mocha@npm:^8.1.1, mocha@npm:^8.4.0": +"mocha@npm:^8.4.0": version: 8.4.0 resolution: "mocha@npm:8.4.0" dependencies: @@ -12081,7 +11995,7 @@ __metadata: languageName: node linkType: hard -"postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.2.0": +"postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" checksum: 10c0/f4142a4f56565f77c1831168e04e3effd9ffcc5aebaf0f538eee4b2d465adfd4b85a44257bb48418202a63806a7da7fe9f56c330aebb3cac898e46b4cbf49161 From b241636c059eebc0f03885a70cc66b295f661be4 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:51:42 +0300 Subject: [PATCH 09/37] Update docs for unified CSS runtime --- docs/api/babel.md | 9 +- docs/api/css.md | 95 +++++++++++++ docs/api/index.md | 17 ++- docs/api/jsx-props.md | 51 ++++--- docs/api/styl.md | 32 ++++- docs/api/variables.md | 34 ++--- docs/guide/animations.md | 6 +- docs/guide/caching.md | 286 ++++++++++++++------------------------- docs/guide/index.md | 4 +- docs/guide/usage.md | 59 ++++++-- docs/guide/variables.md | 11 +- docs/index.md | 2 +- 12 files changed, 352 insertions(+), 254 deletions(-) diff --git a/docs/api/babel.md b/docs/api/babel.md index 4c2c979..e2a40b9 100644 --- a/docs/api/babel.md +++ b/docs/api/babel.md @@ -21,7 +21,7 @@ module.exports = { |--------|------|---------|-------------| | `platform` | `'web'` \| `'ios'` \| `'android'` | `'web'` | Target platform | | `reactType` | `'react-native'` \| `'web'` | auto | React target type | -| `cache` | `'teamplay'` | auto | Caching library | +| `cache` | `'teamplay'` | auto | Legacy compatibility alias | | `transformPug` | `boolean` | `true` | Enable Pug transformation | | `transformCss` | `boolean` | `true` | Enable CSS transformation | @@ -33,7 +33,7 @@ module.exports = { presets: [ ['cssxjs/babel', { transformPug: false, // Disable pug if not using it - cache: 'teamplay' // Force teamplay caching + cache: 'teamplay' // Legacy compatibility alias }] ] } @@ -61,7 +61,9 @@ 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. The old `cache: 'teamplay'` +option is still accepted so existing configs do not break, but CSSX no longer +imports Teamplay and components do not need `observer()`. See the [Caching guide](/guide/caching) for more details. @@ -103,6 +105,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..c0b53e3 100644 --- a/docs/api/css.md +++ b/docs/api/css.md @@ -122,6 +122,39 @@ 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); +} +``` + +### 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 @@ -134,6 +167,65 @@ The custom `u` unit works in `css` too: } ``` +### 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 + +Use `useCompiledCss()` and `cssx()` for CSS generated at runtime, such as CSS +returned by an AI system. + +```jsx +import { cssx, useCompiledCss } from 'cssxjs' + +function Button({ generatedCss, disabled, label }) { + const sheet = useCompiledCss(generatedCss) + + return ( +
+ {label} +
+ ) +} +``` + +Runtime compilation uses graceful diagnostics by default. Invalid CSS does not +throw during render; the returned sheet contains diagnostics and any rules that +could still be compiled. + ## Limitations The `css` template does **not** support: @@ -141,6 +233,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,7 +247,9 @@ 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 | `useCompiledCss()` | ## See Also diff --git a/docs/api/index.md b/docs/api/index.md index 2502ccf..1794135 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -12,8 +12,12 @@ import { variables, setDefaultVariables, defaultVariables, - dimensions, - matcher + cssx, + useCompiledCss, + useCssxSheet, + useCssxTemplate, + CssxProvider, + configureCssx } from 'cssxjs' ``` @@ -28,6 +32,7 @@ import { - [styl() Function](/api/styl-function) — Apply styles via spread - [JSX Props](/api/jsx-props) — `styleName`, `part` - [CSS Variables](/api/variables) — Runtime theming +- [Caching](/guide/caching) — Built-in cache and runtime CSS helpers **Configuration:** - [Babel Config](/api/babel) — Preset options @@ -43,5 +48,9 @@ import { | `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) | +| `cssx` | Function | Resolve a runtime sheet and `styleName` to props | +| `useCompiledCss` | Hook | Compile runtime CSS text into a tracked sheet | +| `useCssxSheet` | Hook | Track an already compiled sheet | +| `useCssxTemplate` | Hook | Track a compiled sheet with interpolation values | +| `CssxProvider` | Component | Provide runtime options to a subtree | +| `configureCssx` | Function | Configure global runtime defaults | diff --git a/docs/api/jsx-props.md b/docs/api/jsx-props.md index b234ca5..1a373da 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,35 @@ 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 +```jsx +import { cssx, useCompiledCss } from 'cssxjs' + +function GeneratedCard({ cssText, selected }) { + const sheet = useCompiledCss(cssText) + + return ( + + ) +} +``` -**Returns:** An object with style props, including `style` and any `{part}Style` props. +`cssx()` returns an object with `style` and any part style props such as +`titleStyle`, `hoverStyle`, or `activeStyle`. diff --git a/docs/api/styl.md b/docs/api/styl.md index 5a3974a..361c273 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,9 +272,9 @@ 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 `useCompiledCss()` with the `css` runtime API ## See Also diff --git a/docs/api/variables.md b/docs/api/variables.md index d3a25b8..27ef2bc 100644 --- a/docs/api/variables.md +++ b/docs/api/variables.md @@ -6,7 +6,7 @@ CSSX provides a reactive system for CSS variables that works at runtime. A reactive object for setting CSS variable values at runtime. Assigning values triggers automatic re-renders in components using those variables. -**Type:** `Observable>` +**Type:** `Record` ```jsx import { variables } from 'cssxjs' @@ -25,7 +25,8 @@ Object.assign(variables, { ``` **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' @@ -81,7 +82,8 @@ setDefaultVariables({ ## defaultVariables -A read-only object containing the default variable values set by `setDefaultVariables`. +A reactive object containing the default variable values set by +`setDefaultVariables`. **Type:** `Record` @@ -91,24 +93,6 @@ import { defaultVariables } from 'cssxjs' console.log(defaultVariables['--primary-color']) // '#007bff' ``` ---- - -## dimensions - -A reactive object containing the current screen width. Used internally for media query support. - -**Type:** `Observable<{ width: number }>` - -```jsx -import { dimensions } from 'cssxjs' - -console.log(dimensions.width) // e.g., 375 -``` - -The `width` property automatically updates when the screen size changes, triggering re-renders in components using media queries. - ---- - ## Variable Resolution Order CSS variables resolve in this priority (highest first): @@ -126,3 +110,11 @@ styl` 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/guide/animations.md b/docs/guide/animations.md index b64ce5f..9234b2c 100644 --- a/docs/guide/animations.md +++ b/docs/guide/animations.md @@ -323,8 +323,12 @@ CSSX compiles animations in a way Reanimated v4 expects: This means you write standard CSS and get native-compatible animations automatically. +Animation and transition values are static-only. Use class changes, CSS +variables, or template interpolation to change the surrounding styles at +runtime; keyframe definitions themselves are compiled from static CSS. + ## 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..a77da72 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -1,59 +1,35 @@ -# 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. There is no `observer()` wrapper and +no Teamplay dependency required. ## How It Works -Without caching, CSSX computes styles on every render: - -```jsx -import { View, Text } from 'react-native' - -function Card({ title }) { - // Style computation runs on EVERY render - return ( - - {title} - - ) - - styl` - .card - padding 16px - background white - ` -} -``` - -With caching enabled, CSSX memoizes the results: +For Babel-compiled styles, generated code calls the CSSX runtime with the +compiled sheet and the current `styleName` value. For runtime CSS strings, +`useCompiledCss()` wraps the compiled sheet in a tracked runtime object. -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 +The resolver caches the final props object for the current inputs: -## Setup +- 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 -### Step 1: Install teamplay - -```bash -npm install teamplay -``` +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. -### Step 2: Wrap Components with observer +## No Setup Required -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 +48,130 @@ 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 +The Babel preset inserts the runtime calls for you. -### Cache Key Components +## Dependency-Aware Updates -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 - -### 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 +Variable notifications are batched in a microtask. Media query updates use the +runtime dimension store, and web resize handling can be configured through +`configureCssx()` or `CssxProvider`. -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 +```jsx +import { configureCssx } from 'cssxjs' -Example with a list: +configureCssx({ + dimensionsDebounceMs: 50 +}) +``` -```jsx -import { observer } from 'teamplay' -import { styl } from 'cssxjs' -import { View, Text } from 'react-native' +## Runtime CSS Strings -const ListItem = observer(function ListItem({ item, isSelected }) { - return ( - - {item.name} - {item.price} - - ) +For client-generated CSS, compile the string with `useCompiledCss()` and pass the +tracked sheet to `cssx()` inline: - 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 - ` -}) +```jsx +import { cssx, useCompiledCss } from 'cssxjs' + +function Button({ generatedCss, disabled, label }) { + const sheet = useCompiledCss(generatedCss) -// Rendering 1000 items benefits significantly from caching -function ProductList({ products, selectedId }) { return ( - - {products.map(item => ( - - ))} - +
+ {label} +
) } ``` -## Using with startupjs +Runtime compilation is graceful by default. Invalid generated CSS produces an +empty or partially compiled sheet with diagnostics attached to the sheet instead +of throwing during render. -If you're using the [startupjs](https://github.com/startupjs/startupjs) framework, caching is automatically configured. Just import `observer` from `startupjs`: +## Inline Style Hashing -```jsx -import { observer, styl } from 'startupjs' -import { View, Text } from 'react-native' +Inline styles are deep-hashed with `JSON.stringify()`. This means callers can +write natural inline objects without manually memoizing every object: -export default observer(function MyComponent() { - return ( - - Content - - ) - - 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 +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. + +## Manual Runtime API + +The public helpers exported from `cssxjs` are: + +```ts +useCompiledCss(cssText, options?) +useCssxSheet(compiledSheet, options?) +useCssxTemplate(compiledSheet, values, options?) +cssx(styleName, sheet, inlineStyleProps?, options?) +CssxProvider +configureCssx(options) +``` + +Most applications only need `styleName`. Use these helpers when CSS arrives as a +runtime string or when building lower-level components that do not use Babel's +`styleName` transform. -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: +## Legacy `cache: 'teamplay'` -- `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) +The Babel option `cache: 'teamplay'` is still accepted for older configs, but it +is now a compatibility alias. CSSX owns its cache internally and does not import +Teamplay. ## Next Steps -- [Examples](/examples/) - Complete component examples -- [API Reference](/api/) - Complete API documentation +- [CSS Variables](/guide/variables) - Runtime theming +- [css Template](/api/css) - Runtime CSS and interpolation +- [Animations](/guide/animations) - Reanimated v4 output diff --git a/docs/guide/index.md b/docs/guide/index.md index 413f7e8..dc386df 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -137,7 +137,9 @@ Built-in `u` unit (1u = 8px) for consistent spacing: ### 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 diff --git a/docs/guide/usage.md b/docs/guide/usage.md index a10b061..3805d87 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -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 [CSS Variables](/guide/variables) for app-wide runtime theming and shared +tokens. ## Style Placement @@ -246,11 +266,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 +284,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 ` diff --git a/docs/guide/variables.md b/docs/guide/variables.md index b56eb35..421b8f6 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -90,7 +90,9 @@ function ThemeToggle() { } ``` -When you assign to `variables`, all components using those variables automatically re-render. +When you assign to `variables`, components that used those specific variables in +their resolved styles automatically re-render. Unrelated variable changes do not +invalidate the component. ## Variable Priority @@ -112,7 +114,8 @@ styl` ## Using Variables in Complex Values -Variables work within compound CSS values: +Variables work within compound CSS values, nested fallbacks, shorthands, and +comma-separated value chunks: ```jsx styl` @@ -122,6 +125,8 @@ styl` border var(--border-width, 1px) solid var(--border-color, #ddd) transform translateX(var(--translate-x, 0)) scale(var(--scale, 1)) + + background-image var(--hero-gradient, linear-gradient(0deg, white, transparent)) ` ``` @@ -303,4 +308,4 @@ setDefaultVariables({ - [Pug Templates](/guide/pug) - Alternative JSX syntax - [Animations](/guide/animations) - CSS transitions and keyframes -- [Caching](/guide/caching) - Performance optimization with teamplay +- [Caching](/guide/caching) - Built-in dependency-aware caching diff --git a/docs/index.md b/docs/index.md index e17c67c..6f2ac24 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,6 @@ features: details: Built-in 'u' unit (1u = 8px) for consistent spacing following Material Design guidelines 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: 🚀 --- From c6dff5a3d2327726ea67172828427ff235a6dc5d Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:52:55 +0300 Subject: [PATCH 10/37] Clarify built-in CSSX caching docs --- .../babel-plugin-rn-stylename-to-style/README.md | 5 +++-- .../babel-plugin-rn-stylename-to-style/index.js | 13 +++++++------ packages/babel-preset-cssxjs/index.js | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/babel-plugin-rn-stylename-to-style/README.md b/packages/babel-plugin-rn-stylename-to-style/README.md index c73dde5..7137197 100644 --- a/packages/babel-plugin-rn-stylename-to-style/README.md +++ b/packages/babel-plugin-rn-stylename-to-style/README.md @@ -131,8 +131,9 @@ so these files shouldn't frequently change. **Default:** `undefined` -Whether to use integration with some caching library. Currently supported ones: -- `"teamplay"` +Legacy compatibility option. `"teamplay"` is still accepted for older configs, +but style caching is owned by CSSX internally and does not require Teamplay or +`observer()`. #### `platform` diff --git a/packages/babel-plugin-rn-stylename-to-style/index.js b/packages/babel-plugin-rn-stylename-to-style/index.js index 5e51549..9a62ab7 100644 --- a/packages/babel-plugin-rn-stylename-to-style/index.js +++ b/packages/babel-plugin-rn-stylename-to-style/index.js @@ -140,9 +140,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 @@ -521,7 +521,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 @@ -587,8 +588,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)) { diff --git a/packages/babel-preset-cssxjs/index.js b/packages/babel-preset-cssxjs/index.js index cb5d9a5..9fe5e71 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, From 734653f7b1aec7e240f598826ff2534b6f812e01 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 23:13:11 +0300 Subject: [PATCH 11/37] Address CSSX runtime review findings --- docs/guide/caching.md | 4 +- .../__snapshots__/index.spec.js.snap | 1336 ++++++++--------- .../index.js | 105 +- .../package.json | 2 +- packages/babel-preset-cssxjs/index.js | 1 + packages/css-to-rn/dist/compiler.d.ts | 3 + packages/css-to-rn/dist/compiler.js | 280 ++++ packages/css-to-rn/dist/diagnostics.d.ts | 6 + packages/css-to-rn/dist/diagnostics.js | 16 + packages/css-to-rn/dist/hash.d.ts | 2 + packages/css-to-rn/dist/hash.js | 10 + packages/css-to-rn/dist/index.d.ts | 7 + packages/css-to-rn/dist/index.js | 4 + packages/css-to-rn/dist/react-native.d.ts | 28 + packages/css-to-rn/dist/react-native.js | 73 + packages/css-to-rn/dist/react/config.d.ts | 12 + packages/css-to-rn/dist/react/config.js | 19 + packages/css-to-rn/dist/react/cssx.d.ts | 18 + packages/css-to-rn/dist/react/cssx.js | 137 ++ packages/css-to-rn/dist/react/hooks.d.ts | 15 + packages/css-to-rn/dist/react/hooks.js | 76 + packages/css-to-rn/dist/react/index.d.ts | 10 + packages/css-to-rn/dist/react/index.js | 5 + packages/css-to-rn/dist/react/store.d.ts | 52 + packages/css-to-rn/dist/react/store.js | 282 ++++ packages/css-to-rn/dist/react/tracker.d.ts | 40 + packages/css-to-rn/dist/react/tracker.js | 126 ++ packages/css-to-rn/dist/resolve.d.ts | 57 + packages/css-to-rn/dist/resolve.js | 431 ++++++ packages/css-to-rn/dist/selectors.d.ts | 8 + packages/css-to-rn/dist/selectors.js | 53 + packages/css-to-rn/dist/transform/index.d.ts | 32 + packages/css-to-rn/dist/transform/index.js | 1129 ++++++++++++++ packages/css-to-rn/dist/types.d.ts | 77 + packages/css-to-rn/dist/types.js | 1 + packages/css-to-rn/dist/values.d.ts | 22 + packages/css-to-rn/dist/values.js | 247 +++ packages/css-to-rn/dist/web.d.ts | 28 + packages/css-to-rn/dist/web.js | 54 + packages/css-to-rn/src/compiler.ts | 21 +- packages/css-to-rn/src/react-native.ts | 34 + packages/css-to-rn/src/react/hooks.ts | 77 +- packages/css-to-rn/src/react/index.ts | 5 + packages/css-to-rn/src/react/store.ts | 62 +- packages/css-to-rn/src/react/tracker.ts | 16 +- packages/css-to-rn/src/vendor.d.ts | 10 + packages/css-to-rn/src/web.ts | 13 + .../css-to-rn/test/engine/compiler.test.ts | 11 + .../css-to-rn/test/react/tracking.test.ts | 146 ++ packages/cssxjs/index.d.ts | 1 + packages/cssxjs/index.js | 1 + packages/cssxjs/runtime/react-native.js | 1 + packages/cssxjs/runtime/web.js | 1 + 53 files changed, 4465 insertions(+), 742 deletions(-) create mode 100644 packages/css-to-rn/dist/compiler.d.ts create mode 100644 packages/css-to-rn/dist/compiler.js create mode 100644 packages/css-to-rn/dist/diagnostics.d.ts create mode 100644 packages/css-to-rn/dist/diagnostics.js create mode 100644 packages/css-to-rn/dist/hash.d.ts create mode 100644 packages/css-to-rn/dist/hash.js create mode 100644 packages/css-to-rn/dist/index.d.ts create mode 100644 packages/css-to-rn/dist/index.js create mode 100644 packages/css-to-rn/dist/react-native.d.ts create mode 100644 packages/css-to-rn/dist/react-native.js create mode 100644 packages/css-to-rn/dist/react/config.d.ts create mode 100644 packages/css-to-rn/dist/react/config.js create mode 100644 packages/css-to-rn/dist/react/cssx.d.ts create mode 100644 packages/css-to-rn/dist/react/cssx.js create mode 100644 packages/css-to-rn/dist/react/hooks.d.ts create mode 100644 packages/css-to-rn/dist/react/hooks.js create mode 100644 packages/css-to-rn/dist/react/index.d.ts create mode 100644 packages/css-to-rn/dist/react/index.js create mode 100644 packages/css-to-rn/dist/react/store.d.ts create mode 100644 packages/css-to-rn/dist/react/store.js create mode 100644 packages/css-to-rn/dist/react/tracker.d.ts create mode 100644 packages/css-to-rn/dist/react/tracker.js create mode 100644 packages/css-to-rn/dist/resolve.d.ts create mode 100644 packages/css-to-rn/dist/resolve.js create mode 100644 packages/css-to-rn/dist/selectors.d.ts create mode 100644 packages/css-to-rn/dist/selectors.js create mode 100644 packages/css-to-rn/dist/transform/index.d.ts create mode 100644 packages/css-to-rn/dist/transform/index.js create mode 100644 packages/css-to-rn/dist/types.d.ts create mode 100644 packages/css-to-rn/dist/types.js create mode 100644 packages/css-to-rn/dist/values.d.ts create mode 100644 packages/css-to-rn/dist/values.js create mode 100644 packages/css-to-rn/dist/web.d.ts create mode 100644 packages/css-to-rn/dist/web.js diff --git a/docs/guide/caching.md b/docs/guide/caching.md index a77da72..9c70035 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -76,8 +76,8 @@ variables['--text-color'] = 'red' // ThemedCard does not update ``` Variable notifications are batched in a microtask. Media query updates use the -runtime dimension store, and web resize handling can be configured through -`configureCssx()` or `CssxProvider`. +runtime dimension store, and web resize handling can be configured globally +through `configureCssx()`. ```jsx import { configureCssx } from 'cssxjs' 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 6354e7e..c96a779 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,38 @@ 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 _cssx = _runtime; function Test({ itemStyle: _itemStyle, style: _style, items, ...props }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( {(() => { const __pugEachResult = []; for (const item of items) { __pugEachResult.push( {item} @@ -83,9 +81,19 @@ 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 _cssx = _runtime; function Test({ style, active, submit, disabled }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); const titleStyle = { color: "red", fontWeight: "bold", @@ -99,39 +107,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 +137,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", @@ -176,50 +172,26 @@ 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 _cssx = _runtime; function Test() { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( -
- - Title - - +
+ Title + Description -
@@ -243,9 +215,19 @@ 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 _cssx = _runtime; function Test({ style, active, submit, disabled, titleStyle }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( ); @@ -331,20 +307,23 @@ 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 _cssx = _runtime; function Layout({ style, children }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); return ( {children} @@ -377,7 +356,10 @@ 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 _cssx = _runtime; function Menu({ style, @@ -389,30 +371,24 @@ function Menu({ activeColor, ...props }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); return (
{children}
@@ -439,22 +415,26 @@ 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 _cssx = _runtime; export default function ComponentFactory(title) { return function Component(_props) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function renderItem() { return ( {title} @@ -462,28 +442,16 @@ export default function ComponentFactory(title) { } const renderFooter = () => (
); return (
{renderItem()} {renderFooter()} @@ -505,20 +473,24 @@ 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 _cssx = _runtime; export default function Test(_props) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return (
); } @@ -539,35 +511,33 @@ 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 _cssx = _runtime; export const Test = (_props) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); const renderItem = () => { return (
); }; return (
{renderItem()}
@@ -590,35 +560,33 @@ 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 _cssx = _runtime; export const Test = (_props) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function renderItem() { return (
); } return (
{renderItem()}
@@ -638,20 +606,24 @@ 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 _cssx = _runtime; export const Test = function (_props) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return (
); }; @@ -669,20 +641,24 @@ 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 _cssx = _runtime; export const Test = (_props) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return (
); }; @@ -698,20 +674,19 @@ function Test () { ↓ ↓ ↓ ↓ ↓ ↓ -import { runtime as _runtime } from "cssxjs/runtime"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; const _cssx = _runtime; function Test() { - return ( -
+ const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + return
; } @@ -729,20 +704,20 @@ 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 _cssx = _runtime; function Test() { - return ( -
+ const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + const _file__css = _useCssxLayer(_css); + return
; } @@ -760,20 +735,20 @@ 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 _cssx = _runtime; function Test() { - return ( -
+ const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + const _file__css = _useCssxLayer(_css); + return
; } @@ -791,20 +766,20 @@ 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 _cssx = _runtime; function Test() { - return ( -
+ const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + const _file__css = _useCssxLayer(_css); + return
; } @@ -822,20 +797,20 @@ 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 _cssx = _runtime; function Test() { - return ( -
+ const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); + return
; } @@ -855,9 +830,19 @@ 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 _cssx = _runtime; function Test({ style, active, submit, disabled }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return (
); @@ -915,67 +894,51 @@ 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 _cssx = _runtime; function Test({ style }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); const titleStyle = { color: "red", fontWeight: "bold", }; return (
Title Description -
@@ -1001,50 +964,26 @@ 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 _cssx = _runtime; function Test() { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( -
- - Title - - +
+ Title + Description -
@@ -1071,52 +1010,43 @@ 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 _cssx = _runtime; export default observer(function Test() { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); return (
Hello @@ -1190,9 +1120,19 @@ 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 _cssx = _runtime; function Test({ style, active, submit, disabled, titleStyle }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( ); @@ -1263,7 +1197,10 @@ 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 _cssx = _runtime; const Test = ({ columnStyle: _columnStyle, @@ -1275,41 +1212,36 @@ const Test = ({ title, ...props }) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function render() { return ( ); @@ -1341,7 +1273,10 @@ 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 _cssx = _runtime; const Test = ({ activeStyle: _activeStyle, @@ -1351,37 +1286,32 @@ const Test = ({ title, ...props }) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function render() { return ( ); @@ -1413,7 +1343,10 @@ 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 _cssx = _runtime; const Test = ({ activeStyle: _activeStyle, @@ -1423,40 +1356,35 @@ const Test = ({ title, ...props }) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function render() { return ( ); @@ -1560,7 +1488,10 @@ 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 _cssx = _runtime; function Test({ activeStyle: _activeStyle, @@ -1569,36 +1500,31 @@ function Test({ style, ...props }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( ); @@ -1625,7 +1551,10 @@ 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 _cssx = _runtime; function Test({ activeStyle: _activeStyle, @@ -1634,36 +1563,31 @@ function Test({ title, ...props }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( ); @@ -1695,48 +1619,46 @@ 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 _cssx = _runtime; function Test(_props) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( + - ); @@ -1766,7 +1688,10 @@ 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 _cssx = _runtime; const Test = ({ activeStyle: _activeStyle, @@ -1776,37 +1701,32 @@ const Test = ({ title, ...props }) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function render() { return ( ); @@ -1835,7 +1755,10 @@ 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 _cssx = _runtime; function Test({ activeStyle: _activeStyle, @@ -1845,36 +1768,31 @@ function Test({ title, ...props }) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( ); @@ -1901,39 +1819,37 @@ 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 _cssx = _runtime; function Test(props) { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); return ( ); @@ -1968,7 +1884,10 @@ 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 _cssx = _runtime; const Test = ({ style, @@ -1979,26 +1898,27 @@ const Test = ({ title, ...props }) => { + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + const _file__css = _useCssxLayer(_css); function render() { return ( diff --git a/packages/babel-plugin-rn-stylename-to-style/index.js b/packages/babel-plugin-rn-stylename-to-style/index.js index 9a62ab7..35671ab 100644 --- a/packages/babel-plugin-rn-stylename-to-style/index.js +++ b/packages/babel-plugin-rn-stylename-to-style/index.js @@ -13,6 +13,7 @@ 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 GLOBAL_NAME = '__CSS_GLOBAL__' const LOCAL_NAME = '__CSS_LOCAL__' @@ -42,6 +43,7 @@ module.exports = function (babel) { let $program let usedCompilers let runtime + let useCssxLayer function getOrCreateRuntime (state) { if (runtime) return runtime @@ -69,15 +71,42 @@ module.exports = function (babel) { return runtime } - function getStyleFromExpression (expression, state) { + function getOrCreateUseCssxLayer (state) { + if (useCssxLayer) return useCssxLayer + const runtimePath = getRuntimePath($program, state, hasObserver) + useCssxLayer = addNamedImport($program, RUNTIME_LAYER_HOOK_NAME, runtimePath) + 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) @@ -194,10 +223,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) ] ) @@ -238,11 +282,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) ] } @@ -277,6 +321,7 @@ module.exports = function (babel) { $program = undefined usedCompilers = undefined runtime = undefined + useCssxLayer = undefined }, visitor: { Program: { @@ -418,10 +463,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([]) @@ -616,6 +676,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 94424a6..e45ceb5 100644 --- a/packages/babel-plugin-rn-stylename-to-style/package.json +++ b/packages/babel-plugin-rn-stylename-to-style/package.json @@ -14,7 +14,7 @@ ], "main": "index.js", "scripts": { - "test": "jest" + "test": "NO_COLOR=1 FORCE_COLOR=0 jest" }, "author": "Pavel Zhukov", "license": "MIT", diff --git a/packages/babel-preset-cssxjs/index.js b/packages/babel-preset-cssxjs/index.js index 9fe5e71..b0ef840 100644 --- a/packages/babel-preset-cssxjs/index.js +++ b/packages/babel-preset-cssxjs/index.js @@ -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/css-to-rn/dist/compiler.d.ts b/packages/css-to-rn/dist/compiler.d.ts new file mode 100644 index 0000000..a81c6ac --- /dev/null +++ b/packages/css-to-rn/dist/compiler.d.ts @@ -0,0 +1,3 @@ +import type { CompileCssOptions, CompileCssTemplateOptions, CompiledCssSheet } from './types.ts' +export declare function compileCss (css: string, options?: CompileCssOptions): CompiledCssSheet +export declare function compileCssTemplate (css: string, options?: CompileCssTemplateOptions): CompiledCssSheet diff --git a/packages/css-to-rn/dist/compiler.js b/packages/css-to-rn/dist/compiler.js new file mode 100644 index 0000000..f289f9c --- /dev/null +++ b/packages/css-to-rn/dist/compiler.js @@ -0,0 +1,280 @@ +import parseCss from 'css/lib/parse/index.js' +import mediaQuery from 'css-mediaquery' +import valueParser from 'postcss-value-parser' +import { addDiagnostic, diagnostic } from './diagnostics.js' +import { cssxHash } from './hash.js' +import { parseSelector } from './selectors.js' +const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/ +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, options = {}) { + return compileCssInternal(css, options) +} +export function compileCssTemplate (css, options = {}) { + return compileCssInternal(css, { + ...options, + sourceIdentity: options.sourceIdentity ?? options.id + }, true) +} +function compileCssInternal (css, options, isTemplate = false) { + const mode = options.mode ?? 'runtime' + const state = { 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 = () => createSheet({ + id, + sourceId, + contentHash, + diagnostics: state.diagnostics, + error: state.diagnostics.find(item => item.level === 'error') + }) + let ast + try { + ast = parseCss(css, { silent: false }) + } catch (error) { + const err = error + const item = diagnostic('CSS_SYNTAX_ERROR', err.reason ?? err.message, 'error', { line: err.line, column: err.column }) + addDiagnostic(state, item) + return empty() + } + const rules = [] + const keyframes = {} + const exports = {} + let order = 0 + for (const rule of ast.stylesheet?.rules ?? []) { + if (rule.type === 'rule') { + const styleRule = rule + compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, state, orderRef(() => order++), isTemplate, exports) + continue + } + if (rule.type === 'media') { + const mediaRule = rule + 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, state, orderRef(() => order++), isTemplate, exports) + } + continue + } + if (rule.type === 'keyframes') { + const keyframesRule = rule + const name = keyframesRule.name + if (!name) { continue } + keyframes[name] = compileKeyframes(keyframesRule, state, orderRef(() => order++), 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, isTemplate) + return createSheet({ + id, + sourceId, + contentHash, + rules, + keyframes, + exports: Object.keys(exports).length > 0 ? exports : undefined, + metadata, + diagnostics: state.diagnostics, + error: state.diagnostics.find(item => item.level === 'error') + }) +} +function compileRuleList (selectors, declarations, media, output, state, nextOrder, isTemplate, exports) { + for (const selector of selectors) { + if (selector === ':export') { + compileExports(declarations, exports, state, isTemplate) + continue + } + if (selector.trim().startsWith(':root')) { + addDiagnostic(state, diagnostic('UNSUPPORTED_SELECTOR', `Unsupported selector "${selector}" ignored. Use setDefaultVariables() for CSS variable defaults.`, 'warning')) + continue + } + const parsed = parseSelector(selector, positionOfDeclarationList(declarations)) + if (parsed.diagnostic) { + addDiagnostic(state, parsed.diagnostic) + continue + } + if (!parsed.result) { continue } + output.push({ + selector: parsed.result.selector, + classes: parsed.result.classes, + part: parsed.result.part, + specificity: parsed.result.specificity, + order: nextOrder(), + media, + declarations: compileDeclarations(declarations, state, isTemplate) + }) + } +} +function compileExports (declarations, exports, state, isTemplate) { + 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 compileDeclarations (declarations, state, isTemplate) { + const output = [] + 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 + output.push({ + property, + value, + raw: `${property}: ${value}`, + order: order++, + dynamicSlots, + line: declaration.position?.start?.line, + column: declaration.position?.start?.column + }) + } + return output +} +function compileKeyframes (rule, state, nextOrder, isTemplate) { + const output = [] + for (const frame of rule.keyframes ?? []) { + output.push({ + selector: (frame.values ?? []).join(', '), + declarations: compileDeclarations(frame.declarations ?? [], state, isTemplate), + order: nextOrder() + }) + } + return output +} +function validateMedia (rule, state, isTemplate) { + if (isTemplate && hasDynamicSlots(rule.media ?? '')) { + addDiagnostic(state, diagnostic('UNSUPPORTED_INTERPOLATION_POSITION', 'Interpolation is not supported inside media queries.', 'error', positionOf(rule))) + return false + } + try { + mediaQuery.parse(rule.media ?? '') + return true + } catch (error) { + addDiagnostic(state, diagnostic('UNSUPPORTED_AT_RULE', `Unsupported media query "${rule.media ?? ''}" ignored: ${error.message}`, 'warning', positionOf(rule))) + return false + } +} +function buildMetadata (rules, keyframes, isTemplate) { + 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) } + } + function scanDeclarations (declarations) { + 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 + } +} +function collectVars (value, vars) { + 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) { + const slots = [] + DYNAMIC_SLOT_RE.lastIndex = 0 + let match + while ((match = DYNAMIC_SLOT_RE.exec(value)) != null) { + slots.push(Number(match[1])) + } + return slots.length > 0 ? slots : undefined +} +function hasDynamicSlots (value) { + DYNAMIC_SLOT_RE.lastIndex = 0 + return DYNAMIC_SLOT_RE.test(value) +} +function createSheet (input) { + return { + version: 1, + id: input.id, + sourceId: input.sourceId, + contentHash: input.contentHash, + rules: input.rules ?? [], + keyframes: input.keyframes ?? {}, + exports: input.exports, + metadata: input.metadata ?? { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: false + }, + diagnostics: input.diagnostics, + error: input.error + } +} +function orderRef (next) { + return next +} +function positionOf (node) { + return { + line: node.position?.start?.line, + column: node.position?.start?.column + } +} +function positionOfDeclarationList (declarations) { + const first = declarations.find(item => item.position) + return first ? positionOf(first) : undefined +} diff --git a/packages/css-to-rn/dist/diagnostics.d.ts b/packages/css-to-rn/dist/diagnostics.d.ts new file mode 100644 index 0000000..ae585bd --- /dev/null +++ b/packages/css-to-rn/dist/diagnostics.d.ts @@ -0,0 +1,6 @@ +import type { CompileState, CssxDiagnostic, CssxDiagnosticCode, CssxDiagnosticLevel } from './types.ts' +export declare function diagnostic (code: CssxDiagnosticCode, message: string, level?: CssxDiagnosticLevel, position?: { + line?: number; + column?: number; +}): CssxDiagnostic +export declare function addDiagnostic (state: CompileState, item: CssxDiagnostic): void diff --git a/packages/css-to-rn/dist/diagnostics.js b/packages/css-to-rn/dist/diagnostics.js new file mode 100644 index 0000000..1b783b8 --- /dev/null +++ b/packages/css-to-rn/dist/diagnostics.js @@ -0,0 +1,16 @@ +export function diagnostic (code, message, level = 'warning', position) { + return { + level, + code, + message, + line: position?.line, + column: position?.column + } +} +export function addDiagnostic (state, item) { + 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/dist/hash.d.ts b/packages/css-to-rn/dist/hash.d.ts new file mode 100644 index 0000000..9ee0ea3 --- /dev/null +++ b/packages/css-to-rn/dist/hash.d.ts @@ -0,0 +1,2 @@ +export declare function simpleNumericHash (value: string): number +export declare function cssxHash (value: string): string diff --git a/packages/css-to-rn/dist/hash.js b/packages/css-to-rn/dist/hash.js new file mode 100644 index 0000000..9036e77 --- /dev/null +++ b/packages/css-to-rn/dist/hash.js @@ -0,0 +1,10 @@ +// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0 +export function simpleNumericHash (value) { + 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) { + return `cssx_${Math.abs(simpleNumericHash(value)).toString(36)}` +} diff --git a/packages/css-to-rn/dist/index.d.ts b/packages/css-to-rn/dist/index.d.ts new file mode 100644 index 0000000..b931216 --- /dev/null +++ b/packages/css-to-rn/dist/index.d.ts @@ -0,0 +1,7 @@ +export { compileCss, compileCssTemplate } from './compiler.ts' +export { cssxHash, simpleNumericHash } from './hash.ts' +export { resolveCssValue } from './values.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, InlineStyleInput, ResolveCssxDependencies, ResolveCssxLayer, ResolveCssxOptions, ResolveCssxResult, ResolvedStyleProps, StyleNameValue } from './resolve.ts' diff --git a/packages/css-to-rn/dist/index.js b/packages/css-to-rn/dist/index.js new file mode 100644 index 0000000..df99721 --- /dev/null +++ b/packages/css-to-rn/dist/index.js @@ -0,0 +1,4 @@ +export { compileCss, compileCssTemplate } from './compiler.js' +export { cssxHash, simpleNumericHash } from './hash.js' +export { resolveCssValue } from './values.js' +export { createCssxCache, cssx, resolveCssx } from './resolve.js' diff --git a/packages/css-to-rn/dist/react-native.d.ts b/packages/css-to-rn/dist/react-native.d.ts new file mode 100644 index 0000000..495e4cc --- /dev/null +++ b/packages/css-to-rn/dist/react-native.d.ts @@ -0,0 +1,28 @@ +export { compileCss, compileCssTemplate } from './compiler.ts' +export { resolveCssValue } from './values.ts' +import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.ts' +import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.ts' +import { createTrackedCssxSheet } from './react/tracker.ts' +import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, 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 { CssxProviderProps, CssxReactConfig } from './react/config.ts' +export type { TrackedCssxSheetOptions } from './react/tracker.ts' +export { CssxProvider, configureCssx, useCssxConfig } from './react/config.ts' +export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.ts' +export { defaultVariables, setDefaultVariables, variables } +export declare function cssx (...args: Parameters): ReturnType +export declare function useCompiledCss (...args: Parameters): ReturnType +export declare function useCssxLayer (...args: Parameters): ReturnType +export declare function useCssxSheet (...args: Parameters): ReturnType +export declare function useCssxTemplate (...args: Parameters): ReturnType +export declare const __cssxInternals: { + clearRawCssCacheForTests: typeof clearRawCssCacheForTests; + configureDimensionsAdapterForTests: typeof configureDimensionsAdapter; + createTrackedCssxSheet: typeof createTrackedCssxSheet; + flushMicrotasksForTests: typeof flushMicrotasksForTests; + getRuntimeSubscriberCountForTests: typeof getRuntimeSubscriberCountForTests; + resetStoreForTests: typeof resetStoreForTests; + setDimensionsForTests: typeof setDimensionsForTests; + subscribeVariablesForTests: typeof subscribeVariablesForTests; +} diff --git a/packages/css-to-rn/dist/react-native.js b/packages/css-to-rn/dist/react-native.js new file mode 100644 index 0000000..597495c --- /dev/null +++ b/packages/css-to-rn/dist/react-native.js @@ -0,0 +1,73 @@ +export { compileCss, compileCssTemplate } from './compiler.js' +export { resolveCssValue } from './values.js' +import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.js' +import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.js' +import { createTrackedCssxSheet } from './react/tracker.js' +import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './react/store.js' +import { Dimensions } from 'react-native' +export { CssxProvider, configureCssx, useCssxConfig } from './react/config.js' +export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.js' +export { defaultVariables, setDefaultVariables, variables } +installReactNativeDimensionsAdapter() +export function cssx (...args) { + const [styleName, sheet, inlineStyleProps, options] = args + return baseCssx(styleName, sheet, inlineStyleProps, { + target: 'react-native', + ...(options ?? {}) + }) +} +export function useCompiledCss (...args) { + const [input, options] = args + return baseUseCompiledCss(input, { + target: 'react-native', + ...(options ?? {}) + }) +} +export function useCssxLayer (...args) { + const [input, options] = args + return baseUseCssxLayer(input, { + target: 'react-native', + ...(options ?? {}) + }) +} +export function useCssxSheet (...args) { + const [sheet, options] = args + return baseUseCssxSheet(sheet, { + target: 'react-native', + ...(options ?? {}) + }) +} +export function useCssxTemplate (...args) { + const [sheet, values, options] = args + return baseUseCssxTemplate(sheet, values, { + target: 'react-native', + ...(options ?? {}) + }) +} +export const __cssxInternals = { + clearRawCssCacheForTests, + configureDimensionsAdapterForTests: configureDimensionsAdapter, + createTrackedCssxSheet, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setDimensionsForTests, + subscribeVariablesForTests +} +function installReactNativeDimensionsAdapter () { + 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() + } + } + }) +} diff --git a/packages/css-to-rn/dist/react/config.d.ts b/packages/css-to-rn/dist/react/config.d.ts new file mode 100644 index 0000000..07a0992 --- /dev/null +++ b/packages/css-to-rn/dist/react/config.d.ts @@ -0,0 +1,12 @@ +import { type ReactNode } from 'react' +import { type CssxRuntimeConfig } from './store.ts' +import type { TrackedCssxSheetOptions } from './tracker.ts' +export interface CssxReactConfig extends CssxRuntimeConfig, TrackedCssxSheetOptions { +} +export interface CssxProviderProps { + value?: CssxReactConfig; + children?: ReactNode; +} +export declare function configureCssx (config: CssxReactConfig): void +export declare function CssxProvider (props: CssxProviderProps): ReactNode +export declare function useCssxConfig (): CssxReactConfig diff --git a/packages/css-to-rn/dist/react/config.js b/packages/css-to-rn/dist/react/config.js new file mode 100644 index 0000000..3dfc8e8 --- /dev/null +++ b/packages/css-to-rn/dist/react/config.js @@ -0,0 +1,19 @@ +import { createContext, createElement, useContext, useMemo } from 'react' +import { getRuntimeConfig, setRuntimeConfig } from './store.js' +const CssxConfigContext = createContext(null) +export function configureCssx (config) { + setRuntimeConfig(config) +} +export function CssxProvider (props) { + const parent = useContext(CssxConfigContext) + const value = useMemo(() => ({ + ...(parent ?? getRuntimeConfig()), + ...(props.value ?? {}) + }), [parent, props.value]) + return createElement(CssxConfigContext.Provider, { + value + }, props.children) +} +export function useCssxConfig () { + return useContext(CssxConfigContext) ?? getRuntimeConfig() +} diff --git a/packages/css-to-rn/dist/react/cssx.d.ts b/packages/css-to-rn/dist/react/cssx.d.ts new file mode 100644 index 0000000..3c7a0e0 --- /dev/null +++ b/packages/css-to-rn/dist/react/cssx.d.ts @@ -0,0 +1,18 @@ +import type { CompiledCssSheet, CssxTarget } from '../types.ts' +import { type CssxCache, type InlineStyleInput, type ResolvedStyleProps, type StyleNameValue } from '../resolve.ts' +import { type TrackedCssxSheet } from './tracker.ts' +export type CssxStyleName = StyleNameValue +export type CssxResolvedProps = ResolvedStyleProps +export interface CssxRuntimeOptions { + target?: CssxTarget; + values?: readonly unknown[]; + cache?: boolean | CssxCache; +} +export type CssxSheetInput = string | CompiledCssSheet | TrackedCssxSheet | CssxReactLayer | readonly CssxSheetInput[] +export interface CssxReactLayer { + sheet: string | CompiledCssSheet | TrackedCssxSheet; + values?: readonly unknown[]; + cacheKey?: unknown; +} +export declare function cssx (styleName: CssxStyleName, sheetInput: CssxSheetInput, inlineStyleProps?: InlineStyleInput, options?: CssxRuntimeOptions): CssxResolvedProps +export declare function clearRawCssCacheForTests (): void diff --git a/packages/css-to-rn/dist/react/cssx.js b/packages/css-to-rn/dist/react/cssx.js new file mode 100644 index 0000000..fd0f86a --- /dev/null +++ b/packages/css-to-rn/dist/react/cssx.js @@ -0,0 +1,137 @@ +import { clearCssxRuntimeCachesForTests, resolveCssx } from '../resolve.js' +import { evaluateMediaQuery, getDefaultVariableValues, getDimensions, getDimensionsVersion, getVariableValues, getVariableVersion } from './store.js' +import { isTrackedCssxSheet } from './tracker.js' +export function cssx (styleName, sheetInput, inlineStyleProps, options = {}) { + const normalized = normalizeSheetInput(sheetInput, options) + const result = resolveCssx({ + styleName, + layers: normalized.layers, + inlineStyleProps, + target: options.target ?? normalized.target ?? 'react-native', + variables: getVariableValues(), + defaultVariables: getDefaultVariableValues(), + dimensions: getDimensions(), + cache: options.cache ?? normalized.cache + }) + for (const collector of normalized.collectors) { + recordDependencies(collector, result) + } + return result.props +} +export function clearRawCssCacheForTests () { + clearCssxRuntimeCachesForTests() +} +function normalizeSheetInput (input, options) { + const rawLayers = Array.isArray(input) ? input : [input] + const layers = [] + const collectors = [] + let cache + let target + 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, options) { + if (Array.isArray(input)) { return normalizeSheetInput(input, options) } + if (isTrackedCssxSheet(input)) { + const trackerOptions = input.getOptions() + const layer = { + 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) { + return Boolean(value && + typeof value === 'object' && + 'sheet' in value && + !isTrackedCssxSheet(value) && + !isCompiledSheet(value)) +} +function isCompiledSheet (value) { + return Boolean(value && + typeof value === 'object' && + value.version === 1 && + Array.isArray(value.rules)) +} +function recordDependencies (collector, result) { + 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, evaluateMediaQuery(query)) + } +} diff --git a/packages/css-to-rn/dist/react/hooks.d.ts b/packages/css-to-rn/dist/react/hooks.d.ts new file mode 100644 index 0000000..1382b0b --- /dev/null +++ b/packages/css-to-rn/dist/react/hooks.d.ts @@ -0,0 +1,15 @@ +import type { CompiledCssSheet } from '../types.ts' +import { type CssxReactConfig } from './config.ts' +import { TrackedCssxSheet } from './tracker.ts' +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 declare function useCssxSheet (sheet: CompiledCssSheet, options?: CssxReactConfig): TrackedCssxSheet +export declare function useCompiledCss (input: string | CompiledCssSheet, options?: CssxReactConfig): TrackedCssxSheet +export declare function useCssxTemplate (sheet: CompiledCssSheet, values: readonly unknown[], options?: CssxReactConfig): TrackedCssxSheet +export declare function useCssxLayer (input: CssxLayerHookInput, options?: CssxReactConfig): CssxLayerHookOutput diff --git a/packages/css-to-rn/dist/react/hooks.js b/packages/css-to-rn/dist/react/hooks.js new file mode 100644 index 0000000..1b095ae --- /dev/null +++ b/packages/css-to-rn/dist/react/hooks.js @@ -0,0 +1,76 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { compileCss } from '../compiler.js' +import { useCssxConfig } from './config.js' +import { TrackedCssxSheet } from './tracker.js' +const useCommitEffect = typeof window === 'undefined' + ? useEffect + : useLayoutEffect +export function useCssxSheet (sheet, options = {}) { + const context = useCssxConfig() + const trackerRef = useRef(null) + const mergedOptions = { + ...context, + ...options + } + if (trackerRef.current == null) { + trackerRef.current = new TrackedCssxSheet(sheet, mergedOptions) + } else { + trackerRef.current.update(sheet, mergedOptions) + } + const tracker = trackerRef.current + const renderDependencies = tracker.startRender() + useSyncExternalStore(tracker.subscribe, tracker.getSnapshot, tracker.getServerSnapshot) + useCommitEffect(() => { + tracker.commitRender(renderDependencies) + }) + return tracker +} +export function useCompiledCss (input, options = {}) { + 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, values, options = {}) { + return useCssxSheet(sheet, { + ...options, + values + }) +} +export function useCssxLayer (input, options = {}) { + if (!input) { return input } + if (typeof input === 'string') { return useCompiledCss(input, options) } + if (input instanceof TrackedCssxSheet) { return input } + if (isCompiledSheet(input)) { return useCssxSheet(input, options) } + if (isLayerObject(input)) { + const sheet = input.sheet + if (typeof sheet === 'string') { + return { + ...input, + sheet: useCompiledCss(sheet, options) + } + } + if (sheet instanceof TrackedCssxSheet) { return input } + if (isCompiledSheet(sheet)) { + return useCssxSheet(sheet, { + ...options, + values: input.values + }) + } + } + return input +} +function isCompiledSheet (value) { + return Boolean(value && + typeof value === 'object' && + value.version === 1 && + Array.isArray(value.rules)) +} +function isLayerObject (value) { + return Boolean(value && + typeof value === 'object' && + 'sheet' in value) +} diff --git a/packages/css-to-rn/dist/react/index.d.ts b/packages/css-to-rn/dist/react/index.d.ts new file mode 100644 index 0000000..bc7746e --- /dev/null +++ b/packages/css-to-rn/dist/react/index.d.ts @@ -0,0 +1,10 @@ +export { cssx, clearRawCssCacheForTests } from './cssx.ts' +export { CssxProvider, configureCssx, useCssxConfig } from './config.ts' +export { useCssxLayer, useCompiledCss, useCssxSheet, useCssxTemplate } from './hooks.ts' +export { TrackedCssxSheet, createTrackedCssxSheet, isTrackedCssxSheet } from './tracker.ts' +export { defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './store.ts' +export type { CssxResolvedProps, CssxRuntimeOptions, CssxStyleName } from './cssx.ts' +export type { CssxProviderProps, CssxReactConfig } from './config.ts' +export type { CssxLayerHookInput, CssxLayerHookOutput } from './hooks.ts' +export type { CssxDependencySnapshot, CssxRuntimeConfig } from './store.ts' +export type { TrackedCssxSheetOptions } from './tracker.ts' diff --git a/packages/css-to-rn/dist/react/index.js b/packages/css-to-rn/dist/react/index.js new file mode 100644 index 0000000..43a219b --- /dev/null +++ b/packages/css-to-rn/dist/react/index.js @@ -0,0 +1,5 @@ +export { cssx, clearRawCssCacheForTests } from './cssx.js' +export { CssxProvider, configureCssx, useCssxConfig } from './config.js' +export { useCssxLayer, useCompiledCss, useCssxSheet, useCssxTemplate } from './hooks.js' +export { TrackedCssxSheet, createTrackedCssxSheet, isTrackedCssxSheet } from './tracker.js' +export { defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './store.js' diff --git a/packages/css-to-rn/dist/react/store.d.ts b/packages/css-to-rn/dist/react/store.d.ts new file mode 100644 index 0000000..b748a42 --- /dev/null +++ b/packages/css-to-rn/dist/react/store.d.ts @@ -0,0 +1,52 @@ +export interface CssxRuntimeConfig { + dimensionsDebounceMs?: number; +} +export interface CssxDimensionsSnapshot { + width: number; + height: number; +} +export interface CssxDimensionsAdapter { + get: () => CssxDimensionsSnapshot; + subscribe: (listener: () => void) => () => void; +} +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; +} +export declare const variables: Record +export declare const defaultVariables: Record +export declare function setDefaultVariables (next: Record): void +export declare function getVariableValues (): Record +export declare function getDefaultVariableValues (): Record +export declare function getVariableVersion (name: string): number +export declare function getRuntimeVersion (): number +export declare function createDependencySnapshot (): CssxDependencySnapshot +export declare function getDimensions (): { + width: number; + height: number; +} +export declare function getDimensionsVersion (): number +export declare function setDimensionsForTests (next: { + width: number; + height: number; +}): void +export declare function configureDimensionsAdapter (adapter: CssxDimensionsAdapter | null): void +export declare function evaluateMediaQuery (query: string): boolean +export declare function setRuntimeConfig (next: CssxRuntimeConfig): void +export declare function getRuntimeConfig (): Required +export declare function subscribeRuntimeStore (listener: (change: RuntimeChangeSnapshot) => void, getDependencies: () => CssxDependencySnapshot): () => void +export declare function hasStaleDependencies (dependencies: CssxDependencySnapshot): boolean +export declare function subscribeVariablesForTests (names: readonly string[], listener: (changedNames: readonly string[]) => void): () => void +export declare function getRuntimeSubscriberCountForTests (): number +export declare function flushMicrotasksForTests (): Promise +export declare function resetStoreForTests (): void diff --git a/packages/css-to-rn/dist/react/store.js b/packages/css-to-rn/dist/react/store.js new file mode 100644 index 0000000..08980bd --- /dev/null +++ b/packages/css-to-rn/dist/react/store.js @@ -0,0 +1,282 @@ +import mediaQuery from 'css-mediaquery' +const FALLBACK_DIMENSIONS = { width: 1024, height: 768 } +const variableValues = Object.create(null) +const defaultVariableValues = Object.create(null) +const variableVersions = new Map() +const runtimeSubscribers = new Set() +const pendingVariableNames = new Set() +let runtimeConfig = { + dimensionsDebounceMs: 0 +} +let variableVersion = 0 +let dimensionsAdapter = null +let dimensionsAdapterUnsubscribe = null +let dimensions = readWindowDimensions() +let dimensionsVersion = 0 +let pendingDimensionsChanged = false +let notifyScheduled = false +let resizeListener = null +let resizeTimer = null +export const variables = createVariableProxy(variableValues) +export const defaultVariables = createVariableProxy(defaultVariableValues) +export function setDefaultVariables (next) { + const changed = new Set() + for (const name of Object.keys(defaultVariableValues)) { + if (!Object.prototype.hasOwnProperty.call(next, name)) { + delete defaultVariableValues[name] + changed.add(name) + } + } + for (const [name, value] of Object.entries(next)) { + if (Object.is(defaultVariableValues[name], value)) { continue } + defaultVariableValues[name] = value + changed.add(name) + } + markVariablesChanged(Array.from(changed)) +} +export function getVariableValues () { + return variableValues +} +export function getDefaultVariableValues () { + return defaultVariableValues +} +export function getVariableVersion (name) { + return variableVersions.get(name) ?? 0 +} +export function getRuntimeVersion () { + return variableVersion + dimensionsVersion +} +export function createDependencySnapshot () { + return { + vars: new Map(), + media: new Map(), + dimensionsVersion: null + } +} +export function getDimensions () { + return dimensions +} +export function getDimensionsVersion () { + return dimensionsVersion +} +export function setDimensionsForTests (next) { + applyDimensions(next) +} +export function configureDimensionsAdapter (adapter) { + if (dimensionsAdapter === adapter) { return } + removeWindowResizeListener() + dimensionsAdapter = adapter + applyDimensions(readWindowDimensions()) + if (runtimeSubscribers.size > 0) { ensureWindowResizeListener() } +} +export function evaluateMediaQuery (query) { + const normalized = stripMediaPrefix(query) + try { + return mediaQuery.match(normalized, mediaValues(dimensions)) + } catch { + return false + } +} +export function setRuntimeConfig (next) { + runtimeConfig = { + ...runtimeConfig, + ...next + } +} +export function getRuntimeConfig () { + return runtimeConfig +} +export function subscribeRuntimeStore (listener, getDependencies) { + const subscriber = { listener, getDependencies } + runtimeSubscribers.add(subscriber) + ensureWindowResizeListener() + return () => { + runtimeSubscribers.delete(subscriber) + if (runtimeSubscribers.size === 0) { removeWindowResizeListener() } + } +} +export function hasStaleDependencies (dependencies) { + 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, listener) { + const dependencies = createDependencySnapshot() + for (const name of names) { + dependencies.vars.set(name, getVariableVersion(name)) + } + return subscribeRuntimeStore(change => listener(change.vars), () => dependencies) +} +export function getRuntimeSubscriberCountForTests () { + return runtimeSubscribers.size +} +export async function flushMicrotasksForTests () { + await Promise.resolve() + await Promise.resolve() +} +export function resetStoreForTests () { + clearRecord(variableValues) + clearRecord(defaultVariableValues) + variableVersions.clear() + pendingVariableNames.clear() + variableVersion = 0 + removeWindowResizeListener() + dimensionsAdapter = null + dimensions = FALLBACK_DIMENSIONS + dimensionsVersion = 0 + pendingDimensionsChanged = false + notifyScheduled = false + runtimeSubscribers.clear() +} +function createVariableProxy (target) { + return new Proxy(target, { + set (record, property, value) { + if (typeof property !== 'string') { + return Reflect.set(record, property, value) + } + 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) + } + if (!Object.prototype.hasOwnProperty.call(record, property)) { return true } + delete record[property] + markVariablesChanged([property]) + return true + } + }) +} +function markVariablesChanged (names) { + if (names.length === 0) { return } + for (const name of names) { + variableVersion += 1 + variableVersions.set(name, variableVersion) + pendingVariableNames.add(name) + } + scheduleNotification() +} +function applyDimensions (next) { + if (Object.is(dimensions.width, next.width) && + Object.is(dimensions.height, next.height)) { + return + } + dimensions = next + dimensionsVersion += 1 + pendingDimensionsChanged = true + scheduleNotification() +} +function scheduleNotification () { + if (notifyScheduled) { return } + notifyScheduled = true + queueMicrotask(() => { + notifyScheduled = false + flushNotifications() + }) +} +function flushNotifications () { + const vars = Array.from(pendingVariableNames) + const dimensionsChanged = pendingDimensionsChanged + pendingVariableNames.clear() + pendingDimensionsChanged = false + if (vars.length === 0 && !dimensionsChanged) { return } + const change = { vars, dimensions: dimensionsChanged } + for (const subscriber of Array.from(runtimeSubscribers)) { + if (shouldNotifySubscriber(subscriber.getDependencies(), change)) { + subscriber.listener(change) + } + } +} +function shouldNotifySubscriber (dependencies, change) { + for (const name of change.vars) { + if (dependencies.vars.has(name)) { return true } + } + if (!change.dimensions) { return false } + if (dependencies.dimensionsVersion != null) { return true } + for (const [query, matches] of dependencies.media) { + if (evaluateMediaQuery(query) !== matches) { return true } + } + return false +} +function ensureWindowResizeListener () { + 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 () { + 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 () { + 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 stripMediaPrefix (query) { + return query.trim().replace(/^@media\s+/i, '').trim() +} +function mediaValues (next) { + 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) { + for (const key of Object.keys(record)) { + delete record[key] + } +} diff --git a/packages/css-to-rn/dist/react/tracker.d.ts b/packages/css-to-rn/dist/react/tracker.d.ts new file mode 100644 index 0000000..ca3e75f --- /dev/null +++ b/packages/css-to-rn/dist/react/tracker.d.ts @@ -0,0 +1,40 @@ +import type { CompiledCssSheet } from '../types.ts' +import { type CssxCache } from '../resolve.ts' +import { type CssxDependencyCollector, type CssxDependencySnapshot } from './store.ts' +declare const TRACKED_SHEET: unique symbol +export interface TrackedCssxSheetOptions { + target?: 'react-native' | 'web'; + values?: readonly unknown[]; + cacheMaxEntries?: number; +} +export declare class TrackedCssxSheet implements CssxDependencyCollector { + readonly [TRACKED_SHEET] = true + private sheet + private options + private pendingDependencies + private committedDependencies + private listeners + private unsubscribeRuntimeStore + private snapshotVersion + private cache + constructor (sheet: CompiledCssSheet, options?: TrackedCssxSheetOptions) + getSheet (): CompiledCssSheet + getOptions (): TrackedCssxSheetOptions + getCache (): CssxCache + update (sheet: CompiledCssSheet, options?: TrackedCssxSheetOptions): void + startRender (): CssxDependencySnapshot + commitRender (dependencies?: CssxDependencySnapshot | null): void + recordVariable (name: string, version: number): void + recordMedia (query: string, matches: boolean): void + recordDimensions (version: number): void + subscribe: (listener: () => void) => (() => void) + getSnapshot: () => number + getServerSnapshot: () => number + getCommittedDependenciesForTests (): CssxDependencySnapshot + getPendingDependenciesForTests (): CssxDependencySnapshot | null + private handleRuntimeChange + private emitChange +} +export declare function isTrackedCssxSheet (value: unknown): value is TrackedCssxSheet +export declare function createTrackedCssxSheet (sheet: CompiledCssSheet, options?: TrackedCssxSheetOptions): TrackedCssxSheet +export {} diff --git a/packages/css-to-rn/dist/react/tracker.js b/packages/css-to-rn/dist/react/tracker.js new file mode 100644 index 0000000..afc628f --- /dev/null +++ b/packages/css-to-rn/dist/react/tracker.js @@ -0,0 +1,126 @@ +import { createCssxCache } from '../resolve.js' +import { createDependencySnapshot, hasStaleDependencies, subscribeRuntimeStore } from './store.js' +const TRACKED_SHEET = Symbol.for('cssx.trackedSheet') +export class TrackedCssxSheet { + [TRACKED_SHEET] = true + sheet + options + pendingDependencies = null + committedDependencies = createDependencySnapshot() + listeners = new Set() + unsubscribeRuntimeStore = null + snapshotVersion = 0 + cache + constructor (sheet, options = {}) { + this.sheet = sheet + this.options = options + this.cache = createCssxCache({ maxEntries: options.cacheMaxEntries }) + } + + getSheet () { + return this.sheet + } + + getOptions () { + return this.options + } + + getCache () { + return this.cache + } + + update (sheet, options = {}) { + this.sheet = sheet + this.options = options + if (options.cacheMaxEntries !== this.cache.maxEntries) { + this.cache.maxEntries = options.cacheMaxEntries ?? this.cache.maxEntries + } + } + + startRender () { + this.pendingDependencies = createDependencySnapshot() + return this.pendingDependencies + } + + commitRender (dependencies = this.pendingDependencies) { + if (dependencies == null) { return } + if (this.pendingDependencies === dependencies) { + this.pendingDependencies = null + } + this.committedDependencies = dependencies + if (hasStaleDependencies(dependencies)) { + this.emitChange() + } + } + + recordVariable (name, version) { + this.pendingDependencies?.vars.set(name, version) + } + + recordMedia (query, matches) { + this.pendingDependencies?.media.set(query, matches) + } + + recordDimensions (version) { + if (this.pendingDependencies == null) { return } + this.pendingDependencies.dimensionsVersion = version + } + + subscribe = (listener) => { + this.listeners.add(listener) + if (this.unsubscribeRuntimeStore == null) { + this.unsubscribeRuntimeStore = subscribeRuntimeStore(this.handleRuntimeChange, () => this.committedDependencies) + } + return () => { + this.listeners.delete(listener) + if (this.listeners.size === 0 && this.unsubscribeRuntimeStore != null) { + this.unsubscribeRuntimeStore() + this.unsubscribeRuntimeStore = null + } + } + } + + getSnapshot = () => { + return this.snapshotVersion + } + + getServerSnapshot = () => { + return this.snapshotVersion + } + + getCommittedDependenciesForTests () { + return cloneDependencySnapshot(this.committedDependencies) + } + + getPendingDependenciesForTests () { + return this.pendingDependencies == null + ? null + : cloneDependencySnapshot(this.pendingDependencies) + } + + handleRuntimeChange = (_change) => { + this.emitChange() + } + + emitChange () { + this.snapshotVersion += 1 + for (const listener of Array.from(this.listeners)) { + listener() + } + } +} +export function isTrackedCssxSheet (value) { + return Boolean(value != null && + typeof value === 'object' && + value[TRACKED_SHEET] === true) +} +export function createTrackedCssxSheet (sheet, options = {}) { + return new TrackedCssxSheet(sheet, options) +} +function cloneDependencySnapshot (input) { + return { + vars: new Map(input.vars), + media: new Map(input.media), + dimensionsVersion: input.dimensionsVersion + } +} diff --git a/packages/css-to-rn/dist/resolve.d.ts b/packages/css-to-rn/dist/resolve.d.ts new file mode 100644 index 0000000..6e0e664 --- /dev/null +++ b/packages/css-to-rn/dist/resolve.d.ts @@ -0,0 +1,57 @@ +import type { TransformStyle, TransformStyleValue } from './transform/index.ts' +import type { CompiledCssSheet, CssxDiagnostic, 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; + defaultVariables?: Record; + dimensions?: CssxDimensions; + target?: CssxTarget; + cache?: boolean | CssxCache; + cacheMaxEntries?: number; +} +export interface CssxDimensions { + width?: number; + height?: number; + type?: string; +} +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[]; + sheets: string[]; +} +export interface CssxCache { + maxEntries: number; + entries: Map; +} +interface ResolveCacheEntry { + dynamicSignature: string; + values: readonly unknown[]; + result: ResolveCssxResult; +} +export declare function createCssxCache (options?: { + maxEntries?: number; +}): CssxCache +export declare function clearCssxRuntimeCachesForTests (): void +export declare function cssx (styleName: StyleNameValue, layers?: CssxLayerInput | readonly CssxLayerInput[], inlineStyleProps?: InlineStyleInput, options?: Omit): ResolvedStyleProps +export declare function resolveCssx (options: ResolveCssxOptions): ResolveCssxResult +export {} diff --git a/packages/css-to-rn/dist/resolve.js b/packages/css-to-rn/dist/resolve.js new file mode 100644 index 0000000..145afb5 --- /dev/null +++ b/packages/css-to-rn/dist/resolve.js @@ -0,0 +1,431 @@ +import mediaQuery from 'css-mediaquery' +import { compileCss } from './compiler.js' +import { diagnostic } from './diagnostics.js' +import { simpleNumericHash } from './hash.js' +import { transformDeclarations } from './transform/index.js' +import { resolveCssValue } from './values.js' +let lastRawCss +let lastRawSheet +let unknownIdentityCounter = 0 +const unknownObjectIds = new WeakMap() +const unknownPrimitiveIds = new Map() +const defaultCache = createCssxCache() +export function createCssxCache (options = {}) { + return { + maxEntries: options.maxEntries ?? 100, + entries: new Map() + } +} +export function clearCssxRuntimeCachesForTests () { + lastRawCss = undefined + lastRawSheet = undefined + defaultCache.entries.clear() + unknownPrimitiveIds.clear() +} +export function cssx (styleName, layers, inlineStyleProps, options = {}) { + return resolveCssx({ + ...options, + styleName, + layers, + inlineStyleProps + }).props +} +export function resolveCssx (options) { + 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) + if (currentSignature === cached.dynamicSignature) { + return { + ...cached.result, + cacheHit: true + } + } + } + const result = resolveCssxUncached(options, layers, classNames) + const dynamicSignature = createDynamicSignature(result.dependencies, options) + if (cache && stableKey) { + remember(cache, stableKey, { + dynamicSignature, + values, + result + }) + } + return result +} +function resolveCssxUncached (options, layers, classNames) { + const context = { + target: options.target ?? 'react-native', + variables: options.variables, + defaultVariables: options.defaultVariables, + dimensions: options.dimensions, + dependencies: createDependencies(), + diagnostics: [], + } + const classSet = new Set(classNames) + const props = {} + for (const layer of layers) { context.dependencies.sheets.add(layer.sheet.id) } + 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) + return { + props, + diagnostics: context.diagnostics, + dependencies: serializeDependencies(context.dependencies), + cacheHit: false + } +} +function getMatchedRules (layers, classSet, context) { + const matched = [] + layers.forEach((layer, layerIndex) => { + for (const rule of layer.sheet.rules) { + 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, context) { + const declarations = [] + 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, layer, context) { + const result = resolveCssValue(declaration.value, { + values: layer.values, + variables: context.variables, + 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, keyframeNames, context) { + const resolved = {} + 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, layer, context) { + const style = {} + for (const frame of keyframes) { + const declarations = [] + 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, keyframes) { + 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, output) { + 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, classSet) { + return rule.classes.every(className => classSet.has(className)) +} +function ruleMatchesMedia (rule, context) { + if (!rule.media) { return true } + const query = stripMediaPrefix(rule.media) + context.dependencies.media.add(query) + return matchesMediaQuery(query, context.dimensions) +} +function matchesMediaQuery (query, dimensions) { + try { + return mediaQuery.match(query, mediaValues(dimensions)) + } catch { + return false + } +} +function mediaValues (dimensions) { + 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) { + return media.replace(/^@media\s*/i, '').trim() +} +function getPartPropName (part) { + return part ? `${part}Style` : 'style' +} +function normalizeLayers (layers) { + 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) { + if (css === lastRawCss && lastRawSheet) { return lastRawSheet } + lastRawCss = css + lastRawSheet = compileCss(css, { mode: 'runtime' }) + return lastRawSheet +} +function isCompiledSheet (value) { + return Boolean(value && + typeof value === 'object' && + value.version === 1 && + Array.isArray(value.rules)) +} +function normalizeStyleName (value) { + const className = classcat(value) + return className.split(/\s+/).filter(Boolean).sort() +} +function classcat (value) { + 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 + for (const key of Object.keys(record)) { + if (record[key]) { output += (output ? ' ' : '') + key } + } + return output +} +function mergeInlineStyleProps (props, inlineStyleProps) { + if (!inlineStyleProps) { return } + if (isStylePropsInput(inlineStyleProps)) { + for (const propName of Object.keys(inlineStyleProps)) { + mergeStyleProp(props, propName, inlineStyleProps[propName]) + } + return + } + mergeStyleProp(props, 'style', inlineStyleProps) +} +function isStylePropsInput (value) { + return Object.keys(value).some(key => key === 'style' || key.endsWith('Style')) +} +function mergeStyleProp (props, propName, style) { + if (style == null || style === false) { return } + const current = props[propName] + const flattened = {} + flattenStyleInto(current, flattened) + flattenStyleInto(style, flattened) + props[propName] = flattened +} +function flattenStyleInto (value, output) { + 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 createStableKey (options, classNames, layers, inlineHash) { + return JSON.stringify({ + target: options.target ?? 'react-native', + 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, options) { + return JSON.stringify({ + vars: dependencies.vars.map(name => [ + name, + valueFromRecord(options.variables, 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) + ]) + }) +} +function hashInlineStyleProps (inlineStyleProps) { + if (!inlineStyleProps) { return '0' } + try { + return String(simpleNumericHash(JSON.stringify(inlineStyleProps))) + } catch { + return undefined + } +} +function flattenLayerValues (layers) { + const values = [] + for (const layer of layers) { values.push(...layer.values) } + return values +} +function sameValues (left, right) { + 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, key, entry) { + 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) { + if (value && (typeof value === 'object' || typeof value === 'function')) { + const object = value + 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 () { + return { + vars: new Set(), + dimensions: false, + media: new Set(), + sheets: new Set() + } +} +function serializeDependencies (dependencies) { + return { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions, + media: Array.from(dependencies.media).sort(), + sheets: Array.from(dependencies.sheets).sort() + } +} +function toCssxDiagnostic (item) { + return diagnostic(item.code, item.message, 'warning') +} +function valueFromRecord (record, key) { + if (!record || !Object.prototype.hasOwnProperty.call(record, key)) { return undefined } + return record[key] +} diff --git a/packages/css-to-rn/dist/selectors.d.ts b/packages/css-to-rn/dist/selectors.d.ts new file mode 100644 index 0000000..446549f --- /dev/null +++ b/packages/css-to-rn/dist/selectors.d.ts @@ -0,0 +1,8 @@ +import type { CssxDiagnostic, SelectorParseResult } from './types.ts' +export declare function parseSelector (selector: string, position?: { + line?: number; + column?: number; +}): { + result?: SelectorParseResult; + diagnostic?: CssxDiagnostic; +} diff --git a/packages/css-to-rn/dist/selectors.js b/packages/css-to-rn/dist/selectors.js new file mode 100644 index 0000000..a46f5c7 --- /dev/null +++ b/packages/css-to-rn/dist/selectors.js @@ -0,0 +1,53 @@ +import { diagnostic } from './diagnostics.js' +const PART_RE = /::?part\(([^)]+)\)$/ +const PSEUDO_PARTS = { + ':hover': 'hover', + ':active': 'active' +} +export function parseSelector (selector, position) { + const original = selector.trim() + let current = original + let part = null + const partMatch = current.match(PART_RE) + if (partMatch) { + part = partMatch[1].trim() + current = current.slice(0, partMatch.index).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.startsWith('.')) { + return unsupported(original, position) + } + if (current.includes(' ') || + current.includes('>') || + current.includes('+') || + current.includes('~') || + current.includes('[') || + current.includes('#') || + current.includes(':')) { + return unsupported(original, position) + } + const classes = current.split('.').filter(Boolean) + if (classes.length === 0 || classes.some(name => !/^[_a-zA-Z][-_a-zA-Z0-9]*$/.test(name))) { + return unsupported(original, position) + } + return { + result: { + selector: original, + classes, + part, + specificity: classes.length + } + } +} +function unsupported (selector, position) { + return { + diagnostic: diagnostic('UNSUPPORTED_SELECTOR', `Unsupported selector "${selector}" ignored. CSSX supports class combinations and :part()/:hover/:active only.`, 'warning', position) + } +} diff --git a/packages/css-to-rn/dist/transform/index.d.ts b/packages/css-to-rn/dist/transform/index.d.ts new file mode 100644 index 0000000..b30af6d --- /dev/null +++ b/packages/css-to-rn/dist/transform/index.d.ts @@ -0,0 +1,32 @@ +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[]; +} +export declare function transformDeclarations (declarations: readonly CssDeclaration[], options?: TransformDeclarationOptions): TransformDeclarationResult +export declare function getPropertyName (property: string): string +export declare function transformRawValue (value: string): TransformStyleValue diff --git a/packages/css-to-rn/dist/transform/index.js b/packages/css-to-rn/dist/transform/index.js new file mode 100644 index 0000000..6c781af --- /dev/null +++ b/packages/css-to-rn/dist/transform/index.js @@ -0,0 +1,1129 @@ +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 = { + 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, + margin: transformMargin, + padding: transformPadding, + textShadow: transformTextShadow, + transform: transformTransform, + transition: transformTransition, + transitionDelay: transformTransitionLonghand, + transitionDuration: transformTransitionLonghand, + transitionProperty: transformTransitionLonghand, + transitionTimingFunction: transformTransitionLonghand, +} +export function transformDeclarations (declarations, options = {}) { + const style = {} + const diagnostics = [] + const shorthandBlacklist = new Set(options.shorthandBlacklist ?? []) + const context = { + 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) { + const trimmed = property.trim() + if (trimmed.startsWith('--')) { return trimmed } + return trimmed.replace(/-([a-z])/g, (_, character) => character.toUpperCase()) +} +export function transformRawValue (value) { + 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) { + 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, value) { + return { style: { [property]: transformRawValue(value) } } +} +function passthroughString (property, value) { + return { style: { [property]: value.trim() } } +} +function transformMargin (property, value) { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: property, + values: parseDirectionalValues(value, valueToken => parseLength(valueToken, { allowAuto: true, allowPercent: true })), + }), + } +} +function transformPadding (property, value) { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: property, + values: parseDirectionalValues(value, valueToken => parseLength(valueToken, { allowPercent: true })), + }), + } +} +function transformDirectionalWidth (property, value) { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: 'border', + suffix: 'Width', + values: parseDirectionalValues(value, valueToken => parseLength(valueToken, { allowPercent: false })), + }), + } +} +function transformDirectionalColor (property, value) { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: 'border', + suffix: 'Color', + values: parseDirectionalValues(value, parseColor), + }), + } +} +function transformDirectionalBorderStyle (property, value) { + return { + style: expandDirectionalValues({ + directions: ['Top', 'Right', 'Bottom', 'Left'], + prefix: 'border', + suffix: 'Style', + values: parseDirectionalValues(value, parseBorderStyle), + }), + } +} +function transformBorderRadius (property, value) { + 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, value) { + 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 + let borderColor + let borderStyle + 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, value) { + const parts = parseFunctionSequence(value) + const transforms = [] + 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, args) { + 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, value) { + 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 + const lengths = [] + 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, value) { + 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, value) { + 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, value) { + 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, value) { + 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, value, declaration, context) { + 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, value, declaration, context) { + 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) { + const tokens = splitByWhitespace(value) + let name + let duration + let timingFunction + let delay + let iterationCount + let direction + let fillMode + let playState + 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) { + const tokens = splitByWhitespace(value) + let property + let duration + let timingFunction + let delay + 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, parseValue) { + 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) { + const [top, right = top, bottom = top, left = right] = options.values + const suffix = options.suffix ?? '' + const values = [top, right, bottom, left] + const style = {} + for (let index = 0; index < options.directions.length; index += 1) { + style[`${options.prefix}${options.directions[index]}${suffix}`] = + values[index] + } + return style +} +function parseLength (value, options = {}) { + 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) { + const trimmed = value.trim() + if (!numberRe.test(trimmed)) { + throw new Error(`Expected number value, got "${value}"`) + } + return Number(trimmed) +} +function parseAngle (value) { + const trimmed = value.trim() + if (!angleRe.test(trimmed)) { + throw new Error(`Expected angle value, got "${value}"`) + } + return trimmed.toLowerCase() +} +function parseColor (value) { + const trimmed = value.trim() + if (!isColor(trimmed)) { throw new Error(`Expected color value, got "${value}"`) } + return trimmed +} +function parseBorderStyle (value) { + const lower = value.trim().toLowerCase() + if (!borderStyles.has(lower)) { + throw new Error(`Expected border style value, got "${value}"`) + } + return lower +} +function parseTime (value) { + const trimmed = value.trim() + if (!isTime(trimmed)) { throw new Error(`Expected time value, got "${value}"`) } + return trimmed +} +function parseTimingFunction (value) { + const trimmed = value.trim() + if (!isTimingFunction(trimmed)) { + throw new Error(`Expected timing function value, got "${value}"`) + } + return trimmed +} +function parseIterationCount (value) { + 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) { + 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, keywords) { + const lower = value.trim().toLowerCase() + if (!keywords.has(lower)) { + throw new Error(`Expected one of ${Array.from(keywords).join(', ')}`) + } + return lower +} +function parseTransitionProperty (value) { + const trimmed = value.trim() + if (trimmed === 'all' || trimmed === 'none') { return trimmed } + return getPropertyName(trimmed) +} +function parseCommaSeparated (value, parseValue) { + const values = splitTopLevel(value, ',').map(parseValue) + return values.length === 1 ? values[0] : values +} +function singleOrArray (values, isSingle) { + return isSingle ? values[0] : values +} +function inlineAnimationKeyframes (style, keyframes) { + 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, allowPercent) { + try { + parseLength(value, { allowPercent }) + return true + } catch { + return false + } +} +function isColor (value) { + const trimmed = value.trim() + const lower = trimmed.toLowerCase() + return (hexColorRe.test(trimmed) || + colorFunctionRe.test(trimmed) || + cssColorKeywords.has(lower) || + lower === 'currentcolor') +} +function isTime (value) { + return timeRe.test(value.trim()) +} +function isTimingFunction (value) { + 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) { + return isFunctionToken(value.trim(), 'calc') +} +function isSupportedBackgroundImageValue (value) { + 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) { + return /\b(?:url|image-set|cross-fade|element|paint)\s*\(/i.test(value) +} +function backgroundImageProperty (platform) { + return platform === 'web' ? 'backgroundImage' : 'experimental_backgroundImage' +} +function isFunctionToken (value, functionName) { + 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) { + const functions = [] + 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) { + const commaParts = splitTopLevel(value, ',') + if (commaParts.length > 1) { return commaParts } + return splitByWhitespace(value) +} +function expectArgumentCount (functionName, args, min, max) { + if (args.length < min || args.length > max) { + throw new Error(`Expected ${functionName}() to have ${min === max ? min : `${min}-${max}`} arguments`) + } +} +function splitByWhitespace (value) { + const parts = [] + let current = '' + let depth = 0 + let quote = 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, separator) { + const parts = [] + let current = '' + let depth = 0 + let quote = 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, openIndex) { + let depth = 0 + let quote = 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, property, value, message, declaration) { + return { + code, + property, + value, + message, + order: declaration.order, + } +} diff --git a/packages/css-to-rn/dist/types.d.ts b/packages/css-to-rn/dist/types.d.ts new file mode 100644 index 0000000..f8104e0 --- /dev/null +++ b/packages/css-to-rn/dist/types.d.ts @@ -0,0 +1,77 @@ +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' +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; +} +export interface CompiledCssSheet { + version: 1; + id: string; + sourceId?: string; + contentHash: string; + rules: CssxRule[]; + keyframes: Record; + exports?: Record; + metadata: CssxMetadata; + diagnostics: CssxDiagnostic[]; + error?: CssxDiagnostic; +} +export interface CssxRule { + selector: string; + 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; + classes: string[]; + part: string | null; + specificity: number; +} +export interface CompileState { + diagnostics: CssxDiagnostic[]; + mode: CompileMode; +} diff --git a/packages/css-to-rn/dist/types.js b/packages/css-to-rn/dist/types.js new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/packages/css-to-rn/dist/types.js @@ -0,0 +1 @@ +export {} diff --git a/packages/css-to-rn/dist/values.d.ts b/packages/css-to-rn/dist/values.d.ts new file mode 100644 index 0000000..254a01e --- /dev/null +++ b/packages/css-to-rn/dist/values.d.ts @@ -0,0 +1,22 @@ +import type { CssxDiagnostic } from './types.ts' +export type InterpolationValue = string | number | null | undefined | false +export interface ResolveCssValueOptions { + values?: readonly unknown[]; + variables?: Record; + defaultVariables?: Record; + dimensions?: { + width?: number; + height?: number; + }; + maxVarDepth?: number; +} +export interface ResolveCssValueResult { + value?: string; + valid: boolean; + dependencies: { + vars: string[]; + dimensions: boolean; + }; + diagnostics: CssxDiagnostic[]; +} +export declare function resolveCssValue (input: string, options?: ResolveCssValueOptions): ResolveCssValueResult diff --git a/packages/css-to-rn/dist/values.js b/packages/css-to-rn/dist/values.js new file mode 100644 index 0000000..6cdeadb --- /dev/null +++ b/packages/css-to-rn/dist/values.js @@ -0,0 +1,247 @@ +import { diagnostic } from './diagnostics.js' +const DYNAMIC_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g +const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/ +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, options = {}) { + const diagnostics = [] + 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) + const calc = resolveCalcs(units.value, diagnostics) + if (!calc.valid) { + return invalid(diagnostics, dependencies) + } + return { + value: calc.value.trim(), + valid: true, + dependencies: serializeDependencies(dependencies), + diagnostics + } +} +function replaceDynamicSlots (input, values, diagnostics) { + DYNAMIC_SLOT_RE.lastIndex = 0 + let valid = true + const value = input.replace(DYNAMIC_SLOT_RE, (_match, rawIndex) => { + 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, options, deps, diagnostics, stack, maxDepth) { + 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) ?? + 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, options, dependencies) { + let value = input.replace(U_UNIT_RE, (_match, prefix, rawNumber) => { + return `${prefix}${Number(rawNumber) * 8}px` + }) + const width = options.dimensions?.width ?? 0 + const height = options.dimensions?.height ?? 0 + value = value.replace(VIEWPORT_UNIT_RE, (_match, prefix, rawNumber, unit) => { + 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, diagnostics) { + 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) { + if (expression.includes('%')) { return null } + const hasPx = /(?:^|[^\w.-])[+-]?(?:\d*\.)?\d+px\b/.test(expression) + const normalized = expression.replace(/([+-]?(?:\d*\.)?\d+)px\b/g, '$1') + if (!/^[0-9+\-*/().\s]+$/.test(normalized)) { return null } + let index = 0 + const skipWhitespace = () => { + while (/\s/.test(normalized[index] ?? '')) { index++ } + } + const parseNumber = () => { + 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 = () => { + 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 = () => { + 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 () { + 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) + ? hasPx ? `${result}px` : String(result) + : null +} +function findMatchingParen (input, openIndex) { + 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) { + const parts = [] + 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, key) { + if (!record || !Object.prototype.hasOwnProperty.call(record, key)) { return undefined } + return record[key] +} +function serializeDependencies (dependencies) { + return { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions + } +} +function invalid (diagnostics, dependencies) { + return { + valid: false, + dependencies: serializeDependencies(dependencies), + diagnostics + } +} diff --git a/packages/css-to-rn/dist/web.d.ts b/packages/css-to-rn/dist/web.d.ts new file mode 100644 index 0000000..495e4cc --- /dev/null +++ b/packages/css-to-rn/dist/web.d.ts @@ -0,0 +1,28 @@ +export { compileCss, compileCssTemplate } from './compiler.ts' +export { resolveCssValue } from './values.ts' +import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.ts' +import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.ts' +import { createTrackedCssxSheet } from './react/tracker.ts' +import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, 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 { CssxProviderProps, CssxReactConfig } from './react/config.ts' +export type { TrackedCssxSheetOptions } from './react/tracker.ts' +export { CssxProvider, configureCssx, useCssxConfig } from './react/config.ts' +export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.ts' +export { defaultVariables, setDefaultVariables, variables } +export declare function cssx (...args: Parameters): ReturnType +export declare function useCompiledCss (...args: Parameters): ReturnType +export declare function useCssxLayer (...args: Parameters): ReturnType +export declare function useCssxSheet (...args: Parameters): ReturnType +export declare function useCssxTemplate (...args: Parameters): ReturnType +export declare const __cssxInternals: { + clearRawCssCacheForTests: typeof clearRawCssCacheForTests; + configureDimensionsAdapterForTests: typeof configureDimensionsAdapter; + createTrackedCssxSheet: typeof createTrackedCssxSheet; + flushMicrotasksForTests: typeof flushMicrotasksForTests; + getRuntimeSubscriberCountForTests: typeof getRuntimeSubscriberCountForTests; + resetStoreForTests: typeof resetStoreForTests; + setDimensionsForTests: typeof setDimensionsForTests; + subscribeVariablesForTests: typeof subscribeVariablesForTests; +} diff --git a/packages/css-to-rn/dist/web.js b/packages/css-to-rn/dist/web.js new file mode 100644 index 0000000..04f085a --- /dev/null +++ b/packages/css-to-rn/dist/web.js @@ -0,0 +1,54 @@ +export { compileCss, compileCssTemplate } from './compiler.js' +export { resolveCssValue } from './values.js' +import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.js' +import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.js' +import { createTrackedCssxSheet } from './react/tracker.js' +import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './react/store.js' +export { CssxProvider, configureCssx, useCssxConfig } from './react/config.js' +export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.js' +export { defaultVariables, setDefaultVariables, variables } +export function cssx (...args) { + const [styleName, sheet, inlineStyleProps, options] = args + return baseCssx(styleName, sheet, inlineStyleProps, { + target: 'web', + ...(options ?? {}) + }) +} +export function useCompiledCss (...args) { + const [input, options] = args + return baseUseCompiledCss(input, { + target: 'web', + ...(options ?? {}) + }) +} +export function useCssxLayer (...args) { + const [input, options] = args + return baseUseCssxLayer(input, { + target: 'web', + ...(options ?? {}) + }) +} +export function useCssxSheet (...args) { + const [sheet, options] = args + return baseUseCssxSheet(sheet, { + target: 'web', + ...(options ?? {}) + }) +} +export function useCssxTemplate (...args) { + const [sheet, values, options] = args + return baseUseCssxTemplate(sheet, values, { + target: 'web', + ...(options ?? {}) + }) +} +export const __cssxInternals = { + clearRawCssCacheForTests, + configureDimensionsAdapterForTests: configureDimensionsAdapter, + createTrackedCssxSheet, + flushMicrotasksForTests, + getRuntimeSubscriberCountForTests, + resetStoreForTests, + setDimensionsForTests, + subscribeVariablesForTests +} diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts index ac5071e..18d6ccb 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -100,7 +100,8 @@ function compileCssInternal ( if (rule.type === 'media') { const mediaRule = rule as CssMediaAst const media = `@media ${mediaRule.media ?? ''}`.trim() - validateMedia(mediaRule, state) + 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, state, orderRef(() => order++), isTemplate, exports) @@ -261,9 +262,24 @@ function compileKeyframes ( return output } -function validateMedia (rule: CssMediaAst, state: CompileState): void { +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 { mediaQuery.parse(rule.media ?? '') + return true } catch (error) { addDiagnostic(state, diagnostic( 'UNSUPPORTED_AT_RULE', @@ -271,6 +287,7 @@ function validateMedia (rule: CssMediaAst, state: CompileState): void { 'warning', positionOf(rule) )) + return false } } diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index a09726f..fc3e0b0 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -10,6 +10,7 @@ import { clearRawCssCacheForTests } from './react/cssx.ts' import { + useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate @@ -18,6 +19,7 @@ import { createTrackedCssxSheet } from './react/tracker.ts' import { + configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, @@ -27,6 +29,7 @@ import { subscribeVariablesForTests, variables } from './react/store.ts' +import { Dimensions } from 'react-native' export type { CompileCssOptions, @@ -61,6 +64,8 @@ export { variables } +installReactNativeDimensionsAdapter() + export function cssx ( ...args: Parameters ): ReturnType { @@ -81,6 +86,16 @@ export function useCompiledCss ( }) } +export function useCssxLayer ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseCssxLayer(input, { + target: 'react-native', + ...(options ?? {}) + }) +} + export function useCssxSheet ( ...args: Parameters ): ReturnType { @@ -103,6 +118,7 @@ export function useCssxTemplate ( export const __cssxInternals = { clearRawCssCacheForTests, + configureDimensionsAdapterForTests: configureDimensionsAdapter, createTrackedCssxSheet, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, @@ -110,3 +126,21 @@ export const __cssxInternals = { setDimensionsForTests, subscribeVariablesForTests } + +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() + } + } + }) +} diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index e7d6c16..5ee785a 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -14,6 +14,29 @@ const useCommitEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect +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 function useCssxSheet ( sheet: CompiledCssSheet, options: CssxReactConfig = {} @@ -32,7 +55,7 @@ export function useCssxSheet ( } const tracker = trackerRef.current - tracker.startRender() + const renderDependencies = tracker.startRender() useSyncExternalStore( tracker.subscribe, @@ -41,7 +64,7 @@ export function useCssxSheet ( ) useCommitEffect(() => { - tracker.commitRender() + tracker.commitRender(renderDependencies) }) return tracker @@ -71,3 +94,53 @@ export function useCssxTemplate ( values }) } + +export function useCssxLayer ( + input: CssxLayerHookInput, + options: CssxReactConfig = {} +): CssxLayerHookOutput { + if (!input) return input + + if (typeof input === 'string') return useCompiledCss(input, options) + if (input instanceof TrackedCssxSheet) return input + if (isCompiledSheet(input)) return useCssxSheet(input, options) + + if (isLayerObject(input)) { + const sheet = input.sheet + if (typeof sheet === 'string') { + return { + ...input, + sheet: useCompiledCss(sheet, options) + } + } + if (sheet instanceof TrackedCssxSheet) return input as CssxLayerHookOutput + if (isCompiledSheet(sheet)) { + return useCssxSheet(sheet, { + ...options, + values: input.values + }) + } + } + + return input as CssxLayerHookOutput +} + +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 + ) +} diff --git a/packages/css-to-rn/src/react/index.ts b/packages/css-to-rn/src/react/index.ts index a791998..904fef4 100644 --- a/packages/css-to-rn/src/react/index.ts +++ b/packages/css-to-rn/src/react/index.ts @@ -8,6 +8,7 @@ export { useCssxConfig } from './config.ts' export { + useCssxLayer, useCompiledCss, useCssxSheet, useCssxTemplate @@ -37,6 +38,10 @@ export type { CssxProviderProps, CssxReactConfig } from './config.ts' +export type { + CssxLayerHookInput, + CssxLayerHookOutput +} from './hooks.ts' export type { CssxDependencySnapshot, CssxRuntimeConfig diff --git a/packages/css-to-rn/src/react/store.ts b/packages/css-to-rn/src/react/store.ts index da48b5f..9d2e6c0 100644 --- a/packages/css-to-rn/src/react/store.ts +++ b/packages/css-to-rn/src/react/store.ts @@ -4,6 +4,16 @@ export interface CssxRuntimeConfig { dimensionsDebounceMs?: number } +export interface CssxDimensionsSnapshot { + width: number + height: number +} + +export interface CssxDimensionsAdapter { + get: () => CssxDimensionsSnapshot + subscribe: (listener: () => void) => () => void +} + export interface CssxDependencySnapshot { vars: Map media: Map @@ -38,6 +48,8 @@ let runtimeConfig: Required = { dimensionsDebounceMs: 0 } let variableVersion = 0 +let dimensionsAdapter: CssxDimensionsAdapter | null = null +let dimensionsAdapterUnsubscribe: (() => void) | null = null let dimensions = readWindowDimensions() let dimensionsVersion = 0 let pendingDimensionsChanged = false @@ -102,19 +114,21 @@ export function setDimensionsForTests (next: { width: number, height: number }): applyDimensions(next) } +export function configureDimensionsAdapter ( + adapter: CssxDimensionsAdapter | null +): void { + if (dimensionsAdapter === adapter) return + removeWindowResizeListener() + dimensionsAdapter = adapter + applyDimensions(readWindowDimensions()) + if (runtimeSubscribers.size > 0) ensureWindowResizeListener() +} + export function evaluateMediaQuery (query: string): boolean { const normalized = stripMediaPrefix(query) - if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { - return window.matchMedia(normalized).matches - } - try { - return mediaQuery.match(normalized, { - type: 'screen', - width: `${dimensions.width}px`, - height: `${dimensions.height}px` - }) + return mediaQuery.match(normalized, mediaValues(dimensions)) } catch { return false } @@ -193,12 +207,13 @@ export function resetStoreForTests (): void { variableVersions.clear() pendingVariableNames.clear() variableVersion = 0 + removeWindowResizeListener() + dimensionsAdapter = null dimensions = FALLBACK_DIMENSIONS dimensionsVersion = 0 pendingDimensionsChanged = false notifyScheduled = false runtimeSubscribers.clear() - removeWindowResizeListener() } function createVariableProxy (target: Record): Record { @@ -297,6 +312,15 @@ function shouldNotifySubscriber ( } 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 = () => { @@ -329,6 +353,11 @@ function removeWindowResizeListener (): void { resizeTimer = null } + if (dimensionsAdapterUnsubscribe != null) { + dimensionsAdapterUnsubscribe() + dimensionsAdapterUnsubscribe = null + } + if (resizeListener == null || typeof window === 'undefined') { resizeListener = null return @@ -339,6 +368,8 @@ function removeWindowResizeListener (): void { } function readWindowDimensions (): { width: number, height: number } { + if (dimensionsAdapter != null) return dimensionsAdapter.get() + if (typeof window === 'undefined') return FALLBACK_DIMENSIONS return { @@ -351,6 +382,17 @@ 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 index 3acd415..1a7601d 100644 --- a/packages/css-to-rn/src/react/tracker.ts +++ b/packages/css-to-rn/src/react/tracker.ts @@ -58,18 +58,20 @@ export class TrackedCssxSheet implements CssxDependencyCollector { } } - startRender (): void { + startRender (): CssxDependencySnapshot { this.pendingDependencies = createDependencySnapshot() + return this.pendingDependencies } - commitRender (): void { - if (this.pendingDependencies == null) return + commitRender (dependencies: CssxDependencySnapshot | null = this.pendingDependencies): void { + if (dependencies == null) return - const nextDependencies = this.pendingDependencies - this.pendingDependencies = null - this.committedDependencies = nextDependencies + if (this.pendingDependencies === dependencies) { + this.pendingDependencies = null + } + this.committedDependencies = dependencies - if (hasStaleDependencies(nextDependencies)) { + if (hasStaleDependencies(dependencies)) { this.emitChange() } } diff --git a/packages/css-to-rn/src/vendor.d.ts b/packages/css-to-rn/src/vendor.d.ts index 58bf215..131ed07 100644 --- a/packages/css-to-rn/src/vendor.d.ts +++ b/packages/css-to-rn/src/vendor.d.ts @@ -22,3 +22,13 @@ declare module 'css-mediaquery' { export default mediaQuery } + +declare module 'react-native' { + export const Dimensions: { + get: (dimension: 'window' | 'screen') => { width: number, height: number } + addEventListener: ( + event: 'change', + listener: () => void + ) => { remove: () => void } + } +} diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index abd6bd4..f7d8d3c 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -10,6 +10,7 @@ import { clearRawCssCacheForTests } from './react/cssx.ts' import { + useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate @@ -18,6 +19,7 @@ import { createTrackedCssxSheet } from './react/tracker.ts' import { + configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, @@ -81,6 +83,16 @@ export function useCompiledCss ( }) } +export function useCssxLayer ( + ...args: Parameters +): ReturnType { + const [input, options] = args + return baseUseCssxLayer(input, { + target: 'web', + ...(options ?? {}) + }) +} + export function useCssxSheet ( ...args: Parameters ): ReturnType { @@ -103,6 +115,7 @@ export function useCssxTemplate ( export const __cssxInternals = { clearRawCssCacheForTests, + configureDimensionsAdapterForTests: configureDimensionsAdapter, createTrackedCssxSheet, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts index b88f698..1d0b4ed 100644 --- a/packages/css-to-rn/test/engine/compiler.test.ts +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -104,6 +104,17 @@ describe('@cssxjs/css-to-rn compiler IR', () => { 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 { diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index 9e9b3fc..f598aab 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -138,6 +138,38 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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); }') @@ -207,4 +239,118 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { unsubscribe() 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('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() + }) }) diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 0ee6c5f..42eae66 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -7,6 +7,7 @@ export { defaultVariables, isTrackedCssxSheet, setDefaultVariables, + useCssxLayer, useCompiledCss, useCssxConfig, useCssxSheet, diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js index 3fcd652..0d3b2ce 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -6,6 +6,7 @@ export { defaultVariables, isTrackedCssxSheet, setDefaultVariables, + useCssxLayer, useCompiledCss, useCssxConfig, useCssxSheet, diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js index 5809a65..5bc84a9 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -9,6 +9,7 @@ export { defaultVariables, isTrackedCssxSheet, setDefaultVariables, + useCssxLayer, useCompiledCss, useCssxConfig, useCssxSheet, diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js index 4cc4ddd..4e68e74 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -9,6 +9,7 @@ export { defaultVariables, isTrackedCssxSheet, setDefaultVariables, + useCssxLayer, useCompiledCss, useCssxConfig, useCssxSheet, From 4eb39540b8abbbec0cc1c093f27258bf4740c319 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 23:19:14 +0300 Subject: [PATCH 12/37] Stop tracking css-to-rn build output --- .gitignore | 1 + packages/css-to-rn/dist/compiler.d.ts | 3 - packages/css-to-rn/dist/compiler.js | 280 ----- packages/css-to-rn/dist/diagnostics.d.ts | 6 - packages/css-to-rn/dist/diagnostics.js | 16 - packages/css-to-rn/dist/hash.d.ts | 2 - packages/css-to-rn/dist/hash.js | 10 - packages/css-to-rn/dist/index.d.ts | 7 - packages/css-to-rn/dist/index.js | 4 - packages/css-to-rn/dist/react-native.d.ts | 28 - packages/css-to-rn/dist/react-native.js | 73 -- packages/css-to-rn/dist/react/config.d.ts | 12 - packages/css-to-rn/dist/react/config.js | 19 - packages/css-to-rn/dist/react/cssx.d.ts | 18 - packages/css-to-rn/dist/react/cssx.js | 137 --- packages/css-to-rn/dist/react/hooks.d.ts | 15 - packages/css-to-rn/dist/react/hooks.js | 76 -- packages/css-to-rn/dist/react/index.d.ts | 10 - packages/css-to-rn/dist/react/index.js | 5 - packages/css-to-rn/dist/react/store.d.ts | 52 - packages/css-to-rn/dist/react/store.js | 282 ----- packages/css-to-rn/dist/react/tracker.d.ts | 40 - packages/css-to-rn/dist/react/tracker.js | 126 -- packages/css-to-rn/dist/resolve.d.ts | 57 - packages/css-to-rn/dist/resolve.js | 431 ------- packages/css-to-rn/dist/selectors.d.ts | 8 - packages/css-to-rn/dist/selectors.js | 53 - packages/css-to-rn/dist/transform/index.d.ts | 32 - packages/css-to-rn/dist/transform/index.js | 1129 ------------------ packages/css-to-rn/dist/types.d.ts | 77 -- packages/css-to-rn/dist/types.js | 1 - packages/css-to-rn/dist/values.d.ts | 22 - packages/css-to-rn/dist/values.js | 247 ---- packages/css-to-rn/dist/web.d.ts | 28 - packages/css-to-rn/dist/web.js | 54 - packages/css-to-rn/package.json | 2 +- packages/cssxjs/package.json | 2 +- packages/loaders/package.json | 2 +- 38 files changed, 4 insertions(+), 3363 deletions(-) delete mode 100644 packages/css-to-rn/dist/compiler.d.ts delete mode 100644 packages/css-to-rn/dist/compiler.js delete mode 100644 packages/css-to-rn/dist/diagnostics.d.ts delete mode 100644 packages/css-to-rn/dist/diagnostics.js delete mode 100644 packages/css-to-rn/dist/hash.d.ts delete mode 100644 packages/css-to-rn/dist/hash.js delete mode 100644 packages/css-to-rn/dist/index.d.ts delete mode 100644 packages/css-to-rn/dist/index.js delete mode 100644 packages/css-to-rn/dist/react-native.d.ts delete mode 100644 packages/css-to-rn/dist/react-native.js delete mode 100644 packages/css-to-rn/dist/react/config.d.ts delete mode 100644 packages/css-to-rn/dist/react/config.js delete mode 100644 packages/css-to-rn/dist/react/cssx.d.ts delete mode 100644 packages/css-to-rn/dist/react/cssx.js delete mode 100644 packages/css-to-rn/dist/react/hooks.d.ts delete mode 100644 packages/css-to-rn/dist/react/hooks.js delete mode 100644 packages/css-to-rn/dist/react/index.d.ts delete mode 100644 packages/css-to-rn/dist/react/index.js delete mode 100644 packages/css-to-rn/dist/react/store.d.ts delete mode 100644 packages/css-to-rn/dist/react/store.js delete mode 100644 packages/css-to-rn/dist/react/tracker.d.ts delete mode 100644 packages/css-to-rn/dist/react/tracker.js delete mode 100644 packages/css-to-rn/dist/resolve.d.ts delete mode 100644 packages/css-to-rn/dist/resolve.js delete mode 100644 packages/css-to-rn/dist/selectors.d.ts delete mode 100644 packages/css-to-rn/dist/selectors.js delete mode 100644 packages/css-to-rn/dist/transform/index.d.ts delete mode 100644 packages/css-to-rn/dist/transform/index.js delete mode 100644 packages/css-to-rn/dist/types.d.ts delete mode 100644 packages/css-to-rn/dist/types.js delete mode 100644 packages/css-to-rn/dist/values.d.ts delete mode 100644 packages/css-to-rn/dist/values.js delete mode 100644 packages/css-to-rn/dist/web.d.ts delete mode 100644 packages/css-to-rn/dist/web.js 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/packages/css-to-rn/dist/compiler.d.ts b/packages/css-to-rn/dist/compiler.d.ts deleted file mode 100644 index a81c6ac..0000000 --- a/packages/css-to-rn/dist/compiler.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { CompileCssOptions, CompileCssTemplateOptions, CompiledCssSheet } from './types.ts' -export declare function compileCss (css: string, options?: CompileCssOptions): CompiledCssSheet -export declare function compileCssTemplate (css: string, options?: CompileCssTemplateOptions): CompiledCssSheet diff --git a/packages/css-to-rn/dist/compiler.js b/packages/css-to-rn/dist/compiler.js deleted file mode 100644 index f289f9c..0000000 --- a/packages/css-to-rn/dist/compiler.js +++ /dev/null @@ -1,280 +0,0 @@ -import parseCss from 'css/lib/parse/index.js' -import mediaQuery from 'css-mediaquery' -import valueParser from 'postcss-value-parser' -import { addDiagnostic, diagnostic } from './diagnostics.js' -import { cssxHash } from './hash.js' -import { parseSelector } from './selectors.js' -const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/ -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, options = {}) { - return compileCssInternal(css, options) -} -export function compileCssTemplate (css, options = {}) { - return compileCssInternal(css, { - ...options, - sourceIdentity: options.sourceIdentity ?? options.id - }, true) -} -function compileCssInternal (css, options, isTemplate = false) { - const mode = options.mode ?? 'runtime' - const state = { 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 = () => createSheet({ - id, - sourceId, - contentHash, - diagnostics: state.diagnostics, - error: state.diagnostics.find(item => item.level === 'error') - }) - let ast - try { - ast = parseCss(css, { silent: false }) - } catch (error) { - const err = error - const item = diagnostic('CSS_SYNTAX_ERROR', err.reason ?? err.message, 'error', { line: err.line, column: err.column }) - addDiagnostic(state, item) - return empty() - } - const rules = [] - const keyframes = {} - const exports = {} - let order = 0 - for (const rule of ast.stylesheet?.rules ?? []) { - if (rule.type === 'rule') { - const styleRule = rule - compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, state, orderRef(() => order++), isTemplate, exports) - continue - } - if (rule.type === 'media') { - const mediaRule = rule - 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, state, orderRef(() => order++), isTemplate, exports) - } - continue - } - if (rule.type === 'keyframes') { - const keyframesRule = rule - const name = keyframesRule.name - if (!name) { continue } - keyframes[name] = compileKeyframes(keyframesRule, state, orderRef(() => order++), 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, isTemplate) - return createSheet({ - id, - sourceId, - contentHash, - rules, - keyframes, - exports: Object.keys(exports).length > 0 ? exports : undefined, - metadata, - diagnostics: state.diagnostics, - error: state.diagnostics.find(item => item.level === 'error') - }) -} -function compileRuleList (selectors, declarations, media, output, state, nextOrder, isTemplate, exports) { - for (const selector of selectors) { - if (selector === ':export') { - compileExports(declarations, exports, state, isTemplate) - continue - } - if (selector.trim().startsWith(':root')) { - addDiagnostic(state, diagnostic('UNSUPPORTED_SELECTOR', `Unsupported selector "${selector}" ignored. Use setDefaultVariables() for CSS variable defaults.`, 'warning')) - continue - } - const parsed = parseSelector(selector, positionOfDeclarationList(declarations)) - if (parsed.diagnostic) { - addDiagnostic(state, parsed.diagnostic) - continue - } - if (!parsed.result) { continue } - output.push({ - selector: parsed.result.selector, - classes: parsed.result.classes, - part: parsed.result.part, - specificity: parsed.result.specificity, - order: nextOrder(), - media, - declarations: compileDeclarations(declarations, state, isTemplate) - }) - } -} -function compileExports (declarations, exports, state, isTemplate) { - 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 compileDeclarations (declarations, state, isTemplate) { - const output = [] - 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 - output.push({ - property, - value, - raw: `${property}: ${value}`, - order: order++, - dynamicSlots, - line: declaration.position?.start?.line, - column: declaration.position?.start?.column - }) - } - return output -} -function compileKeyframes (rule, state, nextOrder, isTemplate) { - const output = [] - for (const frame of rule.keyframes ?? []) { - output.push({ - selector: (frame.values ?? []).join(', '), - declarations: compileDeclarations(frame.declarations ?? [], state, isTemplate), - order: nextOrder() - }) - } - return output -} -function validateMedia (rule, state, isTemplate) { - if (isTemplate && hasDynamicSlots(rule.media ?? '')) { - addDiagnostic(state, diagnostic('UNSUPPORTED_INTERPOLATION_POSITION', 'Interpolation is not supported inside media queries.', 'error', positionOf(rule))) - return false - } - try { - mediaQuery.parse(rule.media ?? '') - return true - } catch (error) { - addDiagnostic(state, diagnostic('UNSUPPORTED_AT_RULE', `Unsupported media query "${rule.media ?? ''}" ignored: ${error.message}`, 'warning', positionOf(rule))) - return false - } -} -function buildMetadata (rules, keyframes, isTemplate) { - 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) } - } - function scanDeclarations (declarations) { - 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 - } -} -function collectVars (value, vars) { - 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) { - const slots = [] - DYNAMIC_SLOT_RE.lastIndex = 0 - let match - while ((match = DYNAMIC_SLOT_RE.exec(value)) != null) { - slots.push(Number(match[1])) - } - return slots.length > 0 ? slots : undefined -} -function hasDynamicSlots (value) { - DYNAMIC_SLOT_RE.lastIndex = 0 - return DYNAMIC_SLOT_RE.test(value) -} -function createSheet (input) { - return { - version: 1, - id: input.id, - sourceId: input.sourceId, - contentHash: input.contentHash, - rules: input.rules ?? [], - keyframes: input.keyframes ?? {}, - exports: input.exports, - metadata: input.metadata ?? { - hasVars: false, - vars: [], - hasMedia: false, - hasViewportUnits: false, - hasInterpolations: false, - hasDynamicRuntimeDependencies: false, - hasAnimations: false, - hasTransitions: false - }, - diagnostics: input.diagnostics, - error: input.error - } -} -function orderRef (next) { - return next -} -function positionOf (node) { - return { - line: node.position?.start?.line, - column: node.position?.start?.column - } -} -function positionOfDeclarationList (declarations) { - const first = declarations.find(item => item.position) - return first ? positionOf(first) : undefined -} diff --git a/packages/css-to-rn/dist/diagnostics.d.ts b/packages/css-to-rn/dist/diagnostics.d.ts deleted file mode 100644 index ae585bd..0000000 --- a/packages/css-to-rn/dist/diagnostics.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CompileState, CssxDiagnostic, CssxDiagnosticCode, CssxDiagnosticLevel } from './types.ts' -export declare function diagnostic (code: CssxDiagnosticCode, message: string, level?: CssxDiagnosticLevel, position?: { - line?: number; - column?: number; -}): CssxDiagnostic -export declare function addDiagnostic (state: CompileState, item: CssxDiagnostic): void diff --git a/packages/css-to-rn/dist/diagnostics.js b/packages/css-to-rn/dist/diagnostics.js deleted file mode 100644 index 1b783b8..0000000 --- a/packages/css-to-rn/dist/diagnostics.js +++ /dev/null @@ -1,16 +0,0 @@ -export function diagnostic (code, message, level = 'warning', position) { - return { - level, - code, - message, - line: position?.line, - column: position?.column - } -} -export function addDiagnostic (state, item) { - 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/dist/hash.d.ts b/packages/css-to-rn/dist/hash.d.ts deleted file mode 100644 index 9ee0ea3..0000000 --- a/packages/css-to-rn/dist/hash.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export declare function simpleNumericHash (value: string): number -export declare function cssxHash (value: string): string diff --git a/packages/css-to-rn/dist/hash.js b/packages/css-to-rn/dist/hash.js deleted file mode 100644 index 9036e77..0000000 --- a/packages/css-to-rn/dist/hash.js +++ /dev/null @@ -1,10 +0,0 @@ -// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0 -export function simpleNumericHash (value) { - 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) { - return `cssx_${Math.abs(simpleNumericHash(value)).toString(36)}` -} diff --git a/packages/css-to-rn/dist/index.d.ts b/packages/css-to-rn/dist/index.d.ts deleted file mode 100644 index b931216..0000000 --- a/packages/css-to-rn/dist/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { compileCss, compileCssTemplate } from './compiler.ts' -export { cssxHash, simpleNumericHash } from './hash.ts' -export { resolveCssValue } from './values.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, InlineStyleInput, ResolveCssxDependencies, ResolveCssxLayer, ResolveCssxOptions, ResolveCssxResult, ResolvedStyleProps, StyleNameValue } from './resolve.ts' diff --git a/packages/css-to-rn/dist/index.js b/packages/css-to-rn/dist/index.js deleted file mode 100644 index df99721..0000000 --- a/packages/css-to-rn/dist/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { compileCss, compileCssTemplate } from './compiler.js' -export { cssxHash, simpleNumericHash } from './hash.js' -export { resolveCssValue } from './values.js' -export { createCssxCache, cssx, resolveCssx } from './resolve.js' diff --git a/packages/css-to-rn/dist/react-native.d.ts b/packages/css-to-rn/dist/react-native.d.ts deleted file mode 100644 index 495e4cc..0000000 --- a/packages/css-to-rn/dist/react-native.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { compileCss, compileCssTemplate } from './compiler.ts' -export { resolveCssValue } from './values.ts' -import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.ts' -import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.ts' -import { createTrackedCssxSheet } from './react/tracker.ts' -import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, 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 { CssxProviderProps, CssxReactConfig } from './react/config.ts' -export type { TrackedCssxSheetOptions } from './react/tracker.ts' -export { CssxProvider, configureCssx, useCssxConfig } from './react/config.ts' -export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.ts' -export { defaultVariables, setDefaultVariables, variables } -export declare function cssx (...args: Parameters): ReturnType -export declare function useCompiledCss (...args: Parameters): ReturnType -export declare function useCssxLayer (...args: Parameters): ReturnType -export declare function useCssxSheet (...args: Parameters): ReturnType -export declare function useCssxTemplate (...args: Parameters): ReturnType -export declare const __cssxInternals: { - clearRawCssCacheForTests: typeof clearRawCssCacheForTests; - configureDimensionsAdapterForTests: typeof configureDimensionsAdapter; - createTrackedCssxSheet: typeof createTrackedCssxSheet; - flushMicrotasksForTests: typeof flushMicrotasksForTests; - getRuntimeSubscriberCountForTests: typeof getRuntimeSubscriberCountForTests; - resetStoreForTests: typeof resetStoreForTests; - setDimensionsForTests: typeof setDimensionsForTests; - subscribeVariablesForTests: typeof subscribeVariablesForTests; -} diff --git a/packages/css-to-rn/dist/react-native.js b/packages/css-to-rn/dist/react-native.js deleted file mode 100644 index 597495c..0000000 --- a/packages/css-to-rn/dist/react-native.js +++ /dev/null @@ -1,73 +0,0 @@ -export { compileCss, compileCssTemplate } from './compiler.js' -export { resolveCssValue } from './values.js' -import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.js' -import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.js' -import { createTrackedCssxSheet } from './react/tracker.js' -import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './react/store.js' -import { Dimensions } from 'react-native' -export { CssxProvider, configureCssx, useCssxConfig } from './react/config.js' -export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.js' -export { defaultVariables, setDefaultVariables, variables } -installReactNativeDimensionsAdapter() -export function cssx (...args) { - const [styleName, sheet, inlineStyleProps, options] = args - return baseCssx(styleName, sheet, inlineStyleProps, { - target: 'react-native', - ...(options ?? {}) - }) -} -export function useCompiledCss (...args) { - const [input, options] = args - return baseUseCompiledCss(input, { - target: 'react-native', - ...(options ?? {}) - }) -} -export function useCssxLayer (...args) { - const [input, options] = args - return baseUseCssxLayer(input, { - target: 'react-native', - ...(options ?? {}) - }) -} -export function useCssxSheet (...args) { - const [sheet, options] = args - return baseUseCssxSheet(sheet, { - target: 'react-native', - ...(options ?? {}) - }) -} -export function useCssxTemplate (...args) { - const [sheet, values, options] = args - return baseUseCssxTemplate(sheet, values, { - target: 'react-native', - ...(options ?? {}) - }) -} -export const __cssxInternals = { - clearRawCssCacheForTests, - configureDimensionsAdapterForTests: configureDimensionsAdapter, - createTrackedCssxSheet, - flushMicrotasksForTests, - getRuntimeSubscriberCountForTests, - resetStoreForTests, - setDimensionsForTests, - subscribeVariablesForTests -} -function installReactNativeDimensionsAdapter () { - 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() - } - } - }) -} diff --git a/packages/css-to-rn/dist/react/config.d.ts b/packages/css-to-rn/dist/react/config.d.ts deleted file mode 100644 index 07a0992..0000000 --- a/packages/css-to-rn/dist/react/config.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type ReactNode } from 'react' -import { type CssxRuntimeConfig } from './store.ts' -import type { TrackedCssxSheetOptions } from './tracker.ts' -export interface CssxReactConfig extends CssxRuntimeConfig, TrackedCssxSheetOptions { -} -export interface CssxProviderProps { - value?: CssxReactConfig; - children?: ReactNode; -} -export declare function configureCssx (config: CssxReactConfig): void -export declare function CssxProvider (props: CssxProviderProps): ReactNode -export declare function useCssxConfig (): CssxReactConfig diff --git a/packages/css-to-rn/dist/react/config.js b/packages/css-to-rn/dist/react/config.js deleted file mode 100644 index 3dfc8e8..0000000 --- a/packages/css-to-rn/dist/react/config.js +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext, createElement, useContext, useMemo } from 'react' -import { getRuntimeConfig, setRuntimeConfig } from './store.js' -const CssxConfigContext = createContext(null) -export function configureCssx (config) { - setRuntimeConfig(config) -} -export function CssxProvider (props) { - const parent = useContext(CssxConfigContext) - const value = useMemo(() => ({ - ...(parent ?? getRuntimeConfig()), - ...(props.value ?? {}) - }), [parent, props.value]) - return createElement(CssxConfigContext.Provider, { - value - }, props.children) -} -export function useCssxConfig () { - return useContext(CssxConfigContext) ?? getRuntimeConfig() -} diff --git a/packages/css-to-rn/dist/react/cssx.d.ts b/packages/css-to-rn/dist/react/cssx.d.ts deleted file mode 100644 index 3c7a0e0..0000000 --- a/packages/css-to-rn/dist/react/cssx.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CompiledCssSheet, CssxTarget } from '../types.ts' -import { type CssxCache, type InlineStyleInput, type ResolvedStyleProps, type StyleNameValue } from '../resolve.ts' -import { type TrackedCssxSheet } from './tracker.ts' -export type CssxStyleName = StyleNameValue -export type CssxResolvedProps = ResolvedStyleProps -export interface CssxRuntimeOptions { - target?: CssxTarget; - values?: readonly unknown[]; - cache?: boolean | CssxCache; -} -export type CssxSheetInput = string | CompiledCssSheet | TrackedCssxSheet | CssxReactLayer | readonly CssxSheetInput[] -export interface CssxReactLayer { - sheet: string | CompiledCssSheet | TrackedCssxSheet; - values?: readonly unknown[]; - cacheKey?: unknown; -} -export declare function cssx (styleName: CssxStyleName, sheetInput: CssxSheetInput, inlineStyleProps?: InlineStyleInput, options?: CssxRuntimeOptions): CssxResolvedProps -export declare function clearRawCssCacheForTests (): void diff --git a/packages/css-to-rn/dist/react/cssx.js b/packages/css-to-rn/dist/react/cssx.js deleted file mode 100644 index fd0f86a..0000000 --- a/packages/css-to-rn/dist/react/cssx.js +++ /dev/null @@ -1,137 +0,0 @@ -import { clearCssxRuntimeCachesForTests, resolveCssx } from '../resolve.js' -import { evaluateMediaQuery, getDefaultVariableValues, getDimensions, getDimensionsVersion, getVariableValues, getVariableVersion } from './store.js' -import { isTrackedCssxSheet } from './tracker.js' -export function cssx (styleName, sheetInput, inlineStyleProps, options = {}) { - const normalized = normalizeSheetInput(sheetInput, options) - const result = resolveCssx({ - styleName, - layers: normalized.layers, - inlineStyleProps, - target: options.target ?? normalized.target ?? 'react-native', - variables: getVariableValues(), - defaultVariables: getDefaultVariableValues(), - dimensions: getDimensions(), - cache: options.cache ?? normalized.cache - }) - for (const collector of normalized.collectors) { - recordDependencies(collector, result) - } - return result.props -} -export function clearRawCssCacheForTests () { - clearCssxRuntimeCachesForTests() -} -function normalizeSheetInput (input, options) { - const rawLayers = Array.isArray(input) ? input : [input] - const layers = [] - const collectors = [] - let cache - let target - 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, options) { - if (Array.isArray(input)) { return normalizeSheetInput(input, options) } - if (isTrackedCssxSheet(input)) { - const trackerOptions = input.getOptions() - const layer = { - 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) { - return Boolean(value && - typeof value === 'object' && - 'sheet' in value && - !isTrackedCssxSheet(value) && - !isCompiledSheet(value)) -} -function isCompiledSheet (value) { - return Boolean(value && - typeof value === 'object' && - value.version === 1 && - Array.isArray(value.rules)) -} -function recordDependencies (collector, result) { - 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, evaluateMediaQuery(query)) - } -} diff --git a/packages/css-to-rn/dist/react/hooks.d.ts b/packages/css-to-rn/dist/react/hooks.d.ts deleted file mode 100644 index 1382b0b..0000000 --- a/packages/css-to-rn/dist/react/hooks.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { CompiledCssSheet } from '../types.ts' -import { type CssxReactConfig } from './config.ts' -import { TrackedCssxSheet } from './tracker.ts' -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 declare function useCssxSheet (sheet: CompiledCssSheet, options?: CssxReactConfig): TrackedCssxSheet -export declare function useCompiledCss (input: string | CompiledCssSheet, options?: CssxReactConfig): TrackedCssxSheet -export declare function useCssxTemplate (sheet: CompiledCssSheet, values: readonly unknown[], options?: CssxReactConfig): TrackedCssxSheet -export declare function useCssxLayer (input: CssxLayerHookInput, options?: CssxReactConfig): CssxLayerHookOutput diff --git a/packages/css-to-rn/dist/react/hooks.js b/packages/css-to-rn/dist/react/hooks.js deleted file mode 100644 index 1b095ae..0000000 --- a/packages/css-to-rn/dist/react/hooks.js +++ /dev/null @@ -1,76 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react' -import { compileCss } from '../compiler.js' -import { useCssxConfig } from './config.js' -import { TrackedCssxSheet } from './tracker.js' -const useCommitEffect = typeof window === 'undefined' - ? useEffect - : useLayoutEffect -export function useCssxSheet (sheet, options = {}) { - const context = useCssxConfig() - const trackerRef = useRef(null) - const mergedOptions = { - ...context, - ...options - } - if (trackerRef.current == null) { - trackerRef.current = new TrackedCssxSheet(sheet, mergedOptions) - } else { - trackerRef.current.update(sheet, mergedOptions) - } - const tracker = trackerRef.current - const renderDependencies = tracker.startRender() - useSyncExternalStore(tracker.subscribe, tracker.getSnapshot, tracker.getServerSnapshot) - useCommitEffect(() => { - tracker.commitRender(renderDependencies) - }) - return tracker -} -export function useCompiledCss (input, options = {}) { - 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, values, options = {}) { - return useCssxSheet(sheet, { - ...options, - values - }) -} -export function useCssxLayer (input, options = {}) { - if (!input) { return input } - if (typeof input === 'string') { return useCompiledCss(input, options) } - if (input instanceof TrackedCssxSheet) { return input } - if (isCompiledSheet(input)) { return useCssxSheet(input, options) } - if (isLayerObject(input)) { - const sheet = input.sheet - if (typeof sheet === 'string') { - return { - ...input, - sheet: useCompiledCss(sheet, options) - } - } - if (sheet instanceof TrackedCssxSheet) { return input } - if (isCompiledSheet(sheet)) { - return useCssxSheet(sheet, { - ...options, - values: input.values - }) - } - } - return input -} -function isCompiledSheet (value) { - return Boolean(value && - typeof value === 'object' && - value.version === 1 && - Array.isArray(value.rules)) -} -function isLayerObject (value) { - return Boolean(value && - typeof value === 'object' && - 'sheet' in value) -} diff --git a/packages/css-to-rn/dist/react/index.d.ts b/packages/css-to-rn/dist/react/index.d.ts deleted file mode 100644 index bc7746e..0000000 --- a/packages/css-to-rn/dist/react/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { cssx, clearRawCssCacheForTests } from './cssx.ts' -export { CssxProvider, configureCssx, useCssxConfig } from './config.ts' -export { useCssxLayer, useCompiledCss, useCssxSheet, useCssxTemplate } from './hooks.ts' -export { TrackedCssxSheet, createTrackedCssxSheet, isTrackedCssxSheet } from './tracker.ts' -export { defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './store.ts' -export type { CssxResolvedProps, CssxRuntimeOptions, CssxStyleName } from './cssx.ts' -export type { CssxProviderProps, CssxReactConfig } from './config.ts' -export type { CssxLayerHookInput, CssxLayerHookOutput } from './hooks.ts' -export type { CssxDependencySnapshot, CssxRuntimeConfig } from './store.ts' -export type { TrackedCssxSheetOptions } from './tracker.ts' diff --git a/packages/css-to-rn/dist/react/index.js b/packages/css-to-rn/dist/react/index.js deleted file mode 100644 index 43a219b..0000000 --- a/packages/css-to-rn/dist/react/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export { cssx, clearRawCssCacheForTests } from './cssx.js' -export { CssxProvider, configureCssx, useCssxConfig } from './config.js' -export { useCssxLayer, useCompiledCss, useCssxSheet, useCssxTemplate } from './hooks.js' -export { TrackedCssxSheet, createTrackedCssxSheet, isTrackedCssxSheet } from './tracker.js' -export { defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './store.js' diff --git a/packages/css-to-rn/dist/react/store.d.ts b/packages/css-to-rn/dist/react/store.d.ts deleted file mode 100644 index b748a42..0000000 --- a/packages/css-to-rn/dist/react/store.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -export interface CssxRuntimeConfig { - dimensionsDebounceMs?: number; -} -export interface CssxDimensionsSnapshot { - width: number; - height: number; -} -export interface CssxDimensionsAdapter { - get: () => CssxDimensionsSnapshot; - subscribe: (listener: () => void) => () => void; -} -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; -} -export declare const variables: Record -export declare const defaultVariables: Record -export declare function setDefaultVariables (next: Record): void -export declare function getVariableValues (): Record -export declare function getDefaultVariableValues (): Record -export declare function getVariableVersion (name: string): number -export declare function getRuntimeVersion (): number -export declare function createDependencySnapshot (): CssxDependencySnapshot -export declare function getDimensions (): { - width: number; - height: number; -} -export declare function getDimensionsVersion (): number -export declare function setDimensionsForTests (next: { - width: number; - height: number; -}): void -export declare function configureDimensionsAdapter (adapter: CssxDimensionsAdapter | null): void -export declare function evaluateMediaQuery (query: string): boolean -export declare function setRuntimeConfig (next: CssxRuntimeConfig): void -export declare function getRuntimeConfig (): Required -export declare function subscribeRuntimeStore (listener: (change: RuntimeChangeSnapshot) => void, getDependencies: () => CssxDependencySnapshot): () => void -export declare function hasStaleDependencies (dependencies: CssxDependencySnapshot): boolean -export declare function subscribeVariablesForTests (names: readonly string[], listener: (changedNames: readonly string[]) => void): () => void -export declare function getRuntimeSubscriberCountForTests (): number -export declare function flushMicrotasksForTests (): Promise -export declare function resetStoreForTests (): void diff --git a/packages/css-to-rn/dist/react/store.js b/packages/css-to-rn/dist/react/store.js deleted file mode 100644 index 08980bd..0000000 --- a/packages/css-to-rn/dist/react/store.js +++ /dev/null @@ -1,282 +0,0 @@ -import mediaQuery from 'css-mediaquery' -const FALLBACK_DIMENSIONS = { width: 1024, height: 768 } -const variableValues = Object.create(null) -const defaultVariableValues = Object.create(null) -const variableVersions = new Map() -const runtimeSubscribers = new Set() -const pendingVariableNames = new Set() -let runtimeConfig = { - dimensionsDebounceMs: 0 -} -let variableVersion = 0 -let dimensionsAdapter = null -let dimensionsAdapterUnsubscribe = null -let dimensions = readWindowDimensions() -let dimensionsVersion = 0 -let pendingDimensionsChanged = false -let notifyScheduled = false -let resizeListener = null -let resizeTimer = null -export const variables = createVariableProxy(variableValues) -export const defaultVariables = createVariableProxy(defaultVariableValues) -export function setDefaultVariables (next) { - const changed = new Set() - for (const name of Object.keys(defaultVariableValues)) { - if (!Object.prototype.hasOwnProperty.call(next, name)) { - delete defaultVariableValues[name] - changed.add(name) - } - } - for (const [name, value] of Object.entries(next)) { - if (Object.is(defaultVariableValues[name], value)) { continue } - defaultVariableValues[name] = value - changed.add(name) - } - markVariablesChanged(Array.from(changed)) -} -export function getVariableValues () { - return variableValues -} -export function getDefaultVariableValues () { - return defaultVariableValues -} -export function getVariableVersion (name) { - return variableVersions.get(name) ?? 0 -} -export function getRuntimeVersion () { - return variableVersion + dimensionsVersion -} -export function createDependencySnapshot () { - return { - vars: new Map(), - media: new Map(), - dimensionsVersion: null - } -} -export function getDimensions () { - return dimensions -} -export function getDimensionsVersion () { - return dimensionsVersion -} -export function setDimensionsForTests (next) { - applyDimensions(next) -} -export function configureDimensionsAdapter (adapter) { - if (dimensionsAdapter === adapter) { return } - removeWindowResizeListener() - dimensionsAdapter = adapter - applyDimensions(readWindowDimensions()) - if (runtimeSubscribers.size > 0) { ensureWindowResizeListener() } -} -export function evaluateMediaQuery (query) { - const normalized = stripMediaPrefix(query) - try { - return mediaQuery.match(normalized, mediaValues(dimensions)) - } catch { - return false - } -} -export function setRuntimeConfig (next) { - runtimeConfig = { - ...runtimeConfig, - ...next - } -} -export function getRuntimeConfig () { - return runtimeConfig -} -export function subscribeRuntimeStore (listener, getDependencies) { - const subscriber = { listener, getDependencies } - runtimeSubscribers.add(subscriber) - ensureWindowResizeListener() - return () => { - runtimeSubscribers.delete(subscriber) - if (runtimeSubscribers.size === 0) { removeWindowResizeListener() } - } -} -export function hasStaleDependencies (dependencies) { - 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, listener) { - const dependencies = createDependencySnapshot() - for (const name of names) { - dependencies.vars.set(name, getVariableVersion(name)) - } - return subscribeRuntimeStore(change => listener(change.vars), () => dependencies) -} -export function getRuntimeSubscriberCountForTests () { - return runtimeSubscribers.size -} -export async function flushMicrotasksForTests () { - await Promise.resolve() - await Promise.resolve() -} -export function resetStoreForTests () { - clearRecord(variableValues) - clearRecord(defaultVariableValues) - variableVersions.clear() - pendingVariableNames.clear() - variableVersion = 0 - removeWindowResizeListener() - dimensionsAdapter = null - dimensions = FALLBACK_DIMENSIONS - dimensionsVersion = 0 - pendingDimensionsChanged = false - notifyScheduled = false - runtimeSubscribers.clear() -} -function createVariableProxy (target) { - return new Proxy(target, { - set (record, property, value) { - if (typeof property !== 'string') { - return Reflect.set(record, property, value) - } - 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) - } - if (!Object.prototype.hasOwnProperty.call(record, property)) { return true } - delete record[property] - markVariablesChanged([property]) - return true - } - }) -} -function markVariablesChanged (names) { - if (names.length === 0) { return } - for (const name of names) { - variableVersion += 1 - variableVersions.set(name, variableVersion) - pendingVariableNames.add(name) - } - scheduleNotification() -} -function applyDimensions (next) { - if (Object.is(dimensions.width, next.width) && - Object.is(dimensions.height, next.height)) { - return - } - dimensions = next - dimensionsVersion += 1 - pendingDimensionsChanged = true - scheduleNotification() -} -function scheduleNotification () { - if (notifyScheduled) { return } - notifyScheduled = true - queueMicrotask(() => { - notifyScheduled = false - flushNotifications() - }) -} -function flushNotifications () { - const vars = Array.from(pendingVariableNames) - const dimensionsChanged = pendingDimensionsChanged - pendingVariableNames.clear() - pendingDimensionsChanged = false - if (vars.length === 0 && !dimensionsChanged) { return } - const change = { vars, dimensions: dimensionsChanged } - for (const subscriber of Array.from(runtimeSubscribers)) { - if (shouldNotifySubscriber(subscriber.getDependencies(), change)) { - subscriber.listener(change) - } - } -} -function shouldNotifySubscriber (dependencies, change) { - for (const name of change.vars) { - if (dependencies.vars.has(name)) { return true } - } - if (!change.dimensions) { return false } - if (dependencies.dimensionsVersion != null) { return true } - for (const [query, matches] of dependencies.media) { - if (evaluateMediaQuery(query) !== matches) { return true } - } - return false -} -function ensureWindowResizeListener () { - 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 () { - 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 () { - 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 stripMediaPrefix (query) { - return query.trim().replace(/^@media\s+/i, '').trim() -} -function mediaValues (next) { - 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) { - for (const key of Object.keys(record)) { - delete record[key] - } -} diff --git a/packages/css-to-rn/dist/react/tracker.d.ts b/packages/css-to-rn/dist/react/tracker.d.ts deleted file mode 100644 index ca3e75f..0000000 --- a/packages/css-to-rn/dist/react/tracker.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CompiledCssSheet } from '../types.ts' -import { type CssxCache } from '../resolve.ts' -import { type CssxDependencyCollector, type CssxDependencySnapshot } from './store.ts' -declare const TRACKED_SHEET: unique symbol -export interface TrackedCssxSheetOptions { - target?: 'react-native' | 'web'; - values?: readonly unknown[]; - cacheMaxEntries?: number; -} -export declare class TrackedCssxSheet implements CssxDependencyCollector { - readonly [TRACKED_SHEET] = true - private sheet - private options - private pendingDependencies - private committedDependencies - private listeners - private unsubscribeRuntimeStore - private snapshotVersion - private cache - constructor (sheet: CompiledCssSheet, options?: TrackedCssxSheetOptions) - getSheet (): CompiledCssSheet - getOptions (): TrackedCssxSheetOptions - getCache (): CssxCache - update (sheet: CompiledCssSheet, options?: TrackedCssxSheetOptions): void - startRender (): CssxDependencySnapshot - commitRender (dependencies?: CssxDependencySnapshot | null): void - recordVariable (name: string, version: number): void - recordMedia (query: string, matches: boolean): void - recordDimensions (version: number): void - subscribe: (listener: () => void) => (() => void) - getSnapshot: () => number - getServerSnapshot: () => number - getCommittedDependenciesForTests (): CssxDependencySnapshot - getPendingDependenciesForTests (): CssxDependencySnapshot | null - private handleRuntimeChange - private emitChange -} -export declare function isTrackedCssxSheet (value: unknown): value is TrackedCssxSheet -export declare function createTrackedCssxSheet (sheet: CompiledCssSheet, options?: TrackedCssxSheetOptions): TrackedCssxSheet -export {} diff --git a/packages/css-to-rn/dist/react/tracker.js b/packages/css-to-rn/dist/react/tracker.js deleted file mode 100644 index afc628f..0000000 --- a/packages/css-to-rn/dist/react/tracker.js +++ /dev/null @@ -1,126 +0,0 @@ -import { createCssxCache } from '../resolve.js' -import { createDependencySnapshot, hasStaleDependencies, subscribeRuntimeStore } from './store.js' -const TRACKED_SHEET = Symbol.for('cssx.trackedSheet') -export class TrackedCssxSheet { - [TRACKED_SHEET] = true - sheet - options - pendingDependencies = null - committedDependencies = createDependencySnapshot() - listeners = new Set() - unsubscribeRuntimeStore = null - snapshotVersion = 0 - cache - constructor (sheet, options = {}) { - this.sheet = sheet - this.options = options - this.cache = createCssxCache({ maxEntries: options.cacheMaxEntries }) - } - - getSheet () { - return this.sheet - } - - getOptions () { - return this.options - } - - getCache () { - return this.cache - } - - update (sheet, options = {}) { - this.sheet = sheet - this.options = options - if (options.cacheMaxEntries !== this.cache.maxEntries) { - this.cache.maxEntries = options.cacheMaxEntries ?? this.cache.maxEntries - } - } - - startRender () { - this.pendingDependencies = createDependencySnapshot() - return this.pendingDependencies - } - - commitRender (dependencies = this.pendingDependencies) { - if (dependencies == null) { return } - if (this.pendingDependencies === dependencies) { - this.pendingDependencies = null - } - this.committedDependencies = dependencies - if (hasStaleDependencies(dependencies)) { - this.emitChange() - } - } - - recordVariable (name, version) { - this.pendingDependencies?.vars.set(name, version) - } - - recordMedia (query, matches) { - this.pendingDependencies?.media.set(query, matches) - } - - recordDimensions (version) { - if (this.pendingDependencies == null) { return } - this.pendingDependencies.dimensionsVersion = version - } - - subscribe = (listener) => { - this.listeners.add(listener) - if (this.unsubscribeRuntimeStore == null) { - this.unsubscribeRuntimeStore = subscribeRuntimeStore(this.handleRuntimeChange, () => this.committedDependencies) - } - return () => { - this.listeners.delete(listener) - if (this.listeners.size === 0 && this.unsubscribeRuntimeStore != null) { - this.unsubscribeRuntimeStore() - this.unsubscribeRuntimeStore = null - } - } - } - - getSnapshot = () => { - return this.snapshotVersion - } - - getServerSnapshot = () => { - return this.snapshotVersion - } - - getCommittedDependenciesForTests () { - return cloneDependencySnapshot(this.committedDependencies) - } - - getPendingDependenciesForTests () { - return this.pendingDependencies == null - ? null - : cloneDependencySnapshot(this.pendingDependencies) - } - - handleRuntimeChange = (_change) => { - this.emitChange() - } - - emitChange () { - this.snapshotVersion += 1 - for (const listener of Array.from(this.listeners)) { - listener() - } - } -} -export function isTrackedCssxSheet (value) { - return Boolean(value != null && - typeof value === 'object' && - value[TRACKED_SHEET] === true) -} -export function createTrackedCssxSheet (sheet, options = {}) { - return new TrackedCssxSheet(sheet, options) -} -function cloneDependencySnapshot (input) { - return { - vars: new Map(input.vars), - media: new Map(input.media), - dimensionsVersion: input.dimensionsVersion - } -} diff --git a/packages/css-to-rn/dist/resolve.d.ts b/packages/css-to-rn/dist/resolve.d.ts deleted file mode 100644 index 6e0e664..0000000 --- a/packages/css-to-rn/dist/resolve.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { TransformStyle, TransformStyleValue } from './transform/index.ts' -import type { CompiledCssSheet, CssxDiagnostic, 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; - defaultVariables?: Record; - dimensions?: CssxDimensions; - target?: CssxTarget; - cache?: boolean | CssxCache; - cacheMaxEntries?: number; -} -export interface CssxDimensions { - width?: number; - height?: number; - type?: string; -} -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[]; - sheets: string[]; -} -export interface CssxCache { - maxEntries: number; - entries: Map; -} -interface ResolveCacheEntry { - dynamicSignature: string; - values: readonly unknown[]; - result: ResolveCssxResult; -} -export declare function createCssxCache (options?: { - maxEntries?: number; -}): CssxCache -export declare function clearCssxRuntimeCachesForTests (): void -export declare function cssx (styleName: StyleNameValue, layers?: CssxLayerInput | readonly CssxLayerInput[], inlineStyleProps?: InlineStyleInput, options?: Omit): ResolvedStyleProps -export declare function resolveCssx (options: ResolveCssxOptions): ResolveCssxResult -export {} diff --git a/packages/css-to-rn/dist/resolve.js b/packages/css-to-rn/dist/resolve.js deleted file mode 100644 index 145afb5..0000000 --- a/packages/css-to-rn/dist/resolve.js +++ /dev/null @@ -1,431 +0,0 @@ -import mediaQuery from 'css-mediaquery' -import { compileCss } from './compiler.js' -import { diagnostic } from './diagnostics.js' -import { simpleNumericHash } from './hash.js' -import { transformDeclarations } from './transform/index.js' -import { resolveCssValue } from './values.js' -let lastRawCss -let lastRawSheet -let unknownIdentityCounter = 0 -const unknownObjectIds = new WeakMap() -const unknownPrimitiveIds = new Map() -const defaultCache = createCssxCache() -export function createCssxCache (options = {}) { - return { - maxEntries: options.maxEntries ?? 100, - entries: new Map() - } -} -export function clearCssxRuntimeCachesForTests () { - lastRawCss = undefined - lastRawSheet = undefined - defaultCache.entries.clear() - unknownPrimitiveIds.clear() -} -export function cssx (styleName, layers, inlineStyleProps, options = {}) { - return resolveCssx({ - ...options, - styleName, - layers, - inlineStyleProps - }).props -} -export function resolveCssx (options) { - 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) - if (currentSignature === cached.dynamicSignature) { - return { - ...cached.result, - cacheHit: true - } - } - } - const result = resolveCssxUncached(options, layers, classNames) - const dynamicSignature = createDynamicSignature(result.dependencies, options) - if (cache && stableKey) { - remember(cache, stableKey, { - dynamicSignature, - values, - result - }) - } - return result -} -function resolveCssxUncached (options, layers, classNames) { - const context = { - target: options.target ?? 'react-native', - variables: options.variables, - defaultVariables: options.defaultVariables, - dimensions: options.dimensions, - dependencies: createDependencies(), - diagnostics: [], - } - const classSet = new Set(classNames) - const props = {} - for (const layer of layers) { context.dependencies.sheets.add(layer.sheet.id) } - 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) - return { - props, - diagnostics: context.diagnostics, - dependencies: serializeDependencies(context.dependencies), - cacheHit: false - } -} -function getMatchedRules (layers, classSet, context) { - const matched = [] - layers.forEach((layer, layerIndex) => { - for (const rule of layer.sheet.rules) { - 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, context) { - const declarations = [] - 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, layer, context) { - const result = resolveCssValue(declaration.value, { - values: layer.values, - variables: context.variables, - 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, keyframeNames, context) { - const resolved = {} - 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, layer, context) { - const style = {} - for (const frame of keyframes) { - const declarations = [] - 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, keyframes) { - 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, output) { - 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, classSet) { - return rule.classes.every(className => classSet.has(className)) -} -function ruleMatchesMedia (rule, context) { - if (!rule.media) { return true } - const query = stripMediaPrefix(rule.media) - context.dependencies.media.add(query) - return matchesMediaQuery(query, context.dimensions) -} -function matchesMediaQuery (query, dimensions) { - try { - return mediaQuery.match(query, mediaValues(dimensions)) - } catch { - return false - } -} -function mediaValues (dimensions) { - 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) { - return media.replace(/^@media\s*/i, '').trim() -} -function getPartPropName (part) { - return part ? `${part}Style` : 'style' -} -function normalizeLayers (layers) { - 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) { - if (css === lastRawCss && lastRawSheet) { return lastRawSheet } - lastRawCss = css - lastRawSheet = compileCss(css, { mode: 'runtime' }) - return lastRawSheet -} -function isCompiledSheet (value) { - return Boolean(value && - typeof value === 'object' && - value.version === 1 && - Array.isArray(value.rules)) -} -function normalizeStyleName (value) { - const className = classcat(value) - return className.split(/\s+/).filter(Boolean).sort() -} -function classcat (value) { - 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 - for (const key of Object.keys(record)) { - if (record[key]) { output += (output ? ' ' : '') + key } - } - return output -} -function mergeInlineStyleProps (props, inlineStyleProps) { - if (!inlineStyleProps) { return } - if (isStylePropsInput(inlineStyleProps)) { - for (const propName of Object.keys(inlineStyleProps)) { - mergeStyleProp(props, propName, inlineStyleProps[propName]) - } - return - } - mergeStyleProp(props, 'style', inlineStyleProps) -} -function isStylePropsInput (value) { - return Object.keys(value).some(key => key === 'style' || key.endsWith('Style')) -} -function mergeStyleProp (props, propName, style) { - if (style == null || style === false) { return } - const current = props[propName] - const flattened = {} - flattenStyleInto(current, flattened) - flattenStyleInto(style, flattened) - props[propName] = flattened -} -function flattenStyleInto (value, output) { - 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 createStableKey (options, classNames, layers, inlineHash) { - return JSON.stringify({ - target: options.target ?? 'react-native', - 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, options) { - return JSON.stringify({ - vars: dependencies.vars.map(name => [ - name, - valueFromRecord(options.variables, 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) - ]) - }) -} -function hashInlineStyleProps (inlineStyleProps) { - if (!inlineStyleProps) { return '0' } - try { - return String(simpleNumericHash(JSON.stringify(inlineStyleProps))) - } catch { - return undefined - } -} -function flattenLayerValues (layers) { - const values = [] - for (const layer of layers) { values.push(...layer.values) } - return values -} -function sameValues (left, right) { - 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, key, entry) { - 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) { - if (value && (typeof value === 'object' || typeof value === 'function')) { - const object = value - 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 () { - return { - vars: new Set(), - dimensions: false, - media: new Set(), - sheets: new Set() - } -} -function serializeDependencies (dependencies) { - return { - vars: Array.from(dependencies.vars).sort(), - dimensions: dependencies.dimensions, - media: Array.from(dependencies.media).sort(), - sheets: Array.from(dependencies.sheets).sort() - } -} -function toCssxDiagnostic (item) { - return diagnostic(item.code, item.message, 'warning') -} -function valueFromRecord (record, key) { - if (!record || !Object.prototype.hasOwnProperty.call(record, key)) { return undefined } - return record[key] -} diff --git a/packages/css-to-rn/dist/selectors.d.ts b/packages/css-to-rn/dist/selectors.d.ts deleted file mode 100644 index 446549f..0000000 --- a/packages/css-to-rn/dist/selectors.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CssxDiagnostic, SelectorParseResult } from './types.ts' -export declare function parseSelector (selector: string, position?: { - line?: number; - column?: number; -}): { - result?: SelectorParseResult; - diagnostic?: CssxDiagnostic; -} diff --git a/packages/css-to-rn/dist/selectors.js b/packages/css-to-rn/dist/selectors.js deleted file mode 100644 index a46f5c7..0000000 --- a/packages/css-to-rn/dist/selectors.js +++ /dev/null @@ -1,53 +0,0 @@ -import { diagnostic } from './diagnostics.js' -const PART_RE = /::?part\(([^)]+)\)$/ -const PSEUDO_PARTS = { - ':hover': 'hover', - ':active': 'active' -} -export function parseSelector (selector, position) { - const original = selector.trim() - let current = original - let part = null - const partMatch = current.match(PART_RE) - if (partMatch) { - part = partMatch[1].trim() - current = current.slice(0, partMatch.index).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.startsWith('.')) { - return unsupported(original, position) - } - if (current.includes(' ') || - current.includes('>') || - current.includes('+') || - current.includes('~') || - current.includes('[') || - current.includes('#') || - current.includes(':')) { - return unsupported(original, position) - } - const classes = current.split('.').filter(Boolean) - if (classes.length === 0 || classes.some(name => !/^[_a-zA-Z][-_a-zA-Z0-9]*$/.test(name))) { - return unsupported(original, position) - } - return { - result: { - selector: original, - classes, - part, - specificity: classes.length - } - } -} -function unsupported (selector, position) { - return { - diagnostic: diagnostic('UNSUPPORTED_SELECTOR', `Unsupported selector "${selector}" ignored. CSSX supports class combinations and :part()/:hover/:active only.`, 'warning', position) - } -} diff --git a/packages/css-to-rn/dist/transform/index.d.ts b/packages/css-to-rn/dist/transform/index.d.ts deleted file mode 100644 index b30af6d..0000000 --- a/packages/css-to-rn/dist/transform/index.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -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[]; -} -export declare function transformDeclarations (declarations: readonly CssDeclaration[], options?: TransformDeclarationOptions): TransformDeclarationResult -export declare function getPropertyName (property: string): string -export declare function transformRawValue (value: string): TransformStyleValue diff --git a/packages/css-to-rn/dist/transform/index.js b/packages/css-to-rn/dist/transform/index.js deleted file mode 100644 index 6c781af..0000000 --- a/packages/css-to-rn/dist/transform/index.js +++ /dev/null @@ -1,1129 +0,0 @@ -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 = { - 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, - margin: transformMargin, - padding: transformPadding, - textShadow: transformTextShadow, - transform: transformTransform, - transition: transformTransition, - transitionDelay: transformTransitionLonghand, - transitionDuration: transformTransitionLonghand, - transitionProperty: transformTransitionLonghand, - transitionTimingFunction: transformTransitionLonghand, -} -export function transformDeclarations (declarations, options = {}) { - const style = {} - const diagnostics = [] - const shorthandBlacklist = new Set(options.shorthandBlacklist ?? []) - const context = { - 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) { - const trimmed = property.trim() - if (trimmed.startsWith('--')) { return trimmed } - return trimmed.replace(/-([a-z])/g, (_, character) => character.toUpperCase()) -} -export function transformRawValue (value) { - 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) { - 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, value) { - return { style: { [property]: transformRawValue(value) } } -} -function passthroughString (property, value) { - return { style: { [property]: value.trim() } } -} -function transformMargin (property, value) { - return { - style: expandDirectionalValues({ - directions: ['Top', 'Right', 'Bottom', 'Left'], - prefix: property, - values: parseDirectionalValues(value, valueToken => parseLength(valueToken, { allowAuto: true, allowPercent: true })), - }), - } -} -function transformPadding (property, value) { - return { - style: expandDirectionalValues({ - directions: ['Top', 'Right', 'Bottom', 'Left'], - prefix: property, - values: parseDirectionalValues(value, valueToken => parseLength(valueToken, { allowPercent: true })), - }), - } -} -function transformDirectionalWidth (property, value) { - return { - style: expandDirectionalValues({ - directions: ['Top', 'Right', 'Bottom', 'Left'], - prefix: 'border', - suffix: 'Width', - values: parseDirectionalValues(value, valueToken => parseLength(valueToken, { allowPercent: false })), - }), - } -} -function transformDirectionalColor (property, value) { - return { - style: expandDirectionalValues({ - directions: ['Top', 'Right', 'Bottom', 'Left'], - prefix: 'border', - suffix: 'Color', - values: parseDirectionalValues(value, parseColor), - }), - } -} -function transformDirectionalBorderStyle (property, value) { - return { - style: expandDirectionalValues({ - directions: ['Top', 'Right', 'Bottom', 'Left'], - prefix: 'border', - suffix: 'Style', - values: parseDirectionalValues(value, parseBorderStyle), - }), - } -} -function transformBorderRadius (property, value) { - 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, value) { - 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 - let borderColor - let borderStyle - 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, value) { - const parts = parseFunctionSequence(value) - const transforms = [] - 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, args) { - 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, value) { - 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 - const lengths = [] - 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, value) { - 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, value) { - 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, value) { - 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, value) { - 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, value, declaration, context) { - 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, value, declaration, context) { - 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) { - const tokens = splitByWhitespace(value) - let name - let duration - let timingFunction - let delay - let iterationCount - let direction - let fillMode - let playState - 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) { - const tokens = splitByWhitespace(value) - let property - let duration - let timingFunction - let delay - 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, parseValue) { - 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) { - const [top, right = top, bottom = top, left = right] = options.values - const suffix = options.suffix ?? '' - const values = [top, right, bottom, left] - const style = {} - for (let index = 0; index < options.directions.length; index += 1) { - style[`${options.prefix}${options.directions[index]}${suffix}`] = - values[index] - } - return style -} -function parseLength (value, options = {}) { - 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) { - const trimmed = value.trim() - if (!numberRe.test(trimmed)) { - throw new Error(`Expected number value, got "${value}"`) - } - return Number(trimmed) -} -function parseAngle (value) { - const trimmed = value.trim() - if (!angleRe.test(trimmed)) { - throw new Error(`Expected angle value, got "${value}"`) - } - return trimmed.toLowerCase() -} -function parseColor (value) { - const trimmed = value.trim() - if (!isColor(trimmed)) { throw new Error(`Expected color value, got "${value}"`) } - return trimmed -} -function parseBorderStyle (value) { - const lower = value.trim().toLowerCase() - if (!borderStyles.has(lower)) { - throw new Error(`Expected border style value, got "${value}"`) - } - return lower -} -function parseTime (value) { - const trimmed = value.trim() - if (!isTime(trimmed)) { throw new Error(`Expected time value, got "${value}"`) } - return trimmed -} -function parseTimingFunction (value) { - const trimmed = value.trim() - if (!isTimingFunction(trimmed)) { - throw new Error(`Expected timing function value, got "${value}"`) - } - return trimmed -} -function parseIterationCount (value) { - 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) { - 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, keywords) { - const lower = value.trim().toLowerCase() - if (!keywords.has(lower)) { - throw new Error(`Expected one of ${Array.from(keywords).join(', ')}`) - } - return lower -} -function parseTransitionProperty (value) { - const trimmed = value.trim() - if (trimmed === 'all' || trimmed === 'none') { return trimmed } - return getPropertyName(trimmed) -} -function parseCommaSeparated (value, parseValue) { - const values = splitTopLevel(value, ',').map(parseValue) - return values.length === 1 ? values[0] : values -} -function singleOrArray (values, isSingle) { - return isSingle ? values[0] : values -} -function inlineAnimationKeyframes (style, keyframes) { - 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, allowPercent) { - try { - parseLength(value, { allowPercent }) - return true - } catch { - return false - } -} -function isColor (value) { - const trimmed = value.trim() - const lower = trimmed.toLowerCase() - return (hexColorRe.test(trimmed) || - colorFunctionRe.test(trimmed) || - cssColorKeywords.has(lower) || - lower === 'currentcolor') -} -function isTime (value) { - return timeRe.test(value.trim()) -} -function isTimingFunction (value) { - 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) { - return isFunctionToken(value.trim(), 'calc') -} -function isSupportedBackgroundImageValue (value) { - 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) { - return /\b(?:url|image-set|cross-fade|element|paint)\s*\(/i.test(value) -} -function backgroundImageProperty (platform) { - return platform === 'web' ? 'backgroundImage' : 'experimental_backgroundImage' -} -function isFunctionToken (value, functionName) { - 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) { - const functions = [] - 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) { - const commaParts = splitTopLevel(value, ',') - if (commaParts.length > 1) { return commaParts } - return splitByWhitespace(value) -} -function expectArgumentCount (functionName, args, min, max) { - if (args.length < min || args.length > max) { - throw new Error(`Expected ${functionName}() to have ${min === max ? min : `${min}-${max}`} arguments`) - } -} -function splitByWhitespace (value) { - const parts = [] - let current = '' - let depth = 0 - let quote = 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, separator) { - const parts = [] - let current = '' - let depth = 0 - let quote = 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, openIndex) { - let depth = 0 - let quote = 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, property, value, message, declaration) { - return { - code, - property, - value, - message, - order: declaration.order, - } -} diff --git a/packages/css-to-rn/dist/types.d.ts b/packages/css-to-rn/dist/types.d.ts deleted file mode 100644 index f8104e0..0000000 --- a/packages/css-to-rn/dist/types.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -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' -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; -} -export interface CompiledCssSheet { - version: 1; - id: string; - sourceId?: string; - contentHash: string; - rules: CssxRule[]; - keyframes: Record; - exports?: Record; - metadata: CssxMetadata; - diagnostics: CssxDiagnostic[]; - error?: CssxDiagnostic; -} -export interface CssxRule { - selector: string; - 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; - classes: string[]; - part: string | null; - specificity: number; -} -export interface CompileState { - diagnostics: CssxDiagnostic[]; - mode: CompileMode; -} diff --git a/packages/css-to-rn/dist/types.js b/packages/css-to-rn/dist/types.js deleted file mode 100644 index 336ce12..0000000 --- a/packages/css-to-rn/dist/types.js +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/packages/css-to-rn/dist/values.d.ts b/packages/css-to-rn/dist/values.d.ts deleted file mode 100644 index 254a01e..0000000 --- a/packages/css-to-rn/dist/values.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CssxDiagnostic } from './types.ts' -export type InterpolationValue = string | number | null | undefined | false -export interface ResolveCssValueOptions { - values?: readonly unknown[]; - variables?: Record; - defaultVariables?: Record; - dimensions?: { - width?: number; - height?: number; - }; - maxVarDepth?: number; -} -export interface ResolveCssValueResult { - value?: string; - valid: boolean; - dependencies: { - vars: string[]; - dimensions: boolean; - }; - diagnostics: CssxDiagnostic[]; -} -export declare function resolveCssValue (input: string, options?: ResolveCssValueOptions): ResolveCssValueResult diff --git a/packages/css-to-rn/dist/values.js b/packages/css-to-rn/dist/values.js deleted file mode 100644 index 6cdeadb..0000000 --- a/packages/css-to-rn/dist/values.js +++ /dev/null @@ -1,247 +0,0 @@ -import { diagnostic } from './diagnostics.js' -const DYNAMIC_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g -const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/ -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, options = {}) { - const diagnostics = [] - 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) - const calc = resolveCalcs(units.value, diagnostics) - if (!calc.valid) { - return invalid(diagnostics, dependencies) - } - return { - value: calc.value.trim(), - valid: true, - dependencies: serializeDependencies(dependencies), - diagnostics - } -} -function replaceDynamicSlots (input, values, diagnostics) { - DYNAMIC_SLOT_RE.lastIndex = 0 - let valid = true - const value = input.replace(DYNAMIC_SLOT_RE, (_match, rawIndex) => { - 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, options, deps, diagnostics, stack, maxDepth) { - 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) ?? - 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, options, dependencies) { - let value = input.replace(U_UNIT_RE, (_match, prefix, rawNumber) => { - return `${prefix}${Number(rawNumber) * 8}px` - }) - const width = options.dimensions?.width ?? 0 - const height = options.dimensions?.height ?? 0 - value = value.replace(VIEWPORT_UNIT_RE, (_match, prefix, rawNumber, unit) => { - 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, diagnostics) { - 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) { - if (expression.includes('%')) { return null } - const hasPx = /(?:^|[^\w.-])[+-]?(?:\d*\.)?\d+px\b/.test(expression) - const normalized = expression.replace(/([+-]?(?:\d*\.)?\d+)px\b/g, '$1') - if (!/^[0-9+\-*/().\s]+$/.test(normalized)) { return null } - let index = 0 - const skipWhitespace = () => { - while (/\s/.test(normalized[index] ?? '')) { index++ } - } - const parseNumber = () => { - 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 = () => { - 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 = () => { - 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 () { - 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) - ? hasPx ? `${result}px` : String(result) - : null -} -function findMatchingParen (input, openIndex) { - 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) { - const parts = [] - 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, key) { - if (!record || !Object.prototype.hasOwnProperty.call(record, key)) { return undefined } - return record[key] -} -function serializeDependencies (dependencies) { - return { - vars: Array.from(dependencies.vars).sort(), - dimensions: dependencies.dimensions - } -} -function invalid (diagnostics, dependencies) { - return { - valid: false, - dependencies: serializeDependencies(dependencies), - diagnostics - } -} diff --git a/packages/css-to-rn/dist/web.d.ts b/packages/css-to-rn/dist/web.d.ts deleted file mode 100644 index 495e4cc..0000000 --- a/packages/css-to-rn/dist/web.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { compileCss, compileCssTemplate } from './compiler.ts' -export { resolveCssValue } from './values.ts' -import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.ts' -import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.ts' -import { createTrackedCssxSheet } from './react/tracker.ts' -import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, 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 { CssxProviderProps, CssxReactConfig } from './react/config.ts' -export type { TrackedCssxSheetOptions } from './react/tracker.ts' -export { CssxProvider, configureCssx, useCssxConfig } from './react/config.ts' -export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.ts' -export { defaultVariables, setDefaultVariables, variables } -export declare function cssx (...args: Parameters): ReturnType -export declare function useCompiledCss (...args: Parameters): ReturnType -export declare function useCssxLayer (...args: Parameters): ReturnType -export declare function useCssxSheet (...args: Parameters): ReturnType -export declare function useCssxTemplate (...args: Parameters): ReturnType -export declare const __cssxInternals: { - clearRawCssCacheForTests: typeof clearRawCssCacheForTests; - configureDimensionsAdapterForTests: typeof configureDimensionsAdapter; - createTrackedCssxSheet: typeof createTrackedCssxSheet; - flushMicrotasksForTests: typeof flushMicrotasksForTests; - getRuntimeSubscriberCountForTests: typeof getRuntimeSubscriberCountForTests; - resetStoreForTests: typeof resetStoreForTests; - setDimensionsForTests: typeof setDimensionsForTests; - subscribeVariablesForTests: typeof subscribeVariablesForTests; -} diff --git a/packages/css-to-rn/dist/web.js b/packages/css-to-rn/dist/web.js deleted file mode 100644 index 04f085a..0000000 --- a/packages/css-to-rn/dist/web.js +++ /dev/null @@ -1,54 +0,0 @@ -export { compileCss, compileCssTemplate } from './compiler.js' -export { resolveCssValue } from './values.js' -import { cssx as baseCssx, clearRawCssCacheForTests } from './react/cssx.js' -import { useCssxLayer as baseUseCssxLayer, useCompiledCss as baseUseCompiledCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.js' -import { createTrackedCssxSheet } from './react/tracker.js' -import { configureDimensionsAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, variables } from './react/store.js' -export { CssxProvider, configureCssx, useCssxConfig } from './react/config.js' -export { TrackedCssxSheet, isTrackedCssxSheet } from './react/tracker.js' -export { defaultVariables, setDefaultVariables, variables } -export function cssx (...args) { - const [styleName, sheet, inlineStyleProps, options] = args - return baseCssx(styleName, sheet, inlineStyleProps, { - target: 'web', - ...(options ?? {}) - }) -} -export function useCompiledCss (...args) { - const [input, options] = args - return baseUseCompiledCss(input, { - target: 'web', - ...(options ?? {}) - }) -} -export function useCssxLayer (...args) { - const [input, options] = args - return baseUseCssxLayer(input, { - target: 'web', - ...(options ?? {}) - }) -} -export function useCssxSheet (...args) { - const [sheet, options] = args - return baseUseCssxSheet(sheet, { - target: 'web', - ...(options ?? {}) - }) -} -export function useCssxTemplate (...args) { - const [sheet, values, options] = args - return baseUseCssxTemplate(sheet, values, { - target: 'web', - ...(options ?? {}) - }) -} -export const __cssxInternals = { - clearRawCssCacheForTests, - configureDimensionsAdapterForTests: configureDimensionsAdapter, - createTrackedCssxSheet, - flushMicrotasksForTests, - getRuntimeSubscriberCountForTests, - resetStoreForTests, - setDimensionsForTests, - subscribeVariablesForTests -} diff --git a/packages/css-to-rn/package.json b/packages/css-to-rn/package.json index 69c3e1d..0e60b6e 100644 --- a/packages/css-to-rn/package.json +++ b/packages/css-to-rn/package.json @@ -40,7 +40,7 @@ "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": "tsc -p tsconfig.build.json", + "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepublishOnly": "npm run build" }, "files": [ diff --git a/packages/cssxjs/package.json b/packages/cssxjs/package.json index 304a255..2724c83 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -33,7 +33,7 @@ "access": "public" }, "scripts": { - "test": "echo 'No tests yet' && exit 0" + "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node --input-type=module -e \"import { cssx, useCssxLayer, useCompiledCss } from 'cssxjs'; if (typeof cssx !== 'function' || typeof useCssxLayer !== 'function' || typeof useCompiledCss !== 'function') throw new Error('cssxjs source-condition import smoke failed')\"" }, "dependencies": { "@cssxjs/babel-plugin-rn-stylename-inline": "^0.3.0", diff --git a/packages/loaders/package.json b/packages/loaders/package.json index cecf50d..1a3e33d 100644 --- a/packages/loaders/package.json +++ b/packages/loaders/package.json @@ -12,7 +12,7 @@ "access": "public" }, "scripts": { - "test": "echo 'No tests yet' && exit 0" + "test": "node -e \"const loader = require('./cssToReactNativeLoader.js'); const output = loader.call({ query: {}, resourcePath: 'smoke.css' }, '.root { color: red }'); if (!output.includes('module.exports')) throw new Error('cssToReactNativeLoader source fallback smoke failed')\"" }, "author": { "name": "Pavel Zhukov", From d7d8c7a6da0876153da8c8f1037ea2046b239b66 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 23:31:33 +0300 Subject: [PATCH 13/37] Strengthen css-to-rn runtime edge coverage --- .github/workflows/test.yml | 9 + architecture.md | 13 +- docs/guide/caching.md | 5 +- example/package.json | 7 +- package.json | 7 +- packages/css-to-rn/package.json | 6 + packages/css-to-rn/src/compiler.ts | 90 +++- packages/css-to-rn/src/index.ts | 1 + packages/css-to-rn/src/react-native.ts | 2 + packages/css-to-rn/src/react/cssx.ts | 2 + packages/css-to-rn/src/react/store.ts | 138 +++++- packages/css-to-rn/src/react/tracker.ts | 28 ++ packages/css-to-rn/src/resolve.ts | 17 +- packages/css-to-rn/src/web.ts | 2 + .../css-to-rn/test/engine/compiler.test.ts | 27 + .../css-to-rn/test/engine/resolve.test.ts | 18 + .../css-to-rn/test/react/tracking.test.ts | 216 ++++++++ yarn.lock | 468 ++++++++++++++++-- 18 files changed, 977 insertions(+), 79 deletions(-) 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/architecture.md b/architecture.md index ced6a40..c50e891 100644 --- a/architecture.md +++ b/architecture.md @@ -218,6 +218,13 @@ The sheet must remain serializable. Cache state, subscriptions, and runtime trac `src/compiler.ts` parses CSS with the lightweight `css` parser. Runtime mode returns an empty diagnostic sheet on syntax errors. Build mode throws for errors that should fail Babel/loader builds. +Build mode validates static declaration values through the shared value resolver +and property transformer. Unsupported static constructs such as +layout-dependent `calc()` expressions, unsupported transform functions, and +unsupported background images fail during Babel/loader compilation. +Declarations containing `var()` or template slots are deferred to runtime +validation because their final value is not knowable at build time. + Supported selectors: - `.root` @@ -303,12 +310,12 @@ Key pieces: - `store.ts`: `variables`, `defaultVariables`, `setDefaultVariables()`, dimensions/media state, microtask-batched notifications. - `tracker.ts`: `TrackedCssxSheet`, committed dependency snapshots, per-tracker cache. - `cssx.ts`: ergonomic `cssx()` wrapper that delegates to `resolveCssx()` and records dependencies into tracked sheets during render. -- `hooks.ts`: `useCssxSheet()`, `useCompiledCss()`, `useCssxTemplate()`. +- `hooks.ts`: `useCssxSheet()`, `useCompiledCss()`, `useCssxTemplate()`, `useCssxLayer()`. - `config.ts`: optional `CssxProvider`, `configureCssx()`, and `useCssxConfig()`. `useCssxSheet()` starts a render-local dependency collection before render and commits it in a layout/effect phase. If a render is aborted, for example because a component throws a promise into Suspense, the pending dependencies are not committed and do not leak global subscriptions. -Variable writes and deletes notify subscribers once per microtask. Subscribers only rerender when a variable they actually used changes. Media and viewport-unit subscribers are tied to dimension changes. Web resize uses leading plus trailing debounced updates. +Variable writes and deletes notify subscribers once per microtask. Subscribers only rerender when a variable they actually used changes. Viewport-unit subscribers are tied to dimension changes. Media-query dependencies store the match value observed during the committed render; dimension changes and platform media adapter changes only rerender subscribers whose committed media result changed. Browser `matchMedia` is used on web when available, and tests can install a media-query adapter for non-DOM media features such as `prefers-color-scheme`, `hover`, and `pointer`. Web resize uses leading plus trailing debounced updates. ## Loaders And Separate Files @@ -392,7 +399,7 @@ cd packages/babel-plugin-rn-stylename-to-style && yarn test `@cssxjs/css-to-rn` tests: - `test/engine/**`: parser IR, value resolution, property transforms, resolver cascade, cache behavior. -- `test/react/**`: variable batching, dependency tracking, aborted-render safety, tracked cache references. +- `test/react/**`: variable batching, dependency tracking, media adapter invalidation, aborted-render safety, tracked cache references, React 19 hook/Suspense behavior. Babel plugin tests use `babel-plugin-tester` and Jest snapshots in: diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 9c70035..53b3033 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -76,8 +76,9 @@ variables['--text-color'] = 'red' // ThemedCard does not update ``` Variable notifications are batched in a microtask. Media query updates use the -runtime dimension store, and web resize handling can be configured globally -through `configureCssx()`. +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 { configureCssx } from 'cssxjs' diff --git a/example/package.json b/example/package.json index 4ab3f00..dc7255c 100644 --- a/example/package.json +++ b/example/package.json @@ -10,11 +10,12 @@ "@babel/core": "^7.0.0", "cssxjs": "^0.3.0", "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/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/css-to-rn/package.json b/packages/css-to-rn/package.json index 0e60b6e..6f839b7 100644 --- a/packages/css-to-rn/package.json +++ b/packages/css-to-rn/package.json @@ -65,8 +65,14 @@ } }, "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/compiler.ts b/packages/css-to-rn/src/compiler.ts index 18d6ccb..dc74647 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -4,6 +4,8 @@ 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, @@ -13,7 +15,8 @@ import type { CssxDiagnostic, CssxKeyframe, CssxMetadata, - CssxRule + CssxRule, + CssxTarget } from './types.ts' const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/ @@ -93,7 +96,7 @@ function compileCssInternal ( for (const rule of ast.stylesheet?.rules ?? []) { if (rule.type === 'rule') { const styleRule = rule as CssStyleRuleAst - compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, state, orderRef(() => order++), isTemplate, exports) + compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, state, orderRef(() => order++), isTemplate, exports, options.target) continue } @@ -104,7 +107,7 @@ function compileCssInternal ( if (!mediaIsValid && state.mode === 'build') continue for (const child of mediaRule.rules ?? []) { if (child.type !== 'rule') continue - compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, state, orderRef(() => order++), isTemplate, exports) + compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, state, orderRef(() => order++), isTemplate, exports, options.target) } continue } @@ -113,7 +116,7 @@ function compileCssInternal ( const keyframesRule = rule as CssKeyframesAst const name = keyframesRule.name if (!name) continue - keyframes[name] = compileKeyframes(keyframesRule, state, orderRef(() => order++), isTemplate) + keyframes[name] = compileKeyframes(keyframesRule, state, orderRef(() => order++), isTemplate, options.target) continue } @@ -149,8 +152,11 @@ function compileRuleList ( state: CompileState, nextOrder: () => number, isTemplate: boolean, - exports: Record + exports: Record, + target: CssxTarget | undefined ): void { + let compiledDeclarations: CssxDeclaration[] | undefined + for (const selector of selectors) { if (selector === ':export') { compileExports(declarations, exports, state, isTemplate) @@ -172,6 +178,7 @@ function compileRuleList ( continue } if (!parsed.result) continue + compiledDeclarations ??= compileDeclarations(declarations, state, isTemplate, target) output.push({ selector: parsed.result.selector, @@ -180,7 +187,7 @@ function compileRuleList ( specificity: parsed.result.specificity, order: nextOrder(), media, - declarations: compileDeclarations(declarations, state, isTemplate) + declarations: compiledDeclarations }) } } @@ -209,7 +216,8 @@ function compileExports ( function compileDeclarations ( declarations: CssDeclarationAst[], state: CompileState, - isTemplate: boolean + isTemplate: boolean, + target: CssxTarget | undefined ): CssxDeclaration[] { const output: CssxDeclaration[] = [] let order = 0 @@ -231,7 +239,7 @@ function compileDeclarations ( } const dynamicSlots = isTemplate ? getDynamicSlots(value) : undefined - output.push({ + const compiledDeclaration: CssxDeclaration = { property, value, raw: `${property}: ${value}`, @@ -239,7 +247,10 @@ function compileDeclarations ( dynamicSlots, line: declaration.position?.start?.line, column: declaration.position?.start?.column - }) + } + + validateBuildDeclaration(compiledDeclaration, state, target) + output.push(compiledDeclaration) } return output @@ -249,19 +260,76 @@ function compileKeyframes ( rule: CssKeyframesAst, state: CompileState, nextOrder: () => number, - isTemplate: boolean + 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), + 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 + } + }) + + 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, diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts index d4f7776..b3420ca 100644 --- a/packages/css-to-rn/src/index.ts +++ b/packages/css-to-rn/src/index.ts @@ -37,6 +37,7 @@ export type { CssxCache, CssxDimensions, CssxLayerInput, + CssxMediaQueryEvaluator, InlineStyleInput, ResolveCssxDependencies, ResolveCssxLayer, diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index fc3e0b0..1b8aabc 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -20,6 +20,7 @@ import { } from './react/tracker.ts' import { configureDimensionsAdapter, + configureMediaQueryAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, @@ -119,6 +120,7 @@ export function useCssxTemplate ( export const __cssxInternals = { clearRawCssCacheForTests, configureDimensionsAdapterForTests: configureDimensionsAdapter, + configureMediaQueryAdapterForTests: configureMediaQueryAdapter, createTrackedCssxSheet, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts index f359fb7..6614bd6 100644 --- a/packages/css-to-rn/src/react/cssx.ts +++ b/packages/css-to-rn/src/react/cssx.ts @@ -11,6 +11,7 @@ import { } from '../resolve.ts' import { evaluateMediaQuery, + getMediaQueryEvaluator, getDefaultVariableValues, getDimensions, getDimensionsVersion, @@ -67,6 +68,7 @@ export function cssx ( variables: getVariableValues(), defaultVariables: getDefaultVariableValues(), dimensions: getDimensions(), + mediaQueryEvaluator: getMediaQueryEvaluator(), cache: options.cache ?? normalized.cache }) diff --git a/packages/css-to-rn/src/react/store.ts b/packages/css-to-rn/src/react/store.ts index 9d2e6c0..c692083 100644 --- a/packages/css-to-rn/src/react/store.ts +++ b/packages/css-to-rn/src/react/store.ts @@ -14,6 +14,11 @@ export interface CssxDimensionsAdapter { subscribe: (listener: () => void) => () => void } +export interface CssxMediaQueryAdapter { + evaluate: (query: string) => boolean + subscribe?: (query: string, listener: () => void) => () => void +} + export interface CssxDependencySnapshot { vars: Map media: Map @@ -29,6 +34,7 @@ export interface CssxDependencyCollector { export interface RuntimeChangeSnapshot { vars: readonly string[] dimensions: boolean + media: boolean } type RuntimeSubscriber = { @@ -43,6 +49,10 @@ const defaultVariableValues: Record = Object.create(null) const variableVersions = new Map() const runtimeSubscribers = new Set() const pendingVariableNames = new Set() +const retainedMediaQueries = new Map void) | null +}>() let runtimeConfig: Required = { dimensionsDebounceMs: 0 @@ -50,9 +60,11 @@ let runtimeConfig: Required = { let variableVersion = 0 let dimensionsAdapter: CssxDimensionsAdapter | null = null let dimensionsAdapterUnsubscribe: (() => void) | null = null +let mediaQueryAdapter: CssxMediaQueryAdapter | null = null 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 @@ -120,13 +132,35 @@ export function configureDimensionsAdapter ( 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 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 { @@ -159,6 +193,32 @@ export function subscribeRuntimeStore ( } } +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 @@ -208,10 +268,13 @@ export function resetStoreForTests (): void { pendingVariableNames.clear() variableVersion = 0 removeWindowResizeListener() + releaseAllRetainedMediaQueries() dimensionsAdapter = null + mediaQueryAdapter = null dimensions = FALLBACK_DIMENSIONS dimensionsVersion = 0 pendingDimensionsChanged = false + pendingMediaChanged = false notifyScheduled = false runtimeSubscribers.clear() } @@ -265,6 +328,11 @@ function applyDimensions (next: { width: number, height: number }): void { scheduleNotification() } +function markMediaChanged (): void { + pendingMediaChanged = true + scheduleNotification() +} + function scheduleNotification (): void { if (notifyScheduled) return notifyScheduled = true @@ -278,13 +346,19 @@ function scheduleNotification (): void { 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) return + if (vars.length === 0 && !dimensionsChanged && !mediaChanged) return - const change = { vars, dimensions: dimensionsChanged } + const change = { + vars, + dimensions: dimensionsChanged, + media: mediaChanged + } for (const subscriber of Array.from(runtimeSubscribers)) { if (shouldNotifySubscriber(subscriber.getDependencies(), change)) { @@ -301,16 +375,60 @@ function shouldNotifySubscriber ( if (dependencies.vars.has(name)) return true } - if (!change.dimensions) return false - if (dependencies.dimensionsVersion != null) return true + if (change.dimensions && dependencies.dimensionsVersion != null) return true - for (const [query, matches] of dependencies.media) { - if (evaluateMediaQuery(query) !== matches) 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 @@ -378,6 +496,14 @@ function readWindowDimensions (): { width: number, height: number } { } } +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() } diff --git a/packages/css-to-rn/src/react/tracker.ts b/packages/css-to-rn/src/react/tracker.ts index 1a7601d..3d26619 100644 --- a/packages/css-to-rn/src/react/tracker.ts +++ b/packages/css-to-rn/src/react/tracker.ts @@ -6,6 +6,7 @@ import { import { createDependencySnapshot, hasStaleDependencies, + retainMediaQuery, subscribeRuntimeStore, type CssxDependencyCollector, type CssxDependencySnapshot, @@ -29,6 +30,7 @@ export class TrackedCssxSheet implements CssxDependencyCollector { private committedDependencies = createDependencySnapshot() private listeners = new Set<() => void>() private unsubscribeRuntimeStore: (() => void) | null = null + private mediaQueryReleases = new Map void>() private snapshotVersion = 0 private cache: CssxCache @@ -70,6 +72,7 @@ export class TrackedCssxSheet implements CssxDependencyCollector { this.pendingDependencies = null } this.committedDependencies = dependencies + this.syncMediaQuerySubscriptions() if (hasStaleDependencies(dependencies)) { this.emitChange() @@ -97,6 +100,7 @@ export class TrackedCssxSheet implements CssxDependencyCollector { this.handleRuntimeChange, () => this.committedDependencies ) + this.syncMediaQuerySubscriptions() } return () => { @@ -105,6 +109,7 @@ export class TrackedCssxSheet implements CssxDependencyCollector { if (this.listeners.size === 0 && this.unsubscribeRuntimeStore != null) { this.unsubscribeRuntimeStore() this.unsubscribeRuntimeStore = null + this.releaseMediaQuerySubscriptions() } } } @@ -137,6 +142,29 @@ export class TrackedCssxSheet implements CssxDependencyCollector { 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 { diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts index 65d52fb..9a54aa8 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -45,6 +45,7 @@ export interface ResolveCssxOptions { variables?: Record defaultVariables?: Record dimensions?: CssxDimensions + mediaQueryEvaluator?: CssxMediaQueryEvaluator target?: CssxTarget cache?: boolean | CssxCache cacheMaxEntries?: number @@ -56,6 +57,11 @@ export interface CssxDimensions { type?: string } +export type CssxMediaQueryEvaluator = ( + query: string, + dimensions: CssxDimensions | undefined +) => boolean + export type InlineStyleInput = | TransformStyle | ResolvedStyleProps @@ -110,6 +116,7 @@ interface ResolutionContext { variables?: Record defaultVariables?: Record dimensions?: CssxDimensions + mediaQueryEvaluator?: CssxMediaQueryEvaluator dependencies: MutableDependencies diagnostics: CssxDiagnostic[] } @@ -209,6 +216,7 @@ function resolveCssxUncached ( variables: options.variables, defaultVariables: options.defaultVariables, dimensions: options.dimensions, + mediaQueryEvaluator: options.mediaQueryEvaluator, dependencies: createDependencies(), diagnostics: [], } @@ -424,13 +432,16 @@ function ruleMatchesMedia ( const query = stripMediaPrefix(rule.media) context.dependencies.media.add(query) - return matchesMediaQuery(query, context.dimensions) + return matchesMediaQuery(query, context.dimensions, context.mediaQueryEvaluator) } function matchesMediaQuery ( query: string, - dimensions: CssxDimensions | undefined + dimensions: CssxDimensions | undefined, + evaluator?: CssxMediaQueryEvaluator ): boolean { + if (evaluator) return evaluator(query, dimensions) + try { return mediaQuery.match(query, mediaValues(dimensions)) } catch { @@ -615,7 +626,7 @@ function createDynamicSignature ( : undefined, media: dependencies.media.map(query => [ query, - matchesMediaQuery(query, options.dimensions) + matchesMediaQuery(query, options.dimensions, options.mediaQueryEvaluator) ]) }) } diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index f7d8d3c..86487c9 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -20,6 +20,7 @@ import { } from './react/tracker.ts' import { configureDimensionsAdapter, + configureMediaQueryAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, @@ -116,6 +117,7 @@ export function useCssxTemplate ( export const __cssxInternals = { clearRawCssCacheForTests, configureDimensionsAdapterForTests: configureDimensionsAdapter, + configureMediaQueryAdapterForTests: configureMediaQueryAdapter, createTrackedCssxSheet, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts index 1d0b4ed..d3a1ee5 100644 --- a/packages/css-to-rn/test/engine/compiler.test.ts +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -81,6 +81,33 @@ describe('@cssxjs/css-to-rn compiler IR', () => { ) }) + 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 and ignores unsupported selectors in runtime mode', () => { const sheet = compileCss(` .root .child { color: red; } diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index 6beea92..673f990 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -233,6 +233,24 @@ describe('@cssxjs/css-to-rn resolver', () => { assert.equal(cache.entries.size, 2) }) + 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 { diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index f598aab..d37e60e 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -1,13 +1,29 @@ 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, cssx, setDefaultVariables, + useCssxLayer, 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() @@ -353,4 +369,204 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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('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() + }) }) diff --git a/yarn.lock b/yarn.lock index 1ca05fb..ff31e9f 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,17 @@ __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 + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -718,6 +769,64 @@ __metadata: languageName: node linkType: hard +"@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.3.0, @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" @@ -759,11 +868,17 @@ __metadata: version: 0.0.0-use.local resolution: "@cssxjs/css-to-rn@workspace:packages/css-to-rn" dependencies: + "@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: "*" @@ -1136,6 +1251,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" @@ -3266,6 +3393,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" @@ -3349,29 +3488,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 @@ -3382,6 +3513,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" @@ -4321,6 +4459,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" @@ -5224,6 +5371,16 @@ __metadata: languageName: node linkType: hard +"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 + "css@npm:^3.0.0": version: 3.0.0 resolution: "css@npm:3.0.0" @@ -5244,10 +5401,10 @@ __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 @@ -5277,6 +5434,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" @@ -5410,6 +5577,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" @@ -5702,6 +5876,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" @@ -6437,12 +6618,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" 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 @@ -7618,6 +7800,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" @@ -8259,6 +8450,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" @@ -9101,6 +9299,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" @@ -9564,7 +9796,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: @@ -9589,6 +9821,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" @@ -9965,6 +10204,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" @@ -11794,6 +12040,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" @@ -12189,7 +12444,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 @@ -12219,15 +12474,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 @@ -12309,12 +12563,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 @@ -12666,6 +12918,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" @@ -12805,15 +13064,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 @@ -12920,12 +13180,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 @@ -13267,6 +13527,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" @@ -13743,6 +14010,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" @@ -13886,6 +14160,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" @@ -13923,6 +14215,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" @@ -14305,6 +14615,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" @@ -14319,6 +14636,13 @@ __metadata: 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" @@ -14655,6 +14979,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" @@ -14687,6 +15020,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" @@ -14960,6 +15318,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" From abb07df02c62a35739e3f1f6c3ac200f4bff8836 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 23:49:22 +0300 Subject: [PATCH 14/37] Document Teamplay compatibility entrypoints --- README.md | 13 +++++++++++++ packages/cssxjs/runtime/react-native-teamplay.js | 4 ++++ packages/cssxjs/runtime/web-teamplay.js | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index c2d384a..3ea6ed4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,19 @@ 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) +- [`teamplay`](https://github.com/startupjs/teamplay) + ## License MIT diff --git a/packages/cssxjs/runtime/react-native-teamplay.js b/packages/cssxjs/runtime/react-native-teamplay.js index 571a068..7f689f8 100644 --- a/packages/cssxjs/runtime/react-native-teamplay.js +++ b/packages/cssxjs/runtime/react-native-teamplay.js @@ -1,3 +1,7 @@ +// 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 diff --git a/packages/cssxjs/runtime/web-teamplay.js b/packages/cssxjs/runtime/web-teamplay.js index def8db3..4911329 100644 --- a/packages/cssxjs/runtime/web-teamplay.js +++ b/packages/cssxjs/runtime/web-teamplay.js @@ -1,3 +1,7 @@ +// 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 d817450586f7db1db8e77a724b9d5e236eaee82d Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 23:50:39 +0300 Subject: [PATCH 15/37] Remove internal Teamplay credit --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 3ea6ed4..7601c5f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ the separate roles previously handled by: The runtime and API design also benefited from studying: - [`cssta`](https://github.com/jacobp100/cssta) -- [`teamplay`](https://github.com/startupjs/teamplay) ## License From fa775eef19543dbe6e093c6f76d140ef696916b0 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 00:59:49 +0300 Subject: [PATCH 16/37] Clarify dynamic animation docs --- docs/api/babel.md | 8 +-- docs/guide/animations.md | 8 +-- docs/guide/caching.md | 10 +--- .../README.md | 4 +- .../css-to-rn/test/engine/resolve.test.ts | 50 +++++++++++++++++++ 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/api/babel.md b/docs/api/babel.md index e2a40b9..2fc99a1 100644 --- a/docs/api/babel.md +++ b/docs/api/babel.md @@ -21,7 +21,6 @@ module.exports = { |--------|------|---------|-------------| | `platform` | `'web'` \| `'ios'` \| `'android'` | `'web'` | Target platform | | `reactType` | `'react-native'` \| `'web'` | auto | React target type | -| `cache` | `'teamplay'` | auto | Legacy compatibility alias | | `transformPug` | `boolean` | `true` | Enable Pug transformation | | `transformCss` | `boolean` | `true` | Enable CSS transformation | @@ -32,8 +31,7 @@ module.exports = { module.exports = { presets: [ ['cssxjs/babel', { - transformPug: false, // Disable pug if not using it - cache: 'teamplay' // Legacy compatibility alias + transformPug: false // Disable pug if not using it }] ] } @@ -61,9 +59,7 @@ You can also set platform-specific variables in your Stylus code: ## Caching -CSSX uses the built-in resolver cache by default. The old `cache: 'teamplay'` -option is still accepted so existing configs do not break, but CSSX no longer -imports Teamplay and components do not need `observer()`. +CSSX uses the built-in resolver cache by default. See the [Caching guide](/guide/caching) for more details. diff --git a/docs/guide/animations.md b/docs/guide/animations.md index 9234b2c..bd3755f 100644 --- a/docs/guide/animations.md +++ b/docs/guide/animations.md @@ -323,9 +323,11 @@ CSSX compiles animations in a way Reanimated v4 expects: This means you write standard CSS and get native-compatible animations automatically. -Animation and transition values are static-only. Use class changes, CSS -variables, or template interpolation to change the surrounding styles at -runtime; keyframe definitions themselves are compiled from static CSS. +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 diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 53b3033..0133051 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -1,7 +1,7 @@ # Caching -CSSX caches resolved style props by default. There is no `observer()` wrapper and -no Teamplay dependency required. +CSSX caches resolved style props by default and tracks the runtime dependencies +used by each element. ## How It Works @@ -165,12 +165,6 @@ Most applications only need `styleName`. Use these helpers when CSS arrives as a runtime string or when building lower-level components that do not use Babel's `styleName` transform. -## Legacy `cache: 'teamplay'` - -The Babel option `cache: 'teamplay'` is still accepted for older configs, but it -is now a compatibility alias. CSSX owns its cache internally and does not import -Teamplay. - ## Next Steps - [CSS Variables](/guide/variables) - Runtime theming diff --git a/packages/babel-plugin-rn-stylename-to-style/README.md b/packages/babel-plugin-rn-stylename-to-style/README.md index 7137197..5ca2c3e 100644 --- a/packages/babel-plugin-rn-stylename-to-style/README.md +++ b/packages/babel-plugin-rn-stylename-to-style/README.md @@ -131,9 +131,7 @@ so these files shouldn't frequently change. **Default:** `undefined` -Legacy compatibility option. `"teamplay"` is still accepted for older configs, -but style caching is owned by CSSX internally and does not require Teamplay or -`observer()`. +Legacy compatibility option. New projects should omit it. #### `platform` diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index 673f990..d66f813 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -284,4 +284,54 @@ describe('@cssxjs/css-to-rn resolver', () => { 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' + }) + }) }) From 7a69390799182d7010375f0fd1cf0043d028a5d0 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 01:15:27 +0300 Subject: [PATCH 17/37] Document runtime CSS compilation --- docs/api/babel.md | 3 + docs/api/css.md | 28 ++---- docs/api/index.md | 3 +- docs/api/jsx-props.md | 17 +--- docs/api/runtime.md | 203 ++++++++++++++++++++++++++++++++++++++++++ docs/api/styl.md | 3 +- docs/guide/caching.md | 45 ++-------- rspress.config.ts | 1 + 8 files changed, 224 insertions(+), 79 deletions(-) create mode 100644 docs/api/runtime.md diff --git a/docs/api/babel.md b/docs/api/babel.md index 2fc99a1..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. diff --git a/docs/api/css.md b/docs/api/css.md index c0b53e3..96f0b58 100644 --- a/docs/api/css.md +++ b/docs/api/css.md @@ -203,28 +203,9 @@ for React Native. Other image values are ignored with a diagnostic. ### Runtime CSS Strings -Use `useCompiledCss()` and `cssx()` for CSS generated at runtime, such as CSS -returned by an AI system. - -```jsx -import { cssx, useCompiledCss } from 'cssxjs' - -function Button({ generatedCss, disabled, label }) { - const sheet = useCompiledCss(generatedCss) - - return ( -
- {label} -
- ) -} -``` - -Runtime compilation uses graceful diagnostics by default. Invalid CSS does not -throw during render; the returned sheet contains diagnostics and any rules that -could still be compiled. +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 @@ -249,9 +230,10 @@ For these features, use the [styl template](/api/styl) instead. | CSS variables | Yes | Yes | | Function-scoped JS interpolation | Yes | Yes | | Part selectors | Yes | Yes | -| Runtime CSS strings | No | `useCompiledCss()` | +| 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 1794135..6486068 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -32,7 +32,8 @@ import { - [styl() Function](/api/styl-function) — Apply styles via spread - [JSX Props](/api/jsx-props) — `styleName`, `part` - [CSS Variables](/api/variables) — Runtime theming -- [Caching](/guide/caching) — Built-in cache and runtime CSS helpers +- [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 diff --git a/docs/api/jsx-props.md b/docs/api/jsx-props.md index 1a373da..9debcde 100644 --- a/docs/api/jsx-props.md +++ b/docs/api/jsx-props.md @@ -148,19 +148,8 @@ function cssx( ): object ``` -```jsx -import { cssx, useCompiledCss } from 'cssxjs' - -function GeneratedCard({ cssText, selected }) { - const sheet = useCompiledCss(cssText) - - return ( - - ) -} -``` - `cssx()` returns an object with `style` and any part style props such as `titleStyle`, `hoverStyle`, or `activeStyle`. + +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..7b8e816 --- /dev/null +++ b/docs/api/runtime.md @@ -0,0 +1,203 @@ +# 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, useCompiledCss } from 'cssxjs' + +function Button({ generatedCss, disabled, label }) { + const sheet = useCompiledCss(generatedCss) + + return ( +
+ {label} +
+ ) +} +``` + +`useCompiledCss()` 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 +useCompiledCss(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 `useCompiledCss()` +- 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 = useCompiledCss(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. + +## 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 + +`useCompiledCss()` 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) +``` + +`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. + +## Platform Resolution + +Import from `cssxjs` in application code: + +```js +import { cssx, useCompiledCss } 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 361c273..96f2b40 100644 --- a/docs/api/styl.md +++ b/docs/api/styl.md @@ -274,10 +274,11 @@ When the same property is defined in multiple places (highest to lowest): - 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 `useCompiledCss()` with the `css` runtime API +- 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/guide/caching.md b/docs/guide/caching.md index 0133051..a762fae 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -90,28 +90,9 @@ configureCssx({ ## Runtime CSS Strings -For client-generated CSS, compile the string with `useCompiledCss()` and pass the -tracked sheet to `cssx()` inline: - -```jsx -import { cssx, useCompiledCss } from 'cssxjs' - -function Button({ generatedCss, disabled, label }) { - const sheet = useCompiledCss(generatedCss) - - return ( -
- {label} -
- ) -} -``` - -Runtime compilation is graceful by default. Invalid generated CSS produces an -empty or partially compiled sheet with diagnostics attached to the sheet instead -of throwing during render. +For client-generated CSS, use `useCompiledCss()` and `cssx()`. Runtime +compilation has its own API reference covering diagnostics, subscriptions, and +platform behavior: [Runtime Compilation](/api/runtime). ## Inline Style Hashing @@ -148,25 +129,9 @@ 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. -## Manual Runtime API - -The public helpers exported from `cssxjs` are: - -```ts -useCompiledCss(cssText, options?) -useCssxSheet(compiledSheet, options?) -useCssxTemplate(compiledSheet, values, options?) -cssx(styleName, sheet, inlineStyleProps?, options?) -CssxProvider -configureCssx(options) -``` - -Most applications only need `styleName`. Use these helpers when CSS arrives as a -runtime string or when building lower-level components that do not use Babel's -`styleName` transform. - ## Next Steps - [CSS Variables](/guide/variables) - Runtime theming -- [css Template](/api/css) - Runtime CSS and interpolation +- [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/rspress.config.ts b/rspress.config.ts index c63447d..efbfd7b 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' } ] }, From dd83f6a6885c294f0b06f1af6de91a70775a50b6 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 01:25:37 +0300 Subject: [PATCH 18/37] Rename runtime CSS hook --- AGENTS.md | 2 +- architecture.md | 4 ++-- docs/api/index.md | 4 ++-- docs/api/runtime.md | 16 ++++++++-------- docs/guide/caching.md | 5 +++-- packages/css-to-rn/src/react-native.ts | 10 +++++----- packages/css-to-rn/src/react/hooks.ts | 6 +++--- packages/css-to-rn/src/react/index.ts | 2 +- packages/css-to-rn/src/web.ts | 10 +++++----- packages/cssxjs/index.d.ts | 2 +- packages/cssxjs/index.js | 2 +- packages/cssxjs/package.json | 2 +- packages/cssxjs/runtime/react-native.js | 2 +- packages/cssxjs/runtime/web.js | 2 +- plan.md | 24 ++++++++++++------------ 15 files changed, 47 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2400c10..02a1990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ CSSX is a monorepo for a CSS-in-JS toolchain. Users write `css`, `styl`, or opti ## Package Map -- `packages/css-to-rn/`: unified compiler/runtime engine. Start here for CSS parsing, selector IR, value resolution, property transforms, caching, `cssx()`, `useCompiledCss()`, variables, and dimensions. +- `packages/css-to-rn/`: unified compiler/runtime engine. Start here for CSS parsing, selector IR, value resolution, property transforms, caching, `cssx()`, `useRuntimeCss()`, variables, and dimensions. - `packages/cssxjs/`: public `cssxjs` facade, CLI, package exports, runtime compatibility wrappers, Babel preset wrapper, loader wrappers, and Metro wrappers. - `packages/loaders/`: Stylus/CSS loaders and direct compiler wrappers. CSS compilation delegates to `@cssxjs/css-to-rn`. - `packages/babel-plugin-rn-stylename-inline/`: compiles inline `css` and `styl` templates, including local template interpolation lowering. diff --git a/architecture.md b/architecture.md index c50e891..34eb266 100644 --- a/architecture.md +++ b/architecture.md @@ -27,7 +27,7 @@ The `cssxjs` package exposes: - `pug`: template tag processed by `@react-pug/babel-plugin-react-pug`. - `cssx`: runtime helper from `@cssxjs/css-to-rn/react`. - `variables`, `defaultVariables`, `setDefaultVariables`: runtime CSS variable registries. -- `useCompiledCss`, `useCssxSheet`, `useCssxTemplate`: React helpers for runtime-generated CSS and local template values. +- `useRuntimeCss`, `useCssxSheet`, `useCssxTemplate`: React helpers for runtime-generated CSS and local template values. - `CssxProvider`, `configureCssx`, `useCssxConfig`: optional runtime configuration. - `cssxjs/runtime`, `cssxjs/runtime/web`, `cssxjs/runtime/react-native`, and `teamplay` compatibility runtime paths used by Babel-generated code. - `cssxjs/babel`, `cssxjs/metro-config`, and `cssxjs/metro-babel-transformer`. @@ -310,7 +310,7 @@ Key pieces: - `store.ts`: `variables`, `defaultVariables`, `setDefaultVariables()`, dimensions/media state, microtask-batched notifications. - `tracker.ts`: `TrackedCssxSheet`, committed dependency snapshots, per-tracker cache. - `cssx.ts`: ergonomic `cssx()` wrapper that delegates to `resolveCssx()` and records dependencies into tracked sheets during render. -- `hooks.ts`: `useCssxSheet()`, `useCompiledCss()`, `useCssxTemplate()`, `useCssxLayer()`. +- `hooks.ts`: `useCssxSheet()`, `useRuntimeCss()`, `useCssxTemplate()`, `useCssxLayer()`. - `config.ts`: optional `CssxProvider`, `configureCssx()`, and `useCssxConfig()`. `useCssxSheet()` starts a render-local dependency collection before render and commits it in a layout/effect phase. If a render is aborted, for example because a component throws a promise into Suspense, the pending dependencies are not committed and do not leak global subscriptions. diff --git a/docs/api/index.md b/docs/api/index.md index 6486068..4e2ffdb 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -13,7 +13,7 @@ import { setDefaultVariables, defaultVariables, cssx, - useCompiledCss, + useRuntimeCss, useCssxSheet, useCssxTemplate, CssxProvider, @@ -50,7 +50,7 @@ import { | `setDefaultVariables` | Function | Set default CSS variable values | | `defaultVariables` | Object | Read-only default variable values | | `cssx` | Function | Resolve a runtime sheet and `styleName` to props | -| `useCompiledCss` | Hook | Compile runtime CSS text into a tracked sheet | +| `useRuntimeCss` | Hook | Compile runtime CSS text into a tracked sheet | | `useCssxSheet` | Hook | Track an already compiled sheet | | `useCssxTemplate` | Hook | Track a compiled sheet with interpolation values | | `CssxProvider` | Component | Provide runtime options to a subtree | diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 7b8e816..d07196b 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -10,10 +10,10 @@ the runtime API when the CSS source is a string at render time. ## Basic Usage ```jsx -import { cssx, useCompiledCss } from 'cssxjs' +import { cssx, useRuntimeCss } from 'cssxjs' function Button({ generatedCss, disabled, label }) { - const sheet = useCompiledCss(generatedCss) + const sheet = useRuntimeCss(generatedCss) return (
0) { reportCssErrors(sheet.getSheet().diagnostics) @@ -141,7 +141,7 @@ media updates invalidate only affected elements. ## Caching -`useCompiledCss()` recompiles only when the CSS string or target changes. +`useRuntimeCss()` recompiles only when the CSS string or target changes. `cssx()` caches the resolved props for the current inputs: - sheet identity and content hash @@ -179,7 +179,7 @@ and dimension debounce behavior. Import from `cssxjs` in application code: ```js -import { cssx, useCompiledCss } from 'cssxjs' +import { cssx, useRuntimeCss } from 'cssxjs' ``` CSSX resolves the correct web or React Native runtime through package export diff --git a/docs/guide/caching.md b/docs/guide/caching.md index a762fae..adbe8c3 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -7,7 +7,8 @@ used by each element. For Babel-compiled styles, generated code calls the CSSX runtime with the compiled sheet and the current `styleName` value. For runtime CSS strings, -`useCompiledCss()` wraps the compiled sheet in a tracked runtime object. +`useRuntimeCss()` compiles the string and wraps the compiled sheet in a tracked +runtime object. The resolver caches the final props object for the current inputs: @@ -90,7 +91,7 @@ configureCssx({ ## Runtime CSS Strings -For client-generated CSS, use `useCompiledCss()` and `cssx()`. Runtime +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). diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index 1b8aabc..cfe7d9b 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -11,7 +11,7 @@ import { } from './react/cssx.ts' import { useCssxLayer as baseUseCssxLayer, - useCompiledCss as baseUseCompiledCss, + useRuntimeCss as baseUseRuntimeCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.ts' @@ -77,11 +77,11 @@ export function cssx ( }) } -export function useCompiledCss ( - ...args: Parameters -): ReturnType { +export function useRuntimeCss ( + ...args: Parameters +): ReturnType { const [input, options] = args - return baseUseCompiledCss(input, { + return baseUseRuntimeCss(input, { target: 'react-native', ...(options ?? {}) }) diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index 5ee785a..2de423f 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -70,7 +70,7 @@ export function useCssxSheet ( return tracker } -export function useCompiledCss ( +export function useRuntimeCss ( input: string | CompiledCssSheet, options: CssxReactConfig = {} ): TrackedCssxSheet { @@ -101,7 +101,7 @@ export function useCssxLayer ( ): CssxLayerHookOutput { if (!input) return input - if (typeof input === 'string') return useCompiledCss(input, options) + if (typeof input === 'string') return useRuntimeCss(input, options) if (input instanceof TrackedCssxSheet) return input if (isCompiledSheet(input)) return useCssxSheet(input, options) @@ -110,7 +110,7 @@ export function useCssxLayer ( if (typeof sheet === 'string') { return { ...input, - sheet: useCompiledCss(sheet, options) + sheet: useRuntimeCss(sheet, options) } } if (sheet instanceof TrackedCssxSheet) return input as CssxLayerHookOutput diff --git a/packages/css-to-rn/src/react/index.ts b/packages/css-to-rn/src/react/index.ts index 904fef4..a4cfa38 100644 --- a/packages/css-to-rn/src/react/index.ts +++ b/packages/css-to-rn/src/react/index.ts @@ -9,7 +9,7 @@ export { } from './config.ts' export { useCssxLayer, - useCompiledCss, + useRuntimeCss, useCssxSheet, useCssxTemplate } from './hooks.ts' diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index 86487c9..2f07f7f 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -11,7 +11,7 @@ import { } from './react/cssx.ts' import { useCssxLayer as baseUseCssxLayer, - useCompiledCss as baseUseCompiledCss, + useRuntimeCss as baseUseRuntimeCss, useCssxSheet as baseUseCssxSheet, useCssxTemplate as baseUseCssxTemplate } from './react/hooks.ts' @@ -74,11 +74,11 @@ export function cssx ( }) } -export function useCompiledCss ( - ...args: Parameters -): ReturnType { +export function useRuntimeCss ( + ...args: Parameters +): ReturnType { const [input, options] = args - return baseUseCompiledCss(input, { + return baseUseRuntimeCss(input, { target: 'web', ...(options ?? {}) }) diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 42eae66..4771cb6 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -8,7 +8,7 @@ export { isTrackedCssxSheet, setDefaultVariables, useCssxLayer, - useCompiledCss, + useRuntimeCss, useCssxConfig, useCssxSheet, useCssxTemplate, diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js index 0d3b2ce..ca93285 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -7,7 +7,7 @@ export { isTrackedCssxSheet, setDefaultVariables, useCssxLayer, - useCompiledCss, + useRuntimeCss, useCssxConfig, useCssxSheet, useCssxTemplate, diff --git a/packages/cssxjs/package.json b/packages/cssxjs/package.json index 2724c83..5c0fd06 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -33,7 +33,7 @@ "access": "public" }, "scripts": { - "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node --input-type=module -e \"import { cssx, useCssxLayer, useCompiledCss } from 'cssxjs'; if (typeof cssx !== 'function' || typeof useCssxLayer !== 'function' || typeof useCompiledCss !== 'function') throw new Error('cssxjs source-condition import smoke failed')\"" + "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node --input-type=module -e \"import { cssx, useCssxLayer, useRuntimeCss } from 'cssxjs'; if (typeof cssx !== 'function' || typeof useCssxLayer !== 'function' || typeof useRuntimeCss !== 'function') throw new Error('cssxjs source-condition import smoke failed')\"" }, "dependencies": { "@cssxjs/babel-plugin-rn-stylename-inline": "^0.3.0", diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js index 5bc84a9..0952e35 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -10,7 +10,7 @@ export { isTrackedCssxSheet, setDefaultVariables, useCssxLayer, - useCompiledCss, + useRuntimeCss, useCssxConfig, useCssxSheet, useCssxTemplate, diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js index 4e68e74..527b07d 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -10,7 +10,7 @@ export { isTrackedCssxSheet, setDefaultVariables, useCssxLayer, - useCompiledCss, + useRuntimeCss, useCssxConfig, useCssxSheet, useCssxTemplate, diff --git a/plan.md b/plan.md index dab4266..3c5d3be 100644 --- a/plan.md +++ b/plan.md @@ -187,7 +187,7 @@ user-facing APIs. - public facade used by users - re-exports `css`, `styl`, `pug` -- re-exports `compileCss`, `cssx`, `useCompiledCss`, `CssxProvider`, +- re-exports `compileCss`, `cssx`, `useRuntimeCss`, `CssxProvider`, `configureCssx`, `variables`, `setDefaultVariables`, `defaultVariables` - keeps conditional runtime entrypoints so Expo/RN picks the RN target automatically and web picks the default target @@ -338,7 +338,7 @@ export function cssx( inlineStyleProps?: InlineStyleProps ): ResolvedStyleProps -export function useCompiledCss( +export function useRuntimeCss( css: string, options?: CompileCssOptions ): TrackedCssSheet @@ -369,12 +369,12 @@ export function setDefaultVariables(vars: Record): voi Public manual runtime CSS usage: ```tsx -import { compileCss, cssx, useCompiledCss } from 'cssxjs' +import { compileCss, cssx, useRuntimeCss } from 'cssxjs' const sheet = compileCss(generatedCss) function Button({ disabled, style }) { - const trackedSheet = useCompiledCss(generatedCss) + const trackedSheet = useRuntimeCss(generatedCss) return (
@@ -388,7 +388,7 @@ Convenience raw string usage is allowed:
``` -But documented React usage should prefer `useCompiledCss()` so subscriptions, +But documented React usage should prefer `useRuntimeCss()` so subscriptions, diagnostics, and parsing are controlled. ### `cssx()` Ergonomics @@ -396,7 +396,7 @@ diagnostics, and parsing are controlled. Do not require a `useCssx()` hook per element. The user should be able to write: ```tsx -const sheet = useCompiledCss(generatedCss) +const sheet = useRuntimeCss(generatedCss) return ( <> @@ -1265,7 +1265,7 @@ Provider is for React tree options. Singleton config is for early app-wide setup Manual runtime CSS should stay ergonomic: ```tsx -const sheet = useCompiledCss(generatedCss) +const sheet = useRuntimeCss(generatedCss) return ( <> @@ -1275,7 +1275,7 @@ return ( ) ``` -`useCompiledCss()` returns a tracked wrapper, not the plain JSON IR. The wrapper: +`useRuntimeCss()` returns a tracked wrapper, not the plain JSON IR. The wrapper: - contains or references the compiled sheet - holds a render-local dependency collector @@ -1440,7 +1440,7 @@ lastCompiledSheet Users who need stronger caching should use: ```ts -const sheet = useCompiledCss(generatedCss) +const sheet = useRuntimeCss(generatedCss) ``` or: @@ -1800,7 +1800,7 @@ Use Jest/jsdom and React 19. Cover: -- `useCompiledCss()` returns tracked wrapper +- `useRuntimeCss()` returns tracked wrapper - inline `
` records dependencies - multiple `cssx()` calls in one component union dependencies - components rerender only for used variables @@ -1916,7 +1916,7 @@ Exit criteria: - Implement platform dimension adapters. - Implement web `matchMedia` support. - Implement React tracked sheet wrapper. -- Implement `useCompiledCss()`, `useCssxSheet()`, `useCssxTemplate()`. +- Implement `useRuntimeCss()`, `useCssxSheet()`, `useCssxTemplate()`. - Implement `CssxProvider` and `configureCssx()`. - Implement Suspense-safe subscription lifecycle. @@ -1967,7 +1967,7 @@ Update docs for: - interpolation - runtime `compileCss()` -- `cssx()` and `useCompiledCss()` +- `cssx()` and `useRuntimeCss()` - diagnostics for AI-generated CSS - no-observer variable/media rerendering - caching behavior From 707f23eafb442f8076589af7a30f1673388d0a83 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 01:56:26 +0300 Subject: [PATCH 19/37] Preserve interpolation setup order --- .../__snapshots__/index.spec.js.snap | 8 +++- .../__tests__/index.spec.js | 4 +- .../babel-plugin-rn-stylename-inline/index.js | 32 ++++++++++++++- .../__snapshots__/index.spec.js.snap | 41 +++++++++++++++++++ .../__tests__/index.spec.js | 13 ++++++ 5 files changed, 94 insertions(+), 4 deletions(-) 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 0cabf9a..ceb7071 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 @@ -942,8 +942,10 @@ exports[`@cssxjs/babel-plugin-rn-stylename-inline Local css interpolation: Local import React from 'react' import { css } from 'cssxjs' import { View } from 'react-native' +import { useThemeColor } from './theme' -export default function Card ({ color, pad }) { +export default function Card ({ pad }) { + const color = useThemeColor('primary') return css\` @@ -959,6 +961,7 @@ export default function Card ({ color, pad }) { import React from "react"; import { css } from "cssxjs"; import { View } from "react-native"; +import { useThemeColor } from "./theme"; const _localCssInstance = { version: 1, id: "cssx_fjh55d", @@ -1007,7 +1010,8 @@ const _localCssInstance = { diagnostics: [], __hash__: -1763352586, }; -export default function Card({ color, pad }) { +export default function Card({ pad }) { + const color = useThemeColor("primary"); const __CSS_LOCAL__ = { sheet: _localCssInstance, values: [color, pad], 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 c1f6fd6..45235be 100644 --- a/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js +++ b/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js @@ -202,8 +202,10 @@ pluginTester({ import React from 'react' import { css } from 'cssxjs' import { View } from 'react-native' + import { useThemeColor } from './theme' - export default function Card ({ color, pad }) { + export default function Card ({ pad }) { + const color = useThemeColor('primary') return css\` diff --git a/packages/babel-plugin-rn-stylename-inline/index.js b/packages/babel-plugin-rn-stylename-inline/index.js index 022ddd0..e78af0b 100644 --- a/packages/babel-plugin-rn-stylename-inline/index.js +++ b/packages/babel-plugin-rn-stylename-inline/index.js @@ -69,7 +69,7 @@ const getVisitor = ({ $program, usedCompilers }) => ({ // 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: localValue })) @@ -105,6 +105,36 @@ function insertAfterImports ($program, expressionStatement) { } } +function insertLocalCss ($function, $template, statement) { + 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') + + if ($statement?.parentPath === $functionBody) { + // Local style templates usually live after the JSX return. Execute the + // generated layer before that return, but after user setup code/hooks. + const $target = findPreviousReturn($statement) || $statement + $target.insertBefore(statement) + return + } + + $functionBody.unshiftContainer('body', statement) +} + +function findPreviousReturn ($statement) { + let $current = $statement.getPrevSibling() + while ($current?.node) { + if ($current.isReturnStatement()) return $current + $current = $current.getPrevSibling() + } +} + function shouldProcess ($template, usedCompilers) { if (!$template.get('tag').isIdentifier()) return if (!usedCompilers.has($template.node.tag.name)) return 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 c96a779..126205e 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 @@ -664,6 +664,47 @@ export const Test = (_props) => { }; +`; + +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 ({ pad }) { + const color = useThemeColor('primary') + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad] + } + return +} + + ↓ ↓ ↓ ↓ ↓ ↓ + +import { useThemeColor } from "./theme"; +import { View } from "react-native"; +import { + runtime as _runtime, + useCssxLayer as _useCssxLayer, +} from "cssxjs/runtime"; +const _cssx = _runtime; +function Card({ pad }) { + const color = useThemeColor("primary"); + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad], + }; + const _local = _useCssxLayer( + typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ + ); + const _global = _useCssxLayer( + typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ + ); + return ; +} + + `; exports[`@startupjs/babel-plugin-rn-stylename-to-style No styles file: No styles file 1`] = ` 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..d31a392 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 @@ -119,6 +119,19 @@ pluginTester({ ) } `, + 'Local css interpolation after hook': /* js */` + import { useThemeColor } from './theme' + import { View } from 'react-native' + + function Card ({ pad }) { + const color = useThemeColor('primary') + const __CSS_LOCAL__ = { + sheet: _localCssInstance, + values: [color, pad] + } + return + } + `, 'Puts compiled attribute to the end of attributes list': /* js */` import './index.styl' function Test ({ style, active, submit, disabled }) { From 7824e2630ebf5a1fc0a0c49583715ab30042b805 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 02:01:16 +0300 Subject: [PATCH 20/37] Handle early returns with local interpolation --- .../__snapshots__/index.spec.js.snap | 86 ++++++++++++--- .../__tests__/index.spec.js | 32 +++++- .../babel-plugin-rn-stylename-inline/index.js | 104 ++++++++++++++++-- .../__snapshots__/index.spec.js.snap | 6 +- .../__tests__/index.spec.js | 3 +- 5 files changed, 202 insertions(+), 29 deletions(-) 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 ceb7071..14b9ecd 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 @@ -935,6 +935,42 @@ export default observer(function Card() { }); +`; + +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`] = ` @@ -944,11 +980,15 @@ import { css } from 'cssxjs' import { View } from 'react-native' import { useThemeColor } from './theme' -export default function Card ({ pad }) { +export default function Card ({ ready, pad }) { const color = useThemeColor('primary') + if (!ready) return return css\` + .loader { + color: \${color}; + } .root { color: \${color}; padding: \${pad} 2u; @@ -964,12 +1004,12 @@ import { View } from "react-native"; import { useThemeColor } from "./theme"; const _localCssInstance = { version: 1, - id: "cssx_fjh55d", - contentHash: "cssx_4m17p8", + id: "cssx_h9kswq", + contentHash: "cssx_ytmomb", rules: [ { - selector: ".root", - classes: ["root"], + selector: ".loader", + classes: ["loader"], part: null, specificity: 1, order: 0, @@ -984,13 +1024,32 @@ const _localCssInstance = { line: 3, column: 7, }, + ], + }, + { + selector: ".root", + 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_1) 2u", - raw: "padding: var(--__cssx_dynamic_1) 2u", + value: "var(--__cssx_dynamic_2) 2u", + raw: "padding: var(--__cssx_dynamic_2) 2u", order: 1, - dynamicSlots: [1], - line: 4, + dynamicSlots: [2], + line: 7, column: 7, }, ], @@ -999,7 +1058,7 @@ const _localCssInstance = { keyframes: {}, metadata: { hasVars: true, - vars: ["--__cssx_dynamic_0", "--__cssx_dynamic_1"], + vars: ["--__cssx_dynamic_0", "--__cssx_dynamic_1", "--__cssx_dynamic_2"], hasMedia: false, hasViewportUnits: false, hasInterpolations: true, @@ -1008,14 +1067,15 @@ const _localCssInstance = { hasTransitions: false, }, diagnostics: [], - __hash__: -1763352586, + __hash__: -65713861, }; -export default function Card({ pad }) { +export default function Card({ ready, pad }) { const color = useThemeColor("primary"); const __CSS_LOCAL__ = { sheet: _localCssInstance, - values: [color, pad], + values: [color, color, pad], }; + if (!ready) return ; return ; } 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 45235be..a090bb9 100644 --- a/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js +++ b/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js @@ -204,17 +204,45 @@ pluginTester({ import { View } from 'react-native' import { useThemeColor } from './theme' - export default function Card ({ pad }) { + 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}; + } + \` + } + ` + } } }) diff --git a/packages/babel-plugin-rn-stylename-inline/index.js b/packages/babel-plugin-rn-stylename-inline/index.js index e78af0b..a797a43 100644 --- a/packages/babel-plugin-rn-stylename-inline/index.js +++ b/packages/babel-plugin-rn-stylename-inline/index.js @@ -72,7 +72,7 @@ const getVisitor = ({ $program, usedCompilers }) => ({ insertLocalCss($function, $this, buildConst({ variable: t.identifier(LOCAL_NAME), value: localValue - })) + }), expressions) // IV. GLOBAL. if parent is program -- handle global } else { @@ -105,7 +105,7 @@ function insertAfterImports ($program, expressionStatement) { } } -function insertLocalCss ($function, $template, statement) { +function insertLocalCss ($function, $template, statement, expressions) { const $body = $function.get('body') if (!$body.isBlockStatement()) { $body.replaceWith(t.blockStatement([ @@ -115,11 +115,18 @@ function insertLocalCss ($function, $template, statement) { const $statement = $template.getStatementParent() const $functionBody = $function.get('body') + // CSSX tracking hooks must run before any render return. Insert the local + // layer before the first return, while keeping it after user setup code. + const $target = findFirstReturnStatement($functionBody) || + ( + $statement?.parentPath === $functionBody + ? $statement + : undefined + ) + + validateInterpolationBindings($function, $functionBody, $target, expressions, $template) - if ($statement?.parentPath === $functionBody) { - // Local style templates usually live after the JSX return. Execute the - // generated layer before that return, but after user setup code/hooks. - const $target = findPreviousReturn($statement) || $statement + if ($target) { $target.insertBefore(statement) return } @@ -127,11 +134,86 @@ function insertLocalCss ($function, $template, statement) { $functionBody.unshiftContainer('body', statement) } -function findPreviousReturn ($statement) { - let $current = $statement.getPrevSibling() - while ($current?.node) { - if ($current.isReturnStatement()) return $current - $current = $current.getPrevSibling() +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) + } } } 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 126205e..a2086bf 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 @@ -671,12 +671,13 @@ exports[`@startupjs/babel-plugin-rn-stylename-to-style Local css interpolation a import { useThemeColor } from './theme' import { View } from 'react-native' -function Card ({ pad }) { +function Card ({ ready, pad }) { const color = useThemeColor('primary') const __CSS_LOCAL__ = { sheet: _localCssInstance, values: [color, pad] } + if (!ready) return return } @@ -689,7 +690,7 @@ import { useCssxLayer as _useCssxLayer, } from "cssxjs/runtime"; const _cssx = _runtime; -function Card({ pad }) { +function Card({ ready, pad }) { const color = useThemeColor("primary"); const __CSS_LOCAL__ = { sheet: _localCssInstance, @@ -701,6 +702,7 @@ function Card({ pad }) { const _global = _useCssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); + if (!ready) return ; 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 d31a392..c86cc89 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 @@ -123,12 +123,13 @@ pluginTester({ import { useThemeColor } from './theme' import { View } from 'react-native' - function Card ({ pad }) { + function Card ({ ready, pad }) { const color = useThemeColor('primary') const __CSS_LOCAL__ = { sheet: _localCssInstance, values: [color, pad] } + if (!ready) return return } `, From f2fca0a8ce2580fd4fc6d9fe7853522a49b6f6fb Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 02:02:47 +0300 Subject: [PATCH 21/37] Stabilize inline plugin error snapshots --- packages/babel-plugin-rn-stylename-inline/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/babel-plugin-rn-stylename-inline/package.json b/packages/babel-plugin-rn-stylename-inline/package.json index 670bdad..2e75d1c 100644 --- a/packages/babel-plugin-rn-stylename-inline/package.json +++ b/packages/babel-plugin-rn-stylename-inline/package.json @@ -14,7 +14,7 @@ ], "main": "index.js", "scripts": { - "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --experimental-vm-modules -C cssx-ts\" jest --runInBand" + "test": "NO_COLOR=1 FORCE_COLOR=0 NODE_OPTIONS=\"${NODE_OPTIONS:-} --experimental-vm-modules -C cssx-ts\" jest --runInBand" }, "author": "Pavel Zhukov", "license": "MIT", From de29e05863e1d4e1ebe99ab6b5ab8f412f4a2df7 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 21 Jun 2026 02:06:20 +0300 Subject: [PATCH 22/37] Respect reachable local style placement --- .../__snapshots__/index.spec.js.snap | 120 ++++++++++++++++++ .../__tests__/index.spec.js | 41 ++++++ .../babel-plugin-rn-stylename-inline/index.js | 57 +++++++-- 3 files changed, 204 insertions(+), 14 deletions(-) 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 14b9ecd..26612d2 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 @@ -1080,6 +1080,126 @@ export default function Card({ ready, pad }) { } +`; + +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", + 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, + }, + diagnostics: [], + __hash__: -1763352586, +}; +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 a090bb9..4161ff7 100644 --- a/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js +++ b/packages/babel-plugin-rn-stylename-inline/__tests__/index.spec.js @@ -243,6 +243,47 @@ pluginTester({ \` } ` + }, + '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 a797a43..e694921 100644 --- a/packages/babel-plugin-rn-stylename-inline/index.js +++ b/packages/babel-plugin-rn-stylename-inline/index.js @@ -72,7 +72,7 @@ const getVisitor = ({ $program, usedCompilers }) => ({ insertLocalCss($function, $this, buildConst({ variable: t.identifier(LOCAL_NAME), value: localValue - }), expressions) + }), expressions, usedCompilers) // IV. GLOBAL. if parent is program -- handle global } else { @@ -105,7 +105,7 @@ function insertAfterImports ($program, expressionStatement) { } } -function insertLocalCss ($function, $template, statement, expressions) { +function insertLocalCss ($function, $template, statement, expressions, usedCompilers) { const $body = $function.get('body') if (!$body.isBlockStatement()) { $body.replaceWith(t.blockStatement([ @@ -115,23 +115,52 @@ function insertLocalCss ($function, $template, statement, expressions) { const $statement = $template.getStatementParent() const $functionBody = $function.get('body') - // CSSX tracking hooks must run before any render return. Insert the local - // layer before the first return, while keeping it after user setup code. - const $target = findFirstReturnStatement($functionBody) || - ( - $statement?.parentPath === $functionBody - ? $statement - : undefined - ) + 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) - if ($target) { - $target.insertBefore(statement) - return + $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 +} - $functionBody.unshiftContainer('body', statement) +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) { From 42ea9a4fff79ce330307197ca0366008b72027f7 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Mon, 22 Jun 2026 00:12:40 +0300 Subject: [PATCH 23/37] Add CSSX provider theming primitives --- AGENTS.md | 3 + architecture.md | 33 +- docs/api/css.md | 42 +- docs/api/index.md | 16 +- docs/api/runtime.md | 61 +- docs/api/variables.md | 63 +- .../__snapshots__/index.spec.js.snap | 50 +- .../__snapshots__/index.spec.js.snap | 221 ++--- .../index.js | 10 +- packages/css-to-rn/package.json | 1 + packages/css-to-rn/src/colors.ts | 253 ++++++ packages/css-to-rn/src/compiler.ts | 68 +- packages/css-to-rn/src/react-native.ts | 19 +- packages/css-to-rn/src/react/config.ts | 273 ++++++- packages/css-to-rn/src/react/cssx.ts | 32 +- packages/css-to-rn/src/react/hooks.ts | 97 ++- packages/css-to-rn/src/react/index.ts | 15 +- packages/css-to-rn/src/react/store.ts | 97 ++- packages/css-to-rn/src/resolve.ts | 92 ++- packages/css-to-rn/src/selectors.ts | 36 +- packages/css-to-rn/src/types.ts | 3 + packages/css-to-rn/src/values.ts | 35 +- packages/css-to-rn/src/web.ts | 19 +- .../css-to-rn/test/engine/compiler.test.ts | 31 + .../css-to-rn/test/engine/resolve.test.ts | 98 +++ packages/css-to-rn/test/engine/values.test.ts | 16 +- .../css-to-rn/test/react/tracking.test.ts | 218 +++++ packages/cssxjs/index.d.ts | 18 + packages/cssxjs/index.js | 9 + packages/cssxjs/matcher.js | 97 +++ packages/cssxjs/package.json | 2 +- packages/cssxjs/runtime/react-native.js | 9 + packages/cssxjs/runtime/web.js | 9 + packages/cssxjs/test/smoke.mjs | 43 + packages/loaders/cssToReactNativeLoader.js | 32 + packages/loaders/package.json | 2 +- .../test/cssToReactNativeLoader.test.cjs | 29 + plan.md | 763 +++++++++++++++++- yarn.lock | 8 + 39 files changed, 2724 insertions(+), 199 deletions(-) create mode 100644 packages/css-to-rn/src/colors.ts create mode 100644 packages/cssxjs/matcher.js create mode 100644 packages/cssxjs/test/smoke.mjs create mode 100644 packages/loaders/test/cssToReactNativeLoader.test.cjs diff --git a/AGENTS.md b/AGENTS.md index 02a1990..0598666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,9 @@ CSSX is a monorepo for a CSS-in-JS toolchain. Users write `css`, `styl`, or opti - Compiled sheets are JSON-serializable IR. Runtime cache/tracking state must stay outside the sheet. - `part='root'` maps to `style`; other parts map to `{partName}Style`. - `:hover` and `:active` compile to `hoverStyle` and `activeStyle`. +- Provider/global `:root` custom properties compile to `sheet.rootVariables` and resolve as scoped defaults below runtime `variables` and above `defaultVariables`. +- Component tag selectors such as `Button` and `Button:part(text)` apply only inside components wrapped with `themed('Button', Component)` or explicit resolver tag options. +- `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`. - `cache: 'teamplay'` remains accepted as a Babel option for compatibility, but runtime caching is owned by `@cssxjs/css-to-rn`, not Teamplay. diff --git a/architecture.md b/architecture.md index 34eb266..0f2102a 100644 --- a/architecture.md +++ b/architecture.md @@ -230,14 +230,21 @@ Supported selectors: - `.root` - `.root.active` - `.root:part(label)` +- `.root::part(label)` - `.root.active:part(icon)` - `.root:hover` - `.root:active` +- `Button` +- `Button.primary` +- `Button:part(text)` +- `Button::part(text)` +- `Button.primary:part(text)` - `:export` +- bare `:root` custom-property declarations -`:hover` maps to `hoverStyle`; `:active` maps to `activeStyle`. Unsupported selectors are ignored with diagnostics in runtime mode. +`:hover` maps to `hoverStyle`; `:active` maps to `activeStyle`. Tag selectors apply only when the current component tag is provided by `themed(tagName, Component)` or explicit resolver options. Unsupported selectors are ignored with diagnostics in runtime mode. -`:root` custom-property declarations and declaration-level custom properties are intentionally not used as defaults. Use `setDefaultVariables()` for defaults. +Bare `:root` custom-property declarations are compiled into `sheet.rootVariables` and become scoped defaults when the sheet is supplied through `CssxProvider style` or another layer. Declaration-level custom properties outside `:root` are ignored with diagnostics. ### Value Resolution @@ -246,13 +253,17 @@ Supported selectors: 1. Replace interpolation slots from `values`. 2. Recursively resolve nested `var()`. 3. Resolve `u`, viewport units, and supported `calc()`. -4. Return dependencies for variables and dimensions. +4. Normalize supported modern color functions (`oklch()`, `oklab()`, `color-mix()`) to `rgba(...)`. +5. Return dependencies for variables and dimensions. Variable priority is: -1. runtime `variables['--name']` -2. `defaultVariables['--name']` -3. inline fallback `var(--name, fallback)` +1. template interpolation values. +2. runtime `variables['--name']`. +3. nearest scoped provider/sheet `:root` variable. +4. outer scoped provider/sheet `:root` variables. +5. `defaultVariables['--name']`. +6. inline fallback `var(--name, fallback)`. Unresolved variables, cycles, depth limits, invalid interpolations, and unsupported `calc()` invalidate only the containing declaration. Earlier fallback declarations in the same rule still apply. @@ -288,7 +299,7 @@ Resolver order: 1. Normalize `styleName` with classcat-like semantics. 2. Normalize one or more sheet layers. -3. Match selectors by class set. +3. Match selectors by component tag and class set. 4. Filter inactive media rules. 5. Group by output prop: `style`, `{part}Style`, `hoverStyle`, `activeStyle`. 6. Apply cascade by layer, specificity, and source order. @@ -307,14 +318,16 @@ Runtime caches are bounded. Static cache keys include sheet identity, style name Key pieces: -- `store.ts`: `variables`, `defaultVariables`, `setDefaultVariables()`, dimensions/media state, microtask-batched notifications. +- `store.ts`: `variables`, `defaultVariables`, `setDefaultVariables()`, variable bulk methods, dimensions/media state, microtask-batched notifications. - `tracker.ts`: `TrackedCssxSheet`, committed dependency snapshots, per-tracker cache. - `cssx.ts`: ergonomic `cssx()` wrapper that delegates to `resolveCssx()` and records dependencies into tracked sheets during render. -- `hooks.ts`: `useCssxSheet()`, `useRuntimeCss()`, `useCssxTemplate()`, `useCssxLayer()`. -- `config.ts`: optional `CssxProvider`, `configureCssx()`, and `useCssxConfig()`. +- `hooks.ts`: `useCssxSheet()`, `useRuntimeCss()`, `useCssxTemplate()`, `useCssxLayer()`, `useCssVariable()`, `useCssVariableRaw()`, `getCssVariable()`, and `getCssVariableRaw()`. +- `config.ts`: optional `CssxProvider`, `configureCssx()`, `useCssxConfig()`, and `themed()`. `useCssxSheet()` starts a render-local dependency collection before render and commits it in a layout/effect phase. If a render is aborted, for example because a component throws a promise into Suspense, the pending dependencies are not committed and do not leak global subscriptions. +`CssxProvider style` accepts raw CSS strings, compiled sheets, tracked sheets, layer objects, arrays, and falsey values. Provider layers are appended after parent provider layers and before component-local layers. Nested providers append additional `:root` variable scopes, with inner scopes winning over outer scopes. `themed()` adds the current component tag and a render-local dependency tracker so provider/global styles that read variables can update themed components even when they have no local sheet. + Variable writes and deletes notify subscribers once per microtask. Subscribers only rerender when a variable they actually used changes. Viewport-unit subscribers are tied to dimension changes. Media-query dependencies store the match value observed during the committed render; dimension changes and platform media adapter changes only rerender subscribers whose committed media result changed. Browser `matchMedia` is used on web when available, and tests can install a media-query adapter for non-DOM media features such as `prefers-color-scheme`, `hover`, and `pointer`. Web resize uses leading plus trailing debounced updates. ## Loaders And Separate Files diff --git a/docs/api/css.md b/docs/api/css.md index 96f0b58..290cdad 100644 --- a/docs/api/css.md +++ b/docs/api/css.md @@ -132,6 +132,27 @@ shorthands, comma-separated value chunks, and nested fallbacks. } ``` +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 @@ -162,11 +183,30 @@ must use plain CSS text. 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 diff --git a/docs/api/index.md b/docs/api/index.md index 4e2ffdb..664c702 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,10 +14,13 @@ import { defaultVariables, cssx, useRuntimeCss, + useCssVariable, + useCssVariableRaw, useCssxSheet, useCssxTemplate, CssxProvider, - configureCssx + configureCssx, + themed } from 'cssxjs' ``` @@ -46,12 +49,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 | +| `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 to a subtree | +| `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/runtime.md b/docs/api/runtime.md index d07196b..4b29d5b 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -120,6 +120,61 @@ 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. + +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: @@ -164,6 +219,9 @@ 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 @@ -172,7 +230,8 @@ 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. +and dimension debounce behavior. `CssxProvider` also accepts a `style` prop for +subtree-scoped CSS. ## Platform Resolution diff --git a/docs/api/variables.md b/docs/api/variables.md index 27ef2bc..fa4cc17 100644 --- a/docs/api/variables.md +++ b/docs/api/variables.md @@ -4,9 +4,10 @@ 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:** `Record` +**Type:** `CssxVariableStore` ```jsx import { variables } from 'cssxjs' @@ -17,13 +18,24 @@ variables['--primary-color'] = '#007bff' // Read a variable console.log(variables['--primary-color']) -// Set multiple variables -Object.assign(variables, { +// Merge multiple variables +variables.assign({ '--primary-color': '#007bff', '--text-color': '#333' }) + +// Replace the whole runtime variable set +variables.set({ + '--primary-color': '#007bff' +}) + +// 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`, components that used those specific variables in their resolved styles automatically re-render with the new values. @@ -82,10 +94,10 @@ setDefaultVariables({ ## defaultVariables -A reactive 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' @@ -93,21 +105,46 @@ import { defaultVariables } from 'cssxjs' console.log(defaultVariables['--primary-color']) // '#007bff' ``` +`setDefaultVariables(vars)` is an alias for `defaultVariables.set(vars)`. + +## Reading Variables In Components + +Use `useCssVariable()` when JavaScript needs the resolved value: + +```jsx +import { useCssVariable } from 'cssxjs' + +function Box() { + const gap = useCssVariable('--gap', '2u') // 16 + return +} +``` + +`useCssVariable()` subscribes only to the variables it resolves, including nested +`var()` references. It returns RN-friendly values: `2u` 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' ` ``` 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 26612d2..0152dbc 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 @@ -31,6 +31,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -68,7 +69,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: -2145056715, + __hash__: -1564835170, }; const _localCssInstance = { version: 1, @@ -77,6 +78,7 @@ const _localCssInstance = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -106,7 +108,7 @@ const _localCssInstance = { hasTransitions: false, }, diagnostics: [], - __hash__: 1523967940, + __hash__: 2093078773, }; export default observer(function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -143,6 +145,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -180,7 +183,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: -2145056715, + __hash__: -1564835170, }; export default observer(function Card() { return ; @@ -232,6 +235,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".active", + tag: null, classes: ["active"], part: null, specificity: 1, @@ -261,7 +265,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: 1497110248, + __hash__: 2075491085, }; const _localCssInstance2 = { version: 1, @@ -270,6 +274,7 @@ const _localCssInstance2 = { rules: [ { selector: ".root", + tag: null, classes: ["root"], part: null, specificity: 1, @@ -307,7 +312,7 @@ const _localCssInstance2 = { hasTransitions: false, }, diagnostics: [], - __hash__: 707783542, + __hash__: 1014023389, }; const _localCssInstance = { version: 1, @@ -316,6 +321,7 @@ const _localCssInstance = { rules: [ { selector: ".root", + tag: null, classes: ["root"], part: null, specificity: 1, @@ -345,7 +351,7 @@ const _localCssInstance = { hasTransitions: false, }, diagnostics: [], - __hash__: -1559627094, + __hash__: -1276586605, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -408,6 +414,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".active", + tag: null, classes: ["active"], part: null, specificity: 1, @@ -437,7 +444,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: -1215509807, + __hash__: 230283094, }; const _localCssInstance2 = { version: 1, @@ -446,6 +453,7 @@ const _localCssInstance2 = { rules: [ { selector: ".root", + tag: null, classes: ["root"], part: null, specificity: 1, @@ -483,7 +491,7 @@ const _localCssInstance2 = { hasTransitions: false, }, diagnostics: [], - __hash__: 707783542, + __hash__: 1014023389, }; const _localCssInstance = { version: 1, @@ -492,6 +500,7 @@ const _localCssInstance = { rules: [ { selector: ".root", + tag: null, classes: ["root"], part: null, specificity: 1, @@ -521,7 +530,7 @@ const _localCssInstance = { hasTransitions: false, }, diagnostics: [], - __hash__: -202231319, + __hash__: -985409394, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -578,6 +587,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -596,6 +606,7 @@ const __CSS_GLOBAL__ = { }, { selector: ".line", + tag: null, classes: ["line"], part: null, specificity: 1, @@ -622,6 +633,7 @@ const __CSS_GLOBAL__ = { }, { selector: ".active", + tag: null, classes: ["active"], part: null, specificity: 1, @@ -651,7 +663,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: -1106277367, + __hash__: 1060737560, }; export default function Card() { return ( @@ -694,6 +706,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -731,7 +744,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: -2145056715, + __hash__: -1564835170, }; export default observer(function Card() { return ; @@ -776,6 +789,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -794,6 +808,7 @@ const __CSS_GLOBAL__ = { }, { selector: ".line", + tag: null, classes: ["line"], part: null, specificity: 1, @@ -820,6 +835,7 @@ const __CSS_GLOBAL__ = { }, { selector: ".active", + tag: null, classes: ["active"], part: null, specificity: 1, @@ -849,7 +865,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: -156895245, + __hash__: -49838010, }; export default function Card() { return ( @@ -891,6 +907,7 @@ const __CSS_GLOBAL__ = { rules: [ { selector: ".card", + tag: null, classes: ["card"], part: null, specificity: 1, @@ -928,7 +945,7 @@ const __CSS_GLOBAL__ = { hasTransitions: false, }, diagnostics: [], - __hash__: 1421483523, + __hash__: -1776916804, }; export default observer(function Card() { return ; @@ -1009,6 +1026,7 @@ const _localCssInstance = { rules: [ { selector: ".loader", + tag: null, classes: ["loader"], part: null, specificity: 1, @@ -1028,6 +1046,7 @@ const _localCssInstance = { }, { selector: ".root", + tag: null, classes: ["root"], part: null, specificity: 1, @@ -1067,7 +1086,7 @@ const _localCssInstance = { hasTransitions: false, }, diagnostics: [], - __hash__: -65713861, + __hash__: -210124161, }; export default function Card({ ready, pad }) { const color = useThemeColor("primary"); @@ -1149,6 +1168,7 @@ const _localCssInstance = { rules: [ { selector: ".root", + tag: null, classes: ["root"], part: null, specificity: 1, @@ -1188,7 +1208,7 @@ const _localCssInstance = { hasTransitions: false, }, diagnostics: [], - __hash__: -1763352586, + __hash__: 762895511, }; export default function Card({ pad }) { const color = useThemeColor("primary"); 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 a2086bf..19349d1 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 @@ -22,15 +22,16 @@ 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 = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return ( Title @@ -219,15 +222,16 @@ 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 = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return ( { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); const renderItem = () => { return (
{ - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); function renderItem() { return (
{ - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return (
; @@ -721,12 +734,13 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); return
; @@ -751,15 +765,16 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return
; } @@ -782,15 +797,16 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime/react-native-teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return
; } @@ -813,15 +829,16 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime/react-native"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return
; } @@ -844,15 +861,16 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime/web"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test() { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return
; } @@ -877,15 +895,16 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; function Test({ style, active, submit, disabled }) { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return (
Title @@ -1057,12 +1078,13 @@ import { runtime as _runtime, useCssxLayer as _useCssxLayer, } from "cssxjs/runtime/teamplay"; +const _cssxLayer = _useCssxLayer; const _cssx = _runtime; export default observer(function Test() { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); return ( @@ -1167,15 +1189,16 @@ 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 = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); return ( { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); function render() { return ( { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); function render() { return ( { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); function render() { return ( { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); function render() { return ( { - const _local = _useCssxLayer( + const _local = _cssxLayer( typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__ ); - const _global = _useCssxLayer( + const _global = _cssxLayer( typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__ ); - const _file__css = _useCssxLayer(_css); + const _file__css = _cssxLayer(_css); function render() { return ( 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 + + return rgbaString({ + r: round(a.r * firstWeight + b.r * secondWeight), + g: round(a.g * firstWeight + b.g * secondWeight), + b: round(a.b * firstWeight + b.b * secondWeight), + alpha: alpha(a.alpha * firstWeight + b.alpha * secondWeight) + }) +} + +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 index dc74647..6f73587 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -20,6 +20,7 @@ import type { } from './types.ts' const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/ +const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/ 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([ @@ -90,13 +91,14 @@ function compileCssInternal ( const rules: CssxRule[] = [] const keyframes: Record = {} + const rootVariables: 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, state, orderRef(() => order++), isTemplate, exports, options.target) + compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, rootVariables, state, orderRef(() => order++), isTemplate, exports, options.target) continue } @@ -107,7 +109,7 @@ function compileCssInternal ( if (!mediaIsValid && state.mode === 'build') continue for (const child of mediaRule.rules ?? []) { if (child.type !== 'rule') continue - compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, state, orderRef(() => order++), isTemplate, exports, options.target) + compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, rootVariables, state, orderRef(() => order++), isTemplate, exports, options.target) } continue } @@ -130,13 +132,14 @@ function compileCssInternal ( } } - const metadata = buildMetadata(rules, keyframes, isTemplate) + const metadata = buildMetadata(rules, keyframes, rootVariables, isTemplate) return createSheet({ id, sourceId, contentHash, rules, keyframes, + rootVariables: Object.keys(rootVariables).length > 0 ? rootVariables : undefined, exports: Object.keys(exports).length > 0 ? exports : undefined, metadata, diagnostics: state.diagnostics, @@ -149,6 +152,7 @@ function compileRuleList ( declarations: CssDeclarationAst[], media: string | null, output: CssxRule[], + rootVariables: Record, state: CompileState, nextOrder: () => number, isTemplate: boolean, @@ -163,10 +167,23 @@ function compileRuleList ( 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 currently unconditional.`, + 'warning' + )) + continue + } + compileRootVariables(declarations, rootVariables, state, isTemplate) + continue + } + if (selector.trim().startsWith(':root')) { addDiagnostic(state, diagnostic( 'UNSUPPORTED_SELECTOR', - `Unsupported selector "${selector}" ignored. Use setDefaultVariables() for CSS variable defaults.`, + `Unsupported selector "${selector}" ignored. CSSX supports only bare :root for provider CSS variables.`, 'warning' )) continue @@ -182,6 +199,7 @@ function compileRuleList ( output.push({ selector: parsed.result.selector, + tag: parsed.result.tag, classes: parsed.result.classes, part: parsed.result.part, specificity: parsed.result.specificity, @@ -192,6 +210,42 @@ function compileRuleList ( } } +function compileRootVariables ( + declarations: CssDeclarationAst[], + rootVariables: Record, + state: CompileState, + isTemplate: boolean +): 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_DECLARATION', + `Only CSS custom properties are supported inside :root. Declaration "${property}" ignored.`, + 'warning', + positionOf(declaration) + )) + continue + } + + const value = declaration.value ?? '' + if (isTemplate && hasDynamicSlots(value)) { + addDiagnostic(state, diagnostic( + 'UNSUPPORTED_INTERPOLATION_POSITION', + 'Interpolation is not supported inside :root variable declarations.', + 'error', + positionOf(declaration) + )) + continue + } + + rootVariables[property] = value + } +} + function compileExports ( declarations: CssDeclarationAst[], exports: Record, @@ -362,6 +416,7 @@ function validateMedia ( function buildMetadata ( rules: CssxRule[], keyframes: Record, + rootVariables: Record, isTemplate: boolean ): CssxMetadata { const vars = new Set() @@ -378,6 +433,10 @@ function buildMetadata ( 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 + } function scanDeclarations (declarations: CssxDeclaration[]): void { for (const declaration of declarations) { @@ -437,6 +496,7 @@ function createSheet (input: Partial & { contentHash: input.contentHash, rules: input.rules ?? [], keyframes: input.keyframes ?? {}, + rootVariables: input.rootVariables, exports: input.exports, metadata: input.metadata ?? { hasVars: false, diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index cfe7d9b..f4ea6d4 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -43,18 +43,33 @@ export type { CssxStyleName } from './react/cssx.ts' export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, CssxProviderProps, - CssxReactConfig + CssxReactConfig, + CssxRuntimeContextValue } from './react/config.ts' export type { TrackedCssxSheetOptions } from './react/tracker.ts' +export type { + CssxVariableStore +} from './react/store.ts' export { CssxProvider, configureCssx, - useCssxConfig + themed, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext } from './react/config.ts' +export { + getCssVariable, + getCssVariableRaw, + useCssVariable, + useCssVariableRaw +} from './react/hooks.ts' export { TrackedCssxSheet, isTrackedCssxSheet diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts index 9b7c04d..df522dd 100644 --- a/packages/css-to-rn/src/react/config.ts +++ b/packages/css-to-rn/src/react/config.ts @@ -1,45 +1,296 @@ import { createContext, createElement, + forwardRef, + type ComponentType, + useEffect, useContext, + useLayoutEffect, useMemo, + useRef, + useSyncExternalStore, type ReactNode } from 'react' +import { compileCss } from '../compiler.ts' +import type { CompiledCssSheet } from '../types.ts' import { getRuntimeConfig, setRuntimeConfig, type CssxRuntimeConfig } from './store.ts' -import type { TrackedCssxSheetOptions } from './tracker.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[] + componentTag: string | null +} + +export type CssxRuntimeLayerInput = + | string + | CompiledCssSheet + | TrackedCssxSheet + | ResolveCssxLayer + export interface CssxProviderProps { value?: CssxReactConfig + style?: CssxProviderStyleInput children?: ReactNode } -const CssxConfigContext = createContext(null) +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 +} +const EMPTY_TRACKING_SHEET: CompiledCssSheet = { + version: 1, + id: 'cssx_theme_tracker', + contentHash: 'cssx_theme_tracker', + rules: [], + keyframes: {}, + metadata: EMPTY_METADATA, + diagnostics: [] +} export function configureCssx (config: CssxReactConfig): void { setRuntimeConfig(config) } export function CssxProvider (props: CssxProviderProps): ReactNode { - const parent = useContext(CssxConfigContext) - const value = useMemo( - () => ({ - ...(parent ?? getRuntimeConfig()), - ...(props.value ?? {}) - }), - [parent, props.value] + const parent = useContext(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext() + const providerStyles = useMemo( + () => normalizeProviderStyles(props.style), + [props.style] ) + const value = useMemo(() => ({ + config: { + ...parent.config, + ...(props.value ?? {}) + }, + layers: parent.layers.concat(providerStyles.layers), + scopedVariables: parent.scopedVariables.concat(providerStyles.scopedVariables), + componentTag: parent.componentTag + }), [parent, props.value, providerStyles]) - return createElement(CssxConfigContext.Provider, { + return createElement(CssxRuntimeContext.Provider, { value }, props.children) } export function useCssxConfig (): CssxReactConfig { - return useContext(CssxConfigContext) ?? getRuntimeConfig() + return useCssxRuntimeContext().config +} + +export function useCssxRuntimeContext (): CssxRuntimeContextValue { + return useContext(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext() +} + +export function useCssxComponentTag (): string | null { + return useCssxRuntimeContext().componentTag +} + +export function themed

( + componentTag: string, + Component: ComponentType

+): ComponentType

{ + const ThemedComponent = forwardRef(function ThemedComponent (props, ref): 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, + ref + } as P & { ref: unknown }) + ) + }) + + ThemedComponent.displayName = `themed(${Component.displayName ?? Component.name ?? componentTag})` + return ThemedComponent as unknown as ComponentType

+} + +function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet { + const trackerRef = useRef(null) + + if (trackerRef.current == null) { + trackerRef.current = new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options) + } else { + trackerRef.current.update(EMPTY_TRACKING_SHEET, options) + } + + const tracker = trackerRef.current + const renderDependencies = tracker.startRender() + + useSyncExternalStore( + tracker.subscribe, + tracker.getSnapshot, + tracker.getServerSnapshot + ) + + useCommitEffect(() => { + tracker.commitRender(renderDependencies) + }) + + return tracker +} + +export function getDefaultCssxRuntimeContext (): CssxRuntimeContextValue { + return { + config: getRuntimeConfig(), + layers: [], + scopedVariables: [], + componentTag: null + } +} + +function normalizeProviderStyles ( + style: CssxProviderStyleInput +): { layers: CssxRuntimeLayerInput[], scopedVariables: Record[] } { + const layers: CssxRuntimeLayerInput[] = [] + const scopedVariables: Record[] = [] + + collectProviderStyle(style, layers, scopedVariables) + + return { + layers, + scopedVariables + } +} + +function collectProviderStyle ( + input: CssxProviderStyleInput, + layers: CssxRuntimeLayerInput[], + scopedVariables: Record[] +): void { + if (!input) return + + if (Array.isArray(input)) { + for (const item of input) collectProviderStyle(item, layers, scopedVariables) + return + } + + if (typeof input === 'string') { + const sheet = compileCss(input, { mode: 'runtime' }) + layers.push(sheet) + collectRootVariables(sheet, scopedVariables) + return + } + + if (isTrackedCssxSheet(input)) { + const sheet = input.getSheet() + layers.push({ sheet, cacheKey: input }) + collectRootVariables(sheet, scopedVariables) + return + } + + if (isCompiledSheet(input)) { + layers.push(input) + collectRootVariables(input, scopedVariables) + return + } + + if (isProviderStyleLayer(input)) { + const layer = normalizeProviderStyleLayer(input) + layers.push(layer) + const sheet = typeof layer.sheet === 'string' + ? compileCss(layer.sheet, { mode: 'runtime' }) + : layer.sheet + collectRootVariables(sheet, scopedVariables) + } +} + +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 collectRootVariables ( + sheet: CompiledCssSheet, + scopedVariables: Record[] +): void { + if (sheet.rootVariables != null) scopedVariables.push(sheet.rootVariables) +} + +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 index 6614bd6..4c136da 100644 --- a/packages/css-to-rn/src/react/cssx.ts +++ b/packages/css-to-rn/src/react/cssx.ts @@ -1,3 +1,4 @@ +import React, { use } from 'react' import type { CompiledCssSheet, CssxTarget } from '../types.ts' import { clearCssxRuntimeCachesForTests, @@ -9,6 +10,10 @@ import { type ResolveCssxLayer, type StyleNameValue } from '../resolve.ts' +import { + CssxRuntimeContext, + getDefaultCssxRuntimeContext +} from './config.ts' import { evaluateMediaQuery, getMediaQueryEvaluator, @@ -24,6 +29,12 @@ import { type TrackedCssxSheet } from './tracker.ts' +const ReactInternals = (React as unknown as { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?: { + H: unknown + } +}).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE + export type CssxStyleName = StyleNameValue export type CssxResolvedProps = ResolvedStyleProps @@ -31,6 +42,7 @@ export interface CssxRuntimeOptions { target?: CssxTarget values?: readonly unknown[] cache?: boolean | CssxCache + componentTag?: string | null } export type CssxSheetInput = @@ -59,13 +71,19 @@ export function cssx ( inlineStyleProps?: InlineStyleInput, options: CssxRuntimeOptions = {} ): CssxResolvedProps { - const normalized = normalizeSheetInput(sheetInput, options) + 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(), dimensions: getDimensions(), mediaQueryEvaluator: getMediaQueryEvaluator(), @@ -79,6 +97,18 @@ export function cssx ( return result.props } +function readRuntimeContext () { + if (ReactInternals?.H == null) { + return getDefaultCssxRuntimeContext() + } + + try { + return use(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext() + } catch { + return getDefaultCssxRuntimeContext() + } +} + export function clearRawCssCacheForTests (): void { clearCssxRuntimeCachesForTests() } diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index 2de423f..d49a3c1 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -7,12 +7,32 @@ import { } from 'react' import { compileCss } from '../compiler.ts' import type { CompiledCssSheet } from '../types.ts' -import { useCssxConfig, type CssxReactConfig } from './config.ts' +import { + useCssxConfig, + useCssxRuntimeContext, + type CssxReactConfig +} from './config.ts' +import { + coerceCssValue, + resolveCssValue +} from '../values.ts' +import { + createDependencySnapshot, + getDefaultVariableValues, + getDimensions, + getDimensionsVersion, + getRuntimeVersion, + getVariableValues, + getVariableVersion, + subscribeRuntimeStore, + type CssxDependencySnapshot +} from './store.ts' import { TrackedCssxSheet } from './tracker.ts' const useCommitEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect +const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/ export type CssxLayerHookInput = | string @@ -125,6 +145,49 @@ export function useCssxLayer ( return input as CssxLayerHookOutput } +export function useCssVariableRaw ( + name: string, + fallback?: unknown +): string | undefined { + assertCssVariableName(name) + const context = useCssxRuntimeContext() + const dependenciesRef = useRef(createDependencySnapshot()) + const result = resolveCssVariableRaw(name, fallback, context.scopedVariables) + dependenciesRef.current = createVariableDependencySnapshot(result) + + useSyncExternalStore( + listener => subscribeRuntimeStore(listener, () => dependenciesRef.current), + getRuntimeVersion, + getRuntimeVersion + ) + + return result.value +} + +export function useCssVariable ( + name: string, + fallback?: unknown +): unknown { + const value = useCssVariableRaw(name, fallback) + return value == null ? value : coerceCssValue(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) +} + function isCompiledSheet (value: unknown): value is CompiledCssSheet { return Boolean( value && @@ -144,3 +207,35 @@ function isLayerObject (value: unknown): value is { 'sheet' in value ) } + +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 createVariableDependencySnapshot ( + result: ReturnType +): 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 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 index a4cfa38..67da398 100644 --- a/packages/css-to-rn/src/react/index.ts +++ b/packages/css-to-rn/src/react/index.ts @@ -5,10 +5,17 @@ export { export { CssxProvider, configureCssx, - useCssxConfig + themed, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext } from './config.ts' export { useCssxLayer, + getCssVariable, + getCssVariableRaw, + useCssVariable, + useCssVariableRaw, useRuntimeCss, useCssxSheet, useCssxTemplate @@ -35,8 +42,11 @@ export type { CssxStyleName } from './cssx.ts' export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, CssxProviderProps, - CssxReactConfig + CssxReactConfig, + CssxRuntimeContextValue } from './config.ts' export type { CssxLayerHookInput, @@ -44,6 +54,7 @@ export type { } from './hooks.ts' export type { CssxDependencySnapshot, + CssxVariableStore, CssxRuntimeConfig } from './store.ts' export type { diff --git a/packages/css-to-rn/src/react/store.ts b/packages/css-to-rn/src/react/store.ts index c692083..82ae7fc 100644 --- a/packages/css-to-rn/src/react/store.ts +++ b/packages/css-to-rn/src/react/store.ts @@ -37,12 +37,19 @@ export interface RuntimeChangeSnapshot { 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) @@ -73,21 +80,7 @@ export const variables = createVariableProxy(variableValues) export const defaultVariables = createVariableProxy(defaultVariableValues) export function setDefaultVariables (next: Record): void { - const changed = new Set() - for (const name of Object.keys(defaultVariableValues)) { - if (!Object.prototype.hasOwnProperty.call(next, name)) { - delete defaultVariableValues[name] - changed.add(name) - } - } - - for (const [name, value] of Object.entries(next)) { - if (Object.is(defaultVariableValues[name], value)) continue - defaultVariableValues[name] = value - changed.add(name) - } - - markVariablesChanged(Array.from(changed)) + defaultVariables.set(next) } export function getVariableValues (): Record { @@ -279,12 +272,37 @@ export function resetStoreForTests (): void { runtimeSubscribers.clear() } -function createVariableProxy (target: Record): Record { +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]) @@ -294,12 +312,57 @@ function createVariableProxy (target: Record): 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 { diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts index 9a54aa8..a68c9a3 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -8,7 +8,7 @@ import type { TransformStyle, TransformStyleValue } from './transform/index.ts' -import { resolveCssValue } from './values.ts' +import { coerceCssValue, resolveCssValue } from './values.ts' import type { CompiledCssSheet, CssxDeclaration, @@ -43,10 +43,12 @@ export interface ResolveCssxOptions { layers?: CssxLayerInput | readonly CssxLayerInput[] inlineStyleProps?: InlineStyleInput variables?: Record + scopedVariables?: readonly Record[] defaultVariables?: Record dimensions?: CssxDimensions mediaQueryEvaluator?: CssxMediaQueryEvaluator target?: CssxTarget + componentTag?: string | null cache?: boolean | CssxCache cacheMaxEntries?: number } @@ -114,9 +116,11 @@ interface MutableDependencies { interface ResolutionContext { target: CssxTarget variables?: Record + scopedVariables?: readonly Record[] defaultVariables?: Record dimensions?: CssxDimensions mediaQueryEvaluator?: CssxMediaQueryEvaluator + componentTag?: string | null dependencies: MutableDependencies diagnostics: CssxDiagnostic[] } @@ -211,12 +215,15 @@ function resolveCssxUncached ( layers: readonly NormalizedLayer[], classNames: readonly string[] ): ResolveCssxResult { + const scopedVariables = collectScopedVariables(options.scopedVariables, layers) const context: ResolutionContext = { target: options.target ?? 'react-native', variables: options.variables, + scopedVariables, defaultVariables: options.defaultVariables, dimensions: options.dimensions, mediaQueryEvaluator: options.mediaQueryEvaluator, + componentTag: options.componentTag ?? null, dependencies: createDependencies(), diagnostics: [], } @@ -239,7 +246,7 @@ function resolveCssxUncached ( if (Object.keys(style).length > 0) mergeStyleProp(props, propName, style) } - mergeInlineStyleProps(props, options.inlineStyleProps) + mergeInlineStyleProps(props, options.inlineStyleProps, context) return { props, @@ -258,6 +265,7 @@ function getMatchedRules ( 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 }) @@ -315,6 +323,7 @@ function resolveDeclarationValue ( const result = resolveCssValue(declaration.value, { values: layer.values, variables: context.variables, + scopedVariables: context.scopedVariables, defaultVariables: context.defaultVariables, dimensions: context.dimensions }) @@ -424,6 +433,13 @@ function ruleMatchesClasses ( 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 @@ -545,18 +561,19 @@ function classcat (value: StyleNameValue): string { function mergeInlineStyleProps ( props: ResolvedStyleProps, - inlineStyleProps: InlineStyleInput + inlineStyleProps: InlineStyleInput, + context: ResolutionContext ): void { if (!inlineStyleProps) return if (isStylePropsInput(inlineStyleProps)) { for (const propName of Object.keys(inlineStyleProps)) { - mergeStyleProp(props, propName, inlineStyleProps[propName]) + mergeStyleProp(props, propName, resolveInlineStyleValue(inlineStyleProps[propName], context)) } return } - mergeStyleProp(props, 'style', inlineStyleProps) + mergeStyleProp(props, 'style', resolveInlineStyleValue(inlineStyleProps, context)) } function isStylePropsInput (value: TransformStyle | ResolvedStyleProps): value is ResolvedStyleProps { @@ -589,6 +606,42 @@ function flattenStyleInto ( 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') { + const output: TransformStyle = {} + for (const [key, child] of Object.entries(value)) { + output[key] = resolveInlineStyleValue(child, context) + } + return output + } + + return value +} + function createStableKey ( options: ResolveCssxOptions, classNames: readonly string[], @@ -597,6 +650,7 @@ function createStableKey ( ): string { return JSON.stringify({ target: options.target ?? 'react-native', + componentTag: options.componentTag ?? null, styleName: classNames, inline: inlineHash, layers: layers.map(layer => ({ @@ -615,6 +669,7 @@ function createDynamicSignature ( vars: dependencies.vars.map(name => [ name, valueFromRecord(options.variables, name) ?? + valueFromScopedRecords(options.scopedVariables, name) ?? valueFromRecord(options.defaultVariables, name) ]), dimensions: dependencies.dimensions @@ -647,6 +702,19 @@ function flattenLayerValues (layers: readonly NormalizedLayer[]): readonly unkno return values } +function collectScopedVariables ( + explicitScopes: readonly Record[] | undefined, + layers: readonly NormalizedLayer[] +): readonly Record[] | undefined { + const scopes: Record[] = explicitScopes ? [...explicitScopes] : [] + + for (const layer of layers) { + if (layer.sheet.rootVariables != null) scopes.push(layer.sheet.rootVariables) + } + + return scopes.length > 0 ? scopes : undefined +} + function sameValues ( left: readonly unknown[], right: readonly unknown[] @@ -721,3 +789,17 @@ function valueFromRecord (record: Record | undefined, key: stri 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 index 29b6b12..3a12d5e 100644 --- a/packages/css-to-rn/src/selectors.ts +++ b/packages/css-to-rn/src/selectors.ts @@ -1,7 +1,9 @@ import { diagnostic } from './diagnostics.ts' import type { CssxDiagnostic, SelectorParseResult } from './types.ts' -const PART_RE = /::?part\(([^)]+)\)$/ +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' @@ -18,7 +20,10 @@ export function parseSelector (selector: string, position?: { line?: number, col const partMatch = current.match(PART_RE) if (partMatch) { part = partMatch[1].trim() - current = current.slice(0, partMatch.index).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)) { @@ -29,10 +34,6 @@ export function parseSelector (selector: string, position?: { line?: number, col } } - if (!current.startsWith('.')) { - return unsupported(original, position) - } - if ( current.includes(' ') || current.includes('>') || @@ -45,14 +46,31 @@ export function parseSelector (selector: string, position?: { line?: number, col return unsupported(original, position) } - const classes = current.split('.').filter(Boolean) - if (classes.length === 0 || classes.some(name => !/^[_a-zA-Z][-_a-zA-Z0-9]*$/.test(name))) { + 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 @@ -64,7 +82,7 @@ function unsupported (selector: string, position?: { line?: number, column?: num return { diagnostic: diagnostic( 'UNSUPPORTED_SELECTOR', - `Unsupported selector "${selector}" ignored. CSSX supports class combinations and :part()/:hover/:active only.`, + `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/types.ts b/packages/css-to-rn/src/types.ts index af70d51..00bef76 100644 --- a/packages/css-to-rn/src/types.ts +++ b/packages/css-to-rn/src/types.ts @@ -57,6 +57,7 @@ export interface CompiledCssSheet { contentHash: string rules: CssxRule[] keyframes: Record + rootVariables?: Record exports?: Record metadata: CssxMetadata diagnostics: CssxDiagnostic[] @@ -65,6 +66,7 @@ export interface CompiledCssSheet { export interface CssxRule { selector: string + tag: string | null classes: string[] part: string | null specificity: number @@ -91,6 +93,7 @@ export interface CssxKeyframe { export interface SelectorParseResult { selector: string + tag: string | null classes: string[] part: string | null specificity: number diff --git a/packages/css-to-rn/src/values.ts b/packages/css-to-rn/src/values.ts index 8e7aecd..1a87c21 100644 --- a/packages/css-to-rn/src/values.ts +++ b/packages/css-to-rn/src/values.ts @@ -1,4 +1,5 @@ import { diagnostic } from './diagnostics.ts' +import { evaluateCssColors } from './colors.ts' import type { CssxDiagnostic } from './types.ts' export type InterpolationValue = string | number | null | undefined | false @@ -6,6 +7,7 @@ export type InterpolationValue = string | number | null | undefined | false export interface ResolveCssValueOptions { values?: readonly unknown[] variables?: Record + scopedVariables?: readonly Record[] defaultVariables?: Record dimensions?: { width?: number @@ -63,15 +65,31 @@ export function resolveCssValue ( if (!calc.valid) { return invalid(diagnostics, dependencies) } + const colors = evaluateCssColors(calc.value) return { - value: calc.value.trim(), + 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[], @@ -165,6 +183,7 @@ function resolveVars ( 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 @@ -375,6 +394,20 @@ function valueFromRecord (record: Record | undefined, key: stri 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(), diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index 2f07f7f..92ca967 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -42,18 +42,33 @@ export type { CssxStyleName } from './react/cssx.ts' export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, CssxProviderProps, - CssxReactConfig + CssxReactConfig, + CssxRuntimeContextValue } from './react/config.ts' export type { TrackedCssxSheetOptions } from './react/tracker.ts' +export type { + CssxVariableStore +} from './react/store.ts' export { CssxProvider, configureCssx, - useCssxConfig + themed, + useCssxComponentTag, + useCssxConfig, + useCssxRuntimeContext } from './react/config.ts' +export { + getCssVariable, + getCssVariableRaw, + useCssVariable, + useCssVariableRaw +} from './react/hooks.ts' export { TrackedCssxSheet, isTrackedCssxSheet diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts index d3a1ee5..a4bb9af 100644 --- a/packages/css-to-rn/test/engine/compiler.test.ts +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -16,6 +16,7 @@ describe('@cssxjs/css-to-rn compiler IR', () => { 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') @@ -27,6 +28,36 @@ describe('@cssxjs/css-to-rn compiler IR', () => { 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('maps hover and active pseudos to logical part aliases', () => { const sheet = compileCss(` .root:hover { color: red; } diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index d66f813..a649488 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -233,6 +233,104 @@ describe('@cssxjs/css-to-rn resolver', () => { 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('evicts raw CSS resolved cache entries when a caller requests a single cache slot', () => { const cache = createCssxCache({ maxEntries: 1 }) const redCss = '.root { color: red; }' diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts index 1dcab33..13bb7d9 100644 --- a/packages/css-to-rn/test/engine/values.test.ts +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -52,7 +52,21 @@ describe('@cssxjs/css-to-rn value resolver', () => { }) assert.equal(result.valid, true) - assert.equal(result.value, 'color-mix(in srgb, green, white)') + 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']) }) diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index d37e60e..85e93ef 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -6,8 +6,15 @@ import { __cssxInternals, compileCss, compileCssTemplate, + CssxProvider, cssx, + defaultVariables, + getCssVariable, + getCssVariableRaw, setDefaultVariables, + themed, + useCssVariable, + useCssVariableRaw, useCssxLayer, variables } from '../../src/web.ts' @@ -256,6 +263,217 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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 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('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('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('uses dimension adapter values for media queries and viewport units', async () => { reset() let dimensions = { width: 320, height: 640 } diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 4771cb6..64ffc4f 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -5,21 +5,32 @@ export { configureCssx, cssx, defaultVariables, + getCssVariable, + getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, + themed, + useCssVariable, + useCssVariableRaw, useCssxLayer, useRuntimeCss, + useCssxComponentTag, useCssxConfig, + useCssxRuntimeContext, useCssxSheet, useCssxTemplate, variables } from '@cssxjs/css-to-rn/react' export type { + CssxProviderStyleInput, + CssxProviderStyleLayer, CssxProviderProps, CssxReactConfig, CssxResolvedProps, + CssxRuntimeContextValue, CssxRuntimeOptions, CssxStyleName, + CssxVariableStore, TrackedCssxSheetOptions } from '@cssxjs/css-to-rn/react' @@ -50,3 +61,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 ca93285..8181b60 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -4,16 +4,25 @@ export { configureCssx, cssx, defaultVariables, + getCssVariable, + getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, + themed, + useCssVariable, + useCssVariableRaw, useCssxLayer, useRuntimeCss, + useCssxComponentTag, useCssxConfig, + useCssxRuntimeContext, useCssxSheet, useCssxTemplate, 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 5c0fd06..2a92289 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -33,7 +33,7 @@ "access": "public" }, "scripts": { - "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node --input-type=module -e \"import { cssx, useCssxLayer, useRuntimeCss } from 'cssxjs'; if (typeof cssx !== 'function' || typeof useCssxLayer !== 'function' || typeof useRuntimeCss !== 'function') throw new Error('cssxjs source-condition import smoke failed')\"" + "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node test/smoke.mjs" }, "dependencies": { "@cssxjs/babel-plugin-rn-stylename-inline": "^0.3.0", diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js index 0952e35..b4f0dbb 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -7,16 +7,25 @@ export { TrackedCssxSheet, configureCssx, defaultVariables, + getCssVariable, + getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, + themed, + useCssVariable, + useCssVariableRaw, useCssxLayer, useRuntimeCss, + useCssxComponentTag, useCssxConfig, + useCssxRuntimeContext, useCssxSheet, useCssxTemplate, variables } from '@cssxjs/css-to-rn/react-native' +export { default as matcher } from '../matcher.js' + export function runtime ( styleName, fileStyles, diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js index 527b07d..97aea02 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -7,16 +7,25 @@ export { TrackedCssxSheet, configureCssx, defaultVariables, + getCssVariable, + getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, + themed, + useCssVariable, + useCssVariableRaw, useCssxLayer, useRuntimeCss, + useCssxComponentTag, useCssxConfig, + useCssxRuntimeContext, useCssxSheet, useCssxTemplate, variables } from '@cssxjs/css-to-rn/web' +export { default as matcher } from '../matcher.js' + export function runtime ( styleName, fileStyles, diff --git a/packages/cssxjs/test/smoke.mjs b/packages/cssxjs/test/smoke.mjs new file mode 100644 index 0000000..551046a --- /dev/null +++ b/packages/cssxjs/test/smoke.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { + CssxProvider, + cssx, + getCssVariable, + matcher, + themed, + useCssVariable, + useCssxLayer, + useRuntimeCss +} from 'cssxjs' + +assert.equal(typeof CssxProvider, 'function') +assert.equal(typeof cssx, 'function') +assert.equal(typeof getCssVariable, 'function') +assert.equal(typeof matcher, 'function') +assert.equal(typeof themed, 'function') +assert.equal(typeof useCssVariable, 'function') +assert.equal(typeof useCssxLayer, 'function') +assert.equal(typeof useRuntimeCss, '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 }] + } +) diff --git a/packages/loaders/cssToReactNativeLoader.js b/packages/loaders/cssToReactNativeLoader.js index f942781..153af24 100644 --- a/packages/loaders/cssToReactNativeLoader.js +++ b/packages/loaders/cssToReactNativeLoader.js @@ -5,6 +5,7 @@ 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*\{/ @@ -20,12 +21,43 @@ module.exports = function cssToReactNative (source) { 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 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 { diff --git a/packages/loaders/package.json b/packages/loaders/package.json index 1a3e33d..bc129bb 100644 --- a/packages/loaders/package.json +++ b/packages/loaders/package.json @@ -12,7 +12,7 @@ "access": "public" }, "scripts": { - "test": "node -e \"const loader = require('./cssToReactNativeLoader.js'); const output = loader.call({ query: {}, resourcePath: 'smoke.css' }, '.root { color: red }'); if (!output.includes('module.exports')) throw new Error('cssToReactNativeLoader source fallback smoke failed')\"" + "test": "node test/cssToReactNativeLoader.test.cjs" }, "author": { "name": "Pavel Zhukov", 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/plan.md b/plan.md index 3c5d3be..7d5bd45 100644 --- a/plan.md +++ b/plan.md @@ -40,7 +40,10 @@ The new package should replace that split with: ## Non-Goals -These are intentionally out of scope for the first implementation: +These were intentionally out of scope for the first unified-engine +implementation. The later "Global Theming And Provider Styles Workstream" +below explicitly reopens provider-scoped variables, `:root`, component tag +selectors, modern color math, Tailwind utilities, and StartupJS UI migration. - Runtime Stylus compilation. Runtime `compileCss()` accepts pure CSS only. - Full browser selector support. CSSX remains a class-combination selector @@ -58,6 +61,764 @@ These are intentionally out of scope for the first implementation: - Interpolation inside Pug `style` blocks. - Dynamic `:export` values. +## Global Theming And Provider Styles Workstream + +This section captures the next batch of agreed work after the unified engine +implementation. It moves global theming, component tag overrides, scoped CSS +variables, and optional Tailwind utilities into CSSX primitives, then migrates +StartupJS and StartupJS UI onto those primitives. + +Work should happen on separate branches in the involved repos: + +- `cssx`: `cssx-theme-provider-plan` +- `startupjs`: `cssx-provider-integration` +- `startupjs-ui`: `cssx-theme-migration` + +Do not treat this as a StartupJS UI-only redesign. CSSX must expose generic +building blocks that standalone CSSX users can use without StartupJS. StartupJS +then re-exports CSSX APIs and wires them into its framework provider. StartupJS +UI becomes a consumer that ships a default theme and components which opt into +CSSX global customization. + +### Goals + +- Add provider-level global CSSX sheets. +- Let provider sheets define scoped `:root` CSS custom properties. +- Let provider sheets define global utility classes. +- Add first-class component tag selectors for globally themeable components. +- Move the `themed()` primitive into CSSX and re-export it from `startupjs`. +- Make StartupJS UI use CSSX primitives instead of owning the theme engine. +- Replace StartupJS UI's JS palette/color object system with CSS-first + variables and CSS color functions where possible. +- Add enough modern color math to reproduce the current StartupJS UI theme + structure in pure CSS. +- Add an optional Tailwind preset/layer that can be consumed by CSSX provider + styles and `cssx()`. +- Add a conditional legacy flag for migration testing. + +### Provider API + +`CssxProvider` should accept a direct `style` prop: + +```tsx + + + +``` + +`style` accepts the same layer inputs as `cssx()`: + +```ts +type CssxProviderStyle = + | string + | CompiledCssSheet + | TrackedCssxSheet + | CssxUtilityLayer + | readonly CssxProviderStyle[] + | null + | undefined + | false +``` + +Raw CSS strings compile at runtime with the same graceful diagnostics model as +`useRuntimeCss()`. Compiled sheets and tracked sheets should work during SSR. +Arrays flatten like React style arrays: ignore falsey values, preserve order, +and let later layers override earlier layers. + +`CssxProvider` should continue to support runtime configuration, either through +the existing `value` prop or a compatible shape. The provider style API should +be the ergonomic public path for global theme/style sheets. + +Provider cascade order: + +1. outer provider `style` +2. inner provider `style` +3. imported/file component styles +4. local `css` / `styl` templates +5. explicit inline style props + +Child provider styles append after parent provider styles, so nested providers +can override parent themes. StartupJS UI `UiProvider style` should override the +StartupJS UI default theme. + +### StartupJS Provider Integration + +Standalone CSSX users use `CssxProvider` directly. + +StartupJS framework users normally use `StartupjsProvider`; StartupJS should +wire its provider `style` prop into CSSX through its plugin/provider hook layer. +That lets apps write: + +```tsx + + + +``` + +without manually adding a separate CSSX provider. `startupjs` already re-exports +`css`, `styl`, and other CSSX APIs; it should also re-export the new provider, +variable, and theming APIs. + +StartupJS UI should not own this framework integration. It should provide an +inner provider for its own default component theme. + +### StartupJS UI Provider Integration + +StartupJS UI should ship a default theme sheet as pure `.css` where possible, +not Stylus unless Stylus features become necessary. + +`UiProvider` should wrap its children with: + +```tsx + + {children} + +``` + +This makes StartupJS UI self-contained when used outside the full StartupJS +framework. The app can still pass a stronger override sheet through +`UiProvider style`. + +The total common cascade is: + +1. outer app/framework provider style +2. StartupJS UI default theme +3. `UiProvider style` overrides +4. component file/local/inline styles + +StartupJS UI should stop seeding global `defaultVariables` for its theme. The +default UI variables move entirely into the default theme CSS sheet. + +### Provider `:root` Variables + +Provider `:root` declarations are scoped to that provider subtree. They should +behave like CSS custom properties in web CSS: nested providers can override +outer variables without mutating singleton global runtime variables. + +Example: + +```css +:root { + --color-primary: oklch(62% 0.18 250); + --Button-height-m: 32px; +} +``` + +Compiled sheets should preserve root custom properties as structured metadata, +for example `sheet.rootVariables`, not as legacy top-level style objects. Expose +helpers such as: + +```ts +getRootVariables(sheet): Record +``` + +The exact name can change, but StartupJS UI must not depend on +`style[':root']` anymore. + +Variable precedence, highest to lowest: + +1. interpolation/template values used by the current layer +2. global imperative `variables['--x']` +3. nearest provider `:root` variable +4. outer provider `:root` variable +5. global `defaultVariables` +6. inline `var(--x, fallback)` + +This keeps current global runtime variables powerful and backward compatible, +while provider variables behave as scoped defaults. + +### Variable Store API + +Keep `variables` and `defaultVariables` as object-like proxies, but make them +stricter and add bulk methods directly on the proxies: + +```ts +variables['--x'] = 'red' +delete variables['--x'] + +variables.assign({ + '--x': 'red', + '--y': 'blue' +}) + +variables.set({ + '--x': 'red' +}) + +variables.clear() +variables.clear(['--x', '--y']) + +defaultVariables.set({ + '--x': 'red' +}) +``` + +`setDefaultVariables(vars)` remains as a compatibility alias for +`defaultVariables.set(vars)`. + +Validation: + +- only valid CSS custom property names are allowed, practically + `/^--[A-Za-z0-9_-]+$/` +- invalid writes throw in every environment +- methods are reserved non-variable properties and should not be enumerable +- bulk operations validate everything before mutating anything +- notify once per bulk operation with exactly changed/removed variable names + +Provider `:root` variables are CSS strings. Imperative global variables should +be CSS-first and documented as strings/numbers. Object values with meaningful +`toString()` can remain tolerated for migration, but StartupJS UI should stop +depending on object-valued variables. + +### Variable Read APIs + +Expose provider-aware and global-only variable readers: + +```ts +useCssVariable(name: string, fallback?: unknown): unknown +useCssVariableRaw(name: string, fallback?: string): string | undefined + +getCssVariable(name: string, fallback?: unknown): unknown +getCssVariableRaw(name: string, fallback?: string): string | undefined +``` + +`useCssVariable()`: + +- React-only +- provider-aware +- subscribed to exactly the variables used, including nested `var()` + dependencies +- returns RN-friendly values by default + +`getCssVariable()`: + +- global-only +- not provider-aware +- not subscribed +- useful outside render or in non-React code + +Internal pure helpers should exist for explicit contexts, for example +`resolveCssVariable(name, context)`. + +RN-friendly value behavior: + +- `32px` -> `32` +- `4u` -> `32` +- unitless number -> number +- `%` remains string +- colors and computed color functions -> RN-compatible color strings +- complex RN-accepted strings remain strings +- unsupported values return fallback or `undefined` and report diagnostics in + development paths + +Raw helpers return the resolved CSS string before RN coercion. + +### Inline Style `var()` Resolution + +CSSX should resolve `var()` inside inline style props when those props flow +through CSSX: + +```tsx + +``` + +or: + +```ts +cssx('root', sheet, { backgroundColor: 'var(--color-bg-main)' }) +``` + +This replaces StartupJS UI's brittle JSON-stringify-based +`useTransformCssVariables()` helper. Plain React Native `style={{ ... }}` +without CSSX cannot be intercepted. + +Inline variable resolution must: + +- use the same variable precedence as declarations +- track exact variable dependencies +- participate in cache keys/invalidation +- resolve nested `var()` and fallbacks +- apply RN-friendly value coercion + +### Component Tag Selectors + +Add component tag/type selectors for provider/global sheets: + +```css +Button { + background-color: red; +} + +Button:part(text), +Button::part(text) { + color: green; +} + +Button.primary { + border-color: var(--color-primary); +} + +Button:part(icon).large { + width: 24px; +} +``` + +Only tag selectors should be supported for global component defaults. Do not +support `.Button` as a long-term alias. StartupJS UI's breaking migration guide +should tell users to change `.Button` global overrides to `Button`. + +Both `:part(name)` and `::part(name)` are long-term supported syntax. + +Supported selector combinations for the first batch: + +- `Tag` +- `Tag.class` +- `Tag:part(name)` +- `Tag::part(name)` +- `Tag:part(name).class` +- `Tag:hover` +- `Tag:active` +- class selectors and class part selectors, such as `.danger:part(icon)` + +Defer descendant selectors such as `Button Text` or `Button .icon`. Parts are +the intended cross-boundary customization API. + +Global utility classes should work without a component tag: + +```css +.danger { + border-color: var(--color-error); +} + +.danger:part(text) { + color: var(--color-error); +} +``` + +### `themed()` + +The CSSX `themed()` primitive should live in CSSX, not StartupJS UI. StartupJS +re-exports it. StartupJS UI imports it from `startupjs`. + +Recommended public APIs: + +```ts +themed(tagName: string, Component: React.ComponentType): React.ComponentType +useThemeTag(): string | undefined +``` + +Implementation should use React 19 context with `use(Context)` where useful so +it does not depend on Babel threading hidden props through every element. + +`cssx()` in React entrypoints may read the current theme tag from context during +render. Pure `resolveCssx()` remains framework-independent and takes an +explicit tag/component option. + +Boundary behavior: + +- `themed('Button', Button)` provides the current tag while rendering the + component implementation. +- internal root and part elements in that component see the `Button` tag. +- nested themed components push their own tag. +- non-themed descendants inherit the nearest tag unless a future escape hatch + is added. + +Parts are explicit. Components must mark exposed internals with `part`: + +```tsx +function Button({ children }) { + return ( + + {children} + + ) +} + +export default themed('Button', Button) +``` + +Do not infer parts from prop names like `textStyle` or `iconStyle`. + +### Modern Color Math + +CSSX should implement enough CSS color math to replace the current StartupJS UI +JS palette/color-object theme structure with CSS variables. + +Use `@colordx/core` pinned exactly: + +```json +"@colordx/core": "5.4.3" +``` + +The version is intentionally fixed because the package is less widely adopted +and CSSX will adapt to a specific API. + +First batch support: + +- `oklch(L C H / alpha?)` +- `oklab(...)` +- `rgb()` / `rgba()` +- `hsl()` / `hsla()` +- nested `var()` inside color function channels +- `calc()` inside color channels +- arithmetic for numeric/percentage channels where needed +- `color-mix()` implemented by CSSX using colordx primitives +- interpolation spaces for `color-mix()`: + - `oklch` + - `oklab` + - `srgb` + +Defer: + +- hue interpolation flags, such as `longer hue` +- exotic color spaces such as `display-p3` +- full relative color syntax +- full CSS unit system inside color math + +On React Native, modern color functions must be evaluated to RN-compatible +strings. On web, pass-through is allowed only if it does not complicate CSSX +variable resolution; otherwise normalize consistently on all platforms. + +Default output for computed modern colors: + +```text +rgba(r, g, b, a) +``` + +Plain authored colors such as `red` or `#fff` can remain as authored unless +they participate in computed color math. + +StartupJS UI default theme should become CSS-first, for example: + +```css +:root { + --ui-primary-h: 250; + --ui-primary-c: 0.18; + --ui-primary-l: 62%; + + --color-primary: oklch(var(--ui-primary-l) var(--ui-primary-c) var(--ui-primary-h)); + --color-primary-strong: color-mix(in oklch, var(--color-primary), black 12%); + --color-primary-transparent: color-mix(in srgb, var(--color-primary) 5%, transparent); + + --Button-height-m: 32px; + --Button-disabledOpacity: 0.25; +} +``` + +### StartupJS UI Migration + +StartupJS UI should stop managing global colors/palettes as its own framework +layer. It should ship a default CSSX theme and use CSSX primitives: + +- `themed()` from `startupjs` +- `CssxProvider` through `startupjs` +- `useCssVariable()` +- `useCssVariableRaw()` only where raw CSS strings are necessary +- provider `style` overrides + +Remove `Colors` compatibility in the breaking StartupJS UI release. Migration +docs should tell users to use string token names or CSS variable names. + +Examples: + +```ts +// old +color = Colors.primary +getColor(color) + +// new +color = 'primary' +useCssVariable(`--color-${color}`) +``` + +Component-level vars use full custom property names: + +```ts +const hoverBg = useCssVariable('--Div-hoverBg') +const height = useCssVariable('--Button-height-m') +``` + +Move configurable visual values from Stylus `:export` config objects into CSS +variables by default. Components can read variables with `useCssVariable()` +when JS needs the value. Keep true structural JS constants as JS constants when +they are not meaningful CSS/theme knobs. + +Examples: + +- good CSS variables: + - radii + - border widths + - disabled opacity + - colors + - heights + - font sizes + - icon margins +- likely JS constants: + - supported size names + - icon component mappings + - structural branching that cannot be a CSS value + +### Optional Tailwind Support + +Tailwind support should be optional and imported explicitly: + +```ts +import { tailwind } from 'cssxjs/tailwind' +``` + +Implementation shape: + +- create a separate package/adapter, likely `@cssxjs/tailwind` +- `cssxjs` may depend on it and expose a separate export entry +- it is only bundled by clients that import `cssxjs/tailwind` +- depend on `@mgcrea/react-native-tailwind` pinned exactly, initially: + +```json +"@mgcrea/react-native-tailwind": "0.16.0" +``` + +Reuse `@mgcrea/react-native-tailwind` for Tailwind token parsing/config support +where possible. Do not reimplement the whole Tailwind utility table unless the +adapter API blocks CSSX integration. + +`tailwind()` returns a CSSX layer/preset: + +```tsx +const tw = tailwind({ + theme: { + extend: { + colors: { + primary: '#1d4ed8' + } + } + } +}) + + + + +``` + +Users can then write: + +```tsx + +``` + +No separate `tw()` helper is needed in the first batch. Users can call the +normal CSSX runtime if they need manual props: + +```ts +cssx('flex-1 bg-gray-100 p-4', tw) +``` + +Arbitrary utilities such as `w-[123px]` cannot be pre-generated. The Tailwind +layer should expose a virtual utility resolver used during `cssx()` resolution: + +- finite generated sheet for standard utilities/default vars when useful +- virtual `resolveUtilityClass(className)` for arbitrary/dynamic classes +- diagnostics for unsupported utilities +- bounded cache behavior consistent with CSSX caching rules + +Tailwind config: + +- build-time/Node can discover `tailwind.config.{js,cjs,mjs,ts}` from project + root when appropriate +- runtime/client cannot use filesystem discovery +- allow explicit config object +- users can layer Tailwind with their own StartupJS UI/app overrides through + provider style arrays + +### Legacy Migration Flag + +Add a conditional legacy flag for migration testing: + +```js +// babel-preset-cssxjs / babel-preset-startupjs +{ + cssxLegacy: true +} +``` + +Also support an env override: + +```sh +CSSX_LEGACY=0 yarn build +CSSX_LEGACY=1 yarn build +``` + +Default: `true` for the migration release. + +When legacy is enabled: + +- `matcher` compatibility export works +- loader emits legacy top-level static style-map entries such as + `STYLES.button` + +When legacy is disabled: + +- `matcher()` throws a clear migration error +- old top-level static style-map entries are not generated +- direct `STYLES.button` reads fail loudly + +The flag must reach both Babel and Metro/file-loader paths: + +- Babel preset passes `cssxLegacy` to inline/styleName plugins +- Babel plugin can fail fast for `import { matcher } from 'startupjs'` or + `cssxjs` +- Metro transformer passes `cssxLegacy` to `cssToReactNativeLoader` +- StartupJS Babel preset and Metro config expose/pass the same option + +CSSX/StartupJS should keep legacy default-on for one migration release, then +flip default or remove it in a later major after real apps pass with +`CSSX_LEGACY=0`. + +### Diagnostics + +Provider raw CSS diagnostics should be visible and controlled: + +- raw provider strings compile through `useRuntimeCss()` +- expose diagnostics through a hook such as `useCssxDiagnostics()` +- development warns once per sheet/cache key unless silenced +- production does not warn by default +- diagnostics remain available to tooling and AI-generated CSS workflows + +### SSR + +Provider global styles should work during SSR: + +- compiled sheets work synchronously +- raw strings compile synchronously +- scoped provider variables resolve deterministically +- media queries use current CSSX dimensions fallback/config +- subscriptions use the existing server snapshot path and do not attach + browser listeners + +### Implementation Phases For This Workstream + +1. **Core provider layer model** + - extend `CssxProvider` with `style` + - normalize provider layer arrays + - include provider layers in React `cssx()` resolution + - add provider diagnostics plumbing + - add SSR tests + +2. **Root variable metadata and scoped resolution** + - compile `:root` custom properties into sheet metadata + - add provider scoped variable stack + - update `resolveCssValue()` to accept scoped variable layers + - implement variable precedence + - add nested provider tests + +3. **Variable proxy methods and read APIs** + - validate variable names + - add `variables.set/assign/clear` + - add matching `defaultVariables` methods + - keep `setDefaultVariables()` compatibility + - add `useCssVariable`, `useCssVariableRaw`, `getCssVariable`, + `getCssVariableRaw` + - test exact subscriptions and bulk notification behavior + +4. **Inline style variable resolution** + - resolve inline style strings containing `var()` + - track dependencies + - convert scalar CSS values to RN-friendly JS values + - update cache tests for inline variable changes + +5. **Component tag selectors and `themed()`** + - extend selector IR with optional component tag + - support tag/class/part/pseudo combinations + - add `themed()` and theme tag context + - make React `cssx()` read theme tag context + - keep pure `resolveCssx()` explicit and framework-independent + - test parts, nested themed components, inherited tag behavior, and utility + classes + +6. **Modern color math** + - add `@colordx/core@5.4.3` + - evaluate OKLCH/OKLab/RGB/HSL and channel `calc()` + - implement `color-mix()` for `oklch`, `oklab`, and `srgb` + - normalize computed modern colors to `rgba(...)` + - add RN and web target tests + +7. **Legacy flag** + - thread `cssxLegacy` through CSSX Babel preset/plugins/loaders/Metro + - thread same option through StartupJS Babel preset/Metro integration + - make legacy-off throw for `matcher()` + - make legacy-off omit static map entries + - test both modes in CSSX and real apps + +8. **Optional Tailwind adapter** + - add `@cssxjs/tailwind` + - expose `cssxjs/tailwind` + - adapt `@mgcrea/react-native-tailwind@0.16.0` + - add CSSX utility layer interface if needed + - support config object and Node config discovery + - test standard and arbitrary utilities + +9. **StartupJS integration** + - re-export new CSSX APIs from `startupjs` + - wire `StartupjsProvider style` into `CssxProvider` + - expose `cssxLegacy` in StartupJS Babel/Metro config paths + - test Dating and other real apps with `CSSX_LEGACY=1` and `0` + +10. **StartupJS UI migration** + - add default `.css` theme sheet + - wrap `UiProvider` in `CssxProvider` + - replace current `themed()` implementation with CSSX `themed()` + - replace direct `matcher` usage + - replace direct `STYLES.class` reads with `cssx()` or CSS variables + - move configurable `:export` values to CSS variables where appropriate + - remove `Colors` compatibility + - update docs and migration guide from `.Button` to `Button` + +### Required Tests + +CSSX engine tests: + +- `:root` extraction +- provider scoped variable precedence +- nested provider overrides +- global variables overriding provider vars +- `variables.set/assign/clear` +- invalid variable names throwing +- `useCssVariable()` exact subscriptions +- inline style `var()` resolution and cache invalidation +- tag selectors and class selectors together +- `:part()` and `::part()` +- `:hover` and `:active` on tag selectors +- utility classes from provider sheets +- runtime raw provider CSS diagnostics +- SSR provider resolution +- OKLCH conversion +- `calc()` in OKLCH channels +- `color-mix()` in `oklch`, `oklab`, and `srgb` +- unsupported color diagnostics +- legacy on/off behavior +- Tailwind standard utilities +- Tailwind arbitrary utilities + +StartupJS tests: + +- `StartupjsProvider style` wraps CSSX provider +- CSSX APIs re-export from `startupjs` +- Babel/Metro `cssxLegacy` flag propagates + +StartupJS UI tests: + +- default theme variables available through `UiProvider` +- `UiProvider style` overrides default theme +- component tag overrides apply to roots and explicit parts +- `.Button` overrides no longer work in new docs/tests +- migrated components no longer import/use `matcher` +- migrated components no longer depend on top-level `STYLES.class` entries + +Real app migration checks: + +- run Dating with `CSSX_LEGACY=1` +- run Dating with `CSSX_LEGACY=0` +- repeat on other StartupJS apps before flipping/removing legacy + ## Research Summary ### Current CSSX diff --git a/yarn.lock b/yarn.lock index ff31e9f..58044a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -760,6 +760,13 @@ __metadata: 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" @@ -868,6 +875,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cssxjs/css-to-rn@workspace:packages/css-to-rn" dependencies: + "@colordx/core": "npm:5.4.3" "@types/jsdom": "npm:^28.0.3" "@types/node": "npm:^22.8.1" "@types/react": "npm:19.2.17" From 2349642d0b42da53bcac297eb914d78daba396be Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Mon, 22 Jun 2026 00:45:40 +0300 Subject: [PATCH 24/37] Harden CSSX React tracking --- docs/api/runtime.md | 3 + packages/css-to-rn/src/compiler.ts | 15 +- packages/css-to-rn/src/react/config.ts | 47 ++-- packages/css-to-rn/src/react/hooks.ts | 168 ++++++++++++--- packages/css-to-rn/src/react/tracker.ts | 40 ++++ packages/css-to-rn/src/resolve.ts | 27 ++- .../css-to-rn/test/engine/resolve.test.ts | 59 +++++ .../css-to-rn/test/react/tracking.test.ts | 203 ++++++++++++++++++ 8 files changed, 505 insertions(+), 57 deletions(-) diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 4b29d5b..0fd1997 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -153,6 +153,9 @@ function App() { 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 diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts index 6f73587..2f9f061 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -176,7 +176,7 @@ function compileRuleList ( )) continue } - compileRootVariables(declarations, rootVariables, state, isTemplate) + compileRootVariables(declarations, rootVariables, state) continue } @@ -213,8 +213,7 @@ function compileRuleList ( function compileRootVariables ( declarations: CssDeclarationAst[], rootVariables: Record, - state: CompileState, - isTemplate: boolean + state: CompileState ): void { for (const declaration of declarations) { if (declaration.type !== 'declaration') continue @@ -232,16 +231,6 @@ function compileRootVariables ( } const value = declaration.value ?? '' - if (isTemplate && hasDynamicSlots(value)) { - addDiagnostic(state, diagnostic( - 'UNSUPPORTED_INTERPOLATION_POSITION', - 'Interpolation is not supported inside :root variable declarations.', - 'error', - positionOf(declaration) - )) - continue - } - rootVariables[property] = value } } diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts index df522dd..e4cbdc2 100644 --- a/packages/css-to-rn/src/react/config.ts +++ b/packages/css-to-rn/src/react/config.ts @@ -88,6 +88,7 @@ const EMPTY_TRACKING_SHEET: CompiledCssSheet = { metadata: EMPTY_METADATA, diagnostics: [] } +const DYNAMIC_ROOT_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g export function configureCssx (config: CssxReactConfig): void { setRuntimeConfig(config) @@ -155,14 +156,10 @@ export function themed

( function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet { const trackerRef = useRef(null) - - if (trackerRef.current == null) { - trackerRef.current = new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options) - } else { - trackerRef.current.update(EMPTY_TRACKING_SHEET, options) - } - - const tracker = trackerRef.current + const committedTracker = trackerRef.current + const tracker = committedTracker?.matches(EMPTY_TRACKING_SHEET, options) + ? committedTracker + : new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options) const renderDependencies = tracker.startRender() useSyncExternalStore( @@ -173,6 +170,7 @@ function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet { useCommitEffect(() => { tracker.commitRender(renderDependencies) + trackerRef.current = tracker }) return tracker @@ -223,7 +221,7 @@ function collectProviderStyle ( if (isTrackedCssxSheet(input)) { const sheet = input.getSheet() layers.push({ sheet, cacheKey: input }) - collectRootVariables(sheet, scopedVariables) + collectRootVariables(sheet, scopedVariables, input.getOptions().values) return } @@ -239,7 +237,7 @@ function collectProviderStyle ( const sheet = typeof layer.sheet === 'string' ? compileCss(layer.sheet, { mode: 'runtime' }) : layer.sheet - collectRootVariables(sheet, scopedVariables) + collectRootVariables(sheet, scopedVariables, layer.values) } } @@ -271,9 +269,34 @@ function normalizeProviderStyleLayer ( function collectRootVariables ( sheet: CompiledCssSheet, - scopedVariables: Record[] + scopedVariables: Record[], + values: readonly unknown[] = [] ): void { - if (sheet.rootVariables != null) scopedVariables.push(sheet.rootVariables) + if (sheet.rootVariables != null) { + scopedVariables.push(applyLayerValuesToRootVariables(sheet.rootVariables, values)) + } +} + +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 { diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index d49a3c1..3ec4f80 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -33,6 +33,25 @@ const useCommitEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/ +const EMPTY_METADATA = { + hasVars: false, + vars: [], + hasMedia: false, + hasViewportUnits: false, + hasInterpolations: false, + hasDynamicRuntimeDependencies: false, + hasAnimations: false, + hasTransitions: 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 @@ -67,14 +86,10 @@ export function useCssxSheet ( ...context, ...options } - - if (trackerRef.current == null) { - trackerRef.current = new TrackedCssxSheet(sheet, mergedOptions) - } else { - trackerRef.current.update(sheet, mergedOptions) - } - - const tracker = trackerRef.current + const committedTracker = trackerRef.current + const tracker = committedTracker?.matches(sheet, mergedOptions) + ? committedTracker + : new TrackedCssxSheet(sheet, mergedOptions) const renderDependencies = tracker.startRender() useSyncExternalStore( @@ -85,6 +100,7 @@ export function useCssxSheet ( useCommitEffect(() => { tracker.commitRender(renderDependencies) + trackerRef.current = tracker }) return tracker @@ -119,30 +135,42 @@ export function useCssxLayer ( input: CssxLayerHookInput, options: CssxReactConfig = {} ): CssxLayerHookOutput { - if (!input) return input - - if (typeof input === 'string') return useRuntimeCss(input, options) - if (input instanceof TrackedCssxSheet) return input - if (isCompiledSheet(input)) return useCssxSheet(input, options) + 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 + }) - if (isLayerObject(input)) { - const sheet = input.sheet - if (typeof sheet === 'string') { - return { - ...input, - sheet: useRuntimeCss(sheet, options) + 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 } - if (sheet instanceof TrackedCssxSheet) return input as CssxLayerHookOutput - if (isCompiledSheet(sheet)) { - return useCssxSheet(sheet, { - ...options, - values: input.values - }) - } + case 'compiled': + case 'string': + case 'layerCompiled': + return tracker + case 'unknown': + default: + return input as CssxLayerHookOutput } - - return input as CssxLayerHookOutput } export function useCssVariableRaw ( @@ -151,16 +179,20 @@ export function useCssVariableRaw ( ): string | undefined { assertCssVariableName(name) const context = useCssxRuntimeContext() - const dependenciesRef = useRef(createDependencySnapshot()) + const committedDependenciesRef = useRef(createDependencySnapshot()) const result = resolveCssVariableRaw(name, fallback, context.scopedVariables) - dependenciesRef.current = createVariableDependencySnapshot(result) + const renderDependencies = createVariableDependencySnapshot(result) useSyncExternalStore( - listener => subscribeRuntimeStore(listener, () => dependenciesRef.current), + listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current), getRuntimeVersion, getRuntimeVersion ) + useCommitEffect(() => { + committedDependenciesRef.current = renderDependencies + }) + return result.value } @@ -208,6 +240,80 @@ function isLayerObject (value: unknown): value is { ) } +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, diff --git a/packages/css-to-rn/src/react/tracker.ts b/packages/css-to-rn/src/react/tracker.ts index 3d26619..69bc48b 100644 --- a/packages/css-to-rn/src/react/tracker.ts +++ b/packages/css-to-rn/src/react/tracker.ts @@ -52,6 +52,10 @@ export class TrackedCssxSheet implements CssxDependencyCollector { 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 @@ -191,3 +195,39 @@ function cloneDependencySnapshot ( 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 index a68c9a3..bed306f 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -137,6 +137,7 @@ 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 export function createCssxCache (options: { maxEntries?: number } = {}): CssxCache { return { @@ -709,12 +710,36 @@ function collectScopedVariables ( const scopes: Record[] = explicitScopes ? [...explicitScopes] : [] for (const layer of layers) { - if (layer.sheet.rootVariables != null) scopes.push(layer.sheet.rootVariables) + if (layer.sheet.rootVariables != null) { + scopes.push(applyLayerValuesToRootVariables(layer.sheet.rootVariables, layer.values)) + } } return scopes.length > 0 ? scopes : undefined } +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[] diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index a649488..727eb6a 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -331,6 +331,65 @@ describe('@cssxjs/css-to-rn resolver', () => { assert.deepEqual(result.dependencies.vars, ['--inline-color', '--inline-space']) }) + 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; }' diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index 85e93ef..caa779b 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -16,6 +16,7 @@ import { useCssVariable, useCssVariableRaw, useCssxLayer, + useCssxTemplate, variables } from '../../src/web.ts' @@ -379,6 +380,66 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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 @@ -787,4 +848,146 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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() + }) }) From 26f6f2fcc6f1b973595834a29ff15e5cb084d1eb Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Mon, 22 Jun 2026 04:10:58 +0300 Subject: [PATCH 25/37] Support CSS color channel calculations --- packages/css-to-rn/src/colors.ts | 13 ++++++---- packages/css-to-rn/src/values.ts | 24 +++++++++++++++---- packages/css-to-rn/test/engine/values.test.ts | 13 ++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/css-to-rn/src/colors.ts b/packages/css-to-rn/src/colors.ts index 731858a..76def27 100644 --- a/packages/css-to-rn/src/colors.ts +++ b/packages/css-to-rn/src/colors.ts @@ -113,12 +113,17 @@ function mixRgb ( 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 * firstWeight + b.r * secondWeight), - g: round(a.g * firstWeight + b.g * secondWeight), - b: round(a.b * firstWeight + b.b * secondWeight), - alpha: alpha(a.alpha * firstWeight + b.alpha * secondWeight) + 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 }) } diff --git a/packages/css-to-rn/src/values.ts b/packages/css-to-rn/src/values.ts index 1a87c21..adca2c8 100644 --- a/packages/css-to-rn/src/values.ts +++ b/packages/css-to-rn/src/values.ts @@ -272,9 +272,10 @@ function resolveCalcs ( } function evaluateCalc (expression: string): string | null { - if (expression.includes('%')) return null - const hasPx = /(?:^|[^\w.-])[+-]?(?:\d*\.)?\d+px\b/.test(expression) - const normalized = expression.replace(/([+-]?(?:\d*\.)?\d+)px\b/g, '$1') + 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 @@ -353,10 +354,25 @@ function evaluateCalc (expression: string): string | null { skipWhitespace() return result != null && index === normalized.length && Number.isFinite(result) - ? hasPx ? `${result}px` : String(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++) { diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts index 13bb7d9..0cd5dbb 100644 --- a/packages/css-to-rn/test/engine/values.test.ts +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -89,6 +89,19 @@ describe('@cssxjs/css-to-rn value resolver', () => { assert.equal(result.dependencies.dimensions, true) }) + 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)') From a3ebe1d7841c32e2269920b33445ef963ed1d72a Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Mon, 22 Jun 2026 04:37:38 +0300 Subject: [PATCH 26/37] Preserve CSSX React API source types --- packages/css-to-rn/src/index.ts | 2 ++ packages/css-to-rn/src/react-native.ts | 3 +++ packages/css-to-rn/src/react/config.ts | 13 +++++++------ packages/css-to-rn/src/react/cssx.ts | 3 +++ packages/css-to-rn/src/vendor.d.ts | 10 ---------- packages/css-to-rn/src/web.ts | 2 ++ .../css-to-rn/test/types/react-api.test.ts | 19 +++++++++++++++++++ 7 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 packages/css-to-rn/test/types/react-api.test.ts diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts index b3420ca..bf7f027 100644 --- a/packages/css-to-rn/src/index.ts +++ b/packages/css-to-rn/src/index.ts @@ -1,3 +1,5 @@ +/// + export { compileCss, compileCssTemplate diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index f4ea6d4..318c4da 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -1,3 +1,5 @@ +/// + export { compileCss, compileCssTemplate @@ -30,6 +32,7 @@ import { subscribeVariablesForTests, variables } from './react/store.ts' +// @ts-ignore react-native is an optional peer for non-RN consumers. import { Dimensions } from 'react-native' export type { diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts index e4cbdc2..0a3d57f 100644 --- a/packages/css-to-rn/src/react/config.ts +++ b/packages/css-to-rn/src/react/config.ts @@ -2,6 +2,7 @@ import { createContext, createElement, forwardRef, + type ComponentProps, type ComponentType, useEffect, useContext, @@ -127,11 +128,11 @@ export function useCssxComponentTag (): string | null { return useCssxRuntimeContext().componentTag } -export function themed

( +export function themed> ( componentTag: string, - Component: ComponentType

-): ComponentType

{ - const ThemedComponent = forwardRef(function ThemedComponent (props, ref): ReactNode { + Component: C +): C { + const ThemedComponent = forwardRef>(function ThemedComponent (props, ref): ReactNode { const parent = useCssxRuntimeContext() const tracker = useCssxRenderTracker(parent.config) const value = useMemo(() => ({ @@ -146,12 +147,12 @@ export function themed

( createElement(Component, { ...props, ref - } as P & { ref: unknown }) + } as ComponentProps & { ref: unknown }) ) }) ThemedComponent.displayName = `themed(${Component.displayName ?? Component.name ?? componentTag})` - return ThemedComponent as unknown as ComponentType

+ return ThemedComponent as unknown as C } function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet { diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts index 4c136da..77c1b5b 100644 --- a/packages/css-to-rn/src/react/cssx.ts +++ b/packages/css-to-rn/src/react/cssx.ts @@ -50,8 +50,11 @@ export type CssxSheetInput = | CompiledCssSheet | TrackedCssxSheet | CssxReactLayer + | CssxOpaqueSheetRecord | readonly CssxSheetInput[] +export type CssxOpaqueSheetRecord = Record + export interface CssxReactLayer { sheet: string | CompiledCssSheet | TrackedCssxSheet values?: readonly unknown[] diff --git a/packages/css-to-rn/src/vendor.d.ts b/packages/css-to-rn/src/vendor.d.ts index 131ed07..58bf215 100644 --- a/packages/css-to-rn/src/vendor.d.ts +++ b/packages/css-to-rn/src/vendor.d.ts @@ -22,13 +22,3 @@ declare module 'css-mediaquery' { export default mediaQuery } - -declare module 'react-native' { - export const Dimensions: { - get: (dimension: 'window' | 'screen') => { width: number, height: number } - addEventListener: ( - event: 'change', - listener: () => void - ) => { remove: () => void } - } -} diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index 92ca967..d3b52c7 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -1,3 +1,5 @@ +/// + export { compileCss, compileCssTemplate 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) From 87580c937785c022e7f0e06a67ac5e9e5665a70d Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 19:57:41 +0300 Subject: [PATCH 27/37] Document CSS-first StartupJS UI refactor plan --- plan.md | 677 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 677 insertions(+) diff --git a/plan.md b/plan.md index 7d5bd45..5305660 100644 --- a/plan.md +++ b/plan.md @@ -2822,3 +2822,680 @@ These are left to implementation judgment: Do not reopen the high-level decisions in this document unless implementation reveals a concrete blocker. + +## CSS-First StartupJS UI Refactor Workstream + +This workstream supersedes and refines the earlier StartupJS UI migration and +optional Tailwind notes in the "Global Theming And Provider Styles Workstream". +The earlier unified CSS engine work remains valid; this section defines the next +large batch: move StartupJS UI away from Stylus, old palette helpers, separate +component style files, and startupjs-ui-owned style primitives. + +Treat the CSSX, StartupJS, and StartupJS UI PRs as connected draft PRs during +this work. It is acceptable to keep temporary cross-repo development wiring, +such as `resolutions`, local file links, or linked packages, while implementing +and validating the batch. Remove that temporary wiring in a final cleanup pass +before the PRs become merge-ready. Commit and push regularly as meaningful +sub-batches land. + +### Goals + +- Use CSSX standards-oriented CSS as the foundation for StartupJS UI styling. +- Replace Stylus functions, mixins, `$UI` config, and `u`-based scales with CSS + variables, `rem`, `calc()`, `oklch()`, `color-mix()`, `@custom-media`, and + component tag/part overrides. +- Use Tailwind CSS variables as the raw token scale. +- Use shadcn semantic variables as the primary theme override surface. +- Let StartupJS UI components consume semantic `--color-*`, `--spacing`, + `--radius-*`, `--text-*`, breakpoint, and component-specific variables. +- Keep Tailwind utility class runtime support optional and separate from the + token/theme preset. +- Move generic style-related JS bridges into CSSX and re-export them from + `startupjs`. +- Remove style-related helpers from `@startupjs-ui/core`. +- Inline StartupJS UI component styles into component files instead of separate + `.cssx.styl` / `.cssx.css` files. +- Keep `@startupjs-ui/core` only as a small shared non-style type/helper package, + currently for `UIRole`. + +### Non-Goals + +- Do not make bare StartupJS include Tailwind/shadcn themes by default. +- Do not make StartupJS UI depend on Tailwind utility classes internally. +- Do not implement the optional Tailwind utility runtime in this batch unless it + becomes necessary for validation. +- Do not preserve old StartupJS UI theme token names as the new API. This is a + breaking release; users should migrate to the new tokens. +- Do not support Tailwind's non-standard `@theme` syntax in the CSSX compiler. + Theme assets are plain CSSX-compatible CSS. + +### CSSX Theme Assets + +CSSX should ship reusable theme asset entrypoints: + +```ts +import tailwindTheme from 'cssxjs/themes/tailwind' +import shadcnTheme from 'cssxjs/themes/shadcn' +``` + +The entrypoints internally export readable `.cssx.css` assets. Do not document +direct `.cssx.css` imports as public API. Docs can link to the source files as +the reference for all customizable variables. + +Theme source files: + +- `tailwind.cssx.css` + - manually copied/adapted from the current Tailwind `theme.css` + - all Tailwind variables included where CSSX can reasonably represent them + - plain `:root` CSS variables, no `@theme` + - transform Tailwind-specific `--theme(...)` expressions into plain + CSS-compatible variable/fallback expressions or omit only truly irrelevant + unsupported pieces +- `shadcn.cssx.css` + - one default shadcn theme with a dark variant + - light/default values in `:root` + - dark values in `:root.dark` + - semantic override tokens such as `--primary` and `--primary-foreground` + - Tailwind consumption mappings such as `--color-primary: + var(--primary)` and `--color-primary-foreground: + var(--primary-foreground)` + +Do not add an auto-regeneration script. The Tailwind and shadcn variable names +are stable enough for manual updates. Add source comments to the files so future +updates are reviewable. + +StartupJS UI should not duplicate the full Tailwind/shadcn files. It should use +CSSX theme entrypoints and own only its component-specific theme layer. + +### Theme Selection Model + +`CssxProvider` owns theme selection: + +```tsx + + + +``` + +`theme` values: + +- `auto`: default. Uses OS color scheme and applies `dark` if the active + provider styles define a dark theme block. +- `dark`: applies `:root` plus `:root.dark`. +- `default`: applies only `:root`. +- `light`: alias for default unless an explicit `:root.light` block exists. +- any custom name: applies `:root` plus `:root.`. + +Theme variable blocks: + +```css +:root { + --background: oklch(1 0 0); + --color-background: var(--background); +} + +:root.dark { + --background: oklch(0.145 0 0); + --color-background: var(--background); +} +``` + +Rules: + +- `:root` and `:root.` are variable blocks only. +- Only CSS custom property declarations are valid inside them. +- Normal declarations inside root/theme blocks produce diagnostics and are + ignored. +- Provider layers collect `:root` and `:root.` variables into scoped + provider variable maps. +- Component-local sheets should not be used to define global/provider theme + variables. Emit a diagnostic if a component-local sheet contains root/theme + custom properties unless it is explicitly used as a provider layer. +- Bare CSSX has no bundled variables, so `theme='auto'` is a no-op until the app + provides a style layer with `:root.dark`. + +OS color-scheme integration: + +- Web: `matchMedia('(prefers-color-scheme: dark)')`. +- React Native: `Appearance.getColorScheme()` and + `Appearance.addChangeListener`. +- Batch theme-change invalidation through the same runtime store/subscription + system used for variables, media, and dimensions. +- Providers with explicit themes do not need OS color-scheme subscriptions. +- Providers with `theme='auto'` subscribe and update when OS color scheme + changes. + +### Theme-Specific Styles + +Theme-specific normal styles use built-in CSSX theme media aliases, not root +theme blocks: + +```css +Button { + box-shadow: var(--shadow-sm); +} + +@media (--theme-dark) { + Button { + box-shadow: none; + border-color: var(--color-border); + } +} +``` + +Rules: + +- `@media (--theme-dark)` matches active theme `dark`. +- `@media (--theme-light)` and `@media (--theme-default)` match default/light. +- `@media (--theme-)` matches a custom active theme name. +- Built-in `--theme-*` aliases are dynamic and independent of user + `@custom-media` declarations. +- User-defined `@custom-media --theme-*` should produce a diagnostic because it + collides with CSSX's built-in theme media namespace. +- Theme media aliases compose with custom media and ordinary media: + +```css +@media (--theme-dark) and (--breakpoint-desktop) { + Button { border-width: 1px; } +} +``` + +### Custom Media And Breakpoints + +CSSX should support standard `@custom-media`: + +```css +:root { + --mobile: 0rem; + --tablet: var(--breakpoint-md); + --desktop: var(--breakpoint-lg); + --wide: var(--breakpoint-xl); +} + +@custom-media --breakpoint-mobile (width < var(--tablet)); +@custom-media --breakpoint-tablet (width >= var(--tablet)); +@custom-media --breakpoint-desktop (width >= var(--desktop)); +@custom-media --breakpoint-wide (width >= var(--wide)); +``` + +Tailwind raw breakpoint variables should stay available: + +```css +--breakpoint-sm: 40rem; +--breakpoint-md: 48rem; +--breakpoint-lg: 64rem; +--breakpoint-xl: 80rem; +--breakpoint-2xl: 96rem; +``` + +CSSX media evaluation must support Media Queries Level 4 range syntax for the +common width/height comparisons used by custom media: + +- `(width >= 48rem)` +- `(width > 48rem)` +- `(width <= 48rem)` +- `(width < 48rem)` +- same for `height` + +If `css-mediaquery` cannot evaluate range syntax accurately, implement a small +normalizer/evaluator for these comparisons instead of relying on lossy +conversion. + +`useMedia()` moves to CSSX and is re-exported through `startupjs`. + +Behavior: + +- reads active `@custom-media` aliases from provider layers +- includes built-in fallback aliases when none are defined: + - `mobile`: width < 48rem + - `tablet`: width >= 48rem + - `desktop`: width >= 64rem + - `wide`: width >= 80rem +- provider-defined aliases override fallbacks +- returns a map with normalized names: + - `--breakpoint-tablet` -> `media.tablet` + - `--compact` -> `media.compact` +- subscribes only to media/dimension changes that affect the aliases read by the + hook + +### CSSX Color Bridge + +Move generic JS color bridging into CSSX and re-export it from `startupjs`. +StartupJS UI should use this instead of `colorToRGBA` and token helpers from +`@startupjs-ui/core`. + +Use a single API, not separate color and color-mix functions: + +```ts +const color = useCssColor('primary') +const foreground = useCssColor('primary-foreground') +const subtle = useCssColor('primary', 0.15) +const onWhite = useCssColor('primary', { mix: 0.3, with: 'white' }) + +const globalColor = getCssColor('primary') +``` + +Input resolution: + +- `primary` -> `var(--color-primary)` +- `primary-foreground` -> `var(--color-primary-foreground)` +- `var(--custom)` -> exact expression +- raw CSS color -> raw expression +- `--primary` is ambiguous and should not be supported + +Mixing: + +- no mix returns the resolved RN-friendly color value +- numeric mix such as `0.15` means 15% +- string mix such as `'15%'` is allowed +- object form supports `{ mix, with }` +- default `with` is `transparent` +- implemented through the same CSS value/color resolver as `color-mix()` + +Tracking: + +- `useCssColor()` is provider-aware and subscribes to all variables used by the + color expression and mix target. +- `getCssColor()` is an imperative escape hatch for global/default variable + reads and non-React code. It is not provider-aware in the first batch. + +Prefer CSS `var()` and `color-mix()` in component stylesheets/templates. Use +`useCssColor()` only for JS-only bridges to non-CSSX props or inline style +composition that cannot be expressed cleanly in CSS. + +### Deprecated `u` + +Move JS `u()` to CSSX/startupjs as a deprecated helper: + +```ts +u(1) // 8 +``` + +Rules: + +- `1u === 0.5rem === 8px` +- warn once in development: use `rem`, `var(--spacing)`, or CSS instead +- keep it for migration of existing JS inline styles +- StartupJS UI internals should stop using it + +CSSX should continue compiling existing CSS `u` units for compatibility, but +emit deprecated-unit diagnostics in build mode. New StartupJS UI styles should +use: + +- `rem` +- `calc(var(--spacing) * n)` +- Tailwind/shadcn/component CSS variables + +### Tailwind Utility Runtime + +The optional Tailwind utility runtime is a follow-up. This batch should prepare +the interfaces and token model, but should not depend on utility classes. + +When implemented, `tailwind()` should be imported explicitly from a separate +entrypoint and only bundled by clients that import it. + +Utility interoperability requirement: + +- utilities should read active `--color-*` variables dynamically +- if provider styles define `--color-warning`, `bg-warning`, `text-warning`, + `border-warning`, etc. can resolve through that variable +- this works even if `warning` is not in the original Tailwind config +- cache invalidation must include the variables used by resolved utilities +- arbitrary classes like `w-[15px]` belong to the optional utility runtime, not + the base theme layer + +StartupJS UI must not use Tailwind utility classes internally. It uses the same +tokens through normal CSS. + +### StartupJS Provider Integration + +Bare StartupJS: + +- `StartupjsProvider style` feeds CSSX provider styles. +- `StartupjsProvider theme` feeds CSSX theme selection. +- Bare StartupJS does not include Tailwind/shadcn themes automatically. +- Users can explicitly import CSSX theme entrypoints if they want them. + +StartupJS with startupjs-ui installed: + +- startupjs-ui plugin injects the internal `UiProvider` into + `StartupjsProvider`. +- App users configure one place: + +```tsx + + + +``` + +Effective style order should let app overrides beat UI defaults: + +1. `tailwindTheme` +2. `shadcnTheme` +3. `startupjsUiTheme` +4. forwarded `StartupjsProvider style` overrides +5. component-local styles +6. inline props + +`StartupjsProvider style` is the single app customization surface. It can +contain: + +- `:root` token overrides +- `:root.dark` / `:root.` theme overrides +- `@custom-media` +- component tag overrides +- `Component:part(partName)` overrides +- global utility classes if the optional utility runtime is enabled +- app-specific classes + +`UiProvider` is internal to startupjs-ui integration. It may technically remain +usable for standalone startupjs-ui consumption, but docs should center +`StartupjsProvider` for StartupJS apps and `CssxProvider` for standalone CSSX. + +### StartupJS UI Theme + +StartupJS UI should own a small component-specific theme layer, for example: + +```css +:root { + --Button-height-sm: 2rem; + --Button-radius: var(--radius-md); + --User-color-online: var(--color-success); + --User-color-offline: var(--color-muted-foreground); +} + +:root.dark { + --SomeComponent-shadow: none; +} +``` + +Rules: + +- Prefer shadcn semantic tokens and `--color-*` consumption variables. +- Minimize direct consumption of raw Tailwind palette variables in component + CSS. +- Use raw palette variables for component defaults only when there is no good + semantic token. +- Component-specific variables use `--Component-name` / `--Component-name-state` + style, for example `--User-color-online`. +- Component-specific variables should not pollute the global `--color-*` + namespace unless utility classes for them are a deliberate public feature. +- Do not keep compatibility aliases for old StartupJS UI token names as the new + API. This is a breaking migration. + +Docs should link to: + +- CSSX Tailwind token source +- CSSX shadcn token source +- StartupJS UI component token source + +so users have a complete variable reference. + +### StartupJS UI Component Style Refactor + +Stop using separate component style files in StartupJS UI: + +- no component `.cssx.styl` +- no component `.cssx.css` +- no MDX/demo `.cssx.styl` / `.cssx.css` +- keep package-level theme files such as `startupjsUiTheme.cssx.css` + +Default placement: + +- components rendering with `pug` should place static CSS in `style` blocks + inside the `pug` template +- components needing JS interpolation should use local `css` template literals + in the component function +- non-Pug components can use local `css` template literals +- subcomponents should own their own local styles instead of sharing one large + file + +Remove Stylus dependencies from component styles: + +- `$UI` +- `merge()` +- `:export config` +- `radius()` +- `shadow()` +- `fontFamily()` +- `bleed()` +- `web()` +- Stylus loops +- `u` + +Move values: + +- themeable visual values -> CSS variables +- structural constants -> JS constants +- exact JS reads of visual values -> `useCssVariable()` or `useCssColor()` + only when truly needed + +The old `:export config` pattern should disappear. Do not replace it with +another big config object. Customization happens through CSS variables, tag +overrides, part overrides, and per-instance props. + +### StartupJS UI Component JS Refactor + +Refactor component JS as needed while moving styles: + +- replace `colorVariableRequest`, `isColorToken`, and `colorToRGBA` with CSSX + `useCssColor()` and CSS `color-mix()` +- replace `useMedia` imports with CSSX/startupjs `useMedia` +- replace `u()` usage with `rem`, CSS variables, or the deprecated CSSX `u()` + only as a migration fallback +- preserve existing public component props unless they are purely artifacts of + the old style engine +- remove imports and package dependencies on `@startupjs-ui/core` unless they + are for `UIRole` or another non-style shared type + +Visual redesign stance: + +- match old components broadly enough that apps do not feel broken +- prefer Tailwind/shadcn token scale over exact old pixel output +- do not preserve old styling quirks that are clearly worse than a simpler + token-based design +- keep component APIs stable unless there is a strong reason to break them + +### Parts And External Styling + +`part` is the canonical external styling API: + +- `part='root'` maps to `style` +- `part='icon'` maps to `iconStyle` +- `part='text'` maps to `textStyle` +- Babel extracts/threads the props automatically + +Refactor rules: + +- Prefer `part='root'` on root elements so external root `style` works + consistently. +- Prefer semantic parts such as `icon`, `text`, `label`, `content`, `loader`, + `control`, `thumb`, etc. where external styling is useful. +- Do not add `part` to every wrapper. +- If JS needs to read/compose a part style manually, still keep the `part` prop + on the rendered element and manually pass the composed style. +- If current code manually extracts and forwards `iconStyle` / `textStyle` + without modifying it, replace that plumbing with the canonical `part`. +- Audit components for missing root style pass-through. + +Docs should teach styling in this order: + +1. theme variables +2. component tag selectors +3. component `:part()` / `::part()` selectors +4. per-instance `style` / `*Style` props as escape hatches + +### `@startupjs-ui/core` + +`@startupjs-ui/core` remains only as a small non-style internal/shared package. + +Keep: + +- `UIRole` type, because React Native types do not cover all web/ARIA roles + needed by StartupJS UI + +Remove or migrate: + +- `u` -> CSSX/startupjs deprecated helper +- `useMedia` -> CSSX/startupjs +- `colorToRGBA` -> CSSX `useCssColor()` mix support / CSS `color-mix()` +- color token helpers -> CSSX `useCssColor()` +- any style/theme runtime helper + +Public re-export: + +- `startupjs-ui` may re-export `UIRole` type for users writing wrappers around + StartupJS UI components. +- Do not re-export style helpers from `@startupjs-ui/core`. + +### Implementation Order + +Work package by package, but fully migrate each touched package. + +Suggested order: + +1. CSSX theme foundation + - `theme` prop on `CssxProvider` + - `auto` theme and color-scheme subscriptions + - `:root.` variable blocks + - built-in `@media (--theme-*)` + - root/theme diagnostics +2. CSSX custom media and breakpoints + - parse/store `@custom-media` + - range media evaluator + - default breakpoint fallbacks + - CSSX `useMedia()` +3. CSSX theme assets + - `cssxjs/themes/tailwind` + - `cssxjs/themes/shadcn` + - plain `.cssx.css` files + - compile/sample-resolution tests +4. CSSX JS bridges + - `useCssColor()` + - `getCssColor()` + - deprecated `u()` + - deprecated CSS `u` diagnostics +5. StartupJS provider wiring + - `StartupjsProvider theme` + - preserve `StartupjsProvider style` + - no default themes in bare StartupJS +6. StartupJS UI provider/theme wiring + - internal `UiProvider` + - include Tailwind/shadcn/UI themes + - forward `StartupjsProvider style` after UI defaults + - re-export `UIRole` type only +7. StartupJS UI foundational components + - `Div` + - `Span` + - `Icon` +8. StartupJS UI core controls + - `Button` + - `Tag` + - `Badge` + - text/input primitives +9. StartupJS UI remaining packages + - layout/navigation/modal/popover + - forms + - lists/tables + - docs/demo components +10. Optional Tailwind utility runtime follow-up + - only after token/theme/style migration patterns are stable + +For every StartupJS UI package migrated: + +- inline or localize styles +- remove separate style files/imports +- remove old style helper imports +- audit root and semantic parts +- move visual config to CSS variables +- regenerate package declarations +- update docs/examples +- run targeted lint/type/export checks + +### Tests And Validation + +CSSX tests: + +- Tailwind theme asset compiles in build mode +- shadcn theme asset compiles in build mode +- no `@theme` appears in bundled CSSX theme assets +- root/theme blocks accept only custom properties +- `:root.dark` variables override `:root` only when active theme is `dark` +- `theme='auto'` follows mocked OS color scheme and updates subscribers +- `theme='default'` / `theme='light'` disables dark selection +- custom named themes work +- `@media (--theme-dark)` matches active theme +- `@media (--theme-dark) and (--breakpoint-desktop)` composes correctly +- `@custom-media` aliases expand and resolve variables +- range syntax comparisons are inclusive/exclusive correctly +- `useMedia()` returns fallbacks without provider styles +- `useMedia()` uses provider custom media aliases when present +- `useCssColor()` resolves named tokens, `var(...)`, raw colors, and mixes +- `useCssColor()` subscribes only to variables it resolves +- `getCssColor()` works for global/default variables +- JS `u()` warns once in dev +- CSS `u` unit emits deprecation diagnostics in build mode + +StartupJS tests: + +- `StartupjsProvider theme` reaches CSSX +- `StartupjsProvider style` still works without startupjs-ui +- bare StartupJS does not include Tailwind/shadcn themes automatically +- startupjs-ui plugin injects UI provider and forwards `theme`/`style` +- app `StartupjsProvider style` overrides UI default theme vars + +StartupJS UI tests: + +- no component imports style helpers from `@startupjs-ui/core` +- `@startupjs-ui/core` exports/re-exports only approved non-style API +- no component `.cssx.styl` / `.cssx.css` files remain +- no docs/demo `.cssx.styl` / `.cssx.css` files remain +- default UI theme compiles with CSSX +- generated declarations stay correct +- key components preserve root `style` and semantic `*Style` props through + `part` +- key color props resolve through `useCssColor()` +- dark/default theme snapshots or smoke checks cover high-risk components + +General validation: + +- `git diff --check` +- CSSX package tests +- StartupJS provider package tests +- StartupJS UI declaration generation +- StartupJS UI export checker +- targeted eslint for migrated packages +- storybook/manual visual checks for foundational components where practical + +### Documentation + +CSSX docs: + +- theme provider API +- `theme='auto'`, explicit themes, and OS color-scheme behavior +- `:root.` variable blocks +- `@media (--theme-*)` +- theme entrypoints +- `@custom-media`, breakpoint aliases, and range syntax +- `useMedia()` +- `useCssColor()` / `getCssColor()` +- deprecated `u` +- migration notes for `u` units + +StartupJS docs: + +- `StartupjsProvider style` +- `StartupjsProvider theme` +- bare StartupJS does not include themes unless user imports them +- startupjs-ui plugin behavior when UI is installed + +StartupJS UI docs: + +- customizing with CSS variables +- links to Tailwind, shadcn, and StartupJS UI theme source files +- component tag overrides +- `:part()` / `::part()` overrides +- per-instance `style` and `*Style` props as escape hatches +- migration from old palette/Colors/CssVariables/useThemeColor/u/Stylus config +- examples should use inline `pug` style blocks or local `css` templates, not + separate component style files From ec40d98de2eaebdd998eadc778a0033f78ea412b Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 20:11:53 +0300 Subject: [PATCH 28/37] Add CSSX theme foundation --- packages/css-to-rn/src/compiler.ts | 55 ++++-- packages/css-to-rn/src/react-native.ts | 20 ++ packages/css-to-rn/src/react/config.ts | 178 +++++++++++++++--- packages/css-to-rn/src/react/cssx.ts | 1 + packages/css-to-rn/src/react/hooks.ts | 3 +- packages/css-to-rn/src/react/index.ts | 3 + packages/css-to-rn/src/react/store.ts | 110 +++++++++++ packages/css-to-rn/src/resolve.ts | 144 ++++++++++++-- packages/css-to-rn/src/types.ts | 3 + packages/css-to-rn/src/web.ts | 5 + .../css-to-rn/test/engine/compiler.test.ts | 27 +++ .../css-to-rn/test/engine/resolve.test.ts | 81 ++++++++ .../css-to-rn/test/react/tracking.test.ts | 51 +++++ 13 files changed, 637 insertions(+), 44 deletions(-) diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts index 2f9f061..9af8aab 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -21,6 +21,8 @@ import type { 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 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([ @@ -92,13 +94,14 @@ function compileCssInternal ( const rules: CssxRule[] = [] const keyframes: Record = {} const rootVariables: Record = {} + const themeVariables: 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, state, orderRef(() => order++), isTemplate, exports, options.target) + compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, rootVariables, themeVariables, state, orderRef(() => order++), isTemplate, exports, options.target) continue } @@ -109,7 +112,7 @@ function compileCssInternal ( 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, state, orderRef(() => order++), isTemplate, exports, options.target) + compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, rootVariables, themeVariables, state, orderRef(() => order++), isTemplate, exports, options.target) } continue } @@ -132,7 +135,7 @@ function compileCssInternal ( } } - const metadata = buildMetadata(rules, keyframes, rootVariables, isTemplate) + const metadata = buildMetadata(rules, keyframes, rootVariables, themeVariables, isTemplate) return createSheet({ id, sourceId, @@ -140,6 +143,7 @@ function compileCssInternal ( rules, keyframes, rootVariables: Object.keys(rootVariables).length > 0 ? rootVariables : undefined, + themeVariables: Object.keys(themeVariables).length > 0 ? themeVariables : undefined, exports: Object.keys(exports).length > 0 ? exports : undefined, metadata, diagnostics: state.diagnostics, @@ -153,6 +157,7 @@ function compileRuleList ( media: string | null, output: CssxRule[], rootVariables: Record, + themeVariables: Record>, state: CompileState, nextOrder: () => number, isTemplate: boolean, @@ -171,19 +176,35 @@ function compileRuleList ( if (media != null) { addDiagnostic(state, diagnostic( 'UNSUPPORTED_SELECTOR', - `Unsupported selector "${selector}" inside media query ignored. CSSX provider :root variables are currently unconditional.`, + `Unsupported selector "${selector}" inside media query ignored. CSSX provider root variables are unconditional.`, 'warning' )) continue } - compileRootVariables(declarations, rootVariables, state) + 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 bare :root for provider CSS variables.`, + `Unsupported selector "${selector}" ignored. CSSX supports only :root and :root. for provider CSS variables.`, 'warning' )) continue @@ -213,7 +234,8 @@ function compileRuleList ( function compileRootVariables ( declarations: CssDeclarationAst[], rootVariables: Record, - state: CompileState + state: CompileState, + selector: string ): void { for (const declaration of declarations) { if (declaration.type !== 'declaration') continue @@ -222,8 +244,8 @@ function compileRootVariables ( if (!VAR_NAME_RE.test(property)) { addDiagnostic(state, diagnostic( - 'INVALID_DECLARATION', - `Only CSS custom properties are supported inside :root. Declaration "${property}" ignored.`, + 'INVALID_THEME_BLOCK', + `Only CSS custom properties are supported inside ${selector}. Declaration "${property}" ignored.`, 'warning', positionOf(declaration) )) @@ -389,6 +411,7 @@ function validateMedia ( } try { + if (THEME_MEDIA_RE.test(rule.media ?? '')) return true mediaQuery.parse(rule.media ?? '') return true } catch (error) { @@ -406,6 +429,7 @@ function buildMetadata ( rules: CssxRule[], keyframes: Record, rootVariables: Record, + themeVariables: Record>, isTemplate: boolean ): CssxMetadata { const vars = new Set() @@ -426,6 +450,12 @@ function buildMetadata ( 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 + } + } function scanDeclarations (declarations: CssxDeclaration[]): void { for (const declaration of declarations) { @@ -445,7 +475,8 @@ function buildMetadata ( hasInterpolations, hasDynamicRuntimeDependencies: vars.size > 0 || hasMedia || hasViewportUnits || hasInterpolations, hasAnimations, - hasTransitions + hasTransitions, + hasThemes: Object.keys(themeVariables).length > 0 } } @@ -486,6 +517,7 @@ function createSheet (input: Partial & { rules: input.rules ?? [], keyframes: input.keyframes ?? {}, rootVariables: input.rootVariables, + themeVariables: input.themeVariables, exports: input.exports, metadata: input.metadata ?? { hasVars: false, @@ -495,7 +527,8 @@ function createSheet (input: Partial & { hasInterpolations: false, hasDynamicRuntimeDependencies: false, hasAnimations: false, - hasTransitions: false + hasTransitions: false, + hasThemes: false }, diagnostics: input.diagnostics, error: input.error diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index 318c4da..8dd2162 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -21,12 +21,14 @@ import { createTrackedCssxSheet } from './react/tracker.ts' import { + configureColorSchemeAdapter, configureDimensionsAdapter, configureMediaQueryAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, + setColorSchemeForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, @@ -34,6 +36,8 @@ import { } 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' export type { CompileCssOptions, @@ -56,6 +60,7 @@ export type { TrackedCssxSheetOptions } from './react/tracker.ts' export type { + CssxColorSchemeAdapter, CssxVariableStore } from './react/store.ts' @@ -83,6 +88,7 @@ export { variables } +installReactNativeColorSchemeAdapter() installReactNativeDimensionsAdapter() export function cssx ( @@ -137,16 +143,30 @@ export function useCssxTemplate ( export const __cssxInternals = { clearRawCssCacheForTests, + configureColorSchemeAdapterForTests: configureColorSchemeAdapter, configureDimensionsAdapterForTests: configureDimensionsAdapter, configureMediaQueryAdapterForTests: configureMediaQueryAdapter, createTrackedCssxSheet, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, + 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: () => { diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts index 0a3d57f..31ae18e 100644 --- a/packages/css-to-rn/src/react/config.ts +++ b/packages/css-to-rn/src/react/config.ts @@ -15,8 +15,11 @@ import { import { compileCss } from '../compiler.ts' import type { CompiledCssSheet } from '../types.ts' import { + getColorScheme, + getColorSchemeVersion, getRuntimeConfig, setRuntimeConfig, + subscribeColorScheme, type CssxRuntimeConfig } from './store.ts' import { @@ -52,6 +55,9 @@ export interface CssxRuntimeContextValue { layers: CssxRuntimeLayerInput[] scopedVariables: Record[] componentTag: string | null + theme: string + themePreference: string + themeNames: string[] } export type CssxRuntimeLayerInput = @@ -63,6 +69,7 @@ export type CssxRuntimeLayerInput = export interface CssxProviderProps { value?: CssxReactConfig style?: CssxProviderStyleInput + theme?: string children?: ReactNode } @@ -78,7 +85,8 @@ const EMPTY_METADATA: CssxMetadata = { hasInterpolations: false, hasDynamicRuntimeDependencies: false, hasAnimations: false, - hasTransitions: false + hasTransitions: false, + hasThemes: false } const EMPTY_TRACKING_SHEET: CompiledCssSheet = { version: 1, @@ -101,15 +109,37 @@ export function CssxProvider (props: CssxProviderProps): ReactNode { () => 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 = props.theme ?? parent.themePreference + const colorSchemeVersion = useAutoThemeColorSchemeVersion(themePreference) + const theme = useMemo( + () => resolveProviderTheme(themePreference, themeNames), + [themePreference, themeNames, colorSchemeVersion] + ) + const scopedVariables = useMemo(() => { + const scopes = [...parent.scopedVariables] + collectProviderRootVariables(providerStyles.layers, scopes, theme) + return scopes + }, [parent.scopedVariables, providerStyles.layers, theme]) const value = useMemo(() => ({ config: { ...parent.config, ...(props.value ?? {}) }, - layers: parent.layers.concat(providerStyles.layers), - scopedVariables: parent.scopedVariables.concat(providerStyles.scopedVariables), - componentTag: parent.componentTag - }), [parent, props.value, providerStyles]) + layers, + scopedVariables, + componentTag: parent.componentTag, + theme, + themePreference, + themeNames + }), [parent.config, parent.componentTag, props.value, layers, scopedVariables, theme, themePreference, themeNames]) return createElement(CssxRuntimeContext.Provider, { value @@ -182,53 +212,56 @@ export function getDefaultCssxRuntimeContext (): CssxRuntimeContextValue { config: getRuntimeConfig(), layers: [], scopedVariables: [], - componentTag: null + componentTag: null, + theme: 'default', + themePreference: 'auto', + themeNames: [] } } function normalizeProviderStyles ( style: CssxProviderStyleInput -): { layers: CssxRuntimeLayerInput[], scopedVariables: Record[] } { +): { layers: CssxRuntimeLayerInput[], themeNames: string[] } { const layers: CssxRuntimeLayerInput[] = [] - const scopedVariables: Record[] = [] + const themeNames = new Set() - collectProviderStyle(style, layers, scopedVariables) + collectProviderStyle(style, layers, themeNames) return { layers, - scopedVariables + themeNames: Array.from(themeNames).sort() } } function collectProviderStyle ( input: CssxProviderStyleInput, layers: CssxRuntimeLayerInput[], - scopedVariables: Record[] + themeNames: Set ): void { if (!input) return if (Array.isArray(input)) { - for (const item of input) collectProviderStyle(item, layers, scopedVariables) + for (const item of input) collectProviderStyle(item, layers, themeNames) return } if (typeof input === 'string') { const sheet = compileCss(input, { mode: 'runtime' }) layers.push(sheet) - collectRootVariables(sheet, scopedVariables) + collectThemeNames(sheet, themeNames) return } if (isTrackedCssxSheet(input)) { const sheet = input.getSheet() layers.push({ sheet, cacheKey: input }) - collectRootVariables(sheet, scopedVariables, input.getOptions().values) + collectThemeNames(sheet, themeNames) return } if (isCompiledSheet(input)) { layers.push(input) - collectRootVariables(input, scopedVariables) + collectThemeNames(input, themeNames) return } @@ -238,7 +271,7 @@ function collectProviderStyle ( const sheet = typeof layer.sheet === 'string' ? compileCss(layer.sheet, { mode: 'runtime' }) : layer.sheet - collectRootVariables(sheet, scopedVariables, layer.values) + collectThemeNames(sheet, themeNames) } } @@ -268,16 +301,119 @@ function normalizeProviderStyleLayer ( } } -function collectRootVariables ( - sheet: CompiledCssSheet, +function collectProviderRootVariables ( + layers: readonly CssxRuntimeLayerInput[], scopedVariables: Record[], - values: readonly unknown[] = [] + 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.rootVariables != null) { - scopedVariables.push(applyLayerValuesToRootVariables(sheet.rootVariables, values)) + if (sheet.themeVariables == null) return + for (const name of Object.keys(sheet.themeVariables)) themeNames.add(name) +} + +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[] diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts index 77c1b5b..ec6aa42 100644 --- a/packages/css-to-rn/src/react/cssx.ts +++ b/packages/css-to-rn/src/react/cssx.ts @@ -90,6 +90,7 @@ export function cssx ( defaultVariables: getDefaultVariableValues(), dimensions: getDimensions(), mediaQueryEvaluator: getMediaQueryEvaluator(), + theme: runtimeContext.theme, cache: options.cache ?? normalized.cache }) diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index 3ec4f80..60de946 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -41,7 +41,8 @@ const EMPTY_METADATA = { hasInterpolations: false, hasDynamicRuntimeDependencies: false, hasAnimations: false, - hasTransitions: false + hasTransitions: false, + hasThemes: false } const EMPTY_LAYER_SHEET: CompiledCssSheet = { version: 1, diff --git a/packages/css-to-rn/src/react/index.ts b/packages/css-to-rn/src/react/index.ts index 67da398..0e323a7 100644 --- a/packages/css-to-rn/src/react/index.ts +++ b/packages/css-to-rn/src/react/index.ts @@ -28,8 +28,10 @@ export { export { defaultVariables, flushMicrotasksForTests, + configureColorSchemeAdapter, getRuntimeSubscriberCountForTests, resetStoreForTests, + setColorSchemeForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, @@ -54,6 +56,7 @@ export type { } from './hooks.ts' export type { CssxDependencySnapshot, + CssxColorSchemeAdapter, CssxVariableStore, CssxRuntimeConfig } from './store.ts' diff --git a/packages/css-to-rn/src/react/store.ts b/packages/css-to-rn/src/react/store.ts index 82ae7fc..9b17b30 100644 --- a/packages/css-to-rn/src/react/store.ts +++ b/packages/css-to-rn/src/react/store.ts @@ -19,6 +19,13 @@ export interface CssxMediaQueryAdapter { subscribe?: (query: string, listener: () => void) => () => void } +export type CssxColorScheme = 'light' | 'dark' + +export interface CssxColorSchemeAdapter { + get: () => CssxColorScheme | null | undefined + subscribe: (listener: () => void) => () => void +} + export interface CssxDependencySnapshot { vars: Map media: Map @@ -55,6 +62,7 @@ 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 pendingVariableNames = new Set() const retainedMediaQueries = new Map 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 dimensions = readWindowDimensions() let dimensionsVersion = 0 let pendingDimensionsChanged = false @@ -139,6 +151,40 @@ export function configureMediaQueryAdapter ( markMediaChanged() } +export function configureColorSchemeAdapter ( + adapter: CssxColorSchemeAdapter | null +): void { + if (colorSchemeAdapter === adapter) return + removeColorSchemeListener() + colorSchemeAdapter = adapter + applyColorScheme(readColorScheme()) + if (colorSchemeSubscribers.size > 0) ensureColorSchemeListener() +} + +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 getMediaQueryEvaluator (): (query: string) => boolean { return query => evaluateMediaQuery(query) } @@ -264,6 +310,11 @@ export function resetStoreForTests (): void { releaseAllRetainedMediaQueries() dimensionsAdapter = null mediaQueryAdapter = null + removeColorSchemeListener() + colorSchemeAdapter = null + colorScheme = 'light' + colorSchemeVersion = 0 + colorSchemeSubscribers.clear() dimensions = FALLBACK_DIMENSIONS dimensionsVersion = 0 pendingDimensionsChanged = false @@ -391,6 +442,16 @@ function applyDimensions (next: { width: number, height: number }): void { 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 markMediaChanged (): void { pendingMediaChanged = true scheduleNotification() @@ -559,6 +620,55 @@ function readWindowDimensions (): { width: number, height: number } { } } +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 && diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts index bed306f..f405026 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -49,6 +49,7 @@ export interface ResolveCssxOptions { mediaQueryEvaluator?: CssxMediaQueryEvaluator target?: CssxTarget componentTag?: string | null + theme?: string | null cache?: boolean | CssxCache cacheMaxEntries?: number } @@ -121,6 +122,7 @@ interface ResolutionContext { dimensions?: CssxDimensions mediaQueryEvaluator?: CssxMediaQueryEvaluator componentTag?: string | null + theme: string dependencies: MutableDependencies diagnostics: CssxDiagnostic[] } @@ -138,6 +140,7 @@ 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_-]+)\)$/ export function createCssxCache (options: { maxEntries?: number } = {}): CssxCache { return { @@ -187,7 +190,8 @@ export function resolveCssx (options: ResolveCssxOptions): ResolveCssxResult { if (cached && sameValues(cached.values, values)) { const currentSignature = createDynamicSignature( cached.result.dependencies, - options + options, + layers ) if (currentSignature === cached.dynamicSignature) { return { @@ -198,7 +202,7 @@ export function resolveCssx (options: ResolveCssxOptions): ResolveCssxResult { } const result = resolveCssxUncached(options, layers, classNames) - const dynamicSignature = createDynamicSignature(result.dependencies, options) + const dynamicSignature = createDynamicSignature(result.dependencies, options, layers) if (cache && stableKey) { remember(cache, stableKey, { @@ -216,7 +220,7 @@ function resolveCssxUncached ( layers: readonly NormalizedLayer[], classNames: readonly string[] ): ResolveCssxResult { - const scopedVariables = collectScopedVariables(options.scopedVariables, layers) + const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme) const context: ResolutionContext = { target: options.target ?? 'react-native', variables: options.variables, @@ -225,6 +229,7 @@ function resolveCssxUncached ( dimensions: options.dimensions, mediaQueryEvaluator: options.mediaQueryEvaluator, componentTag: options.componentTag ?? null, + theme: normalizeTheme(options.theme), dependencies: createDependencies(), diagnostics: [], } @@ -449,18 +454,50 @@ function ruleMatchesMedia ( const query = stripMediaPrefix(rule.media) context.dependencies.media.add(query) - return matchesMediaQuery(query, context.dimensions, context.mediaQueryEvaluator) + return matchesMediaQuery(query, context.dimensions, context.mediaQueryEvaluator, context.theme) } function matchesMediaQuery ( query: string, dimensions: CssxDimensions | undefined, - evaluator?: CssxMediaQueryEvaluator + evaluator?: CssxMediaQueryEvaluator, + theme?: string | null ): boolean { - if (evaluator) return evaluator(query, dimensions) + const normalized = stripMediaPrefix(query) + const branches = splitTopLevelComma(normalized) + if (branches.length > 1) { + return branches.some(branch => matchesSingleMediaQuery(branch, dimensions, evaluator, theme)) + } + + return matchesSingleMediaQuery(normalized, dimensions, evaluator, theme) +} + +function matchesSingleMediaQuery ( + query: string, + dimensions: CssxDimensions | undefined, + evaluator: CssxMediaQueryEvaluator | undefined, + theme: string | null | undefined +): 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(theme))) return false + continue + } + if (trimmed) rest.push(trimmed) + } + + if (rest.length === 0) return true + + const restQuery = rest.join(' and ') + if (evaluator) return evaluator(restQuery, dimensions) try { - return mediaQuery.match(query, mediaValues(dimensions)) + return mediaQuery.match(restQuery, mediaValues(dimensions)) } catch { return false } @@ -484,6 +521,72 @@ 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}Style` : 'style' } @@ -664,13 +767,16 @@ function createStableKey ( function createDynamicSignature ( dependencies: ResolveCssxDependencies, - options: ResolveCssxOptions + options: ResolveCssxOptions, + layers: readonly NormalizedLayer[] ): string { + const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme) return JSON.stringify({ + theme: normalizeTheme(options.theme), vars: dependencies.vars.map(name => [ name, valueFromRecord(options.variables, name) ?? - valueFromScopedRecords(options.scopedVariables, name) ?? + valueFromScopedRecords(scopedVariables, name) ?? valueFromRecord(options.defaultVariables, name) ]), dimensions: dependencies.dimensions @@ -682,7 +788,7 @@ function createDynamicSignature ( : undefined, media: dependencies.media.map(query => [ query, - matchesMediaQuery(query, options.dimensions, options.mediaQueryEvaluator) + matchesMediaQuery(query, options.dimensions, options.mediaQueryEvaluator, options.theme) ]) }) } @@ -705,19 +811,35 @@ function flattenLayerValues (layers: readonly NormalizedLayer[]): readonly unkno function collectScopedVariables ( explicitScopes: readonly Record[] | undefined, - layers: readonly NormalizedLayer[] + 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 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[] diff --git a/packages/css-to-rn/src/types.ts b/packages/css-to-rn/src/types.ts index 00bef76..551958b 100644 --- a/packages/css-to-rn/src/types.ts +++ b/packages/css-to-rn/src/types.ts @@ -15,6 +15,7 @@ export type CssxDiagnosticCode = | 'UNSUPPORTED_CALC' | 'UNSUPPORTED_BACKGROUND_IMAGE' | 'UNSUPPORTED_BACKGROUND_SHORTHAND' + | 'INVALID_THEME_BLOCK' export interface CssxDiagnostic { level: CssxDiagnosticLevel @@ -48,6 +49,7 @@ export interface CssxMetadata { hasDynamicRuntimeDependencies: boolean hasAnimations: boolean hasTransitions: boolean + hasThemes: boolean } export interface CompiledCssSheet { @@ -58,6 +60,7 @@ export interface CompiledCssSheet { rules: CssxRule[] keyframes: Record rootVariables?: Record + themeVariables?: Record> exports?: Record metadata: CssxMetadata diagnostics: CssxDiagnostic[] diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index d3b52c7..d812f43 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -21,12 +21,14 @@ import { createTrackedCssxSheet } from './react/tracker.ts' import { + configureColorSchemeAdapter, configureDimensionsAdapter, configureMediaQueryAdapter, defaultVariables, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, + setColorSchemeForTests, setDefaultVariables, setDimensionsForTests, subscribeVariablesForTests, @@ -54,6 +56,7 @@ export type { TrackedCssxSheetOptions } from './react/tracker.ts' export type { + CssxColorSchemeAdapter, CssxVariableStore } from './react/store.ts' @@ -133,12 +136,14 @@ export function useCssxTemplate ( export const __cssxInternals = { clearRawCssCacheForTests, + configureColorSchemeAdapterForTests: configureColorSchemeAdapter, configureDimensionsAdapterForTests: configureDimensionsAdapter, configureMediaQueryAdapterForTests: configureMediaQueryAdapter, createTrackedCssxSheet, flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, + setColorSchemeForTests, setDimensionsForTests, subscribeVariablesForTests } diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts index a4bb9af..0f6db53 100644 --- a/packages/css-to-rn/test/engine/compiler.test.ts +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -58,6 +58,33 @@ describe('@cssxjs/css-to-rn compiler IR', () => { 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('maps hover and active pseudos to logical part aliases', () => { const sheet = compileCss(` .root:hover { color: red; } diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index 727eb6a..5cd5f8a 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -125,6 +125,87 @@ describe('@cssxjs/css-to-rn resolver', () => { 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, [ + '(--theme-dark)', + '(--theme-dark) and (min-width: 600px)' + ]) + }) + it('resolves template interpolation values through one cache slot', () => { const sheet = compileCssTemplate('.button { color: var(--__cssx_dynamic_0); }') const cache = createCssxCache() diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index caa779b..58181a3 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -380,6 +380,57 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { reset() }) + it('updates auto provider theme from color scheme changes', async () => { + reset() + __cssxInternals.setColorSchemeForTests('light') + let latest: unknown + let latestVar: unknown + let renders = 0 + let root: Root | undefined + const container = document.createElement('div') + document.body.appendChild(container) + + function Component (): React.ReactNode { + renders += 1 + 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') + assert.equal(renders, 1) + + await act(async () => { + __cssxInternals.setColorSchemeForTests('dark') + }) + + assert.deepEqual(latest, { style: { color: 'black' } }) + assert.equal(latestVar, 'black') + 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; }') From 445c540f0b46a7e0ba24a420d83d4a2d1d4782e5 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 20:20:59 +0300 Subject: [PATCH 29/37] Add CSSX custom media support --- packages/css-to-rn/src/compiler.ts | 73 +++++- packages/css-to-rn/src/react-native.ts | 1 + packages/css-to-rn/src/react/config.ts | 40 ++- packages/css-to-rn/src/react/cssx.ts | 4 +- packages/css-to-rn/src/react/hooks.ts | 125 +++++++++- packages/css-to-rn/src/react/index.ts | 1 + packages/css-to-rn/src/resolve.ts | 234 ++++++++++++++++-- packages/css-to-rn/src/types.ts | 3 + packages/css-to-rn/src/web.ts | 1 + .../css-to-rn/test/engine/compiler.test.ts | 16 ++ .../css-to-rn/test/engine/resolve.test.ts | 56 +++++ .../css-to-rn/test/react/tracking.test.ts | 61 +++++ packages/cssxjs/index.d.ts | 1 + packages/cssxjs/index.js | 1 + packages/cssxjs/runtime/react-native.js | 1 + packages/cssxjs/runtime/web.js | 1 + 16 files changed, 588 insertions(+), 31 deletions(-) diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts index 9af8aab..6a48f3a 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -23,6 +23,9 @@ 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([ @@ -95,6 +98,7 @@ function compileCssInternal ( const keyframes: Record = {} const rootVariables: Record = {} const themeVariables: Record> = {} + const customMedia: Record = {} const exports: Record = {} let order = 0 @@ -125,6 +129,11 @@ function compileCssInternal ( continue } + if (rule.type === 'custom-media') { + compileCustomMedia(rule as CssCustomMediaAst, customMedia, state, isTemplate) + continue + } + if (rule.type !== 'comment') { addDiagnostic(state, diagnostic( 'UNSUPPORTED_AT_RULE', @@ -135,7 +144,7 @@ function compileCssInternal ( } } - const metadata = buildMetadata(rules, keyframes, rootVariables, themeVariables, isTemplate) + const metadata = buildMetadata(rules, keyframes, rootVariables, themeVariables, customMedia, isTemplate) return createSheet({ id, sourceId, @@ -144,6 +153,7 @@ function compileCssInternal ( 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, @@ -278,6 +288,48 @@ function compileExports ( } } +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, @@ -411,7 +463,7 @@ function validateMedia ( } try { - if (THEME_MEDIA_RE.test(rule.media ?? '')) return true + if (THEME_MEDIA_RE.test(rule.media ?? '') || MEDIA_RANGE_RE.test(rule.media ?? '')) return true mediaQuery.parse(rule.media ?? '') return true } catch (error) { @@ -430,6 +482,7 @@ function buildMetadata ( keyframes: Record, rootVariables: Record, themeVariables: Record>, + customMedia: Record, isTemplate: boolean ): CssxMetadata { const vars = new Set() @@ -456,6 +509,7 @@ function buildMetadata ( 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) { @@ -476,7 +530,8 @@ function buildMetadata ( hasDynamicRuntimeDependencies: vars.size > 0 || hasMedia || hasViewportUnits || hasInterpolations, hasAnimations, hasTransitions, - hasThemes: Object.keys(themeVariables).length > 0 + hasThemes: Object.keys(themeVariables).length > 0, + hasCustomMedia: Object.keys(customMedia).length > 0 } } @@ -518,6 +573,7 @@ function createSheet (input: Partial & { keyframes: input.keyframes ?? {}, rootVariables: input.rootVariables, themeVariables: input.themeVariables, + customMedia: input.customMedia, exports: input.exports, metadata: input.metadata ?? { hasVars: false, @@ -528,7 +584,8 @@ function createSheet (input: Partial & { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, - hasThemes: false + hasThemes: false, + hasCustomMedia: false }, diagnostics: input.diagnostics, error: input.error @@ -557,7 +614,7 @@ interface CssAst { } } -type CssRuleAst = CssStyleRuleAst | CssMediaAst | CssKeyframesAst | CssUnsupportedAst +type CssRuleAst = CssStyleRuleAst | CssMediaAst | CssKeyframesAst | CssCustomMediaAst | CssUnsupportedAst interface CssPositioned { position?: { @@ -590,6 +647,12 @@ interface CssKeyframesAst extends CssPositioned { }> } +interface CssCustomMediaAst extends CssPositioned { + type: 'custom-media' + name?: string + media?: string +} + interface CssDeclarationAst extends CssPositioned { type: 'declaration' | string property?: string diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index 8dd2162..5e2323c 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -75,6 +75,7 @@ export { export { getCssVariable, getCssVariableRaw, + useMedia, useCssVariable, useCssVariableRaw } from './react/hooks.ts' diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts index 31ae18e..bb3ed90 100644 --- a/packages/css-to-rn/src/react/config.ts +++ b/packages/css-to-rn/src/react/config.ts @@ -54,6 +54,7 @@ export interface CssxRuntimeContextValue { config: CssxReactConfig layers: CssxRuntimeLayerInput[] scopedVariables: Record[] + customMedia: Record componentTag: string | null theme: string themePreference: string @@ -86,7 +87,8 @@ const EMPTY_METADATA: CssxMetadata = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, - hasThemes: false + hasThemes: false, + hasCustomMedia: false } const EMPTY_TRACKING_SHEET: CompiledCssSheet = { version: 1, @@ -128,6 +130,13 @@ export function CssxProvider (props: CssxProviderProps): ReactNode { 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, @@ -135,11 +144,12 @@ export function CssxProvider (props: CssxProviderProps): ReactNode { }, layers, scopedVariables, + customMedia, componentTag: parent.componentTag, theme, themePreference, themeNames - }), [parent.config, parent.componentTag, props.value, layers, scopedVariables, theme, themePreference, themeNames]) + }), [parent.config, parent.componentTag, props.value, layers, scopedVariables, customMedia, theme, themePreference, themeNames]) return createElement(CssxRuntimeContext.Provider, { value @@ -212,6 +222,7 @@ export function getDefaultCssxRuntimeContext (): CssxRuntimeContextValue { config: getRuntimeConfig(), layers: [], scopedVariables: [], + customMedia: {}, componentTag: null, theme: 'default', themePreference: 'auto', @@ -221,27 +232,30 @@ export function getDefaultCssxRuntimeContext (): CssxRuntimeContextValue { function normalizeProviderStyles ( style: CssxProviderStyleInput -): { layers: CssxRuntimeLayerInput[], themeNames: string[] } { +): { layers: CssxRuntimeLayerInput[], themeNames: string[], customMedia: Record } { const layers: CssxRuntimeLayerInput[] = [] const themeNames = new Set() + const customMedia: Record = {} - collectProviderStyle(style, layers, themeNames) + collectProviderStyle(style, layers, themeNames, customMedia) return { layers, - themeNames: Array.from(themeNames).sort() + themeNames: Array.from(themeNames).sort(), + customMedia } } function collectProviderStyle ( input: CssxProviderStyleInput, layers: CssxRuntimeLayerInput[], - themeNames: Set + themeNames: Set, + customMedia: Record ): void { if (!input) return if (Array.isArray(input)) { - for (const item of input) collectProviderStyle(item, layers, themeNames) + for (const item of input) collectProviderStyle(item, layers, themeNames, customMedia) return } @@ -249,6 +263,7 @@ function collectProviderStyle ( const sheet = compileCss(input, { mode: 'runtime' }) layers.push(sheet) collectThemeNames(sheet, themeNames) + collectCustomMedia(sheet, customMedia) return } @@ -256,12 +271,14 @@ function collectProviderStyle ( 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 } @@ -272,6 +289,7 @@ function collectProviderStyle ( ? compileCss(layer.sheet, { mode: 'runtime' }) : layer.sheet collectThemeNames(sheet, themeNames) + collectCustomMedia(sheet, customMedia) } } @@ -357,6 +375,14 @@ function collectThemeNames ( 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 diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts index ec6aa42..9b387c7 100644 --- a/packages/css-to-rn/src/react/cssx.ts +++ b/packages/css-to-rn/src/react/cssx.ts @@ -246,7 +246,7 @@ function isCompiledSheet (value: unknown): value is CompiledCssSheet { function recordDependencies ( collector: CssxDependencyCollector, - result: { dependencies: { vars: string[], dimensions: boolean, media: string[] } } + result: { dependencies: { vars: string[], dimensions: boolean, media: string[], mediaMatches?: Record } } ): void { for (const name of result.dependencies.vars) { collector.recordVariable(name, getVariableVersion(name)) @@ -257,6 +257,6 @@ function recordDependencies ( } for (const query of result.dependencies.media) { - collector.recordMedia(query, evaluateMediaQuery(query)) + collector.recordMedia(query, result.dependencies.mediaMatches?.[query] ?? evaluateMediaQuery(query)) } } diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index 60de946..7c59c0b 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -6,6 +6,7 @@ import { useSyncExternalStore } from 'react' import { compileCss } from '../compiler.ts' +import { evaluateCssxMediaQuery } from '../resolve.ts' import type { CompiledCssSheet } from '../types.ts' import { useCssxConfig, @@ -21,9 +22,11 @@ import { getDefaultVariableValues, getDimensions, getDimensionsVersion, + getMediaQueryEvaluator, getRuntimeVersion, getVariableValues, getVariableVersion, + retainMediaQuery, subscribeRuntimeStore, type CssxDependencySnapshot } from './store.ts' @@ -33,6 +36,12 @@ const useCommitEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/ +const DEFAULT_CUSTOM_MEDIA: Record = { + '--breakpoint-mobile': '(width < 48rem)', + '--breakpoint-tablet': '(width >= 48rem)', + '--breakpoint-desktop': '(width >= 64rem)', + '--breakpoint-wide': '(width >= 80rem)' +} const EMPTY_METADATA = { hasVars: false, vars: [], @@ -42,7 +51,8 @@ const EMPTY_METADATA = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, - hasThemes: false + hasThemes: false, + hasCustomMedia: false } const EMPTY_LAYER_SHEET: CompiledCssSheet = { version: 1, @@ -221,6 +231,34 @@ export function getCssVariable ( return value == null ? value : coerceCssValue(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 && @@ -342,6 +380,91 @@ function createVariableDependencySnapshot ( return dependencies } +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 index 0e323a7..a07ee5e 100644 --- a/packages/css-to-rn/src/react/index.ts +++ b/packages/css-to-rn/src/react/index.ts @@ -16,6 +16,7 @@ export { getCssVariableRaw, useCssVariable, useCssVariableRaw, + useMedia, useRuntimeCss, useCssxSheet, useCssxTemplate diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts index f405026..d205e38 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -87,6 +87,7 @@ export interface ResolveCssxDependencies { vars: string[] dimensions: boolean media: string[] + mediaMatches?: Record sheets: string[] } @@ -110,7 +111,7 @@ interface NormalizedLayer { interface MutableDependencies { vars: Set dimensions: boolean - media: Set + media: Map sheets: Set } @@ -119,6 +120,7 @@ interface ResolutionContext { variables?: Record scopedVariables?: readonly Record[] defaultVariables?: Record + customMedia?: Record dimensions?: CssxDimensions mediaQueryEvaluator?: CssxMediaQueryEvaluator componentTag?: string | null @@ -141,6 +143,8 @@ 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 { @@ -221,11 +225,13 @@ function resolveCssxUncached ( classNames: readonly string[] ): ResolveCssxResult { const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme) + const customMedia = collectCustomMedia(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, @@ -453,30 +459,105 @@ function ruleMatchesMedia ( if (!rule.media) return true const query = stripMediaPrefix(rule.media) - context.dependencies.media.add(query) - return matchesMediaQuery(query, context.dimensions, context.mediaQueryEvaluator, context.theme) + 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) + context.dependencies.media.set(query, result.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 + } + diagnostics: CssxDiagnostic[] +} + +export function evaluateCssxMediaQuery ( + query: string, + options: CssxMediaQueryEvaluationOptions +): CssxMediaQueryEvaluationResult { + const dependencies = { + vars: new Set(), + dimensions: false + } + const diagnostics: CssxDiagnostic[] = [] + const matches = matchesMediaQueryBranchList(query, options, dependencies, diagnostics, []) + + return { + matches, + dependencies: { + vars: Array.from(dependencies.vars).sort(), + dimensions: dependencies.dimensions + }, + diagnostics + } } function matchesMediaQuery ( query: string, dimensions: CssxDimensions | undefined, evaluator?: CssxMediaQueryEvaluator, - theme?: string | null + 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 }, + diagnostics: CssxDiagnostic[], + customMediaStack: string[] ): boolean { const normalized = stripMediaPrefix(query) const branches = splitTopLevelComma(normalized) if (branches.length > 1) { - return branches.some(branch => matchesSingleMediaQuery(branch, dimensions, evaluator, theme)) + return branches.some(branch => matchesSingleMediaQuery(branch, options, dependencies, diagnostics, customMediaStack)) } - return matchesSingleMediaQuery(normalized, dimensions, evaluator, theme) + return matchesSingleMediaQuery(normalized, options, dependencies, diagnostics, customMediaStack) } function matchesSingleMediaQuery ( query: string, - dimensions: CssxDimensions | undefined, - evaluator: CssxMediaQueryEvaluator | undefined, - theme: string | null | undefined + options: CssxMediaQueryEvaluationOptions, + dependencies: { vars: Set, dimensions: boolean }, + diagnostics: CssxDiagnostic[], + customMediaStack: string[] ): boolean { const parts = splitTopLevelAnd(query) const rest: string[] = [] @@ -485,24 +566,120 @@ function matchesSingleMediaQuery ( const trimmed = part.trim() const themeMatch = trimmed.match(THEME_MEDIA_RE) if (themeMatch) { - if (!matchesThemeName(themeMatch[1], normalizeTheme(theme))) return false + 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 = rest.join(' and ') - if (evaluator) return evaluator(restQuery, dimensions) + const restQuery = resolveMediaQueryValue(rest.join(' and '), options, dependencies, diagnostics) + if (restQuery == null) return false + if (options.mediaQueryEvaluator) return options.mediaQueryEvaluator(restQuery, options.dimensions) try { - return mediaQuery.match(restQuery, mediaValues(dimensions)) + return mediaQuery.match(restQuery, mediaValues(options.dimensions)) } catch { return false } } +function evaluateRangeMedia ( + match: RegExpMatchArray, + options: CssxMediaQueryEvaluationOptions, + dependencies: { vars: Set, dimensions: boolean }, + diagnostics: CssxDiagnostic[] +): boolean { + 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 }, + 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 @@ -771,6 +948,7 @@ function createDynamicSignature ( layers: readonly NormalizedLayer[] ): string { const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme) + const customMedia = collectCustomMedia(layers) return JSON.stringify({ theme: normalizeTheme(options.theme), vars: dependencies.vars.map(name => [ @@ -788,7 +966,16 @@ function createDynamicSignature ( : undefined, media: dependencies.media.map(query => [ query, - matchesMediaQuery(query, options.dimensions, options.mediaQueryEvaluator, options.theme) + matchesMediaQuery( + query, + options.dimensions, + options.mediaQueryEvaluator, + options.theme, + customMedia, + options.variables, + scopedVariables, + options.defaultVariables + ) ]) }) } @@ -830,6 +1017,20 @@ function collectScopedVariables ( return scopes.length > 0 ? scopes : undefined } +function collectCustomMedia ( + layers: readonly NormalizedLayer[] +): Record | undefined { + let customMedia: Record | undefined + + 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 @@ -909,7 +1110,7 @@ function createDependencies (): MutableDependencies { return { vars: new Set(), dimensions: false, - media: new Set(), + media: new Map(), sheets: new Set() } } @@ -920,7 +1121,8 @@ function serializeDependencies ( return { vars: Array.from(dependencies.vars).sort(), dimensions: dependencies.dimensions, - media: Array.from(dependencies.media).sort(), + 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() } } diff --git a/packages/css-to-rn/src/types.ts b/packages/css-to-rn/src/types.ts index 551958b..4fb0a16 100644 --- a/packages/css-to-rn/src/types.ts +++ b/packages/css-to-rn/src/types.ts @@ -16,6 +16,7 @@ export type CssxDiagnosticCode = | 'UNSUPPORTED_BACKGROUND_IMAGE' | 'UNSUPPORTED_BACKGROUND_SHORTHAND' | 'INVALID_THEME_BLOCK' + | 'INVALID_CUSTOM_MEDIA' export interface CssxDiagnostic { level: CssxDiagnosticLevel @@ -50,6 +51,7 @@ export interface CssxMetadata { hasAnimations: boolean hasTransitions: boolean hasThemes: boolean + hasCustomMedia: boolean } export interface CompiledCssSheet { @@ -61,6 +63,7 @@ export interface CompiledCssSheet { keyframes: Record rootVariables?: Record themeVariables?: Record> + customMedia?: Record exports?: Record metadata: CssxMetadata diagnostics: CssxDiagnostic[] diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index d812f43..356cc1e 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -71,6 +71,7 @@ export { export { getCssVariable, getCssVariableRaw, + useMedia, useCssVariable, useCssVariableRaw } from './react/hooks.ts' diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts index 0f6db53..6c87f3d 100644 --- a/packages/css-to-rn/test/engine/compiler.test.ts +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -85,6 +85,22 @@ describe('@cssxjs/css-to-rn compiler IR', () => { 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; } diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index 5cd5f8a..d84fd8e 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -206,6 +206,62 @@ describe('@cssxjs/css-to-rn resolver', () => { ]) }) + 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.deepEqual(wide.dependencies.media, ['(--breakpoint-tablet)']) + assert.deepEqual(wide.dependencies.mediaMatches, { + '(--breakpoint-tablet)': true + }) + }) + + 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() diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index 58181a3..2727d24 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -17,6 +17,7 @@ import { useCssVariableRaw, useCssxLayer, useCssxTemplate, + useMedia, variables } from '../../src/web.ts' @@ -431,6 +432,66 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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; }') diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 64ffc4f..6f9e097 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -19,6 +19,7 @@ export { useCssxRuntimeContext, useCssxSheet, useCssxTemplate, + useMedia, variables } from '@cssxjs/css-to-rn/react' export type { diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js index 8181b60..a534a23 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -18,6 +18,7 @@ export { useCssxRuntimeContext, useCssxSheet, useCssxTemplate, + useMedia, variables } from '@cssxjs/css-to-rn/react' diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js index b4f0dbb..538b77d 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -21,6 +21,7 @@ export { useCssxRuntimeContext, useCssxSheet, useCssxTemplate, + useMedia, variables } from '@cssxjs/css-to-rn/react-native' diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js index 97aea02..55af9f0 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -21,6 +21,7 @@ export { useCssxRuntimeContext, useCssxSheet, useCssxTemplate, + useMedia, variables } from '@cssxjs/css-to-rn/web' From 12fabc30c5fcf9e0d33da957e90d34d0cafdcb9b Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 20:26:42 +0300 Subject: [PATCH 30/37] Add CSSX theme assets --- packages/css-to-rn/src/transform/index.ts | 4 + .../css-to-rn/test/engine/transform.test.ts | 11 + packages/cssxjs/package.json | 4 +- packages/cssxjs/test/smoke.mjs | 20 + packages/cssxjs/themes/shadcn.cssx.css | 130 +++++ packages/cssxjs/themes/shadcn.d.ts | 4 + packages/cssxjs/themes/shadcn.js | 1 + packages/cssxjs/themes/tailwind.cssx.css | 527 ++++++++++++++++++ packages/cssxjs/themes/tailwind.d.ts | 4 + packages/cssxjs/themes/tailwind.js | 1 + 10 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 packages/cssxjs/themes/shadcn.cssx.css create mode 100644 packages/cssxjs/themes/shadcn.d.ts create mode 100644 packages/cssxjs/themes/shadcn.js create mode 100644 packages/cssxjs/themes/tailwind.cssx.css create mode 100644 packages/cssxjs/themes/tailwind.d.ts create mode 100644 packages/cssxjs/themes/tailwind.js diff --git a/packages/css-to-rn/src/transform/index.ts b/packages/css-to-rn/src/transform/index.ts index 500bf33..55d2d51 100644 --- a/packages/css-to-rn/src/transform/index.ts +++ b/packages/css-to-rn/src/transform/index.ts @@ -559,6 +559,10 @@ function transformTransform ( property: string, value: string ): PropertyTransformResult { + if (value.trim().toLowerCase() === 'none') { + return { style: { transform: [] } } + } + const parts = parseFunctionSequence(value) const transforms: TransformStyleValue[] = [] diff --git a/packages/css-to-rn/test/engine/transform.test.ts b/packages/css-to-rn/test/engine/transform.test.ts index e8db675..814e304 100644 --- a/packages/css-to-rn/test/engine/transform.test.ts +++ b/packages/css-to-rn/test/engine/transform.test.ts @@ -88,6 +88,17 @@ describe('@cssxjs/css-to-rn declaration transformer', () => { }) }) + 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'], diff --git a/packages/cssxjs/package.json b/packages/cssxjs/package.json index 2a92289..f1f3894 100644 --- a/packages/cssxjs/package.json +++ b/packages/cssxjs/package.json @@ -27,7 +27,9 @@ "./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" diff --git a/packages/cssxjs/test/smoke.mjs b/packages/cssxjs/test/smoke.mjs index 551046a..562fe3b 100644 --- a/packages/cssxjs/test/smoke.mjs +++ b/packages/cssxjs/test/smoke.mjs @@ -1,4 +1,8 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { compileCss } from '@cssxjs/css-to-rn' import { CssxProvider, cssx, @@ -41,3 +45,19 @@ assert.deepEqual( iconStyle: [[{ color: 'blue' }], { marginLeft: 8 }] } ) + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) + +for (const name of ['tailwind', 'shadcn']) { + const source = readFileSync(join(packageDir, 'themes', `${name}.cssx.css`), 'utf8') + 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.cssx.css b/packages/cssxjs/themes/shadcn.cssx.css new file mode 100644 index 0000000..a0072d7 --- /dev/null +++ b/packages/cssxjs/themes/shadcn.cssx.css @@ -0,0 +1,130 @@ +/* + * 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/shadcn.d.ts b/packages/cssxjs/themes/shadcn.d.ts new file mode 100644 index 0000000..e375dd2 --- /dev/null +++ b/packages/cssxjs/themes/shadcn.d.ts @@ -0,0 +1,4 @@ +import type { CompiledCssSheet } from '@cssxjs/css-to-rn' + +declare const theme: CompiledCssSheet +export default theme diff --git a/packages/cssxjs/themes/shadcn.js b/packages/cssxjs/themes/shadcn.js new file mode 100644 index 0000000..5b49469 --- /dev/null +++ b/packages/cssxjs/themes/shadcn.js @@ -0,0 +1 @@ +export { default } from './shadcn.cssx.css' diff --git a/packages/cssxjs/themes/tailwind.cssx.css b/packages/cssxjs/themes/tailwind.cssx.css new file mode 100644 index 0000000..3f7feb7 --- /dev/null +++ b/packages/cssxjs/themes/tailwind.cssx.css @@ -0,0 +1,527 @@ +/* + * 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/cssxjs/themes/tailwind.d.ts b/packages/cssxjs/themes/tailwind.d.ts new file mode 100644 index 0000000..e375dd2 --- /dev/null +++ b/packages/cssxjs/themes/tailwind.d.ts @@ -0,0 +1,4 @@ +import type { CompiledCssSheet } from '@cssxjs/css-to-rn' + +declare const theme: CompiledCssSheet +export default theme diff --git a/packages/cssxjs/themes/tailwind.js b/packages/cssxjs/themes/tailwind.js new file mode 100644 index 0000000..f8882b3 --- /dev/null +++ b/packages/cssxjs/themes/tailwind.js @@ -0,0 +1 @@ +export { default } from './tailwind.cssx.css' From daf2f1505ea088b669e45363d42bdd34cec1e0a2 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 20:31:44 +0300 Subject: [PATCH 31/37] Add CSS color bridge helpers --- packages/css-to-rn/src/colors.ts | 5 + packages/css-to-rn/src/compiler.ts | 14 +- packages/css-to-rn/src/index.ts | 3 + packages/css-to-rn/src/react-native.ts | 12 ++ packages/css-to-rn/src/react/hooks.ts | 123 +++++++++++++++++- packages/css-to-rn/src/types.ts | 1 + packages/css-to-rn/src/units.ts | 14 ++ packages/css-to-rn/src/values.ts | 16 ++- packages/css-to-rn/src/web.ts | 12 ++ .../css-to-rn/test/engine/compiler.test.ts | 8 ++ packages/css-to-rn/test/engine/values.test.ts | 23 +++- .../css-to-rn/test/react/tracking.test.ts | 116 +++++++++++++++++ packages/cssxjs/index.d.ts | 4 + packages/cssxjs/index.js | 3 + packages/cssxjs/test/smoke.mjs | 6 + 15 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 packages/css-to-rn/src/units.ts diff --git a/packages/css-to-rn/src/colors.ts b/packages/css-to-rn/src/colors.ts index 76def27..5331f04 100644 --- a/packages/css-to-rn/src/colors.ts +++ b/packages/css-to-rn/src/colors.ts @@ -38,6 +38,11 @@ export function evaluateCssColors (input: string): string { 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 diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts index 6a48f3a..f3e87e4 100644 --- a/packages/css-to-rn/src/compiler.ts +++ b/packages/css-to-rn/src/compiler.ts @@ -413,9 +413,21 @@ function validateBuildDeclaration ( 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( diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts index bf7f027..fa6cf5f 100644 --- a/packages/css-to-rn/src/index.ts +++ b/packages/css-to-rn/src/index.ts @@ -11,6 +11,9 @@ export { export { resolveCssValue } from './values.ts' +export { + u +} from './units.ts' export { createCssxCache, cssx, diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts index 5e2323c..be5ab0e 100644 --- a/packages/css-to-rn/src/react-native.ts +++ b/packages/css-to-rn/src/react-native.ts @@ -7,6 +7,12 @@ export { export { resolveCssValue } from './values.ts' +export { + u +} from './units.ts' +import { + resetUWarningForTests +} from './units.ts' import { cssx as baseCssx, clearRawCssCacheForTests @@ -73,12 +79,17 @@ export { 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 @@ -151,6 +162,7 @@ export const __cssxInternals = { flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, + resetUWarningForTests, setColorSchemeForTests, setDimensionsForTests, subscribeVariablesForTests diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts index 7c59c0b..33f82fe 100644 --- a/packages/css-to-rn/src/react/hooks.ts +++ b/packages/css-to-rn/src/react/hooks.ts @@ -6,6 +6,7 @@ import { 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 { @@ -15,7 +16,8 @@ import { } from './config.ts' import { coerceCssValue, - resolveCssValue + resolveCssValue, + type ResolveCssValueResult } from '../values.ts' import { createDependencySnapshot, @@ -36,6 +38,8 @@ 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 DEFAULT_CUSTOM_MEDIA: Record = { '--breakpoint-mobile': '(width < 48rem)', '--breakpoint-tablet': '(width >= 48rem)', @@ -87,6 +91,14 @@ export type CssxLayerHookOutput = | undefined | false +export type CssColorMixInput = + | number + | string + | { + mix?: number | string + with?: string + } + export function useCssxSheet ( sheet: CompiledCssSheet, options: CssxReactConfig = {} @@ -192,7 +204,7 @@ export function useCssVariableRaw ( const context = useCssxRuntimeContext() const committedDependenciesRef = useRef(createDependencySnapshot()) const result = resolveCssVariableRaw(name, fallback, context.scopedVariables) - const renderDependencies = createVariableDependencySnapshot(result) + const renderDependencies = createCssValueDependencySnapshot(result) useSyncExternalStore( listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current), @@ -215,6 +227,28 @@ export function useCssVariable ( 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 @@ -231,6 +265,13 @@ export function getCssVariable ( 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()) @@ -367,8 +408,21 @@ function resolveCssVariableRaw ( }) } -function createVariableDependencySnapshot ( - result: ReturnType +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) { @@ -380,6 +434,67 @@ function createVariableDependencySnapshot ( 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 diff --git a/packages/css-to-rn/src/types.ts b/packages/css-to-rn/src/types.ts index 4fb0a16..54bc03d 100644 --- a/packages/css-to-rn/src/types.ts +++ b/packages/css-to-rn/src/types.ts @@ -17,6 +17,7 @@ export type CssxDiagnosticCode = | 'UNSUPPORTED_BACKGROUND_SHORTHAND' | 'INVALID_THEME_BLOCK' | 'INVALID_CUSTOM_MEDIA' + | 'DEPRECATED_UNIT' export interface CssxDiagnostic { level: CssxDiagnosticLevel 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 index adca2c8..8ba162d 100644 --- a/packages/css-to-rn/src/values.ts +++ b/packages/css-to-rn/src/values.ts @@ -14,6 +14,7 @@ export interface ResolveCssValueOptions { height?: number } maxVarDepth?: number + deprecateUUnits?: boolean } export interface ResolveCssValueResult { @@ -60,7 +61,7 @@ export function resolveCssValue ( return invalid(diagnostics, dependencies) } - const units = resolveUnits(variableResolution.value, options, dependencies) + const units = resolveUnits(variableResolution.value, options, dependencies, diagnostics) const calc = resolveCalcs(units.value, diagnostics) if (!calc.valid) { return invalid(diagnostics, dependencies) @@ -213,8 +214,19 @@ function resolveVars ( function resolveUnits ( input: string, options: ResolveCssValueOptions, - dependencies: { vars: Set, dimensions: boolean } + 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` }) diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts index 356cc1e..7856bc8 100644 --- a/packages/css-to-rn/src/web.ts +++ b/packages/css-to-rn/src/web.ts @@ -7,6 +7,12 @@ export { export { resolveCssValue } from './values.ts' +export { + u +} from './units.ts' +import { + resetUWarningForTests +} from './units.ts' import { cssx as baseCssx, clearRawCssCacheForTests @@ -69,12 +75,17 @@ export { 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 @@ -144,6 +155,7 @@ export const __cssxInternals = { flushMicrotasksForTests, getRuntimeSubscriberCountForTests, resetStoreForTests, + resetUWarningForTests, setColorSchemeForTests, setDimensionsForTests, subscribeVariablesForTests diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts index 6c87f3d..41653c4 100644 --- a/packages/css-to-rn/test/engine/compiler.test.ts +++ b/packages/css-to-rn/test/engine/compiler.test.ts @@ -182,6 +182,14 @@ describe('@cssxjs/css-to-rn compiler IR', () => { 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; } diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts index 0cd5dbb..724c29d 100644 --- a/packages/css-to-rn/test/engine/values.test.ts +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' -import { resolveCssValue } from '../../src/index.ts' +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', () => { @@ -89,6 +90,26 @@ describe('@cssxjs/css-to-rn value resolver', () => { assert.equal(result.dependencies.dimensions, true) }) + 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') diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index 2727d24..750e328 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -9,10 +9,12 @@ import { CssxProvider, cssx, defaultVariables, + getCssColor, getCssVariable, getCssVariableRaw, setDefaultVariables, themed, + useCssColor, useCssVariable, useCssVariableRaw, useCssxLayer, @@ -297,6 +299,34 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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 @@ -347,6 +377,43 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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 @@ -647,6 +714,55 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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 } diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts index 6f9e097..951e684 100644 --- a/packages/cssxjs/index.d.ts +++ b/packages/cssxjs/index.d.ts @@ -5,11 +5,14 @@ export { configureCssx, cssx, defaultVariables, + getCssColor, getCssVariable, getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, themed, + u, + useCssColor, useCssVariable, useCssVariableRaw, useCssxLayer, @@ -23,6 +26,7 @@ export { variables } from '@cssxjs/css-to-rn/react' export type { + CssColorMixInput, CssxProviderStyleInput, CssxProviderStyleLayer, CssxProviderProps, diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js index a534a23..89fc328 100644 --- a/packages/cssxjs/index.js +++ b/packages/cssxjs/index.js @@ -4,11 +4,14 @@ export { configureCssx, cssx, defaultVariables, + getCssColor, getCssVariable, getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, themed, + u, + useCssColor, useCssVariable, useCssVariableRaw, useCssxLayer, diff --git a/packages/cssxjs/test/smoke.mjs b/packages/cssxjs/test/smoke.mjs index 562fe3b..98eb93b 100644 --- a/packages/cssxjs/test/smoke.mjs +++ b/packages/cssxjs/test/smoke.mjs @@ -6,9 +6,12 @@ import { compileCss } from '@cssxjs/css-to-rn' import { CssxProvider, cssx, + getCssColor, getCssVariable, matcher, themed, + u, + useCssColor, useCssVariable, useCssxLayer, useRuntimeCss @@ -16,9 +19,12 @@ import { 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') From 06d6bbc23857202105691f23253cdd86c566917e Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 20:53:51 +0300 Subject: [PATCH 32/37] Resolve rem units in CSSX values --- packages/css-to-rn/src/values.ts | 5 +++++ packages/css-to-rn/test/engine/values.test.ts | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/css-to-rn/src/values.ts b/packages/css-to-rn/src/values.ts index 8ba162d..76a678d 100644 --- a/packages/css-to-rn/src/values.ts +++ b/packages/css-to-rn/src/values.ts @@ -29,6 +29,7 @@ export interface ResolveCssValueResult { 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 @@ -231,6 +232,10 @@ function resolveUnits ( 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 diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts index 724c29d..8cab53f 100644 --- a/packages/css-to-rn/test/engine/values.test.ts +++ b/packages/css-to-rn/test/engine/values.test.ts @@ -80,16 +80,26 @@ describe('@cssxjs/css-to-rn value resolver', () => { assert.equal(result.diagnostics[0].code, 'INVALID_INTERPOLATION_VALUE') }) - it('resolves u and viewport units', () => { - const result = resolveCssValue('calc(10vw + 2u)', { + 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, '36px') + 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 From ca8a564de523376888474ac1aaf2b7846d5c9b07 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Tue, 23 Jun 2026 23:53:31 +0300 Subject: [PATCH 33/37] Add CSSX theming guide --- docs/api/index.md | 1 + docs/guide/index.md | 25 +++ docs/guide/theming.md | 339 ++++++++++++++++++++++++++++++++++++++++ docs/guide/usage.md | 5 +- docs/guide/variables.md | 3 + 5 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 docs/guide/theming.md diff --git a/docs/api/index.md b/docs/api/index.md index 664c702..e2a8366 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -34,6 +34,7 @@ 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 diff --git a/docs/guide/index.md b/docs/guide/index.md index dc386df..216bac3 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -124,6 +124,30 @@ import { variables } from 'cssxjs' variables['--primary-color'] = isDarkMode ? '#fff' : '#000' ``` +### 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 Built-in `u` unit (1u = 8px) for consistent spacing: @@ -210,5 +234,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/theming.md b/docs/guide/theming.md new file mode 100644 index 0000000..cc9afb1 --- /dev/null +++ b/docs/guide/theming.md @@ -0,0 +1,339 @@ +# 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 | +| --- | --- | +| `auto` | Default. Uses the OS color scheme and selects `dark` when the provider style defines `:root.dark`. | +| `default` | Uses only `:root`. | +| `light` | Alias for `default` unless `:root.light` exists. | +| `dark` | Uses `:root` plus `:root.dark`. | +| custom name | Uses `:root` plus `:root.`. | + +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 source files are useful as variable +references: + +- `packages/cssxjs/themes/tailwind.cssx.css` +- `packages/cssxjs/themes/shadcn.cssx.css` + +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 3805d87..24c3490 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -204,8 +204,8 @@ function ProgressBar({ progress }) { } ``` -Use [CSS Variables](/guide/variables) for app-wide runtime theming and shared -tokens. +Use [Theming](/guide/theming) for app-wide provider themes and shared tokens. +Use [CSS Variables](/guide/variables) for imperative runtime variable updates. ## Style Placement @@ -375,5 +375,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 421b8f6..c043c1d 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -2,6 +2,9 @@ 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. +For app-wide themes, prefer [Theming](/guide/theming) with `CssxProvider style`. +This page focuses on the lower-level imperative variable stores and JS reads. + ## Basic Usage Use standard CSS `var()` syntax in your styles: From bcbea6e7dde2c254dd0c602d3c9b72bfdeb8288d Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Wed, 24 Jun 2026 00:25:26 +0300 Subject: [PATCH 34/37] Fix CSSX provider style resolution --- .../__snapshots__/index.spec.js.snap | 47 +++++++++++++++++++ .../__tests__/index.spec.js | 12 +++++ .../index.js | 20 +++++++- packages/css-to-rn/src/react/config.ts | 10 ++-- packages/css-to-rn/src/react/cssx.ts | 12 +---- 5 files changed, 82 insertions(+), 19 deletions(-) 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 19349d1..ec77d17 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 @@ -875,6 +875,53 @@ function Test() { } +`; + +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`] = ` 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 c86cc89..bd017a1 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 @@ -81,6 +81,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 () { diff --git a/packages/babel-plugin-rn-stylename-to-style/index.js b/packages/babel-plugin-rn-stylename-to-style/index.js index 1e19d5c..82d3fdd 100644 --- a/packages/babel-plugin-rn-stylename-to-style/index.js +++ b/packages/babel-plugin-rn-stylename-to-style/index.js @@ -23,6 +23,7 @@ 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%% @@ -448,7 +449,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') { @@ -536,6 +541,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) { diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts index bb3ed90..5e40e25 100644 --- a/packages/css-to-rn/src/react/config.ts +++ b/packages/css-to-rn/src/react/config.ts @@ -1,7 +1,6 @@ import { createContext, createElement, - forwardRef, type ComponentProps, type ComponentType, useEffect, @@ -172,7 +171,7 @@ export function themed> ( componentTag: string, Component: C ): C { - const ThemedComponent = forwardRef>(function ThemedComponent (props, ref): ReactNode { + function ThemedComponent (props: ComponentProps): ReactNode { const parent = useCssxRuntimeContext() const tracker = useCssxRenderTracker(parent.config) const value = useMemo(() => ({ @@ -184,12 +183,9 @@ export function themed> ( return createElement( CssxRuntimeContext.Provider, { value }, - createElement(Component, { - ...props, - ref - } as ComponentProps & { ref: unknown }) + createElement(Component, props) ) - }) + } ThemedComponent.displayName = `themed(${Component.displayName ?? Component.name ?? componentTag})` return ThemedComponent as unknown as C diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts index 9b387c7..4c45143 100644 --- a/packages/css-to-rn/src/react/cssx.ts +++ b/packages/css-to-rn/src/react/cssx.ts @@ -1,4 +1,4 @@ -import React, { use } from 'react' +import { use } from 'react' import type { CompiledCssSheet, CssxTarget } from '../types.ts' import { clearCssxRuntimeCachesForTests, @@ -29,12 +29,6 @@ import { type TrackedCssxSheet } from './tracker.ts' -const ReactInternals = (React as unknown as { - __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?: { - H: unknown - } -}).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE - export type CssxStyleName = StyleNameValue export type CssxResolvedProps = ResolvedStyleProps @@ -102,10 +96,6 @@ export function cssx ( } function readRuntimeContext () { - if (ReactInternals?.H == null) { - return getDefaultCssxRuntimeContext() - } - try { return use(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext() } catch { From 24e0481f5668c416cbd450cfc6958a18bea26c01 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Wed, 24 Jun 2026 01:16:42 +0300 Subject: [PATCH 35/37] Fix theme entrypoint CSS compilation --- .../__snapshots__/index.spec.js.snap | 60 ++++++++++++++----- packages/cssxjs/themes/shadcn.js | 4 +- packages/cssxjs/themes/tailwind.js | 4 +- 3 files changed, 51 insertions(+), 17 deletions(-) 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 0152dbc..b2dd91e 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 @@ -67,9 +67,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -1564835170, + __hash__: 320436983, }; const _localCssInstance = { version: 1, @@ -106,9 +108,11 @@ const _localCssInstance = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 2093078773, + __hash__: 1882190464, }; export default observer(function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -181,9 +185,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -1564835170, + __hash__: 320436983, }; export default observer(function Card() { return ; @@ -263,9 +269,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 2075491085, + __hash__: 1538755432, }; const _localCssInstance2 = { version: 1, @@ -310,9 +318,11 @@ const _localCssInstance2 = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 1014023389, + __hash__: 1203604376, }; const _localCssInstance = { version: 1, @@ -349,9 +359,11 @@ const _localCssInstance = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -1276586605, + __hash__: 747323298, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -442,9 +454,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 230283094, + __hash__: 1495717183, }; const _localCssInstance2 = { version: 1, @@ -489,9 +503,11 @@ const _localCssInstance2 = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 1014023389, + __hash__: 1203604376, }; const _localCssInstance = { version: 1, @@ -528,9 +544,11 @@ const _localCssInstance = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -985409394, + __hash__: 674599687, }; export default function Card() { const __CSS_LOCAL__ = _localCssInstance; @@ -661,9 +679,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 1060737560, + __hash__: -976053059, }; export default function Card() { return ( @@ -742,9 +762,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -1564835170, + __hash__: 320436983, }; export default observer(function Card() { return ; @@ -863,9 +885,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -49838010, + __hash__: -941559217, }; export default function Card() { return ( @@ -943,9 +967,11 @@ const __CSS_GLOBAL__ = { hasDynamicRuntimeDependencies: false, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -1776916804, + __hash__: 1665814681, }; export default observer(function Card() { return ; @@ -1084,9 +1110,11 @@ const _localCssInstance = { hasDynamicRuntimeDependencies: true, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: -210124161, + __hash__: -851391434, }; export default function Card({ ready, pad }) { const color = useThemeColor("primary"); @@ -1206,9 +1234,11 @@ const _localCssInstance = { hasDynamicRuntimeDependencies: true, hasAnimations: false, hasTransitions: false, + hasThemes: false, + hasCustomMedia: false, }, diagnostics: [], - __hash__: 762895511, + __hash__: -1761350370, }; export default function Card({ pad }) { const color = useThemeColor("primary"); diff --git a/packages/cssxjs/themes/shadcn.js b/packages/cssxjs/themes/shadcn.js index 5b49469..30df567 100644 --- a/packages/cssxjs/themes/shadcn.js +++ b/packages/cssxjs/themes/shadcn.js @@ -1 +1,3 @@ -export { default } from './shadcn.cssx.css' +import theme from './shadcn.cssx.css' + +export default theme diff --git a/packages/cssxjs/themes/tailwind.js b/packages/cssxjs/themes/tailwind.js index f8882b3..876d5af 100644 --- a/packages/cssxjs/themes/tailwind.js +++ b/packages/cssxjs/themes/tailwind.js @@ -1 +1,3 @@ -export { default } from './tailwind.cssx.css' +import theme from './tailwind.cssx.css' + +export default theme From b191cab180583017c220c1e62733ff911f50f3aa Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Wed, 24 Jun 2026 01:36:16 +0300 Subject: [PATCH 36/37] Tighten CSSX media tracking and diagnostics --- packages/css-to-rn/src/resolve.ts | 42 ++++++++---- .../css-to-rn/test/engine/resolve.test.ts | 27 +++++--- .../css-to-rn/test/react/tracking.test.ts | 64 +++++++++++++++++++ packages/cssxjs/runtime/react-native.js | 3 + packages/cssxjs/runtime/web.js | 3 + packages/cssxjs/test/smoke.mjs | 8 +++ 6 files changed, 128 insertions(+), 19 deletions(-) diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts index d205e38..5be9d8d 100644 --- a/packages/css-to-rn/src/resolve.ts +++ b/packages/css-to-rn/src/resolve.ts @@ -242,7 +242,10 @@ function resolveCssxUncached ( const classSet = new Set(classNames) const props: ResolvedStyleProps = {} - for (const layer of layers) context.dependencies.sheets.add(layer.sheet.id) + 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() @@ -471,7 +474,9 @@ function ruleMatchesMedia ( 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) - context.dependencies.media.set(query, result.matches) + for (const [mediaQuery, matches] of Object.entries(result.dependencies.media)) { + context.dependencies.media.set(mediaQuery, matches) + } return result.matches } @@ -490,6 +495,7 @@ interface CssxMediaQueryEvaluationResult { dependencies: { vars: string[] dimensions: boolean + media: Record } diagnostics: CssxDiagnostic[] } @@ -500,7 +506,8 @@ export function evaluateCssxMediaQuery ( ): CssxMediaQueryEvaluationResult { const dependencies = { vars: new Set(), - dimensions: false + dimensions: false, + media: new Map() } const diagnostics: CssxDiagnostic[] = [] const matches = matchesMediaQueryBranchList(query, options, dependencies, diagnostics, []) @@ -509,7 +516,8 @@ export function evaluateCssxMediaQuery ( matches, dependencies: { vars: Array.from(dependencies.vars).sort(), - dimensions: dependencies.dimensions + dimensions: dependencies.dimensions, + media: Object.fromEntries(Array.from(dependencies.media.entries()).sort()) }, diagnostics } @@ -539,7 +547,7 @@ function matchesMediaQuery ( function matchesMediaQueryBranchList ( query: string, options: CssxMediaQueryEvaluationOptions, - dependencies: { vars: Set, dimensions: boolean }, + dependencies: { vars: Set, dimensions: boolean, media: Map }, diagnostics: CssxDiagnostic[], customMediaStack: string[] ): boolean { @@ -555,7 +563,7 @@ function matchesMediaQueryBranchList ( function matchesSingleMediaQuery ( query: string, options: CssxMediaQueryEvaluationOptions, - dependencies: { vars: Set, dimensions: boolean }, + dependencies: { vars: Set, dimensions: boolean, media: Map }, diagnostics: CssxDiagnostic[], customMediaStack: string[] ): boolean { @@ -607,10 +615,20 @@ function matchesSingleMediaQuery ( const restQuery = resolveMediaQueryValue(rest.join(' and '), options, dependencies, diagnostics) if (restQuery == null) return false - if (options.mediaQueryEvaluator) return options.mediaQueryEvaluator(restQuery, options.dimensions) + 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(restQuery, mediaValues(options.dimensions)) + return mediaQuery.match(query, mediaValues(dimensions)) } catch { return false } @@ -619,9 +637,11 @@ function matchesSingleMediaQuery ( function evaluateRangeMedia ( match: RegExpMatchArray, options: CssxMediaQueryEvaluationOptions, - dependencies: { vars: Set, dimensions: boolean }, + 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() @@ -650,7 +670,7 @@ function evaluateRangeMedia ( function resolveMediaQueryValue ( input: string, options: CssxMediaQueryEvaluationOptions, - dependencies: { vars: Set, dimensions: boolean }, + dependencies: { vars: Set, dimensions: boolean, media: Map }, diagnostics: CssxDiagnostic[] ): string | null { const result = resolveCssValue(input, { @@ -765,7 +785,7 @@ function normalizeTheme (theme: string | null | undefined): string { } function getPartPropName (part: string | null): string { - return part ? `${part}Style` : 'style' + return part && part !== 'root' ? `${part}Style` : 'style' } function normalizeLayers ( diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts index d84fd8e..b809194 100644 --- a/packages/css-to-rn/test/engine/resolve.test.ts +++ b/packages/css-to-rn/test/engine/resolve.test.ts @@ -12,6 +12,7 @@ describe('@cssxjs/css-to-rn resolver', () => { 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; } `) @@ -25,6 +26,7 @@ describe('@cssxjs/css-to-rn resolver', () => { assert.deepEqual(result.props, { style: { color: 'green', + backgroundColor: 'yellow', paddingTop: 8, paddingRight: 8, paddingBottom: 8, @@ -55,6 +57,19 @@ describe('@cssxjs/css-to-rn resolver', () => { }) }) + 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 { @@ -200,10 +215,7 @@ describe('@cssxjs/css-to-rn resolver', () => { paddingLeft: 16 } }) - assert.deepEqual(darkWide.dependencies.media, [ - '(--theme-dark)', - '(--theme-dark) and (min-width: 600px)' - ]) + assert.deepEqual(darkWide.dependencies.media, ['(min-width: 600px)']) }) it('expands custom media aliases with provider variables', () => { @@ -230,10 +242,9 @@ describe('@cssxjs/css-to-rn resolver', () => { assert.deepEqual(narrow.props, { style: { color: 'red' } }) assert.deepEqual(wide.props, { style: { color: 'blue' } }) assert.deepEqual(wide.dependencies.vars, ['--tablet']) - assert.deepEqual(wide.dependencies.media, ['(--breakpoint-tablet)']) - assert.deepEqual(wide.dependencies.mediaMatches, { - '(--breakpoint-tablet)': true - }) + assert.equal(wide.dependencies.dimensions, true) + assert.deepEqual(wide.dependencies.media, []) + assert.deepEqual(wide.dependencies.mediaMatches, {}) }) it('evaluates width and height range media syntax', () => { diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts index 750e328..1b92470 100644 --- a/packages/css-to-rn/test/react/tracking.test.ts +++ b/packages/css-to-rn/test/react/tracking.test.ts @@ -939,6 +939,70 @@ describe('@cssxjs/css-to-rn React tracking prototype', () => { 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>>() diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js index 538b77d..56c14aa 100644 --- a/packages/cssxjs/runtime/react-native.js +++ b/packages/cssxjs/runtime/react-native.js @@ -6,12 +6,15 @@ export { CssxProvider, TrackedCssxSheet, configureCssx, + cssx, defaultVariables, + getCssColor, getCssVariable, getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, themed, + useCssColor, useCssVariable, useCssVariableRaw, useCssxLayer, diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js index 55af9f0..520854e 100644 --- a/packages/cssxjs/runtime/web.js +++ b/packages/cssxjs/runtime/web.js @@ -6,12 +6,15 @@ export { CssxProvider, TrackedCssxSheet, configureCssx, + cssx, defaultVariables, + getCssColor, getCssVariable, getCssVariableRaw, isTrackedCssxSheet, setDefaultVariables, themed, + useCssColor, useCssVariable, useCssVariableRaw, useCssxLayer, diff --git a/packages/cssxjs/test/smoke.mjs b/packages/cssxjs/test/smoke.mjs index 98eb93b..a3e4191 100644 --- a/packages/cssxjs/test/smoke.mjs +++ b/packages/cssxjs/test/smoke.mjs @@ -16,6 +16,11 @@ import { useCssxLayer, useRuntimeCss } from 'cssxjs' +import { + cssx as runtimeCssx, + getCssColor as runtimeGetCssColor, + useCssColor as runtimeUseCssColor +} from 'cssxjs/runtime/web' assert.equal(typeof CssxProvider, 'function') assert.equal(typeof cssx, 'function') @@ -28,6 +33,9 @@ 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', { From 484f86b2387dace8579ae917e80ab49a41231017 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Wed, 24 Jun 2026 02:15:18 +0300 Subject: [PATCH 37/37] Add CSSX 0.4 migration guide --- docs/migration-guides/0.4.md | 303 +++++++++++++++++++++++++++++++++++ rspress.config.ts | 1 + 2 files changed, 304 insertions(+) create mode 100644 docs/migration-guides/0.4.md diff --git a/docs/migration-guides/0.4.md b/docs/migration-guides/0.4.md new file mode 100644 index 0000000..a4518b6 --- /dev/null +++ b/docs/migration-guides/0.4.md @@ -0,0 +1,303 @@ +# 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: + +- `auto`: default. Uses `dark` when the OS color scheme is dark and the provider styles define `:root.dark`. +- `dark`: applies `:root` plus `:root.dark`. +- `default`: applies only `:root`. +- `light`: aliases `default` unless an explicit `:root.light` exists. +- any custom name: applies `:root` plus `:root.`. + +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/rspress.config.ts b/rspress.config.ts index efbfd7b..f705a7b 100644 --- a/rspress.config.ts +++ b/rspress.config.ts @@ -109,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' } ] }