From cb0d5247b78e8ab31d11a25904fb70fffff39d33 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sat, 20 Jun 2026 22:11:44 +0300 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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) {