From 3edc58653948bd13237809e247c0dfe68edb9a6c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:39:11 +0900 Subject: [PATCH 01/32] fix(wasm): enable Boa's js feature so vanilla-extract files stop panicking Boa's Context::default() reads std::time::Instant, which panics on wasm32-unknown-unknown without the js feature. Every .css.ts extraction crashed in the full WASM; the lite build is unaffected. Add a wasm-bindgen test and a bun regression that runs in CI. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- Cargo.lock | 14 ++++++++ bindings/devup-ui-wasm/Cargo.toml | 10 +++++- bindings/devup-ui-wasm/tests/wasm.rs | 13 +++++++ .../bun-plugin/__regression__/css-emit.bun.ts | 36 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 59af8ade9..bfd4eb3fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,7 @@ dependencies = [ "float16", "futures-concurrency", "futures-lite", + "getrandom", "hashbrown 0.17.1", "icu_calendar", "icu_normalizer", @@ -188,6 +189,7 @@ dependencies = [ "thiserror", "time", "timezone_provider", + "web-time", "xsum", ] @@ -582,6 +584,7 @@ name = "devup-ui-wasm" version = "0.1.0" dependencies = [ "bimap", + "boa_engine", "console_error_panic_hook", "css", "extractor", @@ -2380,6 +2383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "libc", "num-conv", "num_threads", @@ -2624,6 +2628,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index 9b61daaf9..aecbd6c33 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -15,7 +15,11 @@ crate-type = ["cdylib"] [features] default = ["vanilla-extract"] -vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"] +vanilla-extract = [ + "extractor/vanilla-extract", + "sheet/vanilla-extract", + "dep:boa_engine", +] [dependencies] wasm-bindgen = "0.2.128" @@ -39,6 +43,10 @@ serde-wasm-bindgen = "0.6.5" # `getrandom_backend="wasm_js"` cfg requires on wasm32 targets. # Keep this major version in sync with boa_engine's transitive getrandom. getrandom = { version = "0.4", features = ["wasm_js"] } +# Not used directly. The extractor evaluates `.css.ts` modules with Boa, whose +# `Context::default()` reads the clock. Without the `js` feature that clock is +# `std::time::Instant`, which panics on wasm32-unknown-unknown. +boa_engine = { version = "0.22", optional = true, features = ["js"] } [dev-dependencies] wasm-bindgen-test = "0.3.78" diff --git a/bindings/devup-ui-wasm/tests/wasm.rs b/bindings/devup-ui-wasm/tests/wasm.rs index 677639f4f..51176bdda 100644 --- a/bindings/devup-ui-wasm/tests/wasm.rs +++ b/bindings/devup-ui-wasm/tests/wasm.rs @@ -46,3 +46,16 @@ fn test_object_to_typography() { assert_eq!(typography.line_height.unwrap(), "1.5"); assert_eq!(typography.letter_spacing.unwrap(), "1px"); } + +#[allow(dead_code)] +#[wasm_bindgen_test] +fn test_extract_vanilla_extract_file() { + let output = extractor::extract( + "styles.css.ts", + "import { style } from '@devup-ui/react'\nexport const box = style({ color: 'red' })\n", + extractor::ExtractOption::default(), + ) + .unwrap(); + assert_eq!(output.styles.len(), 1); + assert!(!output.code.contains("style(")); +} diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 836570bde..dc0e1fa8f 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -68,3 +68,39 @@ await import(cssPath) } }, ) + +it('emits vanilla-extract styles through the full WASM engine', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync( + join(cwd, 'styles.css.ts'), + `import { style } from '@devup-ui/react' +export const box = style({ width: '109px' }) +`, + ) + writeFileSync( + join(cwd, 'check.ts'), + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const { box } = await import('./styles.css.ts') +expect(box).toBeTruthy() +expect(readFileSync('./df/devup-ui/devup-ui.css', 'utf-8')).toContain('width:109px') +`, + ) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) From 519f1b7e2844879db2dc06acbc52925dc781b538 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:43:46 +0900 Subject: [PATCH 02/32] feat(eslint-plugin): add prefer-media-shorthand and no-typography-token-prefix rules prefer-media-shorthand suggests _motionReduce, _print and the other media shorthands for _media entries and @media keys that spell out the same query. no-typography-token-prefix reports typography=$heading, which names no preset. Both are auto-fixable and part of the recommended config. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__snapshots__/recommended.test.ts.snap | 36 +++++ .../eslint-plugin/src/configs/recommended.ts | 6 + .../src/rules/__tests__/index.test.ts | 2 + packages/eslint-plugin/src/rules/index.ts | 2 + .../no-typography-token-prefix/README.md | 39 +++++ .../__tests__/index.test.ts | 56 +++++++ .../rules/no-typography-token-prefix/index.ts | 97 ++++++++++++ .../rules/prefer-media-shorthand/README.md | 48 ++++++ .../__tests__/index.test.ts | 76 ++++++++++ .../src/rules/prefer-media-shorthand/index.ts | 141 ++++++++++++++++++ .../src/utils/property-key-name.ts | 11 ++ 11 files changed, 514 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md create mode 100644 packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md create mode 100644 packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts create mode 100644 packages/eslint-plugin/src/utils/property-key-name.ts diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap index 98996cba1..2771fb317 100644 --- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap +++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap @@ -39,6 +39,23 @@ exports[`recommended export recommended config 1`] = ` }, "name": "no-duplicate-value", }, + "no-typography-token-prefix": { + "create": [Function: create], + "defaultOptions": [], + "meta": { + "docs": { + "description": "Disallow the \`$\` token prefix on typography keys.", + "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/no-typography-token-prefix", + }, + "fixable": "code", + "messages": { + "noTypographyTokenPrefix": "Typography keys take no \`$\` prefix. Use "{{name}}".", + }, + "schema": [], + "type": "problem", + }, + "name": "no-typography-token-prefix", + }, "no-useless-responsive": { "create": [Function: create], "defaultOptions": [], @@ -73,6 +90,23 @@ exports[`recommended export recommended config 1`] = ` }, "name": "no-useless-tailing-nulls", }, + "prefer-media-shorthand": { + "create": [Function: create], + "defaultOptions": [], + "meta": { + "docs": { + "description": "Prefer media shorthand props over their media queries.", + "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/prefer-media-shorthand", + }, + "fixable": "code", + "messages": { + "preferMediaShorthand": "Use the \`{{shorthand}}\` shorthand for this query.", + }, + "schema": [], + "type": "suggestion", + }, + "name": "prefer-media-shorthand", + }, "style-order-range": { "create": [Function: create], "defaultOptions": [], @@ -96,8 +130,10 @@ exports[`recommended export recommended config 1`] = ` "rules": { "@devup-ui/css-utils-literal-only": "error", "@devup-ui/no-duplicate-value": "error", + "@devup-ui/no-typography-token-prefix": "error", "@devup-ui/no-useless-responsive": "error", "@devup-ui/no-useless-tailing-nulls": "error", + "@devup-ui/prefer-media-shorthand": "warn", "@devup-ui/style-order-range": "error", }, }, diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts index 3827b73a7..b36e20df4 100644 --- a/packages/eslint-plugin/src/configs/recommended.ts +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -1,8 +1,10 @@ import { cssUtilsLiteralOnly, noDuplicateValue, + noTypographyTokenPrefix, noUselessResponsive, noUselessTailingNulls, + preferMediaShorthand, styleOrderRange, } from '../rules' @@ -16,6 +18,8 @@ export default [ 'no-duplicate-value': noDuplicateValue, 'no-useless-responsive': noUselessResponsive, 'style-order-range': styleOrderRange, + 'no-typography-token-prefix': noTypographyTokenPrefix, + 'prefer-media-shorthand': preferMediaShorthand, }, }, }, @@ -25,6 +29,8 @@ export default [ '@devup-ui/no-duplicate-value': 'error', '@devup-ui/no-useless-responsive': 'error', '@devup-ui/style-order-range': 'error', + '@devup-ui/no-typography-token-prefix': 'error', + '@devup-ui/prefer-media-shorthand': 'warn', }, }, ] diff --git a/packages/eslint-plugin/src/rules/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/__tests__/index.test.ts index 16aa96261..624966813 100644 --- a/packages/eslint-plugin/src/rules/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/__tests__/index.test.ts @@ -10,6 +10,8 @@ describe('export index', () => { noDuplicateValue: expect.any(Object), noUselessResponsive: expect.any(Object), styleOrderRange: expect.any(Object), + noTypographyTokenPrefix: expect.any(Object), + preferMediaShorthand: expect.any(Object), }) }) }) diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 671847783..4f12cc9f4 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -1,5 +1,7 @@ export * from './css-utils-literal-only' export * from './no-duplicate-value' +export * from './no-typography-token-prefix' export * from './no-useless-responsive' export * from './no-useless-tailing-nulls' +export * from './prefer-media-shorthand' export * from './style-order-range' diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md new file mode 100644 index 000000000..a8d91d3a9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md @@ -0,0 +1,39 @@ +# no-typography-token-prefix + +Disallow the `$` token prefix on `typography` values. + +## Rule Details + +`typography` takes the bare key of a `theme.typography` entry. Unlike color, +length, and shadow tokens it has no `$` prefix; `typography="$heading"` names a +preset that does not exist, so no style is applied. + +The rule checks string values of `typography` on Devup UI components and +utilities, including values inside responsive arrays, conditionals, and +selector objects. + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css } from '@devup-ui/react' + +; +; +css({ _hover: { typography: '$title' } }) +``` + +#### ✅ Correct + +```tsx +import { Box, css } from '@devup-ui/react' + +; +; +css({ _hover: { typography: 'title' } }) +``` + +## Auto-fixable + +The `$` prefix is removed. diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts new file mode 100644 index 000000000..9c0395101 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts @@ -0,0 +1,56 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noTypographyTokenPrefix } from '../index' + +describe('no-typography-token-prefix rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const imports = 'import { Box, css } from "@devup-ui/react";\n' + ruleTester.run('no-typography-token-prefix rule', noTypographyTokenPrefix, { + valid: [ + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}css({ color: '$primary', ['typography']: 1 })` }, + { code: `${imports}css({ '$heading': 1 })` }, + { code: `${imports}css({ typography: 1 })` }, + { code: `import { Box } from "other";\n` }, + { code: `const a = { typography: '$heading' }` }, + ], + invalid: [ + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { messageId: 'noTypographyTokenPrefix', data: { name: 'heading' } }, + ], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { messageId: 'noTypographyTokenPrefix' }, + { messageId: 'noTypographyTokenPrefix' }, + ], + }, + { + code: `${imports}css({ _hover: { typography: '$title' } })`, + output: `${imports}css({ _hover: { typography: 'title' } })`, + errors: [{ messageId: 'noTypographyTokenPrefix' }], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [{ messageId: 'noTypographyTokenPrefix' }], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts new file mode 100644 index 000000000..31ae5ec2a --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts @@ -0,0 +1,97 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' + +import { ImportStorage } from '../../utils/import-storage' +import { propertyKeyName } from '../../utils/property-key-name' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +/** Whether `literal` is a value of a `typography` prop rather than a condition. */ +function isTypographyValue( + child: TSESTree.Node, + parent: TSESTree.Node | undefined = child.parent, +): boolean { + switch (parent?.type) { + case AST_NODE_TYPES.JSXAttribute: + return ( + parent.name.type === AST_NODE_TYPES.JSXIdentifier && + parent.name.name === 'typography' + ) + case AST_NODE_TYPES.Property: + return parent.value === child && propertyKeyName(parent) === 'typography' + case AST_NODE_TYPES.ConditionalExpression: + return parent.test !== child && isTypographyValue(parent) + case AST_NODE_TYPES.JSXExpressionContainer: + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.LogicalExpression: + return isTypographyValue(parent) + default: + return false + } +} + +export const noTypographyTokenPrefix = createRule({ + name: 'no-typography-token-prefix', + defaultOptions: [], + meta: { + schema: [], + messages: { + noTypographyTokenPrefix: + 'Typography keys take no `$` prefix. Use "{{name}}".', + }, + type: 'problem', + fixable: 'code', + docs: { + description: 'Disallow the `$` token prefix on typography keys.', + }, + }, + create(context) { + const importStorage = new ImportStorage() + let devupContext: + TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + CallExpression(node) { + if (!devupContext && importStorage.checkContextType(node) === 'UTIL') { + devupContext = node + } + }, + 'CallExpression:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXOpeningElement(node) { + if (importStorage.checkContextType(node) === 'COMPONENT') { + devupContext = node + } + }, + 'JSXOpeningElement:exit'(node) { + if (devupContext === node) devupContext = null + }, + Literal(node) { + if ( + !devupContext || + typeof node.value !== 'string' || + !node.value.startsWith('$') || + !isTypographyValue(node) + ) + return + const name = node.value.slice(1) + const quote = node.raw[0] + context.report({ + node, + messageId: 'noTypographyTokenPrefix', + data: { name }, + fix: (fixer) => fixer.replaceText(node, `${quote}${name}${quote}`), + }) + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md new file mode 100644 index 000000000..93128c120 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md @@ -0,0 +1,48 @@ +# prefer-media-shorthand + +Prefer the media shorthand props over spelling out the same media query. + +## Rule Details + +`_media` entries and `'@media …'` keys whose query is exactly one of the +shorthands are reported. Whitespace and letter case in the query are ignored. + +| Query | Shorthand | +| ---------------------------------------- | ---------------- | +| `all` | `_all` | +| `print` | `_print` | +| `screen` | `_screen` | +| `(prefers-reduced-motion: reduce)` | `_motionReduce` | +| `(prefers-reduced-motion: no-preference)` | `_motionSafe` | +| `(orientation: portrait)` | `_portrait` | +| `(orientation: landscape)` | `_landscape` | +| `(prefers-contrast: more)` | `_contrastMore` | +| `(prefers-contrast: less)` | `_contrastLess` | +| `(forced-colors: active)` | `_forcedColors` | + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css } from '@devup-ui/react' + +; +css({ '@media print': { color: 'black' } }) +``` + +#### ✅ Correct + +```tsx +import { Box, css } from '@devup-ui/react' + +; +css({ _print: { color: 'black' } }) +css({ _media: { '(min-width: 500px)': { p: 1 } } }) +``` + +## Auto-fixable + +A `_media` object holding only the matched query, and a static `'@media …'` +key, are rewritten to the shorthand. A `_media` object with other queries is +reported without a fix. diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts new file mode 100644 index 000000000..df0232e17 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts @@ -0,0 +1,76 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { preferMediaShorthand } from '../index' + +describe('prefer-media-shorthand rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const imports = 'import { Box, css } from "@devup-ui/react";\n' + ruleTester.run('prefer-media-shorthand rule', preferMediaShorthand, { + valid: [ + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}css({ '@media (hover: none)': { color: 'red' } })` }, + { code: `${imports}css({ [key]: { color: 'red' }, 1: 2, ...rest })` }, + { + code: `${imports}css({ _media: { [key]: { color: 'red' }, ...rest } })`, + }, + { code: `${imports}css({ '@mediaprint': { color: 'red' } })` }, + { code: `${imports}css(styles)` }, + { + code: `import { Box } from "other";\n`, + }, + { code: `css({ '@media print': { color: 'red' } })` }, + ], + invalid: [ + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { + messageId: 'preferMediaShorthand', + data: { shorthand: '_motionReduce' }, + }, + ], + }, + { + code: `${imports}css({ _media: { print: { color: 'red' } } })`, + output: `${imports}css({ _print: { color: 'red' } })`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}css({ _media: { '(orientation:portrait)': { p: 1 }, '(min-width: 1px)': { p: 2 } } })`, + output: null, + errors: [ + { + messageId: 'preferMediaShorthand', + data: { shorthand: '_portrait' }, + }, + ], + }, + { + code: `${imports}css({ '@media (forced-colors: active)': { color: 'red' } })`, + output: `${imports}css({ _forcedColors: { color: 'red' } })`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}css({ ['@media screen']: { color: 'red' } })`, + output: null, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts new file mode 100644 index 000000000..c82d25d4a --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts @@ -0,0 +1,141 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' + +import { ImportStorage } from '../../utils/import-storage' +import { propertyKeyName } from '../../utils/property-key-name' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +const MEDIA_SHORTHANDS = new Map([ + ['all', '_all'], + ['print', '_print'], + ['screen', '_screen'], + ['(prefers-reduced-motion:reduce)', '_motionReduce'], + ['(prefers-reduced-motion:no-preference)', '_motionSafe'], + ['(orientation:portrait)', '_portrait'], + ['(orientation:landscape)', '_landscape'], + ['(prefers-contrast:more)', '_contrastMore'], + ['(prefers-contrast:less)', '_contrastLess'], + ['(forced-colors:active)', '_forcedColors'], +]) + +const AT_MEDIA = /^@media(?=[\s(])/ + +function shorthandFor(query: string): string | undefined { + return MEDIA_SHORTHANDS.get(query.replace(/\s+/g, '').toLowerCase()) +} + +export const preferMediaShorthand = createRule({ + name: 'prefer-media-shorthand', + defaultOptions: [], + meta: { + schema: [], + messages: { + preferMediaShorthand: 'Use the `{{shorthand}}` shorthand for this query.', + }, + type: 'suggestion', + fixable: 'code', + docs: { + description: 'Prefer media shorthand props over their media queries.', + }, + }, + create(context) { + const importStorage = new ImportStorage() + let devupContext: + TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + + function checkMediaRecord( + owner: TSESTree.Property | TSESTree.JSXAttribute, + record: TSESTree.Node, + ) { + if (record.type !== AST_NODE_TYPES.ObjectExpression) return + for (const prop of record.properties) { + if (prop.type !== AST_NODE_TYPES.Property) continue + const query = propertyKeyName(prop) + const shorthand = query === undefined ? undefined : shorthandFor(query) + if (!shorthand) continue + const value = context.sourceCode.getText(prop.value) + context.report({ + node: prop.key, + messageId: 'preferMediaShorthand', + data: { shorthand }, + fix: + record.properties.length === 1 + ? (fixer) => + fixer.replaceText( + owner, + owner.type === AST_NODE_TYPES.JSXAttribute + ? `${shorthand}={${value}}` + : `${shorthand}: ${value}`, + ) + : null, + }) + } + } + + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + CallExpression(node) { + if ( + !devupContext && + importStorage.checkContextType(node) === 'UTIL' && + node.arguments.length === 1 && + node.arguments[0].type === AST_NODE_TYPES.ObjectExpression + ) { + devupContext = node + } + }, + 'CallExpression:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXOpeningElement(node) { + if (importStorage.checkContextType(node) === 'COMPONENT') { + devupContext = node + } + }, + 'JSXOpeningElement:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXAttribute(node) { + if ( + devupContext && + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === '_media' && + node.value?.type === AST_NODE_TYPES.JSXExpressionContainer + ) { + checkMediaRecord(node, node.value.expression) + } + }, + Property(node) { + if (!devupContext) return + const name = propertyKeyName(node) + if (name === undefined) return + if (name === '_media') { + checkMediaRecord(node, node.value) + return + } + const shorthand = AT_MEDIA.test(name) + ? shorthandFor(name.slice('@media'.length)) + : undefined + if (shorthand) { + context.report({ + node: node.key, + messageId: 'preferMediaShorthand', + data: { shorthand }, + fix: node.computed + ? null + : (fixer) => fixer.replaceText(node.key, shorthand), + }) + } + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/utils/property-key-name.ts b/packages/eslint-plugin/src/utils/property-key-name.ts new file mode 100644 index 000000000..41f20250c --- /dev/null +++ b/packages/eslint-plugin/src/utils/property-key-name.ts @@ -0,0 +1,11 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' + +export function propertyKeyName(node: TSESTree.Property): string | undefined { + if (node.key.type === AST_NODE_TYPES.Identifier && !node.computed) + return node.key.name + if ( + node.key.type === AST_NODE_TYPES.Literal && + typeof node.key.value === 'string' + ) + return node.key.value +} From 99ac1bef04bb1689cb156206b7d73e8a13d93f6e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:45:48 +0900 Subject: [PATCH 03/32] feat(css): add at-rule composition primitives Media shorthand queries, at-rule key parsing, query normalization, media query combination (merge, never-match, nest) and the deterministic order at-rule blocks are emitted in. StyleSelector::At now carries its enclosing at-rules and owning file. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/css/src/at_rule.rs | 570 +++++++++++++++++++++++++++++++++ libs/css/src/lib.rs | 26 +- libs/css/src/style_selector.rs | 356 +++++++++++++++++--- libs/css/src/theme_tokens.rs | 24 ++ 4 files changed, 932 insertions(+), 44 deletions(-) create mode 100644 libs/css/src/at_rule.rs diff --git a/libs/css/src/at_rule.rs b/libs/css/src/at_rule.rs new file mode 100644 index 000000000..55b0c8e41 --- /dev/null +++ b/libs/css/src/at_rule.rs @@ -0,0 +1,570 @@ +//! At-rule preludes: media shorthands, query normalization, media query +//! combination, and the deterministic order at-rule blocks are emitted in. + +use crate::style_selector::AtRuleKind; +use crate::utils::to_kebab_case; + +/// Media shorthand props and the media query each one stands for, keyed by the +/// kebab-cased name shared with the matching Tailwind variant. +const MEDIA_SHORTHANDS: [(&str, &str); 10] = [ + ("all", "all"), + ("contrast-less", "(prefers-contrast:less)"), + ("contrast-more", "(prefers-contrast:more)"), + ("forced-colors", "(forced-colors:active)"), + ("landscape", "(orientation:landscape)"), + ("motion-reduce", "(prefers-reduced-motion:reduce)"), + ("motion-safe", "(prefers-reduced-motion:no-preference)"), + ("portrait", "(orientation:portrait)"), + ("print", "print"), + ("screen", "screen"), +]; + +/// The media query a shorthand prop (`print`, `motionReduce`, `motion-reduce`, …) +/// stands for. +#[must_use] +pub fn media_shorthand_query(name: &str) -> Option<&'static str> { + let name = to_kebab_case(name); + MEDIA_SHORTHANDS + .iter() + .find(|(key, _)| *key == name) + .map(|(_, query)| *query) +} + +/// Split an at-rule key such as `@media print` into its kind and prelude. +#[must_use] +pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> { + let rest = key.trim().strip_prefix('@')?; + [ + ("media", AtRuleKind::Media), + ("supports", AtRuleKind::Supports), + ("container", AtRuleKind::Container), + ] + .into_iter() + .find_map(|(name, kind)| { + let query = rest.strip_prefix(name)?; + (query.starts_with([' ', '(']) && !query.trim().is_empty()).then_some((kind, query.trim())) + }) +} + +const fn is_word_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '%') || !c.is_ascii() +} + +fn leading_word(text: &str) -> &str { + let end = text + .char_indices() + .find(|(_, c)| !is_word_char(*c)) + .map_or(text.len(), |(index, _)| index); + &text[..end] +} + +fn trailing_word(text: &str) -> &str { + let start = text + .char_indices() + .rev() + .take_while(|(_, c)| is_word_char(*c)) + .last() + .map_or(text.len(), |(index, _)| index); + &text[start..] +} + +fn is_keyword(word: &str) -> bool { + ["and", "or", "not", "only"] + .iter() + .any(|keyword| word.eq_ignore_ascii_case(keyword)) +} + +/// Whether `text` ends with a standalone `and`/`or`/`not`/`only` keyword, which +/// needs a space before a following `(` so it is not read as a function. +fn ends_with_keyword(text: &str) -> bool { + let word = trailing_word(text); + is_keyword(word) + && text[..text.len() - word.len()] + .chars() + .next_back() + .is_none_or(|c| matches!(c, ' ' | '(' | ')' | ',')) +} + +/// Whether the whitespace between `prev` and `next` must survive as one space. +const fn keeps_space(prev: char, next: char, in_function: bool) -> bool { + if matches!(prev, '(' | ',') || matches!(next, ')' | ',') { + return false; + } + in_function + || !(matches!(prev, ':' | '<' | '>' | '=' | ')') || matches!(next, ':' | '<' | '>' | '=')) +} + +/// Rewrite an at-rule prelude into its shortest equivalent spelling. +/// +/// Whitespace is dropped wherever the tokens stay unambiguous (`(min-width: 1px)` +/// becomes `(min-width:1px)`, `) and (` becomes `)and (`) and kept where CSS +/// needs it: between words and before a `(` that follows a word, since `and(` +/// or `sidebar(` would tokenize as a function. A missing space in `and(` is +/// added back. Function arguments (`calc(1px + 2px)`, `selector(a :hover)`) +/// and quoted strings keep their significant whitespace. +#[must_use] +pub fn normalize_query(query: &str) -> String { + let mut out = String::with_capacity(query.len()); + // One entry per open parenthesis: `true` when it opened a function, whose + // arguments can contain significant whitespace. + let mut parens: Vec = Vec::new(); + let mut pending_space = false; + let mut chars = query.chars(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + pending_space = true; + continue; + } + let in_function = parens.last().copied().unwrap_or(false); + let keyword_paren = c == '(' && !in_function && ends_with_keyword(&out); + let function_paren = c == '(' + && !pending_space + && !keyword_paren + && out.chars().next_back().is_some_and(is_word_char); + if let Some(prev) = out.chars().next_back() + && (keyword_paren || (pending_space && keeps_space(prev, c, in_function))) + { + out.push(' '); + } + pending_space = false; + out.push(c); + match c { + '(' => parens.push(function_paren), + ')' => { + parens.pop(); + } + '"' | '\'' => { + let mut escaped = false; + for inner in chars.by_ref() { + out.push(inner); + if escaped { + escaped = false; + } else if inner == '\\' { + escaped = true; + } else if inner == c { + break; + } + } + } + _ => {} + } + } + out +} + +/// Result of folding two media queries into one. +#[derive(Debug, PartialEq, Eq)] +pub enum MediaCombination { + /// Both queries hold exactly when this single query list holds. + Merged(String), + /// The queries can never hold together (e.g. `print` inside `screen`). + Never, + /// No single query expresses both; emit nested `@media` blocks instead. + Nest, +} + +struct MediaQuery<'a> { + negated: bool, + only: bool, + media_type: Option<&'a str>, + condition: Option<&'a str>, +} + +fn split_list(query: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (index, c) in query.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(query[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + parts.push(query[start..].trim()); + parts +} + +fn parse_media_query(query: &str) -> Option> { + let condition_only = MediaQuery { + negated: false, + only: false, + media_type: None, + condition: Some(query), + }; + if query.starts_with('(') { + return Some(condition_only); + } + let first = leading_word(query); + let rest = &query[first.len()..]; + let (negated, only, typed) = if first.eq_ignore_ascii_case("not") { + if rest.starts_with(" (") { + return Some(condition_only); + } + (true, false, rest.strip_prefix(' ')?) + } else if first.eq_ignore_ascii_case("only") { + (false, true, rest.strip_prefix(' ')?) + } else { + (false, false, query) + }; + let media_type = leading_word(typed); + if media_type.is_empty() || is_keyword(media_type) { + return None; + } + let after = &typed[media_type.len()..]; + let condition = if after.is_empty() { + None + } else { + Some( + after + .strip_prefix(" and ") + .filter(|condition| condition.starts_with('(') || condition.starts_with("not "))?, + ) + }; + Some(MediaQuery { + negated, + only, + media_type: Some(media_type), + condition, + }) +} + +/// Turn `not print` into `screen` (and back), the only negations with a +/// positive spelling; anything else cannot be folded into another query. +const fn resolve_negation(query: MediaQuery<'_>) -> Result, MediaCombination> { + if !query.negated { + return Ok(query); + } + if query.condition.is_some() { + return Err(MediaCombination::Nest); + } + let complement = match query.media_type { + Some(media_type) if media_type.eq_ignore_ascii_case("print") => "screen", + Some(media_type) if media_type.eq_ignore_ascii_case("screen") => "print", + Some(media_type) if media_type.eq_ignore_ascii_case("all") => { + return Err(MediaCombination::Never); + } + _ => return Err(MediaCombination::Nest), + }; + Ok(MediaQuery { + negated: false, + only: false, + media_type: Some(complement), + condition: None, + }) +} + +fn has_top_level_or(condition: &str) -> bool { + let mut depth = 0usize; + let mut previous = ' '; + for (index, c) in condition.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ if depth == 0 + && !is_word_char(previous) + && leading_word(&condition[index..]).eq_ignore_ascii_case("or") => + { + return true; + } + _ => {} + } + previous = c; + } + false +} + +fn specific_type(media_type: Option<&str>) -> Option<&str> { + media_type.filter(|media_type| !media_type.eq_ignore_ascii_case("all")) +} + +fn combine_single(outer: MediaQuery<'_>, inner: MediaQuery<'_>) -> MediaCombination { + let (outer, inner) = match (resolve_negation(outer), resolve_negation(inner)) { + (Ok(outer), Ok(inner)) => (outer, inner), + (Err(MediaCombination::Never), _) | (_, Err(MediaCombination::Never)) => { + return MediaCombination::Never; + } + _ => return MediaCombination::Nest, + }; + let media_type = match ( + specific_type(outer.media_type), + specific_type(inner.media_type), + ) { + (None, media_type) | (media_type, None) => media_type, + (Some(a), Some(b)) if a.eq_ignore_ascii_case(b) => Some(a), + _ => return MediaCombination::Never, + }; + let conditions: Vec<&str> = [outer.condition, inner.condition] + .into_iter() + .flatten() + .collect(); + let joined = conditions.len() > 1; + + let mut result = String::new(); + if let Some(media_type) = media_type { + if outer.only || inner.only { + result.push_str("only "); + } + result.push_str(media_type); + if !conditions.is_empty() { + result.push_str(" and "); + } + } + for (index, condition) in conditions.iter().enumerate() { + if index > 0 { + result.push_str("and "); + } + let wrap = (has_top_level_or(condition) && (joined || media_type.is_some())) + || (joined && leading_word(condition).eq_ignore_ascii_case("not")); + if wrap { + result.push('('); + result.push_str(condition); + result.push(')'); + } else { + result.push_str(condition); + } + } + if result.is_empty() { + result.push_str("all"); + } + MediaCombination::Merged(result) +} + +/// Fold `inner` into `outer` so one `@media` holds exactly when both would. +/// +/// Both preludes must already be normalized. Query lists distribute over each +/// other, `not print`/`not screen` become `screen`/`print`, conflicting media +/// types collapse to [`MediaCombination::Never`], and anything that has no +/// single-query spelling (such as `not print and (color)`) asks for nesting. +#[must_use] +pub fn combine_media_queries(outer: &str, inner: &str) -> MediaCombination { + let mut merged: Vec = Vec::new(); + for outer_query in split_list(outer) { + for inner_query in split_list(inner) { + let (Some(outer_query), Some(inner_query)) = ( + parse_media_query(outer_query), + parse_media_query(inner_query), + ) else { + return MediaCombination::Nest; + }; + match combine_single(outer_query, inner_query) { + MediaCombination::Merged(query) => { + if !merged.contains(&query) { + merged.push(query); + } + } + MediaCombination::Never => {} + MediaCombination::Nest => return MediaCombination::Nest, + } + } + } + if merged.is_empty() { + MediaCombination::Never + } else { + MediaCombination::Merged(merged.join(",")) + } +} + +fn length_px(value: &str) -> Option { + let number_end = value + .char_indices() + .find(|(_, c)| !(c.is_ascii_digit() || *c == '.')) + .map_or(value.len(), |(index, _)| index); + let number: f64 = value[..number_end].parse().ok()?; + let unit = &value[number_end..]; + let scale = if unit.eq_ignore_ascii_case("em") || unit.eq_ignore_ascii_case("rem") { + 16.0 + } else { + 1.0 + }; + Some((number * scale * 100.0).round() as i64) +} + +/// The first width bound of a normalized query: `(true, px)` for a lower bound +/// (`min-width`, `width>=`), `(false, px)` for an upper one (`max-width`, `width<=`). +fn width_bound(query: &str) -> Option<(bool, i64)> { + query.split(['(', ')']).find_map(|term| { + if let Some(value) = term.strip_prefix("min-width:") { + return Some((true, length_px(value)?)); + } + if let Some(value) = term.strip_prefix("max-width:") { + return Some((false, length_px(value)?)); + } + let split = term.find(['<', '>'])?; + let (left, right) = term.split_at(split); + let lower = right.starts_with('>'); + let right = right.trim_start_matches(['<', '>', '=']); + if left == "width" { + Some((lower, length_px(right)?)) + } else if right == "width" || right.starts_with("width<") || right.starts_with("width>") { + Some((!lower, length_px(left)?)) + } else { + None + } + }) +} + +/// Sort key giving at-rule blocks a deterministic cascade. +/// +/// Width queries come first, mobile-first (`min-width` ascending, then +/// `max-width` descending), then bare media types, then other media features, +/// then user preferences (`prefers-*`, `forced-colors`) so those win last. +#[must_use] +pub fn query_order(kind: AtRuleKind, query: &str) -> (u8, i64) { + if matches!(kind, AtRuleKind::Media | AtRuleKind::Container) + && let Some((lower, px)) = width_bound(query) + { + return if lower { (0, px) } else { (1, -px) }; + } + if kind != AtRuleKind::Media { + (3, 0) + } else if query.contains("prefers-") || query.contains("forced-colors") { + (4, 0) + } else if query.contains('(') { + (3, 0) + } else { + (2, 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("print", Some("print"))] + #[case("screen", Some("screen"))] + #[case("all", Some("all"))] + #[case("motionReduce", Some("(prefers-reduced-motion:reduce)"))] + #[case("motion-reduce", Some("(prefers-reduced-motion:reduce)"))] + #[case("motionSafe", Some("(prefers-reduced-motion:no-preference)"))] + #[case("portrait", Some("(orientation:portrait)"))] + #[case("landscape", Some("(orientation:landscape)"))] + #[case("contrastMore", Some("(prefers-contrast:more)"))] + #[case("contrastLess", Some("(prefers-contrast:less)"))] + #[case("forcedColors", Some("(forced-colors:active)"))] + #[case("speech", None)] + #[case("hover", None)] + fn test_media_shorthand_query(#[case] name: &str, #[case] expected: Option<&str>) { + assert_eq!(media_shorthand_query(name), expected); + } + + #[rstest] + #[case("@media print", Some((AtRuleKind::Media, "print")))] + #[case("@media(min-width: 1px)", Some((AtRuleKind::Media, "(min-width: 1px)")))] + #[case(" @supports (display: grid) ", Some((AtRuleKind::Supports, "(display: grid)")))] + #[case("@container sidebar (min-width: 1px)", Some((AtRuleKind::Container, "sidebar (min-width: 1px)")))] + #[case("@media", None)] + #[case("@media ", None)] + #[case("@mediaprint", None)] + #[case("@layer reset", None)] + #[case("media print", None)] + fn test_split_at_rule_key(#[case] key: &str, #[case] expected: Option<(AtRuleKind, &str)>) { + assert_eq!(split_at_rule_key(key), expected); + } + + #[rstest] + #[case("print", "print")] + #[case(" print ", "print")] + #[case("(prefers-reduced-motion: reduce)", "(prefers-reduced-motion:reduce)")] + #[case("( min-width : 768px )", "(min-width:768px)")] + #[case( + "(min-width: 768px) and (max-width: 1024px)", + "(min-width:768px)and (max-width:1024px)" + )] + #[case("screen and (min-width: 500px)", "screen and (min-width:500px)")] + #[case( + "only screen and(min-width:768px)", + "only screen and (min-width:768px)" + )] + #[case("not (display: grid)", "not (display:grid)")] + #[case("not(display:grid)", "not (display:grid)")] + #[case("(a) or(b)", "(a)or (b)")] + #[case("sidebar (min-width: 400px)", "sidebar (min-width:400px)")] + #[case( + "(orientation: portrait), (hover: none)", + "(orientation:portrait),(hover:none)" + )] + #[case("(width >= 600px)", "(width>=600px)")] + #[case("(400px <= width <= 700px)", "(400px<=width<=700px)")] + #[case("(min-width: calc(100px + 2em))", "(min-width:calc(100px + 2em))")] + #[case("selector(a :hover)", "selector(a :hover)")] + #[case("selector(:not(a))", "selector(:not(a))")] + #[case("style(--label: \"a b\")", "style(--label: \"a b\")")] + #[case("style(--label: 'it\\'s x')", "style(--label: 'it\\'s x')")] + #[case("(not (color)) or (hover)", "(not (color))or (hover)")] + #[case("", "")] + fn test_normalize_query(#[case] query: &str, #[case] expected: &str) { + assert_eq!(normalize_query(query), expected); + } + + #[rstest] + #[case("(min-width:768px)", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("(min-width:768px)and (prefers-reduced-motion:reduce)".to_string()))] + #[case("(min-width:768px)", "print", MediaCombination::Merged("print and (min-width:768px)".to_string()))] + #[case("print", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("print and (prefers-reduced-motion:reduce)".to_string()))] + #[case("(min-width:768px)", "screen and (min-width:100px)", MediaCombination::Merged("screen and (min-width:768px)and (min-width:100px)".to_string()))] + #[case("(min-width:768px)", "only screen", MediaCombination::Merged("only screen and (min-width:768px)".to_string()))] + #[case("(min-width:768px)", "not print", MediaCombination::Merged("screen and (min-width:768px)".to_string()))] + #[case("(min-width:768px)", "not screen", MediaCombination::Merged("print and (min-width:768px)".to_string()))] + #[case("(min-width:768px)", "not all", MediaCombination::Never)] + #[case("(min-width:768px)", "not tv", MediaCombination::Nest)] + #[case("(min-width:768px)", "not print and (color)", MediaCombination::Nest)] + #[case("print", "screen", MediaCombination::Never)] + #[case("print", "PRINT", MediaCombination::Merged("print".to_string()))] + #[case("all", "print", MediaCombination::Merged("print".to_string()))] + #[case("all", "all", MediaCombination::Merged("all".to_string()))] + #[case("(min-width:768px)", "(orientation:portrait),(hover:none)", MediaCombination::Merged("(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none)".to_string()))] + #[case("(min-width:768px)", "print,screen", MediaCombination::Merged("print and (min-width:768px),screen and (min-width:768px)".to_string()))] + #[case("print", "print,screen", MediaCombination::Merged("print".to_string()))] + #[case("(min-width:768px)", "(a)or (b)", MediaCombination::Merged("(min-width:768px)and ((a)or (b))".to_string()))] + #[case("print", "(a)or (b)", MediaCombination::Merged("print and ((a)or (b))".to_string()))] + #[case("(min-width:768px)", "not (color)", MediaCombination::Merged("(min-width:768px)and (not (color))".to_string()))] + #[case("print", "not (color)", MediaCombination::Merged("print and not (color)".to_string()))] + #[case("(a)", "(b)", MediaCombination::Merged("(a)and (b)".to_string()))] + #[case("(min-width:768px)", "print and speech", MediaCombination::Nest)] + #[case("(min-width:768px)", "only", MediaCombination::Nest)] + #[case("(min-width:768px)", "not", MediaCombination::Nest)] + #[case("(min-width:768px)", "and", MediaCombination::Nest)] + #[case("(min-width:768px)", "", MediaCombination::Nest)] + fn test_combine_media_queries( + #[case] outer: &str, + #[case] inner: &str, + #[case] expected: MediaCombination, + ) { + assert_eq!(combine_media_queries(outer, inner), expected); + } + + #[rstest] + #[case(AtRuleKind::Media, "(min-width:500px)", (0, 50_000))] + #[case(AtRuleKind::Media, "(min-width:30em)", (0, 48_000))] + #[case(AtRuleKind::Media, "(min-width:2rem)", (0, 3_200))] + #[case(AtRuleKind::Media, "(width>=600px)", (0, 60_000))] + #[case(AtRuleKind::Media, "(width>600px)", (0, 60_000))] + #[case(AtRuleKind::Media, "(600px<=width)", (0, 60_000))] + #[case(AtRuleKind::Media, "(400px<=width<=700px)", (0, 40_000))] + #[case(AtRuleKind::Media, "(max-width:1000px)", (1, -100_000))] + #[case(AtRuleKind::Media, "(width<=900px)", (1, -90_000))] + #[case(AtRuleKind::Media, "(900px>=width)", (1, -90_000))] + #[case(AtRuleKind::Media, "screen and (min-width:10px)", (0, 1_000))] + #[case(AtRuleKind::Media, "(min-width:abc)", (3, 0))] + #[case(AtRuleKind::Media, "(height>=10px)", (3, 0))] + #[case(AtRuleKind::Media, "print", (2, 0))] + #[case(AtRuleKind::Media, "(orientation:portrait)", (3, 0))] + #[case(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", (4, 0))] + #[case(AtRuleKind::Media, "(forced-colors:active)", (4, 0))] + #[case(AtRuleKind::Container, "(min-width:400px)", (0, 40_000))] + #[case(AtRuleKind::Container, "sidebar (orientation:portrait)", (3, 0))] + #[case(AtRuleKind::Supports, "(display:grid)", (3, 0))] + fn test_query_order( + #[case] kind: AtRuleKind, + #[case] query: &str, + #[case] expected: (u8, i64), + ) { + assert_eq!(query_order(kind, query), expected); + } +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index f9479f02e..bdd33131e 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,3 +1,4 @@ +pub mod at_rule; pub mod atom_hoist; pub mod class_map; mod constant; @@ -186,6 +187,11 @@ impl ExactSizeIterator for DisassembleProperty {} #[must_use] pub fn disassemble_property(property: &str) -> DisassembleProperty { + // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties; + // keep them verbatim so class names and case survive. + if property.starts_with(':') || property.contains('&') { + return DisassembleProperty::Fallback(Some(property.to_string())); + } if let Some(properties) = HAS_CUSTOM_SHORTHANDS .load(Ordering::Relaxed) .then(|| { @@ -292,10 +298,14 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect kind, query, selector, + outer, + file, } => StyleSelector::At { kind, query, selector: selector.map(|s| format!("{s}({params})")), + outer, + file, }, } } @@ -1120,7 +1130,9 @@ mod tests { Some(&StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: None + selector: None, + outer: vec![], + file: None, }) ), ".cls" @@ -1132,7 +1144,9 @@ mod tests { Some(&StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: Some("&:hover".to_string()) + selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }) ), ".cls:hover" @@ -1197,14 +1211,18 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: Some("&:is".to_string()) + selector: Some("&:is".to_string()), + outer: vec![], + file: None, }, "test" ), StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: Some("&:is(test)".to_string()) + selector: Some("&:is(test)".to_string()), + outer: vec![], + file: None, } ); } diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index ff839bb3c..a36bdc0f4 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -7,6 +7,7 @@ use std::{ use serde::{Deserialize, Serialize}; use crate::{ + at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query}, constant::SELECTOR_ORDER, selector_separator::SelectorSeparator, to_kebab_case, @@ -47,12 +48,25 @@ impl From<&str> for AtRuleKind { } } +#[derive(Debug, PartialEq, PartialOrd, Ord, Clone, Hash, Eq, Serialize, Deserialize)] +pub struct AtRule { + pub kind: AtRuleKind, + pub query: String, +} + #[derive(Debug, PartialEq, Clone, Hash, Eq, Serialize, Deserialize)] pub enum StyleSelector { At { kind: AtRuleKind, query: String, selector: Option, + /// At-rules enclosing this one, outermost first. Only set when the + /// rules cannot be folded into a single `@media` query. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + outer: Vec, + /// Source file of a `globalCss` rule, so re-extracting that file drops it. + #[serde(default, skip_serializing_if = "Option::is_none")] + file: Option, }, Selector(String), // selector, file @@ -84,10 +98,14 @@ pub fn optimize_selector(selector: StyleSelector) -> StyleSelector { kind, query, selector, + outer, + file, } => StyleSelector::At { kind, query, selector: selector.map(collapse_owned_selector), + outer, + file, }, StyleSelector::Selector(selector) => { StyleSelector::Selector(collapse_owned_selector(selector)) @@ -111,20 +129,22 @@ impl Ord for StyleSelector { kind: ka, query: a, selector: aa, + outer: oa, + file: fa, }, StyleSelector::At { kind: kb, query: b, selector: bb, + outer: ob, + file: fb, }, - ) => { - let k = (*ka as u8).cmp(&(*kb as u8)); - if k != Ordering::Equal { - return k; - } - let c = a.cmp(b); - if c == Ordering::Equal { aa.cmp(bb) } else { c } - } + ) => (*ka as u8) + .cmp(&(*kb as u8)) + .then_with(|| a.cmp(b)) + .then_with(|| aa.cmp(bb)) + .then_with(|| oa.cmp(ob)) + .then_with(|| fa.cmp(fb)), (StyleSelector::Selector(a), StyleSelector::Selector(b)) => { let order_cmp = get_selector_order(a).cmp(&get_selector_order(b)); if order_cmp == Ordering::Equal { @@ -181,11 +201,13 @@ impl From<&str> for StyleSelector { } else if let Some(s) = value.strip_prefix("theme-") { // first character should lower case StyleSelector::Selector(format!(":root[data-theme={}] &", to_camel_case(s))) - } else if matches!(value.as_ref(), "print" | "screen" | "speech" | "all") { + } else if let Some(query) = media_shorthand_query(&value) { StyleSelector::At { kind: AtRuleKind::Media, - query: value.into_owned(), + query: query.to_string(), selector: None, + outer: vec![], + file: None, } } else { let post = to_kebab_case(&value); @@ -214,23 +236,19 @@ impl From<[&str; 2]> for StyleSelector { )) } } -impl From<(&StyleSelector, &str)> for StyleSelector { - fn from(value: (&StyleSelector, &str)) -> Self { - if let StyleSelector::Global(_, file) = value.0 { - let post = to_kebab_case(value.1); - StyleSelector::Global( - format!( - "{}{}{}", - value.0, - SelectorSeparator::from(post.as_ref()), - post - ), - file.clone(), - ) - } else { - StyleSelector::from([&value.0.to_string(), value.1]) - } + +/// Write an at-rule prelude (`@media print`, `@media(min-width:1px)`), spacing +/// the query only when it does not open with a parenthesis. +pub fn write_at_rule( + out: &mut impl std::fmt::Write, + kind: AtRuleKind, + query: &str, +) -> std::fmt::Result { + write!(out, "@{kind}")?; + if !query.starts_with('(') { + out.write_char(' ')?; } + out.write_str(query) } impl Display for StyleSelector { @@ -241,12 +259,14 @@ impl Display for StyleSelector { kind, query, selector, + outer, + .. } => { - write!(f, "@{kind}")?; - if !query.starts_with('(') { + for rule in outer { + write_at_rule(f, rule.kind, &rule.query)?; f.write_str(" ")?; } - f.write_str(query)?; + write_at_rule(f, *kind, query)?; if let Some(selector) = selector { write!(f, " {selector}")?; } @@ -272,6 +292,87 @@ impl StyleSelector { StyleSelector::At { .. } => std::borrow::Cow::Owned(self.to_string()), } } + + /// Nest a selector `template` (e.g. `&:hover`, `:root[data-theme=dark] &`) + /// inside `parent`, substituting the parent's selector for `&`. + #[must_use] + pub fn nest_selector(parent: Option<&Self>, template: &str) -> Self { + match parent { + None => Self::Selector(template.to_string()), + Some(Self::Selector(selector)) => Self::Selector(template.replace('&', selector)), + Some(Self::Global(selector, file)) => { + Self::Global(template.replace('&', selector), file.clone()) + } + Some(Self::At { + kind, + query, + selector, + outer, + file, + }) => Self::At { + kind: *kind, + query: query.clone(), + selector: Some( + selector + .as_deref() + .map_or_else(|| template.to_string(), |s| template.replace('&', s)), + ), + outer: outer.clone(), + file: file.clone(), + }, + } + } + + /// Wrap `parent` (the bare class when `None`) in an at-rule. Nested + /// `@media` rules fold into one query where possible; `None` means the + /// combined condition can never match, so the styles must be dropped. + #[must_use] + pub fn nest_at_rule(parent: Option<&Self>, kind: AtRuleKind, query: &str) -> Option { + let query = normalize_query(query); + let (selector, outer, file) = match parent { + None => (None, vec![], None), + Some(Self::Selector(selector)) => (Some(selector.clone()), vec![], None), + Some(Self::Global(selector, file)) => { + (Some(selector.clone()), vec![], Some(file.clone())) + } + Some(Self::At { + kind: parent_kind, + query: parent_query, + selector, + outer, + file, + }) => { + if *parent_kind == AtRuleKind::Media && kind == AtRuleKind::Media { + match combine_media_queries(parent_query, &query) { + MediaCombination::Merged(query) => { + return Some(Self::At { + kind, + query, + selector: selector.clone(), + outer: outer.clone(), + file: file.clone(), + }); + } + MediaCombination::Never => return None, + MediaCombination::Nest => {} + } + } + let mut outer = outer.clone(); + outer.push(AtRule { + kind: *parent_kind, + query: parent_query.clone(), + }); + (selector.clone(), outer, file.clone()) + } + }; + Some(Self::At { + kind, + query, + selector, + outer, + file, + }) + } } /// Rank a `Global` selector's pseudo suffix by scanning `SELECTOR_ORDER` once. @@ -355,6 +456,7 @@ mod tests { #[case("group-focus-visible", StyleSelector::Selector(":is([role=group],[data-group]):focus-visible &".to_string()))] #[case("group-1", StyleSelector::Selector(":is([role=group],[data-group]):1 &".to_string()))] #[case(["theme-dark", "placeholder"], StyleSelector::Selector(":root[data-theme=dark] &::placeholder".to_string()))] + #[case(["theme-dark", "&:hover"], StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string()))] #[case("theme-light", StyleSelector::Selector(":root[data-theme=light] &".to_string()))] #[case("*[aria=disabled='true'] &:hover", StyleSelector::Selector("*[aria=disabled='true'] &:hover".to_string()))] fn test_style_selector( @@ -369,28 +471,28 @@ mod tests { #[case(StyleSelector::At { kind: AtRuleKind::Media, query: "screen and (max-width: 600px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@media screen and (max-width: 600px)" )] #[case(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@supports(display: grid)" )] #[case(StyleSelector::At { kind: AtRuleKind::Container, query: "(min-width: 768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@container(min-width: 768px)" )] #[case(StyleSelector::At { kind: AtRuleKind::Container, query: "sidebar (min-width: 400px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@container sidebar (min-width: 400px)" )] @@ -398,7 +500,7 @@ mod tests { #[case(StyleSelector::At { kind: AtRuleKind::Layer, query: "reset".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@layer reset" )] @@ -412,7 +514,7 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "screen".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, StyleSelector::Selector("&:hover".to_string()), std::cmp::Ordering::Greater @@ -426,12 +528,12 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "a".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, StyleSelector::At { kind: AtRuleKind::Media, query: "b".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, std::cmp::Ordering::Less )] @@ -439,12 +541,12 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width: 768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, StyleSelector::At { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, std::cmp::Ordering::Less )] @@ -463,7 +565,7 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "screen".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, std::cmp::Ordering::Less )] @@ -631,6 +733,180 @@ mod tests { ); } + fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector { + StyleSelector::At { + kind, + query: query.to_string(), + selector: selector.map(str::to_string), + outer: vec![], + file: None, + } + } + + #[rstest] + #[case(None, "&:hover", StyleSelector::Selector("&:hover".to_string()))] + #[case( + Some(StyleSelector::Selector("&:hover".to_string())), + ":root[data-theme=dark] &", + StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string()) + )] + #[case( + Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())), + "&:hover", + StyleSelector::Global("body:hover".to_string(), "a.tsx".to_string()) + )] + #[case( + Some(at(AtRuleKind::Media, "print", None)), + "&:hover", + at(AtRuleKind::Media, "print", Some("&:hover")) + )] + #[case( + Some(at(AtRuleKind::Media, "print", Some("&:focus"))), + "&:hover", + at(AtRuleKind::Media, "print", Some("&:focus:hover")) + )] + fn test_nest_selector( + #[case] parent: Option, + #[case] template: &str, + #[case] expected: StyleSelector, + ) { + assert_eq!( + StyleSelector::nest_selector(parent.as_ref(), template), + expected + ); + } + + #[rstest] + #[case( + None, + AtRuleKind::Media, + "(prefers-reduced-motion: reduce)", + Some(at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None)) + )] + #[case( + Some(StyleSelector::Selector("&:hover".to_string())), + AtRuleKind::Media, + "print", + Some(at(AtRuleKind::Media, "print", Some("&:hover"))) + )] + #[case( + Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())), + AtRuleKind::Media, + "print", + Some(StyleSelector::At { + kind: AtRuleKind::Media, + query: "print".to_string(), + selector: Some("body".to_string()), + outer: vec![], + file: Some("a.tsx".to_string()), + }) + )] + #[case( + Some(at(AtRuleKind::Media, "print", Some("&:hover"))), + AtRuleKind::Media, + "(prefers-reduced-motion:reduce)", + Some(at( + AtRuleKind::Media, + "print and (prefers-reduced-motion:reduce)", + Some("&:hover") + )) + )] + #[case( + Some(at(AtRuleKind::Media, "print", None)), + AtRuleKind::Media, + "screen", + None + )] + #[case( + Some(at(AtRuleKind::Media, "print", None)), + AtRuleKind::Supports, + "(display: grid)", + Some(StyleSelector::At { + kind: AtRuleKind::Supports, + query: "(display:grid)".to_string(), + selector: None, + outer: vec![AtRule { kind: AtRuleKind::Media, query: "print".to_string() }], + file: None, + }) + )] + #[case( + Some(at(AtRuleKind::Media, "not print and (color)", None)), + AtRuleKind::Media, + "(hover:none)", + Some(StyleSelector::At { + kind: AtRuleKind::Media, + query: "(hover:none)".to_string(), + selector: None, + outer: vec![AtRule { kind: AtRuleKind::Media, query: "not print and (color)".to_string() }], + file: None, + }) + )] + fn test_nest_at_rule( + #[case] parent: Option, + #[case] kind: AtRuleKind, + #[case] query: &str, + #[case] expected: Option, + ) { + assert_eq!( + StyleSelector::nest_at_rule(parent.as_ref(), kind, query), + expected + ); + } + + #[test] + fn test_nested_at_rule_display_and_order() { + let nested = StyleSelector::At { + kind: AtRuleKind::Supports, + query: "(display:grid)".to_string(), + selector: Some("&:hover".to_string()), + outer: vec![AtRule { + kind: AtRuleKind::Media, + query: "print".to_string(), + }], + file: None, + }; + assert_eq!( + nested.to_string(), + "@media print @supports(display:grid) &:hover" + ); + assert_eq!( + nested.as_class_str(), + "@media print @supports(display:grid) &:hover" + ); + + let plain = at(AtRuleKind::Supports, "(display:grid)", Some("&:hover")); + assert_eq!(plain.cmp(&nested), std::cmp::Ordering::Less); + let global = StyleSelector::At { + kind: AtRuleKind::Supports, + query: "(display:grid)".to_string(), + selector: Some("&:hover".to_string()), + outer: vec![], + file: Some("a.tsx".to_string()), + }; + assert_eq!(plain.cmp(&global), std::cmp::Ordering::Less); + assert_eq!( + optimize_selector(global.clone()), + global, + "optimizing keeps the enclosing rules and file" + ); + } + + #[test] + fn test_media_shorthand_selector() { + assert_eq!( + StyleSelector::from("motion-reduce"), + at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None) + ); + assert_eq!( + StyleSelector::from("print"), + at(AtRuleKind::Media, "print", None) + ); + assert_eq!( + StyleSelector::from("speech"), + StyleSelector::Selector("&:speech".to_string()) + ); + } + #[rstest] #[case(AtRuleKind::Media, "media")] #[case(AtRuleKind::Supports, "supports")] diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 961e8c1f4..d98859d87 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -5,6 +5,7 @@ use std::sync::{LazyLock, RwLock}; struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, + typography: Vec, } static TOKEN_REGISTRY: LazyLock> = @@ -20,6 +21,21 @@ pub fn set_theme_token_levels( } } +pub fn set_typography_keys(keys: Vec) { + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + registry.typography = keys; + } +} + +/// Typography names defined by the registered theme, so a dynamic +/// `typography` value under a selector can resolve to one class per name. +pub fn get_typography_keys() -> Vec { + TOKEN_REGISTRY + .read() + .map(|registry| registry.typography.clone()) + .unwrap_or_default() +} + /// Look up a `$token` in the length and shadow registries. /// Returns the responsive breakpoint levels if the token is defined /// with more than one level, regardless of which CSS property it's used on. @@ -94,6 +110,14 @@ mod tests { assert!(!is_responsive_theme_token("noprefix")); } + #[test] + fn test_typography_keys() { + set_typography_keys(vec!["body".to_string(), "title".to_string()]); + assert_eq!(get_typography_keys(), vec!["body", "title"]); + set_typography_keys(vec![]); + assert!(get_typography_keys().is_empty()); + } + #[test] fn test_is_responsive_theme_token_without_prefix() { let value = std::hint::black_box("noprefix"); From 986173053ee112f682b1af29f4cacb63f7405058 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:48:00 +0900 Subject: [PATCH 04/32] fix(extractor): compose at-rules with selectors and resolve vanilla-extract references Nest _print, _media and @media keys with selectors in both directions (props, globalCss, template literals, Tailwind variants, StyleX conditions), apply typography conditionally, and keep responsive globalCss values in the base layer. vanilla-extract selectors, values and globalStyle now resolve references to styles, keyframes, fonts and themes, and createVar/fontFace/layer exports no longer shift onto each other. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/css_utils.rs | 434 ++++------- .../extract_global_style_from_expression.rs | 114 ++- .../extract_style_from_expression.rs | 307 +++++--- libs/extractor/src/lib.rs | 53 +- libs/extractor/src/prop_modify_utils.rs | 10 +- ...xtractor__tests__at_rules_at_prefix-2.snap | 4 +- ...xtractor__tests__at_rules_at_prefix-3.snap | 4 +- .../extractor__tests__at_rules_at_prefix.snap | 4 +- ...__tests__at_rules_underscore_prefix-2.snap | 4 +- ...__tests__at_rules_underscore_prefix-3.snap | 4 +- ...__tests__at_rules_underscore_prefix-4.snap | 4 +- ...or__tests__at_rules_underscore_prefix.snap | 4 +- ...or__tests__container_at_rule_in_css-2.snap | 4 +- ...ctor__tests__container_at_rule_in_css.snap | 4 +- ...__css_container_at_rule_with_selector.snap | 4 +- ...extract_static_css_with_media_query-2.snap | 4 + ...extract_static_css_with_media_query-3.snap | 2 + ...__extract_static_css_with_media_query.snap | 2 + ...tractor__tests__global_css_at_rules-2.snap | 6 +- ...tractor__tests__global_css_at_rules-3.snap | 6 +- ...tractor__tests__global_css_at_rules-4.snap | 18 +- ...extractor__tests__global_css_at_rules.snap | 6 +- ...actor__tests__media_query_selectors-2.snap | 2 + ...actor__tests__media_query_selectors-3.snap | 4 +- ...actor__tests__media_query_selectors-4.snap | 2 + ...actor__tests__media_query_selectors-5.snap | 4 + ...tractor__tests__media_query_selectors.snap | 4 + ...ests__selector_refs_with_global_theme.snap | 4 +- .../extractor__tests__stylex_media_query.snap | 4 +- ...__tests__stylex_nested_hover_in_media.snap | 4 +- ...tests__stylex_nested_media_then_hover.snap | 8 +- ...tractor__tests__stylex_supports_query.snap | 4 +- ...tor__tests__stylex_types_in_condition.snap | 4 +- ..._vanilla_extract_all_exports_combined.snap | 2 +- ...tor__tests__vanilla_extract_container.snap | 4 +- ...__vanilla_extract_imports_combination.snap | 6 +- ...sts__vanilla_extract_keyframes_export.snap | 4 +- ..._tests__vanilla_extract_media_queries.snap | 16 +- ...la_extract_selector_class_replacement.snap | 4 +- ...__vanilla_extract_selector_references.snap | 6 +- ...t_selector_refs_triggers_with_classes.snap | 4 +- ...r__tests__vanilla_extract_selectors-2.snap | 4 +- ...ctor__tests__vanilla_extract_supports.snap | 4 +- ...la_extract_with_at_container_selector.snap | 8 +- libs/extractor/src/stylex.rs | 107 +-- libs/extractor/src/tailwind.rs | 159 ++-- libs/extractor/src/vanilla_extract.rs | 689 ++++++++++-------- libs/extractor/src/visit.rs | 48 +- 48 files changed, 1162 insertions(+), 949 deletions(-) diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index be4b5e863..b594560af 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -4,9 +4,10 @@ use std::fmt::Write as _; use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call}; use css::{ + at_rule::split_at_rule_key, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, rm_css_comment::rm_css_comment, - style_selector::{AtRuleKind, StyleSelector}, + style_selector::StyleSelector, }; use oxc_allocator::Allocator; use oxc_span::SPAN; @@ -421,238 +422,13 @@ pub fn css_to_style_literal( styles } -const AT_RULES: [(&str, AtRuleKind); 3] = [ - ("@media", AtRuleKind::Media), - ("@supports", AtRuleKind::Supports), - ("@container", AtRuleKind::Container), -]; - pub fn css_to_style( css: &str, level: u8, selector: &Option, ) -> Vec { let mut styles = vec![]; - let mut input = css; - - // Split by at-rules (@media, @supports, @container) to handle multiple at-rules in a single input. - // Every at-rule prefix begins with `@`, so a single `@` byte scan is a sound necessary-condition - // guard: if the input has no `@` at all (the overwhelmingly common declaration block), none of the - // three prefixes can match, so skip all three `input.contains(at_rule)` substring scans entirely. - if input.contains('@') { - // Classify which of the three at-rule prefixes are present in ONE pass over the - // `@`-anchored positions, instead of re-scanning the whole input with a separate - // `input.contains(at_rule)` per prefix inside the loop. For an `@`-bearing block - // carrying just one kind (the common single-`@media`/`@supports`/`@container` case), - // the two absent kinds previously each paid a full substring scan; now every - // `@`-run is examined once here and the loop reads a precomputed `bool`. The - // presence check keys off the byte right after `@` (`m`/`s`/`c`), matching the - // prefixes' first distinguishing letter, so it is byte-identical to `contains`. - let mut present = [false; AT_RULES.len()]; - for (pos, _) in input.match_indices('@') { - let rest = &input[pos..]; - for (i, (at_rule, _)) in AT_RULES.iter().enumerate() { - if !present[i] && rest.starts_with(at_rule) { - present[i] = true; - } - } - } - for (idx, (at_rule, _)) in AT_RULES.iter().enumerate() { - // Only the multi-segment case recurses. Walk the non-empty trimmed `@rule` segments with - // a single `split` pass (dropping the earlier separate `count` scan + identical `collect` - // scan) via a peekable iterator: confirm a *second* non-empty segment before allocating, - // so the common single-`@media`/`@supports`/`@container` block (already dispatched by an - // outer recursion level) still skips materializing a throwaway `Vec`. - // Absent prefixes are skipped via the precomputed `present` flags (no re-scan). - if !present[idx] { - continue; - } - let mut segments = input.split(at_rule).filter_map(|s| { - let s = s.trim(); - (!s.is_empty()).then_some(s) - }); - if let Some(first) = segments.next() - && let Some(second) = segments.next() - { - // Re-attach the known `at_rule` prefix to each segment with a presized - // `String` + two `push_str` instead of `format!`, which pulls in the - // `Arguments` formatting machinery and its grow path. Both lengths are - // known up front, so a single exact allocation suffices. Byte-identical - // to `format!("{at_rule}{seg}")`. - let join_at = |seg: &str| { - let mut s = String::with_capacity(at_rule.len() + seg.len()); - s.push_str(at_rule); - s.push_str(seg); - s - }; - styles.extend(css_to_style(&join_at(first), level, selector)); - styles.extend(css_to_style(&join_at(second), level, selector)); - for rest in segments { - styles.extend(css_to_style(&join_at(rest), level, selector)); - } - return styles; - } - } - } - - if input.contains('{') { - while let Some(start) = input.find('{') { - // Check if there are properties before the selector - let before_brace = &input[..start].trim(); - - // The overwhelmingly common case has no `;`-separated plain props before the - // selector (e.g. `&:hover { ... }`), which maps to the single-part `else` - // branch below. Only build the `Vec<&str>` split when a `;` is actually present. - let (plain_props, selector_part): (&str, &str) = if before_brace.contains(';') { - // Split by semicolon to find the last part which should be the selector - let parts: Vec<&str> = before_brace.split(';').map(str::trim).collect(); - - // Find the selector part (the last part that doesn't contain ':') - // or if all parts contain ':', then the last part is the selector - // `before_brace` contains `;`, so `split` always yields at least two parts. - // Check if any part doesn't contain ':' (which would be a selector). - let mut selector_idx = parts.len(); - for (i, part) in parts.iter().enumerate().rev() { - if !part.contains(':') || part.starts_with('&') || part.starts_with('@') { - selector_idx = i; - break; - } - } - - // Borrow the props/selector partition directly from `before_brace` - // instead of allocating two `join(";")` Strings. The split boundary is the - // byte offset of the `selector_idx`-th `;` (parts are `;`-separated), so - // `[..boundary]` is the props run and `[boundary + 1..]` the selector run. - // `css_to_style_block` re-splits/re-trims each `;` part on the props side and - // the selector side is `.trim()`ed downstream, so the parsed output stays - // byte-identical to the previous `join(";")` form. - // `selector_idx` is 0 when the very first `;`-part is the selector - // (e.g. `foo;color:red { .. }`), leaving no props run before it. - // `checked_sub` yields `None` there, which the match below already - // treats as "the whole run is the selector" - the same result release - // builds reached by wrapping, without the debug-build overflow panic. - let boundary = selector_idx - .checked_sub(1) - .and_then(|nth| before_brace.match_indices(';').nth(nth).map(|(idx, _)| idx)); - match boundary { - Some(b) => (before_brace[..b].trim(), before_brace[b + 1..].trim()), - // No selector boundary: whole `before_brace` is the selector. - None => ("", before_brace), - } - } else { - ("", before_brace) - }; - - // Process plain properties if any - if !plain_props.is_empty() { - styles.extend(css_to_style_block(plain_props, level, selector)); - } - - let rest = &input[start + 1..]; - - // Find the matching closing brace by counting braces - let mut brace_count = 1; - let mut end = 0; - for (i, byte) in rest.bytes().enumerate() { - match byte { - b'{' => brace_count += 1, - b'}' => { - brace_count -= 1; - if brace_count == 0 { - end = i; - break; - } - } - _ => {} - } - } - - // If we didn't find a matching brace, use the first '}' as fallback - if brace_count > 0 { - end = rest.find('}').unwrap_or(rest.len()); - } - let block = &rest[..end]; - let sel = &if let Some(StyleSelector::At { kind, query, .. }) = selector { - let local_sel = selector_part.trim().to_string(); - Some(StyleSelector::At { - kind: *kind, - query: query.clone(), - selector: if local_sel == "&" { - None - } else { - Some(local_sel) - }, - }) - } else { - let sel = selector_part.trim().to_string(); - if let Some((prefix, kind)) = - AT_RULES.iter().find(|(prefix, _)| sel.starts_with(prefix)) - { - // The prefix contains neither spaces nor "and(", so slicing it off - // first is equivalent to slicing after the replaces. Strip spaces into - // one pre-sized buffer, then a single "and(" -> "and (" normalization. - let rest = &sel[prefix.len()..]; - let mut query = String::with_capacity(rest.len()); - for ch in rest.chars() { - if ch != ' ' { - query.push(ch); - } - } - // `and(` only appears in multi-condition media queries. Skip the - // second-buffer allocation of `replace` on the common - // single-condition path where the normalization is a no-op. - let query = if query.contains("and(") { - query.replace("and(", "and (") - } else { - query - }; - Some(StyleSelector::At { - kind: *kind, - query, - selector: None, - }) - } else if sel.is_empty() { - selector.clone() - } else { - Some(StyleSelector::Selector(sel)) - } - }; - let block = if block.contains('{') { - css_to_style(block, level, sel) - } else { - css_to_style_block(block, level, sel) - }; - - // Find the matching closing brace - let closing_brace_pos = start + 1 + end; - - // Process the block - styles.extend(block); - - // Update input to continue processing after the closing brace - // Check if there's more content after the closing brace - if closing_brace_pos + 1 < input.len() { - let remaining = &input[closing_brace_pos + 1..].trim(); - if remaining.is_empty() { - break; - } - // If there's remaining text after the closing brace, process it - // This handles cases like "} color: blue;" - if remaining.contains('{') { - // If it contains '{', continue the loop - input = remaining; - } else { - // If it doesn't contain '{', process it as a block and break - styles.extend(css_to_style_block(remaining, level, selector)); - break; - } - } else { - break; - } - } - } else { - styles.extend(css_to_style_block(input, level, selector)); - } + collect_css_block(&rm_css_comment(css), level, selector, &mut styles); // A single declaration (or none) is trivially ordered, so skip the comparison // sort's setup entirely for the very common single-property case. The multi-source @@ -664,6 +440,70 @@ pub fn css_to_style( styles } +/// Walk one level of CSS text: declarations apply to `selector`, and every +/// nested `prelude { … }` block recurses with its prelude composed onto it, so +/// `&:hover { @media print { … } }` keeps both the hover and the media query. +fn collect_css_block( + css: &str, + level: u8, + selector: &Option, + styles: &mut Vec, +) { + let mut rest = css; + while let Some(open) = rest.find('{') { + let head = &rest[..open]; + let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head)); + styles.extend(css_to_style_block(declarations, level, selector)); + + let body_start = open + 1; + let mut depth = 1usize; + let body_end = rest[body_start..] + .char_indices() + .find_map(|(index, c)| { + match c { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + (depth == 0).then_some(body_start + index) + }) + .unwrap_or(rest.len()); + let body = &rest[body_start..body_end]; + let prelude = prelude.trim(); + if prelude.is_empty() || prelude == "&" { + collect_css_block(body, level, selector, styles); + } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) { + collect_css_block(body, level, &Some(nested), styles); + } + rest = rest.get(body_end + 1..).unwrap_or_default(); + } + styles.extend(css_to_style_block(rest, level, selector)); +} + +/// The selector a nested block applies to: at-rule preludes wrap `parent`, +/// `&`-selectors substitute it, and a bare selector under a selector nests as +/// a descendant. `None` drops the block (an unknown at-rule, or conditions +/// that can never match together). +fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { + if prelude.starts_with('@') { + let (kind, query) = split_at_rule_key(prelude)?; + return StyleSelector::nest_at_rule(parent, kind, query); + } + let parent_selector = match parent { + Some(StyleSelector::Selector(selector) | StyleSelector::Global(selector, _)) => { + Some(selector) + } + Some(StyleSelector::At { selector, .. }) => selector.as_ref(), + None => None, + }; + let template = if prelude.contains('&') || parent_selector.is_none() { + Cow::Borrowed(prelude) + } else { + Cow::Owned(format!("& {prelude}")) + }; + Some(StyleSelector::nest_selector(parent, &template)) +} + /// Optimize a declaration's value only when its property warrants multi-value /// optimization, borrowing otherwise. Shared by `css_to_style_block` and /// `optimize_css_block`, which both made this identical decision inline. @@ -868,6 +708,7 @@ fn remove_semicolon_before_closing_brace(value: &mut String) { mod tests { use super::*; + use css::style_selector::AtRuleKind; use oxc_allocator::Allocator; use oxc_ast::ast::{Expression, Statement}; use oxc_parser::Parser; @@ -942,12 +783,12 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -965,22 +806,22 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -999,22 +840,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ] )] @@ -1033,22 +874,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1077,42 +918,42 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1131,22 +972,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1169,12 +1010,12 @@ mod tests { ("display", "grid", Some(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display:grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("grid-template-columns", "1fr 1fr", Some(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display:grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1188,7 +1029,7 @@ mod tests { ("display", "flex", Some(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display:flex)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1199,8 +1040,8 @@ mod tests { vec![ ("display", "block", Some(StyleSelector::At { kind: AtRuleKind::Supports, - query: "not(display:grid)".to_string(), - selector: None, + query: "not (display:grid)".to_string(), + selector: None, outer: vec![], file: None, })), ] )] @@ -1213,7 +1054,7 @@ mod tests { ("padding", "10px", Some(StyleSelector::At { kind: AtRuleKind::Container, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1224,8 +1065,8 @@ mod tests { vec![ ("display", "flex", Some(StyleSelector::At { kind: AtRuleKind::Container, - query: "sidebar(min-width:400px)".to_string(), - selector: None, + query: "sidebar (min-width:400px)".to_string(), + selector: None, outer: vec![], file: None, })), ] )] @@ -1451,12 +1292,12 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1474,22 +1315,22 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1508,22 +1349,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ] )] @@ -1542,22 +1383,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1586,42 +1427,42 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1640,22 +1481,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1681,29 +1522,30 @@ mod tests { ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), ] )] + // As in CSS nesting, only the text after the last `;` is the nested rule's + // selector; everything before it is declarations. #[case( "color:red;background:blue { width: 1px; }", - vec![( - "width", - "1px", - Some(StyleSelector::Selector( - "color:red;background:blue".to_string() - )) - )] + vec![ + ("color", "red", None), + ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ] )] - // The selector is the very first `;`-part, so there is no props run before it. #[case( "foo;color:red { width: 1px; }", vec![( "width", "1px", - Some(StyleSelector::Selector("foo;color:red".to_string())) + Some(StyleSelector::Selector("color:red".to_string())) )] )] #[case( "color: red;;invalid;display: block;", vec![("color", "red", None), ("display", "block", None)] )] + // Blocks that can never apply produce nothing: unknown at-rules and + // mutually exclusive media types. + #[case("@layer base { color: red; } @media print { @media screen { color: red; } }", vec![])] fn test_css_to_style( #[case] input: &str, #[case] expected: Vec<(&str, &str, Option)>, diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 15ba1c04f..fd8a70208 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -9,7 +9,9 @@ use crate::{ }, extractor::{ GlobalExtractResult, - extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + extract_style_from_expression::{ + LiteralHandling, at_rule_record_kind, extract_style_from_expression, + }, }, utils::{ get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, @@ -17,9 +19,10 @@ use crate::{ }, }; use css::{ + at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url}, - style_selector::StyleSelector, + style_selector::{AtRule, AtRuleKind, StyleSelector}, }; use oxc_ast::{ ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, @@ -32,6 +35,35 @@ pub fn extract_global_style_from_expression<'a>( file: &str, ) -> GlobalExtractResult<'a> { let mut styles = vec![]; + collect_global_styles(ast_builder, expression, file, &[], &mut styles); + GlobalExtractResult { + styles, + style_order: None, + } +} + +/// A top-level at-rule key wrapping a selector map: `'@media print'` or a +/// media shorthand such as `_print` / `_motionReduce`. +fn global_at_rule_key(name: &str) -> Option { + let (kind, query) = split_at_rule_key(name).or_else(|| { + let query = media_shorthand_query(name.strip_prefix('_')?)?; + Some((AtRuleKind::Media, query)) + })?; + Some(AtRule { + kind, + query: query.to_string(), + }) +} + +/// Collect a `globalCss` selector map whose rules sit inside `at_rules` +/// (outermost first). +fn collect_global_styles<'a>( + ast_builder: &AstBuilder<'a>, + expression: &mut Expression<'a>, + file: &str, + at_rules: &[AtRule], + styles: &mut Vec>, +) { let expression = unwrap_syntax_only_mut(expression); if let Expression::ObjectExpression(obj) = expression { @@ -39,7 +71,29 @@ pub fn extract_global_style_from_expression<'a>( match p { ObjectPropertyKind::ObjectProperty(o) => { if let Some(name) = get_string_by_property_key(&o.key) { - if name == "imports" { + if let Some(kind) = at_rule_record_kind(&name) + && let Expression::ObjectExpression(record) = &mut o.value + { + for entry in &mut record.properties { + if let ObjectPropertyKind::ObjectProperty(entry) = entry + && let Some(query) = get_string_by_property_key(&entry.key) + { + let mut nested = at_rules.to_vec(); + nested.push(AtRule { kind, query }); + collect_global_styles( + ast_builder, + &mut entry.value, + file, + &nested, + styles, + ); + } + } + } else if let Some(at_rule) = global_at_rule_key(&name) { + let mut nested = at_rules.to_vec(); + nested.push(at_rule); + collect_global_styles(ast_builder, &mut o.value, file, &nested, styles); + } else if name == "imports" { if let Expression::ArrayExpression(arr) = &o.value { for p in &arr.elements { // `...spread` elements carry no statically readable url. @@ -169,21 +223,30 @@ pub fn extract_global_style_from_expression<'a>( None }; - let extracted = extract_style_from_expression( - ast_builder, - None, - &mut o.value, - 0, - &Some(StyleSelector::Global( - if let Some(name) = name.strip_prefix("_") { - StyleSelector::from(name).to_string().replace('&', "*") - } else { - name - }, - file.to_string(), - )), - LiteralHandling::ExpandResponsiveThemeToken, + let global = StyleSelector::Global( + if let Some(name) = name.strip_prefix("_") { + StyleSelector::from(name).to_string().replace('&', "*") + } else { + name + }, + file.to_string(), ); + // `None` when the enclosing at-rules can never match together. + let selector = at_rules.iter().try_fold(global, |selector, rule| { + StyleSelector::nest_at_rule(Some(&selector), rule.kind, &rule.query) + }); + let extracted = selector + .map(|selector| { + extract_style_from_expression( + ast_builder, + None, + &mut o.value, + 0, + &Some(selector), + LiteralHandling::ExpandResponsiveThemeToken, + ) + }) + .unwrap_or_default(); // Filter out @layer property from styles and set layer on remaining styles for mut style in extracted.styles { @@ -206,20 +269,15 @@ pub fn extract_global_style_from_expression<'a>( } } ObjectPropertyKind::SpreadProperty(o) => { - styles.extend( - extract_global_style_from_expression( - ast_builder, - o.argument.get_inner_expression_mut(), - file, - ) - .styles, + collect_global_styles( + ast_builder, + o.argument.get_inner_expression_mut(), + file, + at_rules, + styles, ); } } } } - GlobalExtractResult { - styles, - style_order: None, - } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index cc4a9311b..2d98df84d 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -16,11 +16,12 @@ use crate::{ }, }; use css::{ - add_selector_params, disassemble_property, get_enum_property_map, get_enum_property_value, - is_enum_property, + add_selector_params, + at_rule::{media_shorthand_query, split_at_rule_key}, + disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property, is_special_property::is_special_property, - style_selector::StyleSelector, - theme_tokens::{get_responsive_theme_token, is_responsive_theme_token}, + style_selector::{AtRuleKind, StyleSelector, optimize_selector}, + theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token}, utils::to_kebab_case, }; use oxc_allocator::{CloneIn, GetAllocator}; @@ -220,6 +221,31 @@ pub fn extract_style_from_expression<'a>( }; } + if let Some(name) = name + && is_nested_selector_key(name) + { + let mut styles = vec![]; + for part in split_selector_list(name) { + if let Some(nested) = nest_selectors_key(selector.as_ref(), part) { + styles.extend( + extract_style_from_expression( + ast_builder, + None, + expression, + level, + &Some(nested), + literal_handling, + ) + .styles, + ); + } + } + return ExtractResult { + styles, + ..ExtractResult::default() + }; + } + if let Some(name) = name // First-byte dispatch: only names beginning with `a` (`as`), `s` // (`selectors`), `@`/`_` (at-rule + `_`-selector prefixes) or `t` @@ -242,76 +268,23 @@ pub fn extract_style_from_expression<'a>( { let mut props = vec![]; for p in &mut obj.properties { - if let ObjectPropertyKind::ObjectProperty(o) = p { - let Some(key_name) = o.key.name() else { - continue; - }; - let name = key_name.trim(); - let mut part_of_selector = vec![]; - - let mut level = 0; - let mut last_idx = 0; - for (idx, c) in name.char_indices() { - if c == '(' { - level += 1; - } - if c == ')' { - level -= 1; - } - if c == ',' && level == 0 { - part_of_selector.push(name[last_idx..idx].trim()); - last_idx = idx + 1; - } - } - if !name.is_empty() { - part_of_selector.push(name[last_idx..].trim()); - } - - // The parent selector's owned string is invariant across every - // comma-part, so materialize it ONCE here instead of calling - // `selector.to_string()` up to 3× per part inside the closure. - let parent_sel = selector.as_ref().map(ToString::to_string); - for sel in part_of_selector.iter().map(|name| { - if let Some(parent_sel) = parent_sel.as_deref() { - if name.starts_with('_') { - if name.starts_with("_theme") { - StyleSelector::from([ - to_kebab_case(name.strip_prefix("_").unwrap_or(name)) - .as_ref(), - parent_sel, - ]) - .to_string() - } else { - StyleSelector::from([ - parent_sel, - to_kebab_case(name.strip_prefix("_").unwrap_or(name)) - .as_ref(), - ]) - .to_string() - } - } else { - name.replace('&', parent_sel) - } - } else if name.starts_with('_') { - StyleSelector::from( - to_kebab_case(name.strip_prefix("_").unwrap_or(name)).as_ref(), - ) - .to_string() - } else { - StyleSelector::from(name.strip_prefix("_").unwrap_or(name)).to_string() + if let ObjectPropertyKind::ObjectProperty(o) = p + && let Some(key_name) = o.key.name() + { + for part in split_selector_list(key_name.trim()) { + if let Some(child) = nest_selectors_key(selector.as_ref(), part) { + props.extend( + extract_style_from_expression( + ast_builder, + None, + &mut o.value, + level, + &Some(child), + literal_handling, + ) + .styles, + ); } - }) { - props.extend( - extract_style_from_expression( - ast_builder, - None, - &mut o.value, - level, - &Some(StyleSelector::Selector(sel)), - literal_handling, - ) - .styles, - ); } } } @@ -321,31 +294,16 @@ pub fn extract_style_from_expression<'a>( }; } - // Handle at-rules: @media, @supports, @container (or _media, _supports, _container) - let at_rule_name = name - .strip_prefix("@") - .or_else(|| name.strip_prefix("_")) - .filter(|n| matches!(*n, "media" | "supports" | "container")); - - if let Some(at_rule) = at_rule_name + if let Some(kind) = at_rule_record_kind(name) && let Expression::ObjectExpression(obj) = expression { let mut props = vec![]; - // The parent selector renders identically for every query in this - // at-rule object: format it once and clone the resulting `String` - // per iteration instead of re-running `Display` formatting. `query` - // is moved into the selector (it was previously cloned even though - // the original was dropped immediately after). - let parent = selector.as_ref().map(ToString::to_string); for p in &mut obj.properties { if let ObjectPropertyKind::ObjectProperty(o) = p && let Some(query) = get_string_by_property_key(&o.key) + && let Some(at_selector) = + StyleSelector::nest_at_rule(selector.as_ref(), kind, &query) { - let at_selector = StyleSelector::At { - kind: at_rule.into(), - query, - selector: parent.clone(), - }; props.extend( extract_style_from_expression( ast_builder, @@ -365,19 +323,23 @@ pub fn extract_style_from_expression<'a>( }; } - if let Some(new_selector) = name.strip_prefix("_") { - return extract_style_from_expression( - ast_builder, - None, - expression, - level, - &Some(if let Some(selector) = selector { - (selector, new_selector).into() - } else { - new_selector.into() - }), - literal_handling, - ); + let nested = if let Some((kind, query)) = split_at_rule_key(name) { + Some(StyleSelector::nest_at_rule(selector.as_ref(), kind, query)) + } else { + name.strip_prefix('_') + .map(|child| nest_underscore_name(selector.as_ref(), child)) + }; + if let Some(nested) = nested { + return nested.map_or_else(ExtractResult::default, |nested| { + extract_style_from_expression( + ast_builder, + None, + expression, + level, + &Some(nested), + literal_handling, + ) + }); } typo = name == "typography"; } @@ -385,9 +347,7 @@ pub fn extract_style_from_expression<'a>( if let Some(name) = name { ExtractResult { styles: if typo { - vec![ExtractStyleProp::Static(ExtractStyleValue::Typography( - value.into_owned(), - ))] + vec![typography_style(value.into_owned(), level, selector)] } else if matches!( literal_handling, LiteralHandling::ExpandResponsiveThemeToken @@ -508,7 +468,12 @@ pub fn extract_style_from_expression<'a>( // typo branch is safe. The non-typo branch must handle the // `name.is_none()` case (pseudo-selector recursion) by // returning empty styles. - styles: if typo { + styles: if typo + && let Some(style) = + conditional_typography(ast_builder, expression, level, selector) + { + vec![style] + } else if typo { vec![ExtractStyleProp::Expression { expression: Expression::new_template_literal( SPAN, @@ -566,6 +531,18 @@ pub fn extract_style_from_expression<'a>( // like any other non-extracted style prop. if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) { ExtractResult::default() + } else if typo + && let Some(style) = conditional_typography( + ast_builder, + &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder), + level, + selector, + ) + { + ExtractResult { + styles: vec![style], + ..ExtractResult::default() + } } else if let Some(name) = name { if typo { ExtractResult { @@ -841,6 +818,120 @@ pub fn extract_style_from_expression<'a>( } } +pub(crate) fn at_rule_record_kind(name: &str) -> Option { + match name.strip_prefix('@').or_else(|| name.strip_prefix('_'))? { + "media" => Some(AtRuleKind::Media), + "supports" => Some(AtRuleKind::Supports), + "container" => Some(AtRuleKind::Container), + _ => None, + } +} + +fn split_selector_list(key: &str) -> Vec<&str> { + let mut parts = vec![]; + let mut depth = 0usize; + let mut start = 0; + for (index, c) in key.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(key[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + if !key.is_empty() { + parts.push(key[start..].trim()); + } + parts +} + +/// Resolve a `_name` style key (without the `_`) under `parent`: media +/// shorthands such as `print` or `motionReduce` wrap it in `@media`, anything +/// else nests a selector. `None` means the styles can never apply. +fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option { + let name = to_kebab_case(name); + if let Some(query) = media_shorthand_query(&name) { + return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query); + } + Some(StyleSelector::nest_selector( + parent, + &StyleSelector::from(name.as_ref()).to_string(), + )) +} + +/// Emotion/styled-components style object keys that are nested selectors +/// (`'&:hover'`, `':hover'`, `'.parent &'`) rather than CSS properties. +fn is_nested_selector_key(key: &str) -> bool { + key.starts_with(':') || key.contains('&') +} + +fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { + if let Some((kind, query)) = split_at_rule_key(key) { + StyleSelector::nest_at_rule(parent, kind, query) + } else if let Some(name) = key.strip_prefix('_') { + nest_underscore_name(parent, name) + } else if key.starts_with(':') { + Some(StyleSelector::nest_selector(parent, &format!("&{key}"))) + } else if parent.is_some() { + Some(StyleSelector::nest_selector(parent, key)) + } else { + Some(StyleSelector::from(key)) + } +} + +fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle { + ExtractStaticStyle { + property: "typography".to_string(), + value: name.to_string(), + level, + selector: selector.clone().map(optimize_selector), + style_order: None, + layer: None, + theme_token_resolution: ThemeTokenResolution::default(), + } +} + +/// A `typography` preset outside any selector or breakpoint uses the theme's +/// shared `typo-*` class. Under a condition the class would apply +/// unconditionally, so it becomes a `typography` atom the sheet expands into +/// the preset's declarations under that condition. +fn typography_style<'a>( + name: String, + level: u8, + selector: &Option, +) -> ExtractStyleProp<'a> { + ExtractStyleProp::Static(if selector.is_none() && level == 0 { + ExtractStyleValue::Typography(name) + } else { + ExtractStyleValue::Static(typography_atom(&name, level, selector)) + }) +} + +/// A dynamic `typography` value under a condition picks, at runtime, one +/// conditional atom per typography preset registered in the theme. +fn conditional_typography<'a>( + ast_builder: &AstBuilder<'a>, + condition: &Expression<'a>, + level: u8, + selector: &Option, +) -> Option> { + if selector.is_none() && level == 0 { + return None; + } + let condition = condition.clone_in(ast_builder.allocator()); + let map = get_typography_keys() + .into_iter() + .map(|key| { + let atom = ExtractStyleValue::Static(typography_atom(&key, level, selector)); + (key, vec![ExtractStyleProp::Static(atom)]) + }) + .collect(); + Some(ExtractStyleProp::Enum { condition, map }) +} + pub fn dynamic_style<'a>( ast_builder: &AstBuilder<'a>, name: &str, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 62018cfa3..9b3759ae4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -269,37 +269,28 @@ fn extract_with_source_map( match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename) { Ok(collected) => { - // Check if any styles are referenced in selectors - let referenced = vanilla_extract::find_selector_references(&collected); - - if referenced.is_empty() { - // No selector references, use simple code generation - Some(vanilla_extract::collected_styles_to_code( - &collected, - &option.package, - )) + // Keyframes names are generated, so extract the referenced ones + // first and substitute their names into the styles using them. + let referenced = vanilla_extract::referenced_keyframes(&collected); + let keyframes_names = if referenced.is_empty() { + FxHashMap::default() } else { - // Two-pass extraction: first extract referenced styles to get their class names - let partial_code = vanilla_extract::collected_styles_to_code_partial( - &collected, - &option.package, + extract_class_map_from_code( + filename, + &vanilla_extract::keyframes_to_code( + &collected, + &option.package, + &referenced, + ), + &option, &referenced, - ); - - // Build class map by extracting the partial code - let class_map = if partial_code.is_empty() { - FxHashMap::default() - } else { - extract_class_map_from_code(filename, &partial_code, &option, &referenced)? - }; - - // Generate full code with class names substituted into selectors - Some(vanilla_extract::collected_styles_to_code_with_classes( - &collected, - &option.package, - &class_map, - )) - } + )? + }; + Some(vanilla_extract::collected_styles_to_code_with_keyframes( + &collected, + &option.package, + &keyframes_names, + )) } // Fall back to treating as regular file if execution fails Err(_) => None, @@ -11077,14 +11068,14 @@ const color = "red"; .unwrap() )); - // Test _speech media query selector + // Test _motionReduce media query shorthand reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r#"import {Box} from '@devup-ui/core' - + "#, ExtractOption { package: "@devup-ui/core".to_string(), diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index bf26a8bb6..07cd67a71 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -2,7 +2,9 @@ use crate::extract_style::ExtractStyleProperty; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{has_tailwind_classes, parse_single_class, parse_tailwind_to_styles}; +use crate::tailwind::{ + TailwindClass, has_tailwind_classes, parse_single_class, parse_tailwind_to_styles, +}; use crate::utils::{get_str_by_property_key, merge_object_expressions}; use crate::{ExtractStyleProp, ExtractStyleValue}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -432,8 +434,10 @@ fn extract_tailwind_from_class_name<'a>( let mut tailwind_styles: Vec = Vec::with_capacity(all_classes.bytes().filter(u8::is_ascii_whitespace).count() + 1); for class in all_classes.split_whitespace() { - if let Some(parsed) = parse_single_class(class) { - let mut static_style = parsed.to_static_style(); + if let Some(mut static_style) = parse_single_class(class) + .as_ref() + .and_then(TailwindClass::to_static_style) + { if let Some(order) = style_order { static_style.style_order = Some(order); } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap index 64b06f5e4..d997635d3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap index 76e04e7ef..3c0446efc 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: flex)", + query: "(display:flex)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap index 4955f592a..50e7177f0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap index 6838840f4..0519ae097 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap index c8acba680..831bd6971 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap index 46797c5fa..5784017e3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "sidebar (min-width: 400px)", + query: "sidebar (min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap index 24ed3c839..ebb729213 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap index 83b4b00f5..b22b41278 100644 --- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 500px)", + query: "(min-width:500px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap index 039f02dde..d5654738f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap +++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap index aaee60699..6d92afc50 100644 --- a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap +++ b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 300px)", + query: "(min-width:300px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap index fbecc3169..98721d76f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap @@ -16,6 +16,8 @@ ToBTreeSet { selector: Some( "&:active", ), + outer: [], + file: None, }, ), style_order: None, @@ -34,6 +36,8 @@ ToBTreeSet { selector: Some( "&:hover", ), + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap index 36e107415..fe6259b5e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap index 2077d8c2d..ec26c961a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap index 5d0a57e40..d0c56ad6b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: Some( ".grid-container", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap index b8b66f73a..87e38938b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: Some( ".card", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap index 111d8b3ac..603a5090c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(prefers-color-scheme: dark)", + query: "(prefers-color-scheme:dark)", selector: Some( "body", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( @@ -49,10 +53,14 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: Some( "body", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( @@ -69,10 +77,14 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: Some( ".container", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap index 6344e3cac..22912fd62 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: Some( "body", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap index 55c45fd83..35ea4ba4c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "screen", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap index 65ff6056f..6eed386ef 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "speech", + query: "(prefers-reduced-motion:reduce)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap index 53fca335f..98fafeea5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "all", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap index d0dbe75ae..7f9d008aa 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "screen", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -40,6 +42,8 @@ ToBTreeSet { kind: Media, query: "print", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap index b344f636e..748b4a0d2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "print", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -30,6 +32,8 @@ ToBTreeSet { kind: Media, query: "print", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap index 3fb1e1961..bb890d2f8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap @@ -55,7 +55,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -63,5 +63,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap index 28cdad898..f5a9bc039 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(max-width: 600px)", + query: "(max-width:600px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap index 61381013d..fd53e2ba7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap @@ -12,10 +12,12 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(hover: hover)", + query: "(hover:hover)", selector: Some( "&:hover", ), + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap index f03c0266e..02e2c49cd 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap @@ -12,10 +12,12 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: Some( "&:hover", ), + outer: [], + file: None, }, ), style_order: None, @@ -30,8 +32,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap index fcfcdda02..bd72492ed 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap index 2488f50ad..3dc45fd4e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 6c1e22b62..9f4b29a89 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -114,5 +114,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myLayer = \"--var-0\";\nexport const myVar = \"__devup_font_0_0\";\nexport const myFont = \"__container_0__\";\nexport const vars = \"components\";\nexport const myContainer = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"components\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap index 070fd6a42..563c444df 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap @@ -32,8 +32,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "__container_0__ (min-width: 400px)", + query: "__container_0__ (min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap index 7c1f58e30..695a64586 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "animation", - value: "myFont", + value: "a", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__font_0__", + value: "__devup_font_0_0", level: 0, selector: Some( Global( @@ -84,5 +84,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\n;\n;\nexport const myFont = \"b\";\nexport const fadeIn = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"b\";\n;\n;\nexport const fadeIn = \"a\";\nexport const myFont = \"__devup_font_0_0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap index 4f9210114..2cf86d4c6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "animation", - value: "spin", + value: "a", level: 0, selector: None, style_order: None, @@ -67,5 +67,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"a\";\nconst internal = \"b\";\nexport const spin = \"c\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"b\";\nconst internal = \"c\";\nexport const spin = \"a\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap index b90173c4b..b693d81a3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(prefers-color-scheme: dark)", + query: "(prefers-color-scheme:dark)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -28,8 +30,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(prefers-color-scheme: dark)", + query: "(prefers-color-scheme:dark)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -54,8 +58,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "screen and (min-width: 768px)", + query: "screen and (min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -70,8 +76,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "screen and (min-width: 1024px)", + query: "screen and (min-width:1024px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap index 86bf61d85..8ef25f573 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap @@ -11,7 +11,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -29,5 +29,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap index c591ffd98..e7f52c8e8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap @@ -21,7 +21,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -35,7 +35,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a + &", + ".f0_parent + &", ), ), style_order: None, @@ -43,5 +43,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\nexport const sibling = \"c\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\nexport const sibling = \"c\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap index 833027ad5..66d773c71 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap @@ -11,7 +11,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -81,5 +81,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\n;\nexport const fade = \"c\";\nexport const myContainer = \"--var-0\";\nexport const colorVar = \"__devup_font_0_0\";\nexport const myLayer = \"__container_0__\";\nexport const myFont = \"ui\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\n;\nexport const fade = \"c\";\nexport const colorVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"ui\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap index 97d273b2f..c6d642b08 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap @@ -21,7 +21,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -43,5 +43,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b c\";\nexport const parent = \"a\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a b\";\nexport const parent = \"c\" + \" f0_parent\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap index d2aa8abd1..d68c6b0da 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap index 20ef3082f..096ea417c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -38,8 +40,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index 9f3519ab0..10cddf7c3 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -165,20 +165,16 @@ pub fn decompose_value_conditions( ) -> Vec { // String literal → leaf if let Some(s) = get_string_by_literal_expression(value) { - return vec![DecomposedStyle { - property: css_property.to_string(), - value: Some(s.into_owned()), - selector: compose_selectors(parent_selectors), - }]; + return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) + .into_iter() + .collect(); } // NullLiteral → tracked but no CSS if matches!(value, Expression::NullLiteral(_)) { - return vec![DecomposedStyle { - property: css_property.to_string(), - value: None, - selector: compose_selectors(parent_selectors), - }]; + return decomposed_leaf(css_property, None, parent_selectors) + .into_iter() + .collect(); } // CallExpression: firstThatWorks() → multiple fallback values with current selectors @@ -190,11 +186,11 @@ pub fn decompose_value_conditions( if let Some(arg_expr) = arg.as_expression() && let Some(s) = get_string_by_literal_expression(arg_expr) { - results.push(DecomposedStyle { - property: css_property.to_string(), - value: Some(s.into_owned()), - selector: compose_selectors(parent_selectors), - }); + results.extend(decomposed_leaf( + css_property, + Some(s.into_owned()), + parent_selectors, + )); } } return results; @@ -206,11 +202,9 @@ pub fn decompose_value_conditions( && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { if let Some(s) = get_string_by_literal_expression(inner) { - return vec![DecomposedStyle { - property: css_property.to_string(), - value: Some(s.into_owned()), - selector: compose_selectors(parent_selectors), - }]; + return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) + .into_iter() + .collect(); } return vec![]; } @@ -258,14 +252,15 @@ pub fn decompose_value_conditions( results } -/// Compose a list of selector parts into a single `StyleSelector`. -fn compose_selectors(parts: &[SelectorPart]) -> Option { - if parts.is_empty() { - return None; - } - +/// A leaf style under `parent_selectors`, or `None` when those conditions can +/// never match together (e.g. `@media print` inside `@media screen`). +fn decomposed_leaf( + css_property: &str, + value: Option, + parent_selectors: &[SelectorPart], +) -> Option { let mut pseudo_str: Option = None; - for p in parts { + for p in parent_selectors { if let SelectorPart::Pseudo(s) = p { pseudo_str .get_or_insert_with(|| String::from("&")) @@ -273,21 +268,23 @@ fn compose_selectors(parts: &[SelectorPart]) -> Option { } } - // Last at-rule wins; read it with a reverse scan (no intermediate Vec). - let last_at_rule = parts.iter().rev().find_map(|p| match p { - SelectorPart::AtRule { kind, query } => Some((*kind, query.as_str())), - SelectorPart::Pseudo(_) => None, - }); - - if let Some((kind, query)) = last_at_rule { - Some(StyleSelector::At { - kind, - query: query.to_string(), - selector: pseudo_str, - }) - } else { - pseudo_str.map(StyleSelector::Selector) + // Every enclosing at-rule applies, outermost first. + let mut selector = pseudo_str.map(StyleSelector::Selector); + for p in parent_selectors { + if let SelectorPart::AtRule { kind, query } = p { + selector = Some(StyleSelector::nest_at_rule( + selector.as_ref(), + *kind, + query, + )?); + } } + + Some(DecomposedStyle { + property: css_property.to_string(), + value, + selector, + }) } /// Parse an at-rule key like `"@media (max-width: 600px)"` into kind + query. @@ -319,4 +316,32 @@ mod tests { assert_eq!(normalize_stylex_property("color"), "color"); assert_eq!(normalize_stylex_property("zIndex"), "z-index"); } + + #[test] + fn test_decompose_folds_every_at_rule() { + let allocator = oxc_allocator::Allocator::default(); + let source = "({ default: 'red', '@media print': { ':hover': { '@media (prefers-reduced-motion: reduce)': 'blue', '@media screen': 'green' } } })"; + let program = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::ts()) + .parse() + .program; + let oxc_ast::ast::Statement::ExpressionStatement(statement) = &program.body[0] else { + panic!("expected expression statement"); + }; + + let value = statement.expression.without_parentheses(); + let styles: Vec<_> = decompose_value_conditions("color", value, &[]) + .into_iter() + .map(|style| (style.value, style.selector.map(|s| s.to_string()))) + .collect(); + assert_eq!( + styles, + vec![ + (Some("red".to_string()), None), + ( + Some("blue".to_string()), + Some("@media print and (prefers-reduced-motion:reduce) &:hover".to_string()) + ), + ] + ); + } } diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 26ecb8357..87e9f5178 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -179,51 +179,16 @@ impl TailwindVariant { TailwindVariant::PeerInvalid => { StyleSelector::Selector(".peer:invalid ~ &".to_string()) } - TailwindVariant::Print => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "print".to_string(), - selector: None, - }, - TailwindVariant::Screen => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "screen".to_string(), - selector: None, - }, - TailwindVariant::Portrait => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(orientation: portrait)".to_string(), - selector: None, - }, - TailwindVariant::Landscape => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(orientation: landscape)".to_string(), - selector: None, - }, - TailwindVariant::MotionReduce => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-reduced-motion: reduce)".to_string(), - selector: None, - }, - TailwindVariant::MotionSafe => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-reduced-motion: no-preference)".to_string(), - selector: None, - }, - TailwindVariant::ContrastMore => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-contrast: more)".to_string(), - selector: None, - }, - TailwindVariant::ContrastLess => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-contrast: less)".to_string(), - selector: None, - }, - TailwindVariant::ForcedColors => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(forced-colors: active)".to_string(), - selector: None, - }, + // Media variants share the `_print`/`_motionReduce`/… prop table. + TailwindVariant::Print => StyleSelector::from("print"), + TailwindVariant::Screen => StyleSelector::from("screen"), + TailwindVariant::Portrait => StyleSelector::from("portrait"), + TailwindVariant::Landscape => StyleSelector::from("landscape"), + TailwindVariant::MotionReduce => StyleSelector::from("motion-reduce"), + TailwindVariant::MotionSafe => StyleSelector::from("motion-safe"), + TailwindVariant::ContrastMore => StyleSelector::from("contrast-more"), + TailwindVariant::ContrastLess => StyleSelector::from("contrast-less"), + TailwindVariant::ForcedColors => StyleSelector::from("forced-colors"), TailwindVariant::Rtl => StyleSelector::Selector("[dir=rtl] &".to_string()), TailwindVariant::Ltr => StyleSelector::Selector("[dir=ltr] &".to_string()), } @@ -331,8 +296,9 @@ fn remove_all_substr(haystack: &mut String, needle: &str) { } impl TailwindClass { - /// Convert to `ExtractStaticStyle` - pub fn to_static_style(&self) -> ExtractStaticStyle { + /// Convert to `ExtractStaticStyle`; `None` when the variants' media + /// conditions exclude each other (e.g. `print:screen:`). + pub fn to_static_style(&self) -> Option { // For transform property, negative is already incorporated into the value // (e.g., translateX(-1rem)), so don't add prefix again. Only the negative // branch needs an owned String for the `-` prefix; the common non-negative @@ -350,22 +316,27 @@ impl TailwindClass { None } else { // Combine multiple variants into a single selector - Some(self.combine_selectors()) + Some(self.combine_selectors()?) }; - ExtractStaticStyle::new(self.property, value.as_ref(), self.responsive, selector) + Some(ExtractStaticStyle::new( + self.property, + value.as_ref(), + self.responsive, + selector, + )) } - /// Combine multiple variant selectors - fn combine_selectors(&self) -> StyleSelector { + /// Combine multiple variant selectors; `None` when they can never match together. + fn combine_selectors(&self) -> Option { if self.variants.len() == 1 { - return self.variants[0].to_selector(); + return Some(self.variants[0].to_selector()); } // For multiple variants, combine them // e.g., dark:hover: becomes :root[data-theme=dark] &:hover let mut selector_str = String::new(); - let mut has_at_rule = None; + let mut at_rules = vec![]; for variant in &self.variants { let sel = variant.to_selector(); @@ -394,9 +365,7 @@ impl TailwindClass { } } } - StyleSelector::At { kind, query, .. } => { - has_at_rule = Some((kind, query)); - } + StyleSelector::At { kind, query, .. } => at_rules.push((kind, query)), // SAFETY: TailwindVariant::to_selector() never produces Global. // This arm exists only for exhaustive matching. If reached, it indicates // a bug where a new TailwindVariant was added that produces Global. @@ -406,19 +375,18 @@ impl TailwindClass { } } - if let Some((kind, query)) = has_at_rule { - StyleSelector::At { + // Every media variant applies, so fold them all in order (`print:motion-reduce:` + // becomes `@media print and (prefers-reduced-motion:reduce)`). + let mut selector = + (!selector_str.is_empty()).then_some(StyleSelector::Selector(selector_str)); + for (kind, query) in at_rules { + selector = Some(StyleSelector::nest_at_rule( + selector.as_ref(), kind, - query, - selector: if selector_str.is_empty() { - None - } else { - Some(selector_str) - }, - } - } else { - StyleSelector::Selector(selector_str) + &query, + )?); } + selector } } @@ -1292,8 +1260,10 @@ pub fn parse_tailwind_to_styles(class_str: &str) -> Vec { Vec::with_capacity(class_str.bytes().filter(u8::is_ascii_whitespace).count() + 1); for class in class_str.split_whitespace() { - if let Some(parsed) = parse_single_class(class) { - let static_style = parsed.to_static_style(); + if let Some(static_style) = parse_single_class(class) + .as_ref() + .and_then(TailwindClass::to_static_style) + { styles.push(ExtractStyleValue::Static(static_style)); } } @@ -3715,7 +3685,7 @@ mod tests { assert_eq!(parsed.value, "1rem"); assert!(parsed.negative); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.value(), "-1rem"); } @@ -3749,7 +3719,7 @@ mod tests { #[test] fn test_to_static_style() { let parsed = parse_single_class("bg-red-500").expect("Should parse"); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.property(), "background-color"); // ExtractStaticStyle::new() uses optimize_value() which uppercases hex colors @@ -3761,7 +3731,7 @@ mod tests { #[test] fn test_to_static_style_with_responsive() { let parsed = parse_single_class("md:p-4").expect("Should parse"); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.property(), "padding"); assert_eq!(static_style.value(), "1rem"); @@ -3771,7 +3741,7 @@ mod tests { #[test] fn test_to_static_style_with_variant() { let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse"); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.property(), "background-color"); assert!(static_style.selector().is_some()); @@ -4072,14 +4042,15 @@ mod tests { // Wave 1.4: Media variant selectors (lines 153-162) #[rstest] + #[case(TailwindVariant::Print, "print")] #[case(TailwindVariant::Screen, "screen")] - #[case(TailwindVariant::Portrait, "(orientation: portrait)")] - #[case(TailwindVariant::Landscape, "(orientation: landscape)")] - #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion: reduce)")] - #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion: no-preference)")] - #[case(TailwindVariant::ContrastMore, "(prefers-contrast: more)")] - #[case(TailwindVariant::ContrastLess, "(prefers-contrast: less)")] - #[case(TailwindVariant::ForcedColors, "(forced-colors: active)")] + #[case(TailwindVariant::Portrait, "(orientation:portrait)")] + #[case(TailwindVariant::Landscape, "(orientation:landscape)")] + #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion:reduce)")] + #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion:no-preference)")] + #[case(TailwindVariant::ContrastMore, "(prefers-contrast:more)")] + #[case(TailwindVariant::ContrastLess, "(prefers-contrast:less)")] + #[case(TailwindVariant::ForcedColors, "(forced-colors:active)")] fn test_variant_to_selector_media_queries( #[case] variant: TailwindVariant, #[case] expected_query: &str, @@ -4132,13 +4103,14 @@ mod tests { assert_eq!(parsed.variants[0], TailwindVariant::Print); assert_eq!(parsed.variants[1], TailwindVariant::Hover); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); let selector = static_style.selector().expect("Should have selector"); // Should combine into At rule with nested hover selector if let StyleSelector::At { kind, query, selector: nested, + .. } = selector { assert_eq!(*kind, css::style_selector::AtRuleKind::Media); @@ -4149,6 +4121,29 @@ mod tests { } } + #[rstest] + #[case( + "print:motion-reduce:hidden", + Some("@media print and (prefers-reduced-motion:reduce)") + )] + #[case( + "motion-reduce:landscape:hover:hidden", + Some("@media(prefers-reduced-motion:reduce)and (orientation:landscape) &:hover") + )] + #[case("print:screen:hidden", None)] + fn test_combine_selectors_folds_every_media_variant( + #[case] class: &str, + #[case] expected: Option<&str>, + ) { + let parsed = parse_single_class(class).expect("Should parse"); + assert_eq!( + parsed + .to_static_style() + .and_then(|style| style.selector().map(ToString::to_string)), + expected.map(str::to_string) + ); + } + #[test] fn test_remove_all_substr_matches_str_replace() { for (haystack, needle) in [ @@ -4221,7 +4216,7 @@ mod tests { negative: false, }; let expected = old_combine(variants); - match cls.combine_selectors() { + match cls.combine_selectors().expect("selector") { StyleSelector::Selector(actual) => { assert_eq!(actual, expected, "variants={variants:?}"); } diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 7c0bd3cef..276369a98 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -94,6 +94,22 @@ pub struct CollectedStyles { pub themes: FxHashMap, /// Non-style constant exports: `variable_name` -> value (as code string) pub constant_exports: FxHashMap, + /// `__style_N__` placeholder -> what it refers to, for placeholders left in + /// selectors and values + pub references: FxHashMap, +} + +/// Target of a placeholder that a selector or value interpolated +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Reference { + /// `style()`: `variable_name` and the unique class it gains when referenced + Style { name: String, class_name: String }, + /// `keyframes()`: `variable_name`, resolved to the generated keyframes name + Keyframes(String), + /// `createTheme()`: its generated class name + Class(String), + /// `fontFace()`: its generated font-family name + Font(String), } /// Internal state for collecting styles during JS execution @@ -387,7 +403,7 @@ pub fn execute_vanilla_extract( #[derive(Debug, Clone)] enum VarInfo { /// A style API call (style, keyframes, createContainer, etc.) - StyleApi { exported: bool }, + StyleApi { exported: bool, api: StyleApi }, /// A regular constant export with its original code Constant(String), /// The vars object from createTheme array destructuring [themeClass, vars] @@ -409,12 +425,12 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { // Check for array destructuring: const [themeClass, vars] = createTheme(...) if let oxc_ast::ast::BindingPattern::ArrayPattern(array_pat) = &decl.id { - if is_style_api_call(init) { + if let Some(api) = style_api_call(init) { // First element is the theme class if let Some(Some(first)) = array_pat.elements.first() && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = first { - vars.push((id.name.to_string(), VarInfo::StyleApi { exported })); + vars.push((id.name.to_string(), VarInfo::StyleApi { exported, api })); } // Second element is the vars object - mark as ThemeVars if let Some(Some(second)) = array_pat.elements.get(1) @@ -424,8 +440,8 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { } } } else if let Some(name) = decl.id.get_identifier_name() { - if is_style_api_call(init) { - vars.push((name.to_string(), VarInfo::StyleApi { exported })); + if let Some(api) = style_api_call(init) { + vars.push((name.to_string(), VarInfo::StyleApi { exported, api })); } else if exported { // Extract the original init expression using span let span = init.span(); @@ -460,26 +476,28 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { vars } -/// Check if an expression is a call to a style API (style, keyframes, styleVariants, etc.) -fn is_style_api_call(expr: &oxc_ast::ast::Expression) -> bool { +/// Which placeholder counter a style API call draws from +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StyleApi { + FontFace, + GlobalTheme, + Other, +} + +/// The style API an expression calls (style, keyframes, styleVariants, etc.) +fn style_api_call(expr: &oxc_ast::ast::Expression) -> Option { if let oxc_ast::ast::Expression::CallExpression(call) = expr && let oxc_ast::ast::Expression::Identifier(id) = &call.callee { - let name = id.name.as_str(); - return matches!( - name, - "style" - | "keyframes" - | "styleVariants" - | "fontFace" - | "createVar" - | "createContainer" - | "layer" - | "createGlobalTheme" - | "createTheme" - ); - } - false + return match id.name.as_str() { + "fontFace" => Some(StyleApi::FontFace), + "createGlobalTheme" => Some(StyleApi::GlobalTheme), + "style" | "keyframes" | "styleVariants" | "createVar" | "createContainer" | "layer" + | "createTheme" => Some(StyleApi::Other), + _ => None, + }; + } + None } /// Remap style placeholder IDs to original variable names @@ -495,7 +513,6 @@ fn remap_style_names( // Build mapping from placeholder ID to original name // The order of style() calls matches the order of variable declarations let mut placeholder_to_name: FxHashMap = FxHashMap::default(); - let mut font_placeholder_to_name: FxHashMap = FxHashMap::default(); let mut new_styles = FxHashMap::default(); let mut new_keyframes = FxHashMap::default(); let mut new_style_variants = FxHashMap::default(); @@ -524,35 +541,54 @@ fn remap_style_names( for (name, info) in vars { match info { - VarInfo::StyleApi { exported } => { - // First check if this is a fontFace (uses __font_N__ placeholder) + VarInfo::StyleApi { + exported, + api: StyleApi::FontFace, + } => { let font_placeholder = format!("__font_{font_idx}__"); if let Some((json, font_family, _)) = old_font_faces.remove(&font_placeholder) { - font_placeholder_to_name.insert(font_placeholder, name.clone()); + collected + .references + .insert(font_placeholder, Reference::Font(font_family.clone())); new_font_faces.insert(name.clone(), (json, font_family, *exported)); font_idx += 1; - continue; } - - // Check if this is a createGlobalTheme (uses __global_theme_N__ placeholder) + } + VarInfo::StyleApi { + exported, + api: StyleApi::GlobalTheme, + } => { let global_theme_placeholder = format!("__global_theme_{global_theme_idx}__"); if let Some(mut entry) = old_global_themes.remove(&global_theme_placeholder) { entry.exported = *exported; new_global_themes.insert(name.clone(), entry); global_theme_idx += 1; - continue; } - + } + VarInfo::StyleApi { + exported, + api: StyleApi::Other, + } => { let placeholder = format!("__style_{style_idx}__"); placeholder_to_name.insert(placeholder.clone(), name.clone()); if let Some(mut entry) = old_styles.remove(&placeholder) { entry.exported = *exported; new_styles.insert(name.clone(), entry); + collected.references.insert( + placeholder, + Reference::Style { + name: name.clone(), + class_name: format!("{file_prefix}_{name}"), + }, + ); style_idx += 1; } else if let Some(mut entry) = old_keyframes.remove(&placeholder) { entry.exported = *exported; new_keyframes.insert(name.clone(), entry); + collected + .references + .insert(placeholder, Reference::Keyframes(name.clone())); style_idx += 1; } else if let Some((variants, _)) = old_style_variants.remove(&placeholder) { new_style_variants.insert(name.clone(), (variants, *exported)); @@ -591,6 +627,9 @@ fn remap_style_names( .push((format!(".{class_name}"), vars_json)); } + collected + .references + .insert(placeholder, Reference::Class(class_name.clone())); entry.exported = *exported; entry.vars_name = None; entry.class_name = class_name; @@ -641,23 +680,6 @@ fn remap_style_names( .collect(); } - // Replace font placeholders in style JSONs with actual font-family names - // Build a mapping from placeholder to font-family name - let font_family_map: FxHashMap<&str, &str> = font_placeholder_to_name - .iter() - .filter_map(|(placeholder, name)| { - new_font_faces - .get(name) - .map(|(_, font_family, _)| (placeholder.as_str(), font_family.as_str())) - }) - .collect(); - - // Replace placeholders in a single pass per style JSON. - // This is needed for font-family references and selectors like `${parent}:hover &`. - for entry in new_styles.values_mut() { - replace_placeholders_in_json(&mut entry.json, &font_family_map, &placeholder_to_name); - } - collected.styles = new_styles; collected.keyframes = new_keyframes; collected.style_variants = new_style_variants; @@ -669,12 +691,12 @@ fn remap_style_names( collected.themes = new_themes; } -fn replace_placeholders_in_json( - json: &mut String, - font_family_map: &FxHashMap<&str, &str>, - placeholder_to_name: &FxHashMap, -) { - let source = json.as_str(); +/// Replace every `__name__` token that `lookup` resolves; `None` when nothing +/// was replaced. +fn replace_placeholders( + source: &str, + mut lookup: impl FnMut(&str, &str) -> Option, +) -> Option { let mut search_start = 0; let mut last_copied = 0; let mut output = None::; @@ -685,16 +707,10 @@ fn replace_placeholders_in_json( break; }; let end = start + 2 + relative_end + 2; - let placeholder = &source[start..end]; - let replacement = font_family_map - .get(placeholder) - .copied() - .or_else(|| placeholder_to_name.get(placeholder).map(String::as_str)); - - if let Some(replacement) = replacement { + if let Some(replacement) = lookup(&source[start..end], &source[end..]) { let output = output.get_or_insert_with(|| String::with_capacity(source.len())); output.push_str(&source[last_copied..start]); - output.push_str(replacement); + output.push_str(&replacement); last_copied = end; search_start = end; } else { @@ -702,12 +718,11 @@ fn replace_placeholders_in_json( } } - if let Some(mut output) = output { + output.map(|mut output| { output.push_str(&source[last_copied..]); - *json = output; - } + output + }) } - /// Convert TypeScript to JavaScript using Oxc Transformer and replace imports fn preprocess_typescript(code: &str, package: &str) -> String { let allocator = Allocator::default(); @@ -1165,83 +1180,178 @@ fn register_vanilla_extract_apis( Ok(()) } -/// Find all style names that are referenced in selectors of other styles -/// Returns a set of style names that need to be extracted first -pub fn find_selector_references(collected: &CollectedStyles) -> FxHashSet { - let mut referenced = rustc_hash::FxHashSet::default(); - // Build the probe strings once per style name instead of per (entry, name) pair - let probes: Vec<(String, String, &str)> = collected - .styles - .keys() - .map(|name| (format!("\"{name}:"), format!("\"{name} "), name.as_str())) - .collect(); +/// Placeholders interpolated into a selector end inside a JSON key. +fn in_json_key(rest: &str) -> bool { + rest.find('"') + .is_some_and(|i| rest[i + 1..].starts_with(':')) +} - for entry in collected.styles.values() { - // Cheap necessary-condition gate: a `":"` / `" "` selector reference can - // only appear inside a `"selectors":{...}` block, so a JSON with no `selectors` object - // cannot match ANY probe. Skip the whole K-probe inner loop for those entries (the - // common case — most style JSONs carry no cross-references). Byte-identical to probing - // every entry: the skipped entries provably contribute nothing to `referenced`. - if !entry.json.contains("selectors") { - continue; - } - // Check if this style's JSON contains references to other style names - for (probe_colon, probe_space, style_name) in &probes { - // Look for patterns like "stylename:" or "stylename " in selectors - // The JSON has selectors like {"selectors":{"parent:hover &":{...}}} - if entry.json.contains(probe_colon.as_str()) - || entry.json.contains(probe_space.as_str()) - { - referenced.insert((*style_name).to_string()); - } +impl CollectedStyles { + fn resolve( + &self, + source: &str, + keyframes_names: &FxHashMap, + selector: impl Fn(&str) -> bool, + ) -> String { + replace_placeholders(source, |token, rest| { + Some(match self.references.get(token)? { + Reference::Style { class_name, .. } | Reference::Class(class_name) => { + if selector(rest) { + format!(".{class_name}") + } else { + class_name.clone() + } + } + Reference::Keyframes(name) => keyframes_names.get(name)?.clone(), + Reference::Font(family) => family.clone(), + }) + }) + .unwrap_or_else(|| source.to_string()) + } + + fn resolve_json(&self, json: &str, keyframes_names: &FxHashMap) -> String { + self.resolve(json, keyframes_names, in_json_key) + } + + /// Every text a placeholder can be interpolated into. + fn texts(&self) -> impl Iterator { + self.styles + .values() + .map(|entry| entry.json.as_str()) + .chain(self.keyframes.values().map(|entry| entry.json.as_str())) + .chain( + self.style_variants + .values() + .flat_map(|(variants, _)| variants.values()) + .map(|variant| variant.styles_json.as_str()), + ) + .chain( + self.global_styles + .iter() + .flat_map(|(selector, json)| [selector.as_str(), json.as_str()]), + ) + } + + fn referenced(&self) -> FxHashSet<&Reference> { + let mut referenced = FxHashSet::default(); + for text in self.texts() { + replace_placeholders(text, |token, _| { + if let Some(reference) = self.references.get(token) { + referenced.insert(reference); + } + None + }); } + referenced } - referenced + /// `variable_name` -> unique class of every style some selector or value refers to + fn referenced_classes(&self) -> FxHashMap<&str, &str> { + self.referenced() + .into_iter() + .filter_map(|reference| match reference { + Reference::Style { name, class_name } => Some((name.as_str(), class_name.as_str())), + _ => None, + }) + .collect() + } } -/// Generate code only for specific styles (used for first-pass extraction) -pub fn collected_styles_to_code_partial( +/// Names of the keyframes some style refers to; their generated names have to be +/// known before the styles referring to them are generated. +pub fn referenced_keyframes(collected: &CollectedStyles) -> FxHashSet { + collected + .referenced() + .into_iter() + .filter_map(|reference| match reference { + Reference::Keyframes(name) => Some(name.clone()), + _ => None, + }) + .collect() +} + +/// Code declaring only the keyframes in `names`, extracted first to learn their +/// generated names +pub fn keyframes_to_code( collected: &CollectedStyles, package: &str, - style_names: &FxHashSet, + names: &FxHashSet, ) -> String { - let mut output = String::new(); - - if !style_names.is_empty() { - output.push_str("import { css } from '"); - output.push_str(package); - output.push('\''); - } - - // Generate only the specified styles - let mut styles: Vec<_> = collected - .styles + let mut keyframes: Vec<_> = collected + .keyframes .iter() - .filter(|(name, _)| style_names.contains(*name)) + .filter(|(name, _)| names.contains(*name)) .collect(); - styles.sort_by_key(|(name, _)| *name); - - for (name, entry) in styles { - // Generate as non-exported for first pass - if !output.is_empty() { - output.push('\n'); - } - output.push_str("const "); + keyframes.sort_by_key(|(name, _)| *name); + let mut output = format!("import {{ keyframes }} from '{package}'"); + for (name, entry) in keyframes { + output.push_str("\nconst "); output.push_str(name); - output.push_str(" = css("); - output.push_str(&entry.json); + output.push_str(" = keyframes("); + output.push_str(&collected.resolve_json(&entry.json, &FxHashMap::default())); output.push(')'); } - output } -/// Convert collected styles to code with selector references replaced by class names -pub fn collected_styles_to_code_with_classes( +fn inner_json(json: &str) -> &str { + json.trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim() +} + +/// `css(...)` of `own` composed onto `bases`, plus the unique classes that let +/// selectors target them +fn composed_css( + collected: &CollectedStyles, + keyframes_names: &FxHashMap, + referenced_classes: &FxHashMap<&str, &str>, + bases: &[&str], + own_name: Option<&str>, + own_json: &str, +) -> String { + let mut parts = Vec::with_capacity(bases.len() + 1); + let mut classes = Vec::new(); + for base in bases { + if let Some(entry) = collected.styles.get(*base) { + let json = collected.resolve_json(&entry.json, keyframes_names); + let inner = inner_json(&json); + if !inner.is_empty() { + parts.push(inner.to_string()); + } + } + classes.extend(referenced_classes.get(base)); + } + let own = collected.resolve_json(own_json, keyframes_names); + let css = if bases.is_empty() { + format!("css({own})") + } else { + let inner = inner_json(&own); + if !inner.is_empty() { + parts.push(inner.to_string()); + } + format!("css({{{}}})", parts.join(",")) + }; + classes.extend(own_name.and_then(|name| referenced_classes.get(name))); + if classes.is_empty() { + css + } else { + let mut unique = String::new(); + for class in classes { + unique.push(' '); + unique.push_str(class); + } + format!("{css} + \"{unique}\"") + } +} + +/// Convert collected styles to code, with keyframes references replaced by +/// `keyframes_names` (`variable_name` -> generated name) +pub fn collected_styles_to_code_with_keyframes( collected: &CollectedStyles, package: &str, - class_map: &FxHashMap, + keyframes_names: &FxHashMap, ) -> String { let mut code_parts = Vec::with_capacity(collected.styles.len() + 4); @@ -1271,73 +1381,23 @@ pub fn collected_styles_to_code_with_classes( )); } - // Generate style declarations with selector references replaced - let style_json_map: FxHashMap<&str, &str> = collected - .styles - .iter() - .map(|(name, entry)| (name.as_str(), entry.json.as_str())) - .collect(); - - // Pre-build search/replace pairs to avoid format!() per iteration - let replacements: Vec<_> = class_map - .iter() - .map(|(style_name, class_name)| { - ( - format!("\"{style_name}:"), - format!("\"{class_name}:"), - format!("\"{style_name} "), - format!("\"{class_name} "), - ) - }) - .collect(); - + let referenced_classes = collected.referenced_classes(); let mut styles: Vec<_> = collected.styles.iter().collect(); styles.sort_by_key(|(name, _)| *name); for (name, entry) in styles { let prefix = if entry.exported { "export " } else { "" }; - - // Replace style name references with class names in JSON - let mut json = entry.json.clone(); - for (search_colon, replace_colon, search_space, replace_space) in &replacements { - if json.contains(search_colon.as_str()) { - json = json.replace(search_colon, replace_colon); - } - if json.contains(search_space.as_str()) { - json = json.replace(search_space, replace_space); - } - } - - if entry.bases.is_empty() { - code_parts.push(format!("{prefix}const {name} = css({json})")); - } else { - // Composition: merge all base styles - let mut merged_parts = Vec::new(); - for base_name in &entry.bases { - if let Some(base_json) = style_json_map.get(base_name.as_str()) { - let inner = base_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - merged_parts.push(inner.to_string()); - } - } - } - let own_inner = json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !own_inner.is_empty() { - merged_parts.push(own_inner.to_string()); - } - let merged_json = format!("{{{}}}", merged_parts.join(",")); - code_parts.push(format!("{prefix}const {name} = css({merged_json})")); - } + let bases: Vec<&str> = entry.bases.iter().map(String::as_str).collect(); + let css = composed_css( + collected, + keyframes_names, + &referenced_classes, + &bases, + Some(name), + &entry.json, + ); + code_parts.push(format!("{prefix}const {name} = {css}")); } - // Generate createTheme exports (class name and optionally vars object) // Note: CSS variables are added to global_styles during remapping let mut themes: Vec<_> = collected.themes.iter().collect(); @@ -1364,8 +1424,12 @@ pub fn collected_styles_to_code_with_classes( } } - // Add remaining code generation (globalCss, keyframes, etc.) - call original function's logic - append_non_style_code(collected, package, &mut code_parts); + append_non_style_code( + collected, + keyframes_names, + &referenced_classes, + &mut code_parts, + ); code_parts.join("\n") } @@ -1373,12 +1437,17 @@ pub fn collected_styles_to_code_with_classes( /// Append non-style code parts (globalCss, keyframes, fontFaces, etc.) fn append_non_style_code( collected: &CollectedStyles, - _package: &str, + keyframes_names: &FxHashMap, + referenced_classes: &FxHashMap<&str, &str>, code_parts: &mut Vec, ) { - // Generate globalCss calls for (selector, json) in &collected.global_styles { - code_parts.push(format!("globalCss({{ \"{selector}\": {json} }})")); + let selector = collected.resolve(selector, keyframes_names, |_| true); + let json = collected.resolve_json(json, keyframes_names); + code_parts.push(format!( + "globalCss({{ {}: {json} }})", + serde_json::Value::String(selector) + )); } // Generate @font-face rules @@ -1426,61 +1495,38 @@ fn append_non_style_code( let prefix = if entry.exported { "export " } else { "" }; code_parts.push(format!( "{}const {} = keyframes({})", - prefix, name, entry.json + prefix, + name, + collected.resolve_json(&entry.json, keyframes_names) )); } - // Generate styleVariants let mut variants: Vec<_> = collected.style_variants.iter().collect(); - if !variants.is_empty() { - let style_json_map: FxHashMap<&str, &str> = collected - .styles - .iter() - .map(|(name, entry)| (name.as_str(), entry.json.as_str())) - .collect(); - variants.sort_by_key(|(name, _)| *name); - for (name, (variant_map, exported)) in variants { - let mut variant_entries: Vec<_> = variant_map.iter().collect(); - variant_entries.sort_by_key(|(k, _)| *k); - let mut object_parts = Vec::new(); - for (variant_key, variant) in variant_entries { - let value = if let Some(base_name) = &variant.base { - let mut merged_parts = Vec::new(); - if let Some(base_json) = style_json_map.get(base_name.as_str()) { - let inner = base_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - merged_parts.push(inner.to_string()); - } - } - let own_inner = variant - .styles_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !own_inner.is_empty() { - merged_parts.push(own_inner.to_string()); - } - format!("css({{{}}})", merged_parts.join(",")) - } else { - format!("css({})", variant.styles_json) - }; - object_parts.push(format!(" {variant_key}: {value}")); - } - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {{\n{}\n}}", - prefix, - name, - object_parts.join(",\n") - )); + variants.sort_by_key(|(name, _)| *name); + for (name, (variant_map, exported)) in variants { + let mut variant_entries: Vec<_> = variant_map.iter().collect(); + variant_entries.sort_by_key(|(k, _)| *k); + let mut object_parts = Vec::new(); + for (variant_key, variant) in variant_entries { + let bases: Vec<&str> = variant.base.iter().map(String::as_str).collect(); + let value = composed_css( + collected, + keyframes_names, + referenced_classes, + &bases, + None, + &variant.styles_json, + ); + object_parts.push(format!(" {variant_key}: {value}")); } + let prefix = if *exported { "export " } else { "" }; + code_parts.push(format!( + "{}const {} = {{\n{}\n}}", + prefix, + name, + object_parts.join(",\n") + )); } - // Generate createVar declarations let mut vars: Vec<_> = collected.vars.iter().collect(); vars.sort_by_key(|(name, _)| *name); @@ -1529,8 +1575,9 @@ fn append_non_style_code( } /// Convert collected styles to code that can be processed by existing extract logic +#[cfg(test)] pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> String { - collected_styles_to_code_with_classes(collected, package, &FxHashMap::default()) + collected_styles_to_code_with_keyframes(collected, package, &FxHashMap::default()) } /// Parse a styleVariants object and extract variant info @@ -1935,8 +1982,11 @@ export const lightTheme = createTheme(vars, { .into_iter() .collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("import { css } from '@devup-ui/react'")); // The composed style should have both base and own styles merged assert!(code.contains("padding")); @@ -2638,66 +2688,99 @@ export const box = style({ padding: 8 })"; // handle the target package imports #[test] - fn test_find_selector_references_no_refs() { - // Test find_selector_references with no selector references + fn test_referenced_keyframes_without_references() { let mut collected = CollectedStyles::default(); collected.styles.insert( "box".to_string(), StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.styles.insert( - "text".to_string(), - StyleEntry { - json: r#"{"color":"blue"}"#.to_string(), + json: r#"{"padding":"8px","animation":"__style_9__ 1s"}"#.to_string(), exported: true, bases: SmallVec::new(), }, ); - - let refs = super::find_selector_references(&collected); - assert!(refs.is_empty()); + assert!(super::referenced_keyframes(&collected).is_empty()); } #[test] - fn test_collected_styles_to_code_partial_empty() { - // Test collected_styles_to_code_partial with empty style_names - let collected = CollectedStyles::default(); - let empty_set = rustc_hash::FxHashSet::default(); - let code = - super::collected_styles_to_code_partial(&collected, "@devup-ui/react", &empty_set); - assert!(code.is_empty()); + fn test_keyframes_to_code_only_referenced() { + let mut collected = CollectedStyles::default(); + for name in ["fade", "spin"] { + collected.keyframes.insert( + name.to_string(), + StyleEntry { + json: r#"{"from":{"opacity":0}}"#.to_string(), + exported: false, + bases: SmallVec::new(), + }, + ); + } + let names = std::iter::once("fade".to_string()).collect(); + assert_eq!( + super::keyframes_to_code(&collected, "@devup-ui/react", &names), + "import { keyframes } from '@devup-ui/react'\nconst fade = keyframes({\"from\":{\"opacity\":0}})" + ); } #[test] - fn test_collected_styles_to_code_with_classes_selector_replacement() { - // Test selector class name replacement in collected_styles_to_code_with_classes + fn test_collected_styles_to_code_resolves_references() { let mut collected = CollectedStyles::default(); - collected.styles.insert( - "parent".to_string(), - StyleEntry { - json: r#"{"background":"white"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); + let entry = |json: &str| StyleEntry { + json: json.to_string(), + exported: true, + bases: SmallVec::new(), + }; + collected + .styles + .insert("parent".to_string(), entry(r#"{"background":"white"}"#)); collected.styles.insert( "child".to_string(), - StyleEntry { - json: r#"{"selectors":{"parent:hover &":{"color":"blue"}}}"#.to_string(), - exported: true, - bases: SmallVec::new(), + entry(r#"{"animation":"__style_2__ 1s","selectors":{"__style_0__:hover &":{"color":"blue"}}}"#), + ); + collected + .keyframes + .insert("fade".to_string(), entry(r#"{"from":{"opacity":0}}"#)); + collected.global_styles.push(( + "__style_0__ > a".to_string(), + r#"{"color":"__style_1__"}"#.to_string(), + )); + collected.references.insert( + "__style_0__".to_string(), + Reference::Style { + name: "parent".to_string(), + class_name: "f0_parent".to_string(), }, ); + collected.references.insert( + "__style_1__".to_string(), + Reference::Class("f0_theme".to_string()), + ); + collected.references.insert( + "__style_2__".to_string(), + Reference::Keyframes("fade".to_string()), + ); + collected.references.insert( + "__font_0__".to_string(), + Reference::Font("__devup_font_0_0".to_string()), + ); - let class_map: rustc_hash::FxHashMap = - std::iter::once(("parent".to_string(), "a".to_string())).collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("a:hover")); + let keyframes_names = std::iter::once(("fade".to_string(), "k".to_string())).collect(); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &keyframes_names, + ); + assert!(code.contains(r#"const child = css({"animation":"k 1s","selectors":{".f0_parent:hover &":{"color":"blue"}}})"#), "{code}"); + assert!( + code.contains(r#"const parent = css({"background":"white"}) + " f0_parent""#), + "{code}" + ); + assert!( + code.contains(r#"globalCss({ ".f0_parent > a": {"color":"f0_theme"} })"#), + "{code}" + ); + // An unresolved keyframes reference is left as is. + let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); + assert!(code.contains("__style_2__ 1s"), "{code}"); } #[test] @@ -2885,8 +2968,11 @@ export const box = style({ padding: 8 })"; ); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("[theme, vars]")); } @@ -2914,8 +3000,11 @@ export const box = style({ padding: 8 })"; ); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("const simpleTheme = \"f0_simple\"")); } @@ -3047,8 +3136,11 @@ export const box = style({ padding: 8 })"; .insert("sizes".to_string(), (variants, true)); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("sizes")); } @@ -3069,8 +3161,11 @@ export const box = style({ padding: 8 })"; .insert("CONFIG".to_string(), "{ debug: true }".to_string()); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("export const CONFIG")); } @@ -3126,8 +3221,11 @@ export const box = style({ padding: 8 })"; let class_map: rustc_hash::FxHashMap = std::iter::once(("box".to_string(), "a".to_string())).collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("css")); assert!(code.contains("globalCss")); @@ -3158,8 +3256,11 @@ export const box = style({ padding: 8 })"; ); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("const simpleTheme = \"f0_simple\"")); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 6da7cceed..8850391ec 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1092,12 +1092,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &self.filename, ); // already set style order - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); self.global_css_result(r.is_component()) } } else if call.arguments.len() == 2 @@ -1139,12 +1143,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut folded, &self.filename, ); - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); *it = self.global_css_result(util_type.is_component()); } else { *it = match util_type.as_ref() { @@ -1633,12 +1641,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { styles, style_order, } = extract_global_style_from_expression(&self.ast, expression, &self.filename); - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); } elem.opening_element.attributes.remove(i); } From 7986d4b9845eca719c99489c0080c293c87089f8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:50:08 +0900 Subject: [PATCH 05/32] fix(sheet): order at-rules after breakpoints and merge adjacent rules Rules order by selector group before breakpoint level and at-rules follow every plain rule; globalCss and custom layers keep SELECTOR_ORDER instead of sorting selectors alphabetically. Adjacent rules with the same selector share one block, and a declaration written next to a conditional typography preset wins over it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/sheet/src/lib.rs | 953 ++++++++++++------ .../snapshots/sheet__tests__create_css-5.snap | 2 +- .../sheet__tests__create_css_sort_test-2.snap | 2 +- .../sheet__tests__create_css_sort_test.snap | 2 +- ..._create_css_with_selector_sort_test-3.snap | 2 +- .../snapshots/sheet__tests__deserialize.snap | 1 + ...sheet__tests__motion_reduce_selector.snap} | 2 +- .../sheet__tests__print_selector-2.snap | 2 +- .../sheet__tests__print_selector.snap | 2 +- .../sheet__tests__selector_with_prefix.snap | 2 +- .../sheet__tests__selector_with_query.snap | 2 +- .../sheet__tests__style_order_create_css.snap | 2 +- .../sheet__tests__theme_selector-2.snap | 2 +- .../sheet__tests__theme_selector-3.snap | 2 +- .../sheet__tests__theme_selector.snap | 2 +- .../sheet__tests__wrong_breakpoint.snap | 2 +- libs/sheet/src/theme.rs | 45 + 17 files changed, 702 insertions(+), 325 deletions(-) rename libs/sheet/src/snapshots/{sheet__tests__speech_selector.snap => sheet__tests__motion_reduce_selector.snap} (64%) diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 695a0c2e6..a2b824991 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -2,12 +2,15 @@ pub mod theme; use crate::theme::Theme; use css::{ + at_rule::{MediaCombination, combine_media_queries, query_order}, atom_hoist::{atom_hoist_threshold, is_atom_hoist}, file_map::canonical, file_routes::route_count_for_files, get_custom_shorthand_names, sheet_to_classname, - style_selector::{AtRuleKind, StyleSelector, get_selector_order, global_selector_order}, - theme_tokens::set_theme_token_levels, + style_selector::{ + AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, + }, + theme_tokens::{set_theme_token_levels, set_typography_keys}, utils::compile_regex, write_merge_selector, }; @@ -31,43 +34,50 @@ macro_rules! push_fmt { }}; } -/// Shared 5-field comparator for the decorate-sort-undecorate keyed tuples in -/// `create_style_with_layers`. Both the `global_keyed` (`bool` first field) and -/// `keyed` (`u8` first field) buckets share the shape -/// `(K, order: u8, selector_str: &str, prop: &StyleSheetProperty)` and sort by the -/// exact same chain: precomputed order key first (`bool`/`u8` both compare via -/// `Ord`), then selector string, then property, then value. Extracted so the two -/// call sites cannot drift; produces byte-identical ordering to the former inline -/// closures. -fn keyed_prop_cmp( - a: &(K, u8, &str, &StyleSheetProperty), - b: &(K, u8, &str, &StyleSheetProperty), -) -> std::cmp::Ordering { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(b.2)) - .then_with(|| a.3.property.cmp(&b.3.property)) - .then_with(|| a.3.value.cmp(&b.3.value)) +/// Plain rules first, then pseudo selectors in `SELECTOR_ORDER`. +fn selector_group(selector: Option<&str>) -> (u8, u8) { + selector.map_or((0, 0), |selector| (1, get_selector_order(selector))) } -fn global_prop_key(prop: &StyleSheetProperty) -> (bool, u8, &str, &StyleSheetProperty) { - match &prop.selector { - Some(StyleSelector::Global(selector, _)) => { - if let Some(i) = selector.find(':') { - ( - true, - global_selector_order(&selector[i..]), - selector.as_str(), - prop, - ) - } else { - (false, 0u8, selector.as_str(), prop) - } +/// Typography preset declarations come first so a declaration written directly +/// on the same selector wins, and each preset stays contiguous so its rules merge. +fn prop_cmp(a: &StyleSheetProperty, b: &StyleSheetProperty) -> std::cmp::Ordering { + b.typography.cmp(&a.typography).then_with(|| { + if a.typography { + a.class_name + .cmp(&b.class_name) + .then_with(|| a.property.cmp(&b.property)) + .then_with(|| a.value.cmp(&b.value)) + } else { + a.property + .cmp(&b.property) + .then_with(|| a.value.cmp(&b.value)) + .then_with(|| a.class_name.cmp(&b.class_name)) } - _ => (false, 0u8, "", prop), - } + }) } +/// (selector group, level, selector) +type RuleOrder<'a> = ((u8, u8), u8, &'a str); +/// (enclosing at-rules outermost first, selector group, level, selector) +type AtRuleOrder<'a> = (Vec<(u8, (u8, i64), &'a str)>, (u8, u8), u8, &'a str); + +/// The blocks a rule sits in: its breakpoint level and at-rule chain. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Wrapper<'a> { + level: u8, + at_rule: Option<(&'a [AtRule], AtRuleKind, &'a str)>, +} + +/// Global selectors without a pseudo part first, then by `SELECTOR_ORDER`. +fn global_selector_group(selector: &str) -> (bool, u8) { + selector.find(':').map_or((false, 0), |i| { + (true, global_selector_order(&selector[i..])) + }) +} + +type GlobalProp<'a> = (u8, &'a str, &'a StyleSheetProperty); + #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct StyleSheetProperty { @@ -82,6 +92,9 @@ pub struct StyleSheetProperty { /// CSS layer name (from vanilla-extract `layer()`) #[serde(rename = "l", skip_serializing_if = "Option::is_none")] pub layer: Option, + /// Declaration expanded from a conditional `typography` preset + #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")] + pub typography: bool, } #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)] @@ -117,13 +130,14 @@ impl Ord for StyleSheetProperty { } impl StyleSheetProperty { - fn write_extract(&self, css: &mut String) { - write_merge_selector(css, &self.class_name, self.selector.as_ref()); - css.push('{'); + fn same_rule(&self, other: &Self) -> bool { + self.class_name == other.class_name && self.selector == other.selector + } + + fn write_declaration(&self, css: &mut String) { css.push_str(&self.property); css.push(':'); css.push_str(&convert_theme_variable_value(&self.value)); - css.push('}'); } } @@ -291,9 +305,37 @@ impl StyleSheet { style_order: Option, filename: Option<&str>, layer: Option<&str>, + ) -> bool { + self.insert_property( + level, + style_order, + filename, + StyleSheetProperty { + class_name: class_name.to_string(), + property: property.to_string(), + value: value.to_string(), + selector: selector.cloned(), + layer: layer.map(ToString::to_string), + typography: false, + }, + ) + } + + fn insert_property( + &mut self, + level: u8, + style_order: Option, + filename: Option<&str>, + prop: StyleSheetProperty, ) -> bool { // register global css file for cache - if let Some(StyleSelector::Global(_, file)) = selector { + if let Some( + StyleSelector::Global(_, file) + | StyleSelector::At { + file: Some(file), .. + }, + ) = &prop.selector + { // Probe with the borrowed `&str` first so the owned `String` is only // allocated on first registration, not on repeat (HMR/multi-property) calls. // Matches the borrow-probe-first pattern in add_import/add_font_face/add_css. @@ -314,13 +356,7 @@ impl StyleSheet { .or_default() .entry(level) .or_default() - .insert(StyleSheetProperty { - class_name: class_name.to_string(), - property: property.to_string(), - value: value.to_string(), - selector: selector.cloned(), - layer: layer.map(ToString::to_string), - }) + .insert(prop) } pub fn add_import(&mut self, file: &str, import: &str) { @@ -414,12 +450,11 @@ impl StyleSheet { let bucket_empty = if let Some(prop_map) = self.properties.get_mut(&property_key) { for map in prop_map.values_mut() { for props in map.values_mut() { - props.retain(|prop| { - if let Some(StyleSelector::Global(_, f)) = prop.selector.as_ref() { - f != file - } else { - true - } + props.retain(|prop| match prop.selector.as_ref() { + Some( + StyleSelector::Global(_, f) | StyleSelector::At { file: Some(f), .. }, + ) => f != file, + _ => true, }); } // remove empty map @@ -443,6 +478,7 @@ impl StyleSheet { theme.get_length_token_levels(), theme.get_shadow_token_levels(), ); + set_typography_keys(theme.typography.keys().cloned().collect()); self.theme = theme; } @@ -466,6 +502,34 @@ impl StyleSheet { let bucket_scope = if single_css { None } else { Some(filename) }; for style in styles { match style { + // A conditional `typography` preset: its class is the atom, and the + // preset's declarations are emitted under the atom's selector. + ExtractStyleValue::Static(st) if st.property() == "typography" => { + let (StyleProperty::ClassName(class_name) + | StyleProperty::Variable { class_name, .. }) = st.extract(name_scope); + for (level, property, value) in + self.theme.typography_declarations(st.value(), st.level()) + { + if self.insert_property( + level, + st.style_order(), + bucket_scope, + StyleSheetProperty { + class_name: class_name.clone(), + property: property.to_string(), + value, + selector: st.selector().cloned(), + layer: st.layer().map(ToString::to_string), + typography: true, + }, + ) { + collected = true; + if st.style_order() == Some(0) { + updated_base_style = true; + } + } + } + } ExtractStyleValue::Static(st) => { let is_first_value = st.theme_token_resolution() == ThemeTokenResolution::FirstValue; @@ -744,245 +808,232 @@ impl StyleSheet { fn create_style_with_layers>( &self, map: &BTreeMap>, - mut layered_styles: Option<&mut LayeredStyles>, + layered_styles: Option<&mut LayeredStyles>, ) -> String { // Estimate ~64 bytes per property for pre-allocation let prop_count: usize = map.values().map(FxHashSet::len).sum(); let mut current_css = String::with_capacity(prop_count * 64); + let mut class_rules: Vec<(RuleOrder<'_>, &StyleSheetProperty)> = + Vec::with_capacity(prop_count); + let mut at_rules: Vec<(AtRuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::new(); + let mut global_props: Vec> = Vec::new(); for (level, props) in map { - // Single pass bucketing: the two prior `partition` calls always - // heap-allocated all four output `Vec`s even when the Global/At - // buckets stay empty (the common case). One scan pushing each - // `&prop` into one of three pre-declared `Vec`s leaves empty buckets - // as non-allocating `Vec::new()` and avoids the second full scan. - let mut global_props: Vec<_> = Vec::new(); - let mut at_rules: Vec<_> = Vec::new(); - // Plain (non-Global, non-At) selectors dominate: presize this bucket - // to `props.len()` to avoid its 1→4→8→… grow-reallocs. `global_props` - // / `at_rules` stay `Vec::new()` so empty buckets never allocate. - let mut sorted_props: Vec<&StyleSheetProperty> = Vec::with_capacity(props.len()); for prop in props { let prop: &StyleSheetProperty = prop.borrow(); - match prop.selector { - Some(StyleSelector::Global(_, _)) => global_props.push(prop), - Some(StyleSelector::At { .. }) => at_rules.push(prop), - _ => sorted_props.push(prop), - } - } - // Decorate-sort-undecorate for the Global bucket, mirroring the - // plain-selector `keyed` sort below. `global_props.sort()` compares via - // `StyleSheetProperty::cmp` → `StyleSelector::cmp`'s Global arm, which for - // EACH comparison re-runs `global_selector_order` (a `SELECTOR_ORDER` table - // walk) on BOTH operands — O(n log n) redundant re-scans of the same - // selector strings. Compute each prop's Global order key ONCE. The key - // `(has_colon, order, selector_str, property, value)` reproduces the Global - // arm byte-for-byte: equal selector strings ⇒ `Ordering::Equal` there, so - // `StyleSheetProperty::cmp` falls through to (property, value); no-colon - // props (`has_colon=false`) sort before colon props and tie on the selector - // string; colon props sort by (order, selector string). - // The common level has no Global selectors, so `global_props` is empty. - // Skip the three vector materializations (keyed `with_capacity`, `extend`, - // sorted `collect`) entirely in that case, mirroring the `at_rules.is_empty()` - // guard below; only decorate-sort-undecorate when there is actually work. - // An empty sort/collect yields an empty vec anyway, so output is byte-identical. - let global_props: Vec<&StyleSheetProperty> = if global_props.is_empty() { - Vec::new() - } else { - let mut global_keyed: Vec<(bool, u8, &str, &StyleSheetProperty)> = - Vec::with_capacity(global_props.len()); - global_keyed.extend(global_props.into_iter().map(global_prop_key)); - global_keyed.sort_by(keyed_prop_cmp); - global_keyed - .into_iter() - .map(|(_, _, _, prop)| prop) - .collect() - }; - // Decorate-sort-undecorate for the plain-selector bucket: sorting via - // `StyleSheetProperty::cmp` re-runs `get_selector_order` (a byte scan + - // linear `SELECTOR_ORDER` probe) on BOTH operands of every comparison, - // i.e. O(n log n) redundant scans of the same `Selector` strings. Here - // `sorted_props` holds only `None`/`Selector(_)` variants (Global/At were - // filtered out above), so compute each prop's order key ONCE into a keyed - // vec and sort that. The key `(is_some, order, selector_str, property, - // value)` reproduces `StyleSheetProperty::cmp` byte-for-byte: `None` props - // (is_some=0, order=0, selector_str="") sort by (property, value); `Selector` - // props (is_some=1) sort by (order, selector_str, property, value) — matching - // `StyleSelector::cmp`'s `get_selector_order` then string tie-break. - // The common level often has ONLY global/at props, leaving `sorted_props` - // empty. Skip the `with_capacity`/`extend`/`sort_by` triple entirely in that - // case, mirroring the `global_props`/`at_rules` guards above and below; an - // empty build+sort yields an empty vec anyway, so output is byte-identical. - let keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = if sorted_props.is_empty() { - Vec::new() - } else { - let mut keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = - Vec::with_capacity(sorted_props.len()); - keyed.extend(sorted_props.into_iter().map(|prop| match &prop.selector { - Some(StyleSelector::Selector(s)) => { - (1u8, get_selector_order(s), s.as_str(), prop) + match &prop.selector { + Some(StyleSelector::Global(selector, _)) => { + global_props.push((*level, selector.as_str(), prop)); } - _ => (0u8, 0u8, "", prop), - })); - keyed.sort_by(keyed_prop_cmp); - keyed - }; - // The common level has no `@`-rule atoms, so `at_rules` is empty. - // Skip the `sort()` no-op and the per-level `BTreeMap` construction - // entirely in that case; only regroup when there is actually work. - let at_rules: BTreeMap<(AtRuleKind, &String), Vec<_>> = if at_rules.is_empty() { - BTreeMap::new() - } else { - at_rules.sort(); - let mut map: BTreeMap<(AtRuleKind, &String), Vec<_>> = BTreeMap::new(); - for prop in at_rules { - if let Some(StyleSelector::At { kind, query, .. }) = &prop.selector { - map.entry((*kind, query)).or_default().push(prop); + Some(StyleSelector::At { + kind, + query, + selector, + outer, + .. + }) => { + let chain = outer + .iter() + .map(|rule| (rule.kind, rule.query.as_str())) + .chain(std::iter::once((*kind, query.as_str()))) + .map(|(kind, query)| (kind as u8, query_order(kind, query), query)) + .collect(); + let selector = selector.as_deref(); + at_rules.push(( + ( + chain, + selector_group(selector), + *level, + selector.unwrap_or(""), + ), + Wrapper { + level: *level, + at_rule: Some((outer.as_slice(), *kind, query.as_str())), + }, + prop, + )); } + Some(StyleSelector::Selector(selector)) => class_rules.push(( + (selector_group(Some(selector)), *level, selector.as_str()), + prop, + )), + None => class_rules.push(((selector_group(None), *level, ""), prop)), } - map - }; + } + } + if !global_props.is_empty() { + self.write_global_props(&mut current_css, global_props, layered_styles); + } - let break_point = if *level == 0 { - None - } else { - Some( - self.theme - .breakpoints - .get(*level as usize) - .copied() - .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)), + // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint + // level, so `:active` still follows a `:hover` set at a wider breakpoint while + // one selector's responsive values keep ascending. At-rules follow every plain + // rule so a condition beats the breakpoint values it overrides; among + // themselves they order by condition, then selector group, then level. + class_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.1, b.1))); + at_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.2, b.2))); + + let mut open: Option<(Wrapper<'_>, Option)> = None; + let mut open_rule: Option<&StyleSheetProperty> = None; + for (wrapper, prop) in class_rules + .iter() + .map(|((_, level, _), prop)| { + ( + Wrapper { + level: *level, + at_rule: None, + }, + *prop, ) - }; - - if !global_props.is_empty() { - // Separate layered and non-layered global props. Only pay the - // partition + clone-into-map cost when the caller actually - // consumes the layered output (base/global path); callers that - // discard layers pass `None` and keep every global prop inline. - let non_layered_props = if let Some(layered_styles) = layered_styles.as_deref_mut() - { - let (layered_props, non_layered_props): (Vec<_>, Vec<_>) = global_props - .into_iter() - .partition(|prop| prop.layer.is_some()); - - // Collect layered props for later processing - for prop in layered_props { - if let Some(layer) = &prop.layer - && let Some(StyleSelector::Global(selector, _)) = &prop.selector - { - // Borrow-probe existing entry by layer.as_str() before cloning - let bucket = match layered_styles.get_mut(layer.as_str()) { - Some(bucket) => bucket, - None => layered_styles.entry(layer.clone()).or_default(), - }; - bucket.push(( - selector.clone(), - prop.property.clone(), - prop.value.clone(), - )); - } - } - non_layered_props + }) + .chain(at_rules.iter().map(|(_, wrapper, prop)| (*wrapper, *prop))) + { + if open.as_ref().is_none_or(|(current, _)| *current != wrapper) { + if open_rule.take().is_some() { + current_css.push('}'); + } + if let Some((_, Some(depth))) = open { + current_css.extend(std::iter::repeat_n('}', depth)); + } + let depth = self.open_wrapper(&mut current_css, wrapper); + open = Some((wrapper, depth)); + } + if let Some((_, Some(_))) = open { + if open_rule.is_some_and(|rule| rule.same_rule(prop)) { + current_css.push(';'); } else { - global_props - }; - - // Process non-layered global props as before - if !non_layered_props.is_empty() { - let mut selector_map: BTreeMap<_, Vec<_>> = BTreeMap::new(); - for prop in non_layered_props { - if let Some(StyleSelector::Global(selector, _)) = &prop.selector { - selector_map - .entry(selector) - .or_insert_with(|| Vec::with_capacity(1)) - .push(prop); - } - } - if let Some(break_point) = break_point { - push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{"); - } - for (selector, props) in selector_map { - current_css.push_str(selector); - current_css.push('{'); - let mut first = true; - for prop in props { - if !first { - current_css.push(';'); - } - first = false; - current_css.push_str(&prop.property); - current_css.push(':'); - current_css.push_str(&prop.value); - } - current_css.push('}'); - } - if break_point.is_some() { + if open_rule.is_some() { current_css.push('}'); } + write_merge_selector( + &mut current_css, + &prop.class_name, + prop.selector.as_ref(), + ); + current_css.push('{'); + open_rule = Some(prop); + } + prop.write_declaration(&mut current_css); + } + } + if open_rule.is_some() { + current_css.push('}'); + } + if let Some((_, Some(depth))) = open { + current_css.extend(std::iter::repeat_n('}', depth)); + } + current_css + } + + fn break_point(&self, level: u8) -> u16 { + self.theme + .breakpoints + .get(level as usize) + .copied() + .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)) + } + + /// Open the blocks `wrapper` needs and return how many to close, or `None` + /// when the breakpoint and the media query can never hold together. + fn open_wrapper(&self, css: &mut String, wrapper: Wrapper<'_>) -> Option { + let mut rules: Vec<(AtRuleKind, Cow<'_, str>)> = + wrapper + .at_rule + .map_or_else(Vec::new, |(outer, kind, query)| { + outer + .iter() + .map(|rule| (rule.kind, Cow::Borrowed(rule.query.as_str()))) + .chain(std::iter::once((kind, Cow::Borrowed(query)))) + .collect() + }); + if wrapper.level > 0 { + let bp_query = format!("(min-width:{}px)", self.break_point(wrapper.level)); + // The breakpoint folds into the outermost `@media` when one query can + // express both; otherwise it wraps the whole chain. + let combined = match rules.first() { + Some((AtRuleKind::Media, query)) => combine_media_queries(&bp_query, query), + _ => MediaCombination::Nest, + }; + match combined { + MediaCombination::Merged(merged) => rules[0].1 = Cow::Owned(merged), + MediaCombination::Never => return None, + MediaCombination::Nest => { + rules.insert(0, (AtRuleKind::Media, Cow::Owned(bp_query))); } } + } + for (kind, query) in &rules { + let _ = write_at_rule(css, *kind, query); + css.push('{'); + } + Some(rules.len()) + } - if !keyed.is_empty() { - if let Some(break_point) = break_point { - push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{"); + fn write_global_props( + &self, + css: &mut String, + mut global_props: Vec>, + mut layered_styles: Option<&mut LayeredStyles>, + ) { + // Same order as class rules: selector group before breakpoint level, so a + // `:hover` set at a wider breakpoint still precedes `:active`. + global_props.sort_by(|a, b| { + global_selector_group(a.1) + .cmp(&global_selector_group(b.1)) + .then_with(|| a.0.cmp(&b.0)) + .then_with(|| a.1.cmp(b.1)) + .then_with(|| a.2.property.cmp(&b.2.property)) + .then_with(|| a.2.value.cmp(&b.2.value)) + }); + + let mut open_level: Option = None; + let mut open_selector: Option<&str> = None; + for (level, selector, prop) in global_props { + if let (Some(layered_styles), Some(layer)) = + (layered_styles.as_deref_mut(), prop.layer.as_ref()) + { + let bucket = match layered_styles.get_mut(layer.as_str()) { + Some(bucket) => bucket, + None => layered_styles.entry(layer.clone()).or_default(), + }; + bucket.push(( + selector.to_string(), + prop.property.clone(), + prop.value.clone(), + )); + continue; + } + if open_level != Some(level) { + if open_selector.take().is_some() { + css.push('}'); } - for (_, _, _, prop) in &keyed { - prop.write_extract(&mut current_css); + if open_level.is_some_and(|open| open > 0) { + css.push('}'); } - if break_point.is_some() { - current_css.push('}'); + if level > 0 { + push_fmt!(css, "@media(min-width:{}px){{", self.break_point(level)); } + open_level = Some(level); } - for ((kind, query), props) in at_rules { - if let Some(break_point) = break_point { - match kind { - AtRuleKind::Media => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px)and {query}{{" - ); - } - AtRuleKind::Supports => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@supports{query}{{" - ); - } - AtRuleKind::Container => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@container{query}{{" - ); - } - AtRuleKind::Layer => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@layer {query}{{" - ); - } - } - for prop in props { - prop.write_extract(&mut current_css); - } - match kind { - AtRuleKind::Media => current_css.push('}'), - _ => current_css.push_str("}}"), - } - } else { - push_fmt!(&mut current_css, "@{kind}"); - if query.starts_with('(') { - push_fmt!(&mut current_css, "{query}{{"); - } else { - push_fmt!(&mut current_css, " {query}{{"); - } - for prop in props { - prop.write_extract(&mut current_css); - } - current_css.push('}'); + if open_selector == Some(selector) { + css.push(';'); + } else { + if open_selector.is_some() { + css.push('}'); } + css.push_str(selector); + css.push('{'); + open_selector = Some(selector); } + css.push_str(&prop.property); + css.push(':'); + css.push_str(&prop.value); + } + if open_selector.is_some() { + css.push('}'); + } + if open_level.is_some_and(|open| open > 0) { + css.push('}'); } - current_css } #[inline] @@ -1155,30 +1206,22 @@ impl StyleSheet { // Generate styles wrapped in @layer blocks for (layer_name, styles) in layered_styles { - // Group by selector - let mut selector_map: BTreeMap> = BTreeMap::new(); - for (selector, property, value) in styles { - selector_map - .entry(selector) - .or_default() - .push((property, value)); - } - push_fmt!(&mut css, "@layer {layer_name}{{"); - for (selector, props) in selector_map { - css.push_str(&selector); - css.push('{'); - let mut first = true; - for (p, v) in props { - if !first { - css.push(';'); + let mut open_selector: Option<&str> = None; + for (selector, p, v) in &styles { + if open_selector == Some(selector.as_str()) { + css.push(';'); + } else { + if open_selector.is_some() { + css.push('}'); } - first = false; - push_fmt!(&mut css, "{p}:{v}"); + css.push_str(selector); + css.push('{'); + open_selector = Some(selector); } - css.push('}'); + push_fmt!(&mut css, "{p}:{v}"); } - css.push('}'); + css.push_str("}}"); } } // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global @@ -1412,6 +1455,8 @@ mod tests { kind: AtRuleKind::Media, query: "(hover:hover)".to_string(), selector: None, + outer: vec![], + file: None, }; sheet.add_property( "atr", @@ -2198,14 +2243,14 @@ mod tests { } #[test] - fn test_speech_selector() { + fn test_motion_reduce_selector() { let mut sheet = StyleSheet::default(); sheet.add_property( "test", "display", 0, "none", - Some(&"speech".into()), + Some(&"motion-reduce".into()), None, None, ); @@ -2241,6 +2286,8 @@ mod tests { kind: AtRuleKind::Media, query: "(min-width: 1024px)".to_string(), selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }), None, None, @@ -2254,6 +2301,8 @@ mod tests { kind: AtRuleKind::Media, query: "(min-width: 1024px)".to_string(), selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }), None, None, @@ -2274,6 +2323,8 @@ mod tests { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), selector: None, + outer: vec![], + file: None, }), None, None, @@ -2294,6 +2345,8 @@ mod tests { kind: AtRuleKind::Container, query: "(min-width: 768px)".to_string(), selector: None, + outer: vec![], + file: None, }), None, None, @@ -2868,6 +2921,31 @@ mod tests { assert_debug_snapshot!(css.split("*/").nth(1).unwrap()); } + #[test] + fn test_custom_layer_keeps_selector_order() { + let mut sheet = StyleSheet::default(); + for (selector, value) in [("a:active", "blue"), ("a:hover", "red"), ("a", "black")] { + sheet.add_property_with_layer( + "a", + "color", + 0, + value, + Some(&StyleSelector::Global( + selector.to_string(), + "links.css.ts".to_string(), + )), + Some(0), + None, + Some("ui"), + ); + } + let css = sheet.create_css(None, false); + assert!( + css.contains("@layer ui{a{color:black}a:hover{color:red}a:active{color:blue}}"), + "{css}" + ); + } + #[test] fn test_at_rules_with_breakpoints() { let mut sheet = StyleSheet::default(); @@ -2881,6 +2959,8 @@ mod tests { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2904,6 +2984,8 @@ mod tests { kind: AtRuleKind::Container, query: "(min-width: 400px)".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2945,6 +3027,8 @@ mod tests { kind: AtRuleKind::Layer, query: "components".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2975,6 +3059,7 @@ mod tests { value: value.to_string(), selector: None, layer: None, + typography: false, }; assert_eq!(make("color", "red").cmp(&make("color", "red")), Equal); assert!(make("color", "red") < make("color", "white")); @@ -2991,6 +3076,7 @@ mod tests { value: value.to_string(), selector, layer: None, + typography: false, }; let hover = || Some(StyleSelector::Selector("&:hover".to_string())); @@ -3008,29 +3094,11 @@ mod tests { } #[test] - fn test_global_prop_key_variants() { - let make = |selector: Option| StyleSheetProperty { - class_name: "a".to_string(), - property: "color".to_string(), - value: "red".to_string(), - selector, - layer: None, - }; - let pseudo = make(Some(StyleSelector::Global( - "a:hover".to_string(), - "test.tsx".to_string(), - ))); - let plain = make(Some(StyleSelector::Global( - "body".to_string(), - "test.tsx".to_string(), - ))); - let local = make(Some(StyleSelector::Selector("&:hover".to_string()))); - - assert!(global_prop_key(&pseudo).0); - assert_eq!(global_prop_key(&pseudo).2, "a:hover"); - assert!(!global_prop_key(&plain).0); - assert_eq!(global_prop_key(&plain).2, "body"); - assert_eq!(global_prop_key(&local).2, ""); + fn test_global_selector_group() { + assert_eq!(global_selector_group("body"), (false, 0)); + assert_eq!(global_selector_group("a:hover"), (true, 0)); + assert_eq!(global_selector_group("a:active"), (true, 3)); + assert_eq!(global_selector_group(":root"), (true, 0)); } #[test] @@ -3196,6 +3264,269 @@ mod tests { ); } + fn pipeline_css(theme: Theme, source: &str) -> String { + css::debug::set_debug(false); + css::set_prefix(None); + css::atom_hoist::set_atom_hoist(None); + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + sheet.set_theme(theme); + let output = extract( + "test.tsx", + &format!("import {{Box,css,styled,globalCss}} from '@devup-ui/core';\n{source}"), + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "test.tsx", true); + // Class names come from a process-wide counter; number them by first + // appearance so the expected CSS only pins down structure and order. + let mut names: Vec = vec![]; + compile_regex(r"\.([A-Za-z_][\w-]*)") + .replace_all( + sheet.create_css(None, false).split("*/").nth(1).unwrap(), + |caps: ®ex_lite::Captures| { + let index = names.iter().position(|n| *n == caps[1]).unwrap_or_else(|| { + names.push(caps[1].to_string()); + names.len() - 1 + }); + format!(".c{index}") + }, + ) + .into_owned() + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_at_rule_pipeline() { + for (source, expected) in [ + // Conditions (at-rules) win over breakpoint values; pseudo states keep + // `SELECTOR_ORDER` across breakpoints. + ( + "", + ".c0{background:red}@media(min-width:768px){.c1{background:blue}}.c2:hover{background:green}", + ), + ( + "", + ".c0:hover{background:red}@media(min-width:768px){.c1:hover{background:blue}}.c2:active{background:green}", + ), + ( + "", + ".c0{transition:opacity .2s}@media(min-width:768px){.c1{transition:all .3s}}@media(prefers-reduced-motion:reduce){.c2{transition:none}}", + ), + ( + "", + ".c0{background:red}@media(min-width:768px){.c1{background:blue}}@supports(display:grid){.c2{background:green}}", + ), + ( + "", + "@media(min-width:500px){.c0{padding:4px}}@media(min-width:1000px){.c1{padding:8px}}@media(max-width:900px){.c2{padding:16px}}@media(max-width:300px){.c3{padding:12px}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){.c0:hover{background:b}.c1:active{background:a}}", + ), + // Nesting in either direction keeps both the condition and the selector. + ( + "", + "@media print{.c0:hover{background:red}.c1:focus{color:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:no-preference){.c0:hover{transform:scale(1.05)}:is([role=group],[data-group]):hover .c1{color:blue}:root[data-theme=dark] .c2{color:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){.c0:hover{background:red}.c1::before{content:\"\"}}", + ), + ( + "", + ":root[data-theme=dark] .c0:hover{background:red}", + ), + // Nested at-rules fold into one query or nest; exclusive ones are dropped. + ( + "", + "@media print and (prefers-reduced-motion:reduce){.c0{background:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){@supports(display:grid){.c0{background:red}}}", + ), + // Breakpoints combine with the query: media type first, lists distribute, + // `not print` becomes `screen`, anything else nests. + ( + "", + "@media print{.c0{background:red}}@media print and (min-width:768px){.c1{background:blue}}", + ), + ( + "", + "@media screen and (min-width:768px){.c0{color:red}}@media(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none){.c1{background:blue}}", + ), + ( + "", + "@media(min-width:768px){@media not print and (color){.c0{background:blue}}}", + ), + ( + "", + "@media(min-width:768px){@supports not (display:grid){.c0{background:blue}}}", + ), + // Emotion-style object keys and template literals. + ( + "
", + ".c0:focus{color:red}@media print{.c1{color:blue}.c2:hover{color:green}}", + ), + ( + "
", + ".c0{transition:all .3s}.c1:hover span{color:blue}@media print{.c2:hover{color:red}}@media(prefers-reduced-motion:reduce){.c3{transition:none}}", + ), + ( + "", + "@media print and (prefers-reduced-motion:reduce){.c0{display:none}}", + ), + ( + "globalCss({ '@media (prefers-reduced-motion: reduce)': { '*': { transition: 'none' } }, _print: { body: { bg: 'white', _hover: { color: 'red' } } }, _media: { '(min-width: 768px)': { body: { m: 2 } } }, _screen: { _print: { body: { color: 'red' } } } })", + "@layer b;@layer b{@media(min-width:768px){body{margin:8px}}@media print{body{background:white}body:hover{color:red}}@media(prefers-reduced-motion:reduce){*{transition:none}}}", + ), + ( + "", + ".c0:hover{margin:4px}.c1:focus{margin:4px}@media print{.c2{background:red}}@media(hover:none){.c3{color:red}}", + ), + // Global pseudo rules keep `SELECTOR_ORDER` across breakpoints. + ( + "globalCss({ a: { _hover: { color: 'red' }, _active: { color: 'blue' }, _focus: { color: 'green' } } })", + "@layer b;@layer b{a:hover{color:red}a:focus{color:green}a:active{color:blue}}", + ), + ( + "globalCss({ a: { color: 'black', _hover: { color: [null, null, 'red'] }, _active: { color: 'blue' } } })", + "@layer b;@layer b{a{color:black}@media(min-width:768px){a:hover{color:red}}a:active{color:blue}}", + ), + ( + "globalCss({ a: { color: ['red', null, 'blue'] } })", + "@layer b;@layer b{a{color:red}@media(min-width:768px){a{color:blue}}}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_conditional_typography_pipeline() { + for (source, expected) in [ + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}", + ), + ( + "", + "@layer t;@media(prefers-reduced-motion:reduce){.c2{font-family:var(--heading);font-size:20px;font-weight:700}}@media(min-width:768px)and (prefers-reduced-motion:reduce){.c2{font-size:32px}}", + ), + ( + "", + "@layer t;@media(min-width:768px){.c2:hover{font-family:var(--heading);font-size:32px;font-weight:700}}", + ), + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", + ), + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", + ), + ("", "@layer t;"), + // A declaration written next to the preset wins over the preset's. + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-size:11px}", + ), + ( + "globalCss({ body: { typography: 'small' } })", + "@layer b,t;@layer b{body{font-size:12px;line-height:1.2}}", + ), + ] { + let mut theme = Theme::default(); + theme.add_typography( + "small", + vec![Some(Typography::new( + None, + Some("12px".to_string()), + None, + Some("1.2".to_string()), + None, + ))], + ); + theme.add_typography( + "title", + vec![ + Some(Typography::new( + Some("$heading".to_string()), + Some("20px".to_string()), + Some("700".to_string()), + None, + Some(" ".to_string()), + )), + None, + Some(Typography::new( + None, + Some("32px".to_string()), + None, + None, + None, + )), + ], + ); + let css = pipeline_css(theme, &format!("const size = 'small';\n{source}")); + // Drop the theme's own `.typo-*` layer; only the conditional atoms matter here. + let start = css.find("@layer t{").unwrap(); + let mut depth = 0; + let end = css[start..] + .char_indices() + .find_map(|(index, c)| { + match c { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + (c == '}' && depth == 0).then_some(start + index + 1) + }) + .unwrap(); + assert_eq!(format!("{}{}", &css[..start], &css[end..]), expected); + } + } + + #[test] + #[serial] + fn test_rm_global_css_drops_global_at_rules() { + let mut sheet = StyleSheet::default(); + let output = extract( + "global.tsx", + "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: 'red', _motionReduce: { transition: 'none' } } })", + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "global.tsx", true); + assert!(sheet.create_css(None, false).contains("transition:none")); + + assert!(sheet.rm_global_css("global.tsx", true)); + let css = sheet.create_css(None, false); + assert!(!css.contains("transition:none"), "{css}"); + assert!(!css.contains("color:red"), "{css}"); + } + #[test] #[serial] fn test_dynamic_style_important_full_pipeline() { diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap index c57eb2315..851fa55b1 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":is([role=group],[data-group]):focus-visible .test{background:blue}:is([role=group],[data-group]):focus-visible .test{background:red}" +":is([role=group],[data-group]):focus-visible .test{background:blue;background:red}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap index 3193a504f..3f9785c49 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{border:1px solid}.test{border-color:red}" +".test{border:1px solid;border-color:red}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap index d9ef15491..6062adb5f 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){.test{background:some}.test{background-color:red}}" +"@media(min-width:480px){.test{background:some;background-color:red}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap index d9ef15491..6062adb5f 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){.test{background:some}.test{background-color:red}}" +"@media(min-width:480px){.test{background:some;background-color:red}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap index 77513dadb..252f09dc8 100644 --- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -13,6 +13,7 @@ StyleSheet { value: "40px", selector: None, layer: None, + typography: false, }, }, }, diff --git a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap similarity index 64% rename from libs/sheet/src/snapshots/sheet__tests__speech_selector.snap rename to libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap index a180ad3b7..4ac3f1f88 100644 --- a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media speech{.test{display:none}}" +"@media(prefers-reduced-motion:reduce){.test{display:none}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap index ca6c61257..88e3e2b12 100644 --- a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{margin-bottom:40px}.test{margin-top:40px}@media print{.test{margin-left:40px}.test{margin-right:40px}}" +".test{margin-bottom:40px;margin-top:40px}@media print{.test{margin-left:40px;margin-right:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap index 053d618c4..0cce6708d 100644 --- a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}@media(min-width:480px)and print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}" +"@media print{.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}@media print and (min-width:480px){.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap index 57a93b52f..b19be1fae 100644 --- a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap +++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px}:is([role=group],[data-group]):hover .test{margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px}:is([role=group],[data-group]):hover .test{margin-right:50px}}" +"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px;margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px;margin-right:50px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap index b693d37d7..824a6032e 100644 --- a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap +++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width: 1024px){.test:hover{margin-bottom:40px}.test:hover{margin-top:40px}}" +"@media(min-width: 1024px){.test:hover{margin-bottom:40px;margin-top:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap index 2c1121290..c59953840 100644 --- a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap +++ b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@layer o1,o2;@layer o1{.test{margin-left:40px}.test{margin-right:40px}@media(min-width:480px){.test{margin-left:40px}.test{margin-left:44px}.test{margin-right:40px}.test{margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px}.test{margin-right:50px}}}.test{margin-left:70px}.test{margin-right:70px}@media(min-width:480px){.test{margin-left:60px}.test{margin-right:60px}}" +"@layer o1,o2;@layer o1{.test{margin-left:40px;margin-right:40px}@media(min-width:480px){.test{margin-left:40px;margin-left:44px;margin-right:40px;margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px;margin-right:50px}}}.test{margin-left:70px;margin-right:70px}@media(min-width:480px){.test{margin-left:60px;margin-right:60px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap index 1946f076e..f2d00072b 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{margin-left:41px}.test{margin-left:42px}.test{margin-right:41px}.test{margin-right:42px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-left:51px}:root[data-theme=light] .test{margin-right:50px}:root[data-theme=light] .test{margin-right:51px}" +".test{margin-left:41px;margin-left:42px;margin-right:41px;margin-right:42px}:root[data-theme=light] .test{margin-left:50px;margin-left:51px;margin-right:50px;margin-right:51px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap index 771bbc05b..71c4134fb 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":root[data-theme=light] .test:hover{margin-left:50px}:root[data-theme=light] .test:hover{margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px}:root[data-theme=light] .test:active{margin-right:50px}" +":root[data-theme=light] .test:hover{margin-left:50px;margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px;margin-right:50px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap index db1c61873..b576c552b 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":root[data-theme=dark] .test{margin-bottom:40px}:root[data-theme=dark] .test{margin-left:40px}:root[data-theme=dark] .test{margin-right:40px}:root[data-theme=dark] .test{margin-top:40px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-right:50px}" +":root[data-theme=dark] .test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}:root[data-theme=light] .test{margin-left:50px;margin-right:50px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap index a227ce36d..c051ab081 100644 --- a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap +++ b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:1600px){.test{margin-left:40px}.test{margin-right:40px}}" +"@media(min-width:1600px){.test{margin-left:40px;margin-right:40px}}" diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 9d7b11c62..29ff73962 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -606,6 +606,51 @@ impl Theme { default_variant_key(&self.colors).map(str::to_string) } + /// Declarations of typography preset `name` applied from breakpoint `level` + /// up: preset entries at or below `level` merge into `level`, wider ones + /// keep their own breakpoint. Values resolve like the `.typo-*` classes. + #[must_use] + pub fn typography_declarations( + &self, + name: &str, + level: u8, + ) -> Vec<(u8, &'static str, String)> { + let mut declarations: Vec<(u8, &'static str, String)> = vec![]; + let Some(typography) = self.typography.get(name) else { + return declarations; + }; + for (index, entry) in typography.0.iter().enumerate() { + let Some(entry) = entry else { + continue; + }; + let target = level.max(index as u8); + for (property, value) in [ + ("font-family", &entry.font_family), + ("font-size", &entry.font_size), + ("font-weight", &entry.font_weight), + ("line-height", &entry.line_height), + ("letter-spacing", &entry.letter_spacing), + ] { + let Some(value) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else { + continue; + }; + let resolved = value.strip_prefix('$').map_or_else( + || optimize_value(value).into_owned(), + |token| format!("var(--{token})"), + ); + if let Some(existing) = declarations + .iter_mut() + .find(|(l, p, _)| *l == target && *p == property) + { + existing.2 = resolved; + } else { + declarations.push((target, property, resolved)); + } + } + } + declarations + } + #[must_use] pub fn get_length_token_levels(&self) -> BTreeMap> { token_levels(&self.length) From 31a8aa5f9e3e9255caa054b542ddb869ae1f7366 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:52:13 +0900 Subject: [PATCH 06/32] feat(react): type media shorthands and at-rule keys Add _motionReduce, _motionSafe, _portrait, _landscape, _contrastMore, _contrastLess and _forcedColors, accept @media/@supports/@container keys, allow top-level at-rules in globalCss, and drop the never-matching _speech. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/react/AGENTS.md | 2 +- .../src/types/props/__tests__/index.test-d.ts | 24 +++++++++++++++ .../react/src/types/props/selector/index.ts | 29 +++++++++++++++---- .../src/utils/__tests__/global-css.test-d.ts | 13 +++++++++ packages/react/src/utils/global-css.ts | 15 ++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md index b9011b401..2807aa5f5 100644 --- a/packages/react/AGENTS.md +++ b/packages/react/AGENTS.md @@ -68,7 +68,7 @@ src/ ### Theme Tokens ```tsx // Color token - // Typography preset + // Typography preset ``` ## ANTI-PATTERNS diff --git a/packages/react/src/types/props/__tests__/index.test-d.ts b/packages/react/src/types/props/__tests__/index.test-d.ts index 309493e4b..32bc57f14 100644 --- a/packages/react/src/types/props/__tests__/index.test-d.ts +++ b/packages/react/src/types/props/__tests__/index.test-d.ts @@ -128,6 +128,30 @@ describe('index', () => { `, }) }) + it('media shorthands and at-rule keys', () => { + assertType>({ + _motionReduce: { transition: 'none' }, + _motionSafe: { _hover: { transform: 'scale(1.05)' } }, + _portrait: { flexDir: 'column' }, + _landscape: [{ flexDir: 'row' }, null, { flexDir: 'column' }], + _contrastMore: { borderColor: 'black' }, + _contrastLess: { borderColor: 'gray' }, + _forcedColors: { borderColor: 'CanvasText' }, + _print: { display: 'none' }, + _screen: { display: 'block' }, + _all: { color: 'black' }, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + '@supports (display: grid)': { display: 'grid' }, + '@container sidebar (min-width: 400px)': { p: 2 }, + _hover: { + _motionReduce: { transform: 'none' }, + '@media print': { color: 'black' }, + }, + }) + + expectTypeOf>().not.toHaveProperty('_speech') + }) + it('DevupComponentAdditionalProps', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars function Foo({ children: _ }: { children?: string; c: string }) { diff --git a/packages/react/src/types/props/selector/index.ts b/packages/react/src/types/props/selector/index.ts index 891a13b1f..81d19ad0c 100644 --- a/packages/react/src/types/props/selector/index.ts +++ b/packages/react/src/types/props/selector/index.ts @@ -71,13 +71,30 @@ export type AtRuleRecord = Partial< Record> > +/** Props that wrap styles in a fixed `@media` query, e.g. `_motionReduce`. */ +export type MediaShorthand = + | '_print' + | '_screen' + | '_all' + | '_portrait' + | '_landscape' + | '_motionReduce' + | '_motionSafe' + | '_contrastMore' + | '_contrastLess' + | '_forcedColors' + +export type MediaShorthandProps = { + [K in MediaShorthand]?: SelectorProps +} + +/** At-rule keys carrying their condition, e.g. `'@media print'`. */ +export type AtRuleKey = + `@media ${string}` | `@supports ${string}` | `@container ${string}` + export interface DevupSelectorProps - extends SimpleSelectorProps, AdvancedSelectorProps { - // media query - _print?: SelectorProps - _screen?: SelectorProps - _speech?: SelectorProps - _all?: SelectorProps + extends SimpleSelectorProps, AdvancedSelectorProps, MediaShorthandProps { + [key: AtRuleKey]: SelectorProps | undefined // at-rules (underscore prefix) _container?: AtRuleRecord diff --git a/packages/react/src/utils/__tests__/global-css.test-d.ts b/packages/react/src/utils/__tests__/global-css.test-d.ts index 0be126b4e..5175a9987 100644 --- a/packages/react/src/utils/__tests__/global-css.test-d.ts +++ b/packages/react/src/utils/__tests__/global-css.test-d.ts @@ -12,4 +12,17 @@ describe('globalCss', () => { }, }) }) + + it('top-level at-rules', () => { + assertType({ + '@media (prefers-reduced-motion: reduce)': { + '*, *::before, *::after': { transition: 'none' }, + }, + _motionReduce: { html: { scrollBehavior: 'auto' } }, + _print: { body: { bg: 'white' } }, + _media: { '(min-width: 768px)': { body: { m: 2 } } }, + '@supports': { '(display: grid)': { main: { display: 'grid' } } }, + body: { _motionReduce: { transition: 'none' } }, + }) + }) }) diff --git a/packages/react/src/utils/global-css.ts b/packages/react/src/utils/global-css.ts index c35d0ad3e..dd86476c8 100644 --- a/packages/react/src/utils/global-css.ts +++ b/packages/react/src/utils/global-css.ts @@ -1,10 +1,12 @@ import type { DevupCommonProps } from '../types/props' import type { AdvancedSelector, + AtRuleKey, CamelCase, DevupSelectorProps, DevupThemeSelectorProps, ExtractSelector, + MediaShorthand, SimpleSelector, } from '../types/props/selector' @@ -36,6 +38,19 @@ export type GlobalCssProps = { [ K in `${keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap | '.' | '*' | '#' | ':' | '['}${string}` ]?: DevupCommonProps & DevupSelectorProps & DevupThemeSelectorProps +} & { + // Top-level at-rules wrap whole selector maps: `'@media print': { body: … }`. + [K in AtRuleKey | MediaShorthand]?: GlobalCssProps +} & { + [ + K in + | '_media' + | '_supports' + | '_container' + | '@media' + | '@supports' + | '@container' + ]?: Record } interface FontFaceProps { From b21b6185c85eb8335127ca0a9a32f65ee37af943 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:54:20 +0900 Subject: [PATCH 07/32] docs: document media shorthands and at-rules, fix typography examples typography takes the bare preset key, so the examples no longer write typography=$heading. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 +- README_ko.md | 2 +- SKILL.md | 17 ++- .../app/(detail)/docs/api/selector/page.mdx | 126 ++++++++++++++++++ .../(detail)/docs/devup/breakpoints/page.mdx | 2 +- .../(detail)/docs/devup/devup-json/page.mdx | 4 +- .../(detail)/docs/devup/typography/page.mdx | 24 ++-- .../src/app/(detail)/docs/features/page.mdx | 2 +- 8 files changed, 160 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d51c35d6b..14c0d1658 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ const example = ( ```tsx // Type-safe theme tokens const textExample = -const boxExample = +const boxExample = ``` **Custom shorthands:** diff --git a/README_ko.md b/README_ko.md index 095a0183c..1f97db94c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -186,7 +186,7 @@ const generated = ( ```tsx // 타입 세이프 테마 토큰 const textExample = -const boxExample = +const boxExample = ``` **사용자 정의 shorthand:** diff --git a/SKILL.md b/SKILL.md index ade2c1c06..cdb07c166 100644 --- a/SKILL.md +++ b/SKILL.md @@ -215,8 +215,23 @@ Available: `_groupHover`, `_groupFocus`, `_groupActive`, `_groupDisabled`. // @ prefix syntax (equivalent) + +// Query in the key (Emotion style), also inside `selectors` and css()/styled() + +``` + +Media shorthands wrap styles in a fixed query and nest in either direction with selectors: +`_print`, `_screen`, `_all`, `_portrait`, `_landscape`, `_motionReduce` (`prefers-reduced-motion: reduce`), `_motionSafe` (`no-preference`), `_contrastMore`, `_contrastLess`, `_forcedColors`. + +```tsx + + +globalCss({ _motionReduce: { "*, *::before, *::after": { transition: "none" } } }) ``` +- Conditions beat breakpoint values: at-rule styles are emitted after every responsive rule. +- Nested `@media` rules merge into one query (`print and (prefers-reduced-motion:reduce)`); ones that can never match together (`_print` inside `_screen`) are dropped. + ### Custom Selectors ```tsx @@ -365,7 +380,7 @@ const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rot ``` - **Colors**: Use with `$` prefix in JSX props: `` -- **Typography**: Use with `$` prefix: `` +- **Typography**: Use the preset name without `$`: ``. Under selectors or at-rules (`_hover={{ typography: "heading" }}`) the preset applies only under that condition. - **Length**: Responsive length tokens: ``, `` - **Shadow**: Responsive shadow tokens: `` - **extends**: Inherit from base config files (deep merge, last wins) diff --git a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx index f48115420..ce48b5440 100644 --- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx @@ -182,3 +182,129 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector, + +## Media Shorthands + +These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorMedia Query
+ _print + + print +
+ _screen + + screen +
+ _all + + all +
+ _portrait + + (orientation:portrait) +
+ _landscape + + (orientation:landscape) +
+ _motionReduce + + (prefers-reduced-motion:reduce) +
+ _motionSafe + + (prefers-reduced-motion:no-preference) +
+ _contrastMore + + (prefers-contrast:more) +
+ _contrastLess + + (prefers-contrast:less) +
+ _forcedColors + + (forced-colors:active) +
+ +```tsx +const example = ( + +) +``` + +## At-Rules + +`_media`, `_supports` and `_container` take a record of queries to styles. A query can also sit in the key itself, as in Emotion, and `globalCss` accepts at-rules around whole selector maps. + +```tsx +const example = ( + +) + +globalCss({ + _motionReduce: { '*, *::before, *::after': { transition: 'none' } }, +}) +``` + +Conditions always win over breakpoint values: at-rule styles come after every responsive rule. Among themselves they are ordered by condition, with width queries mobile-first (`min-width` ascending, then `max-width` descending), then media types, other features, and user preferences such as `prefers-*` last. diff --git a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx index 205036bb9..77195034b 100644 --- a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx @@ -172,7 +172,7 @@ Or use responsive typography from `devup.json`: ``` ```tsx -Responsive Heading +Responsive Heading ``` ## Responsive with Pseudo Selectors diff --git a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx index 9344e90d1..462776cec 100644 --- a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx @@ -223,8 +223,8 @@ See [Shadow](/docs/devup/shadow) for more details. ### Typography ```tsx -const headingExample = Heading -const bodyExample = Body text +const headingExample = Heading +const bodyExample = Body text ``` ### Length diff --git a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx index a356fffd6..9df1f4f6f 100644 --- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx @@ -60,10 +60,10 @@ Define typography styles in `devup.json`: Use typography with the `typography` prop: ```tsx -const h1Example = Heading 1 -const h2Example = Heading 2 -const bodyExample = Body text -const captionExample = Caption text +const h1Example = Heading 1 +const h2Example = Heading 2 +const bodyExample = Body text +const captionExample = Caption text ``` ## Typography Properties @@ -298,17 +298,17 @@ Here's a recommended typography scale: ```tsx // With Text component -const titleExample = Main Title -const bodyExample = Paragraph text goes here. +const titleExample = Main Title +const bodyExample = Paragraph text goes here. // With Box component -const boxExample = Caption in a box +const boxExample = Caption in a box ``` ```tsx // With styled components const Title = styled('h1', { - typography: '$h1', + typography: 'h1', color: '$text', }) ``` @@ -319,7 +319,7 @@ Typography can be combined with other style props: ```tsx const example = ( - + Centered Title ) @@ -330,9 +330,9 @@ const example = ( Typography tokens are fully type-safe: ```tsx -// TypeScript autocomplete shows: $h1, $h2, $body, etc. -const example = +// TypeScript autocomplete shows: h1, h2, body, etc. +const example = // Invalid tokens show TypeScript errors -const invalidExample = // Error! +const invalidExample = // Error! ``` diff --git a/apps/landing/src/app/(detail)/docs/features/page.mdx b/apps/landing/src/app/(detail)/docs/features/page.mdx index 26c9e0071..ebb85645c 100644 --- a/apps/landing/src/app/(detail)/docs/features/page.mdx +++ b/apps/landing/src/app/(detail)/docs/features/page.mdx @@ -162,7 +162,7 @@ Define themes in `devup.json` with full TypeScript support. ```tsx // Type-safe theme tokens with autocomplete const textExample = -const boxExample = +const boxExample = ``` Theme switching happens via CSS variables — zero JavaScript execution required. From ab73e17238251f004dd4bc0e020df65929938497 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:56:27 +0900 Subject: [PATCH 08/32] chore: add changepacks for at-rule composition and CSS output fixes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_at_rule_composition.json | 8 ++++++++ .changepacks/changepack_log_css_output_fixes.json | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changepacks/changepack_log_at_rule_composition.json create mode 100644 .changepacks/changepack_log_css_output_fixes.json diff --git a/.changepacks/changepack_log_at_rule_composition.json b/.changepacks/changepack_log_at_rule_composition.json new file mode 100644 index 000000000..9c7889f27 --- /dev/null +++ b/.changepacks/changepack_log_at_rule_composition.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Fix at-rule composition and cascade: `_print`/`_media`/`@media …` nest correctly with selectors in both directions and inside `globalCss`, nested media queries merge (`print and (…)`), breakpoints put media types first, distribute query lists, and nest `not …` queries, and at-rules are emitted after all breakpoint values while pseudo selectors keep `SELECTOR_ORDER` across breakpoints. Add `_motionReduce`, `_motionSafe`, `_portrait`, `_landscape`, `_contrastMore`, `_contrastLess` and `_forcedColors`, remove the never-matching `_speech`, normalize query whitespace, support Emotion-style `'@media …'`/`'&:hover'` object keys, fix template literals losing declarations before `@media` or selectors around nested at-rules, apply `typography` conditionally under selectors, and drop stale global at-rules on re-extraction", + "date": "2026-09-26T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_css_output_fixes.json b/.changepacks/changepack_log_css_output_fixes.json new file mode 100644 index 000000000..1cdced775 --- /dev/null +++ b/.changepacks/changepack_log_css_output_fixes.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Fix the full WASM build panicking on every vanilla-extract `.css.ts` file (Boa read the clock without its `js` feature), resolve vanilla-extract references to other styles, keyframes, fonts and themes in selectors, values and `globalStyle`, keep `globalCss` pseudo selectors in `SELECTOR_ORDER` across breakpoints, merge adjacent rules with the same selector, and let declarations written next to a conditional `typography` preset win over it. Add the `prefer-media-shorthand` and `no-typography-token-prefix` ESLint rules", + "date": "2026-09-27T00:00:00.000Z" +} From f6ea583e2487cf8a31c261fad936a5c214cecdbb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:19:00 +0900 Subject: [PATCH 09/32] fix(extractor): give vanilla-extract and StyleX numbers their library units vanilla-extract styles add px to numbers (in .css.ts files and in style/globalStyle/keyframes calls of ordinary modules) and StyleX adds px/ms to static and dynamic numbers, instead of Devup UI's spacing scale. The unitless property list now matches vanilla-extract's, so strokeWidth, columns, zoom, orphans and similar keep bare numbers everywhere. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/extract_style/constant.rs | 35 ++- .../extractor/extract_style_from_stylex.rs | 35 +-- libs/extractor/src/import_alias_visit.rs | 137 ++++++++- libs/extractor/src/lib.rs | 126 +++++++++ ...__tests__stylex_create_numeric_values.snap | 4 +- ...ests__stylex_create_numeric_with_unit.snap | 2 +- ...xtractor__tests__stylex_dynamic_basic.snap | 2 +- ...xtractor__tests__stylex_dynamic_mixed.snap | 2 +- ...or__tests__stylex_dynamic_multi_param.snap | 2 +- ...__tests__stylex_dynamic_numeric_value.snap | 2 +- ...xtractor__tests__stylex_types_numeric.snap | 2 +- ..._vanilla_extract_all_exports_combined.snap | 2 +- ...r__tests__vanilla_extract_composition.snap | 4 +- ...sts__vanilla_extract_constant_exports.snap | 2 +- ...ct_css_ts_with_vanilla_extract_import.snap | 2 +- ...nilla_extract_global_theme_empty_vars.snap | 2 +- ...illa_extract_global_theme_placeholder.snap | 2 +- ...anilla_extract_global_theme_with_vars.snap | 2 +- ...__vanilla_extract_non_exported_styles.snap | 4 +- ...__vanilla_extract_selector_references.snap | 2 +- ...r__tests__vanilla_extract_selectors-2.snap | 2 +- ...la_extract_style_composition_multiple.snap | 4 +- ...__tests__vanilla_extract_style_css_js.snap | 2 +- ...__tests__vanilla_extract_style_css_ts.snap | 2 +- ...sts__vanilla_extract_style_variants-2.snap | 4 +- ..._vanilla_extract_style_variants_mixed.snap | 4 +- ...illa_extract_style_variants_with_base.snap | 10 +- ...nilla_extract_theme_export_variations.snap | 2 +- ...nilla_extract_theme_without_vars_json.snap | 2 +- ..._tests__vanilla_extract_with_computed.snap | 4 +- ...r__tests__vanilla_extract_with_spread.snap | 4 +- ..._tests__vanilla_extract_with_variable.snap | 2 +- libs/extractor/src/stylex.rs | 260 +++++++++++++++++- libs/extractor/src/utils.rs | 51 ++++ libs/extractor/src/vanilla_extract.rs | 66 ++++- libs/extractor/src/visit.rs | 81 +++++- .../src/__tests__/static-vanilla.test.ts | 77 ------ packages/next-plugin/src/static-vanilla.ts | 194 ------------- 38 files changed, 787 insertions(+), 355 deletions(-) delete mode 100644 packages/next-plugin/src/__tests__/static-vanilla.test.ts delete mode 100644 packages/next-plugin/src/static-vanilla.ts diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs index a1e1821c4..1bb014102 100644 --- a/libs/extractor/src/extract_style/constant.rs +++ b/libs/extractor/src/extract_style/constant.rs @@ -1,6 +1,9 @@ use phf::phf_set; -pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { +/// Properties whose numbers are unitless in CSS, so they are kept as written +/// instead of being scaled to `px`. Includes every property vanilla-extract +/// leaves unitless, so numbers from `.css.ts` files keep their meaning. +pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "opacity", "flex", "z-index", @@ -12,6 +15,7 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "flex-grow", "flex-shrink", "order", + "grid-area", "grid-column", "grid-column-start", "grid-column-end", @@ -21,5 +25,32 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "animation-iteration-count", "tab-size", "moz-tab-size", - "-webkit-line-clamp" + "-webkit-line-clamp", + "webkit-line-clamp", + "border-image", + "border-image-outset", + "border-image-slice", + "border-image-width", + "box-flex", + "box-flex-group", + "box-ordinal-group", + "column-count", + "columns", + "initial-letter", + "max-lines", + "orphans", + "widows", + "zoom", + "fill-opacity", + "flood-opacity", + "mask-border", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "shape-image-threshold", + "stop-opacity", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width" }; diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 568ec9b42..7b469760b 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,10 +3,10 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::stylex::{ - SelectorPart, StylexIncludeRef, decompose_value_conditions, is_first_that_works_call, - is_include_call_static, is_types_call, normalize_stylex_property, + SelectorPart, StylexIncludeRef, decompose_value_conditions, dynamic_number_suffix, + is_first_that_works_call, is_include_call_static, is_types_call, normalize_stylex_property, + stylex_value, }; -use crate::utils::get_string_by_literal_expression; use css::optimize_value::optimize_value; use css::sheet_to_variable_name; use css::style_selector::StyleSelector; @@ -45,12 +45,9 @@ pub fn extract_stylex_declarations(value: &Expression<'_>) -> Vec<(String, Strin let ObjectPropertyKind::ObjectProperty(prop) = prop else { return None; }; - let name = get_str_by_property_key(&prop.key)?; - let value = get_string_by_literal_expression(&prop.value)?; - Some(( - normalize_stylex_property(name.as_ref()), - optimize_value(&value).into_owned(), - )) + let property = normalize_stylex_property(get_str_by_property_key(&prop.key)?.as_ref()); + let value = stylex_value(&property, &prop.value)?; + Some((property, optimize_value(&value).into_owned())) }) .collect() } @@ -110,7 +107,7 @@ pub fn extract_stylex_namespace_styles<'a>( ) -> Vec<( String, Vec>, - Option>, + Option>, Vec, )> { let Expression::ObjectExpression(obj) = expression else { @@ -250,7 +247,7 @@ pub fn extract_stylex_namespace_styles<'a>( } // Phase 1: static string/number values - let css_value = if let Some(s) = get_string_by_literal_expression(&style_prop.value) { + let css_value = if let Some(s) = stylex_value(&css_property, &style_prop.value) { s } else if matches!(&style_prop.value, Expression::ObjectExpression(_)) { // Phase 2: value-level conditions @@ -272,7 +269,7 @@ pub fn extract_stylex_namespace_styles<'a>( // CSS fallback: output in reverse order (least preferred first, most preferred last). for arg in call.arguments.iter().rev() { if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) + && let Some(s) = stylex_value(&css_property, arg_expr) { styles.push(raw_static_style(css_property.clone(), &s, None)); } @@ -283,7 +280,7 @@ pub fn extract_stylex_namespace_styles<'a>( // stylex.types.length('100px') → extract inner value '100px' && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - let css_value = if let Some(s) = get_string_by_literal_expression(inner) { + let css_value = if let Some(s) = stylex_value(&css_property, inner) { s } else { continue; // Can't resolve inner value @@ -310,12 +307,16 @@ pub fn extract_stylex_namespace_styles<'a>( } /// Extract styles from a dynamic `StyleX` namespace (arrow function). -/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to CSS variable name. +/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to a CSS variable +/// name and the unit a number passed for it gets. #[allow(clippy::type_complexity)] fn extract_stylex_dynamic_namespace<'a>( arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>, keyframe_names: &FxHashMap, -) -> Option<(Vec>, Vec<(usize, String)>)> { +) -> Option<( + Vec>, + Vec<(usize, String, &'static str)>, +)> { // 1. Extract parameter names let param_names: Vec = arrow .params @@ -373,7 +374,7 @@ fn extract_stylex_dynamic_namespace<'a>( if let Some(param_idx) = is_dynamic { // Dynamic property: generate CSS variable let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name)); + css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); let param_name = ¶m_names[param_idx]; styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( ExtractDynamicStyle::new(&css_property, 0, param_name, None), @@ -386,7 +387,7 @@ fn extract_stylex_dynamic_namespace<'a>( styles.push(raw_static_style(css_property, anim_name, None)); continue; } - let css_value = if let Some(s) = get_string_by_literal_expression(&prop.value) { + let css_value = if let Some(s) = stylex_value(&css_property, &prop.value) { s } else { continue; diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 259bf357f..c039151de 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,14 +8,83 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; -use crate::utils::is_vanilla_extract_file; +use crate::utils::{ + get_str_by_property_key, is_unitless_key, is_vanilla_extract_file, js_number_literal, +}; use oxc_allocator::Allocator; -use oxc_ast::ast::{ImportDeclarationSpecifier, ModuleExportName}; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, ImportDeclarationSpecifier, ModuleExportName, + ObjectPropertyKind, Statement, +}; +use oxc_ast_visit::{Visit, walk::walk_call_expression}; use oxc_parser::Parser; -use oxc_span::SourceType; +use oxc_span::{GetSpan, SourceType}; use std::borrow::Cow; use std::collections::HashMap; +/// Numbers in vanilla-extract `style()` / `globalStyle()` / `keyframes()` rules outside a +/// stylesheet. Those calls become Devup UI's `css()` / `globalCss()` / `keyframes()`, which read a +/// number as its spacing scale, so it is rewritten to the `px` string +/// vanilla-extract makes of it. +struct VanillaNumbers<'n> { + /// Local name of each call and the index of its rules argument + calls: Vec<(&'n str, usize)>, + replacements: Vec<(usize, usize, String)>, +} + +impl VanillaNumbers<'_> { + fn pixelify(&mut self, rules: &Expression) { + match rules { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify(element); + } + } + } + Expression::ObjectExpression(object) => { + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = get_str_by_property_key(&property.key) + { + if matches!(property.value, Expression::ObjectExpression(_)) { + if key != "vars" { + self.pixelify(&property.value); + } + } else if let Some(number) = js_number_literal(&property.value) + && number != 0.0 + && !is_unitless_key(&key) + { + let span = property.value.span(); + self.replacements.push(( + span.start as usize, + span.end as usize, + format!("\"{number}px\""), + )); + } + } + } + } + _ => {} + } + } +} + +impl<'a> Visit<'a> for VanillaNumbers<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Expression::Identifier(callee) = &call.callee + && let Some(&(_, index)) = self + .calls + .iter() + .find(|(name, _)| *name == callee.name.as_str()) + && let Some(rules) = call.arguments.get(index).and_then(Argument::as_expression) + { + self.pixelify(rules); + } + walk_call_expression(self, call); + } +} + /// Map an aliased package's export onto the `@devup-ui/react` export that implements the /// same behaviour, so the extractor consumes the call and drops the import entirely — no /// dependency on either package survives. @@ -87,9 +156,13 @@ pub fn transform_import_aliases<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + let mut numbers = VanillaNumbers { + calls: Vec::new(), + replacements: Vec::new(), + }; for stmt in &program.body { - if let oxc_ast::ast::Statement::ImportDeclaration(import_decl) = stmt { + if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); if let Some(alias) = import_aliases.get(source_value) { @@ -97,9 +170,26 @@ pub fn transform_import_aliases<'a>( let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); + if source_value == "@vanilla-extract/css" && !redirect_every_name { + for specifier in import_decl.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { + let rules_index = match imported_name(&spec.imported).as_ref() { + "style" | "keyframes" => 0, + "globalStyle" => 1, + _ => continue, + }; + numbers.calls.push((spec.local.name.as_str(), rules_index)); + } + } + } } } } + if !numbers.calls.is_empty() { + numbers.visit_program(&program); + transformations.append(&mut numbers.replacements); + transformations.sort_unstable_by_key(|(start, ..)| *start); + } // Apply transformations in reverse order to preserve positions if transformations.is_empty() { @@ -501,6 +591,45 @@ export const container = style({ background: 'red' })", )); } + #[test] + fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { + let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' +export const a = s({ padding: 8, top: -2, left: (1.5), width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: 4 } } }) +export const b = s([a, { right: 3 }], 'debug') +globalStyle('body', { margin: 2 }) +keyframes({ from: { width: 10, opacity: 0 }, '50%': { width: 20 } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"; + assert_eq!( + transform_import_aliases( + code, + "test.tsx", + "@devup-ui/react", + &vanilla_extract_alias() + ), + r#"import { css as s, globalCss as globalStyle, keyframes } from '@devup-ui/react'; import { styleVariants } from '@vanilla-extract/css'; +export const a = s({ padding: "8px", top: "-2px", left: "1.5px", width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: "4px" } } }) +export const b = s([a, { right: "3px" }], 'debug') +globalStyle('body', { margin: "2px" }) +keyframes({ from: { width: "10px", opacity: 0 }, '50%': { width: "20px" } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"# + ); + let stylesheet = + "import { style } from '@vanilla-extract/css'\nexport const a = style({ padding: 8 })"; + assert!( + transform_import_aliases( + stylesheet, + "a.css.ts", + "@devup-ui/react", + &vanilla_extract_alias() + ) + .ends_with("style({ padding: 8 })") + ); + } + #[test] fn test_multiple_imports_same_file() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 9b3759ae4..b287c1a8f 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14746,6 +14746,132 @@ const styles = stylex.create({ )); } + fn static_values(output: &ExtractOutput) -> BTreeSet<(String, String)> { + output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(st) => { + Some((st.property().to_string(), st.value().to_string())) + } + ExtractStyleValue::Keyframes(keyframes) => keyframes + .keyframes + .values() + .flatten() + .next() + .map(|st| (st.property().to_string(), st.value().to_string())), + _ => None, + }) + .collect() + } + + #[test] + #[serial] + fn test_vanilla_extract_numbers_follow_vanilla_extract_units() { + reset_class_map(); + reset_file_map(); + let output = extract( + "numbers.css.ts", + r"import { style, globalStyle, keyframes, styleVariants } from '@devup-ui/react' +export const box = style({ + fontSize: 16, + top: -2, + width: 0, + lineHeight: 1.5, + zIndex: 2, + columnCount: 3, + strokeWidth: 2, + aspectRatio: 1.5, + selectors: { '&:hover': { marginTop: 4 } }, + '@media': { print: { left: 1.5 } }, +}) +export const spin = keyframes({ to: { height: 10 } }) +export const size = styleVariants({ small: { maxWidth: 20 } }) +globalStyle('body', { margin: 2 }) +", + ExtractOption::default(), + ) + .unwrap(); + let values = static_values(&output); + for (property, value) in [ + ("font-size", "16px"), + ("top", "-2px"), + ("width", "0"), + ("line-height", "1.5"), + ("z-index", "2"), + ("column-count", "3"), + ("stroke-width", "2"), + ("aspect-ratio", "1.5"), + ("margin-top", "4px"), + ("left", "1.5px"), + ("height", "10px"), + ("max-width", "20px"), + ("margin", "2px"), + ] { + assert!( + values.contains(&(property.to_string(), value.to_string())), + "{property}: {value} not in {values:?}" + ); + } + } + + #[test] + #[serial] + fn test_stylex_numbers_follow_stylex_units() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ + base: { + fontSize: 16, + marginTop: -8, + width: -0, + height: '16', + lineHeight: 1.5, + transitionDuration: 300, + '--size': 4, + left: 1.33333, + top: { default: 2, ':hover': 4 }, + right: stylex.firstThatWorks(6, 'auto'), + }, + dynamic: (opacity, delay) => ({ opacity, transitionDelay: delay, bottom: 3 }), +}); +const result = stylex.props(styles.dynamic(o, d));", + ExtractOption::default(), + ) + .unwrap(); + let values = static_values(&output); + for (property, value) in [ + ("font-size", "16px"), + ("margin-top", "-8px"), + ("width", "0"), + ("height", "16"), + ("line-height", "1.5"), + ("transition-duration", "300ms"), + ("--size", "4"), + ("left", "1.3333px"), + ("top", "2px"), + ("top", "4px"), + ("right", "6px"), + ("bottom", "3px"), + ] { + assert!( + values.contains(&(property.to_string(), value.to_string())), + "{property}: {value} not in {values:?}" + ); + } + assert!(output.code.contains(r#""--a": o"#), "{}", output.code); + assert!( + output + .code + .contains(r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#), + "{}", + output.code + ); + } + #[test] #[serial] fn test_stylex_create_numeric_values() { diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap index d8a33c39a..af003b424 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "8", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap index 4478cd7c8..5a952aed5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap index 0e64214a1..2432d59ed 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": h }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap index 255d23b9e..d4c3552f1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap @@ -24,5 +24,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": h }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap index af63ba3ef..81725cdab 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": myH,\n\t\t\"--b\": myW\n\t}\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myH),\n\t\t\"--b\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myW)\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap index 21272d366..d2c86cbbf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap index 28c1eda92..b04a3ed7c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 9f4b29a89..29976b6ec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -71,7 +71,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap index 796a2aade..307ac06da 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap index 239a9d9a2..611abb463 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap index ad214187d..cbaf24c7f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "128px", + value: "32px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap index 1c1914d52..754ab6170 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap index e25bfd199..26b2f670b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap index f6dafe73f..09eedf6be 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap index 15a6ef98d..b37bcbadb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap index e7f52c8e8..39460fa16 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap @@ -31,7 +31,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin-top", - value: "32px", + value: "8px", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap index c6d642b08..65fd54dcf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap @@ -31,7 +31,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin-top", - value: "32px", + value: "8px", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap index 6f0442004..143f62597 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap index 2fc830773..019824caf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap index 9ae96afb3..f3d821654 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap index a546eb220..84a2b9346 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -57,7 +57,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap index 204774f28..27943e415 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap @@ -37,7 +37,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -67,7 +67,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap index 31b0af989..1d2367b8e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, @@ -37,7 +37,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "80px", + value: "20px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap index 32beafe5d..33392c10c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap index 0379d0c0d..b718003f7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap @@ -24,7 +24,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap index ce2e9b896..e4a5505d8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap index 474649ffd..cc83fec00 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap index 5a9821856..bb689c12b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index 10cddf7c3..134360fdc 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -1,7 +1,11 @@ +use std::borrow::Cow; + use css::style_selector::{AtRuleKind, StyleSelector}; use oxc_ast::ast::{Expression, ObjectPropertyKind}; -use crate::utils::{get_string_by_literal_expression, get_string_by_property_key}; +use crate::utils::{ + get_string_by_literal_expression, get_string_by_property_key, js_number_literal, +}; /// Which `StyleX` function a named import refers to #[derive(Debug, Clone, PartialEq, Eq)] @@ -113,6 +117,248 @@ pub fn normalize_stylex_property(name: &str) -> String { css::utils::to_kebab_case(name).into_owned() } +/// Properties `StyleX` leaves unitless when given a number. +static UNITLESS_NUMBER_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "webkit-line-clamp", + "animation-iteration-count", + "aspect-ratio", + "border-image-outset", + "border-image-slice", + "border-image-width", + "counter-set", + "counter-reset", + "column-count", + "flex", + "flex-grow", + "flex-shrink", + "flex-order", + "grid-row", + "grid-row-start", + "grid-row-end", + "grid-column", + "grid-column-start", + "grid-column-end", + "grid-area", + "font-size-adjust", + "font-weight", + "hyphenate-limit-chars", + "line-clamp", + "line-height", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "opacity", + "order", + "orphans", + "tab-size", + "widows", + "z-index", + "fill-opacity", + "flood-opacity", + "rotate", + "scale", + "shape-image-threshold", + "stop-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "math-depth", + "zoom", +}; + +/// Properties whose numbers `StyleX` reads as milliseconds. +static TIME_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "animation-delay", + "animation-duration", + "transition-delay", + "transition-duration", + "voice-duration", +}; + +/// Properties whose dynamic values `StyleX` gives a unit when they are numbers. +static LENGTH_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "background-position-x", + "background-position-y", + "block-size", + "border-block-end-width", + "border-block-start-width", + "border-block-width", + "border-vertical-width", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-bottom-width", + "border-end-end-radius", + "border-end-start-radius", + "border-inline-end-width", + "border-end-width", + "border-inline-start-width", + "border-start-width", + "border-inline-width", + "border-horizontal-width", + "border-left-width", + "border-right-width", + "border-spacing", + "border-start-end-radius", + "border-start-start-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-top-width", + "bottom", + "column-gap", + "column-rule-width", + "column-width", + "contain-intrinsic-block-size", + "contain-intrinsic-height", + "contain-intrinsic-inline-size", + "contain-intrinsic-width", + "flex-basis", + "font-size", + "font-smooth", + "height", + "inline-size", + "inset-block-end", + "inset-block-start", + "inset-inline-end", + "inset-inline-start", + "left", + "letter-spacing", + "margin-block-end", + "margin-block-start", + "margin-bottom", + "margin-inline-end", + "margin-end", + "margin-inline-start", + "margin-start", + "margin-left", + "margin-right", + "margin-top", + "max-block-size", + "max-height", + "max-inline-size", + "max-width", + "min-block-size", + "min-height", + "min-inline-size", + "min-width", + "offset-distance", + "outline-offset", + "outline-width", + "overflow-clip-margin", + "padding-block-end", + "padding-block-start", + "padding-bottom", + "padding-inline-end", + "padding-end", + "padding-inline-start", + "padding-start", + "padding-left", + "padding-right", + "padding-top", + "perspective", + "right", + "row-gap", + "scroll-margin-block-end", + "scroll-margin-block-start", + "scroll-margin-bottom", + "scroll-margin-inline-end", + "scroll-margin-inline-start", + "scroll-margin-left", + "scroll-margin-right", + "scroll-margin-top", + "scroll-padding-block-end", + "scroll-padding-block-start", + "scroll-padding-bottom", + "scroll-padding-inline-end", + "scroll-padding-inline-start", + "scroll-padding-left", + "scroll-padding-right", + "scroll-padding-top", + "scroll-snap-margin-bottom", + "scroll-snap-margin-left", + "scroll-snap-margin-right", + "scroll-snap-margin-top", + "shape-margin", + "tab-size", + "text-decoration-thickness", + "text-indent", + "text-underline-offset", + "top", + "transform-origin", + "translate", + "vertical-align", + "width", + "word-spacing", + "border", + "border-block", + "border-block-end", + "border-block-start", + "border-bottom", + "border-left", + "border-radius", + "border-right", + "border-top", + "border-width", + "column-rule", + "contain-intrinsic-size", + "gap", + "inset", + "inset-block", + "inset-inline", + "margin", + "margin-block", + "margin-vertical", + "margin-inline", + "margin-horizontal", + "offset", + "outline", + "padding", + "padding-block", + "padding-vertical", + "padding-inline", + "padding-horizontal", + "scroll-margin", + "scroll-margin-block", + "scroll-margin-inline", + "scroll-padding", + "scroll-padding-block", + "scroll-padding-inline", + "scroll-snap-margin", +}; + +/// The unit `StyleX` appends to a number on `property`. +fn number_suffix(property: &str) -> &'static str { + if UNITLESS_NUMBER_PROPERTIES.contains(property) || property.starts_with("--") { + "" + } else if TIME_PROPERTIES.contains(property) { + "ms" + } else { + "px" + } +} + +/// The unit a dynamic value on `property` gets when it is a number at runtime. +pub fn dynamic_number_suffix(property: &str) -> &'static str { + if TIME_PROPERTIES.contains(property) || LENGTH_PROPERTIES.contains(property) { + number_suffix(property) + } else { + "" + } +} + +/// A literal `StyleX` value as CSS text: a number gets the unit `StyleX` gives it. +pub fn stylex_value<'a>(property: &str, value: &Expression<'a>) -> Option> { + js_number_literal(value).map_or_else( + || get_string_by_literal_expression(value), + |number| { + // `+ 0.0` turns `-0` into `0`, as JS prints it. + let rounded = (number * 10_000.0).round() / 10_000.0 + 0.0; + Some(Cow::Owned(format!("{rounded}{}", number_suffix(property)))) + }, + ) +} + /// Intermediate selector parts collected during recursion. #[derive(Debug, Clone)] pub enum SelectorPart { @@ -136,8 +382,8 @@ pub struct DecomposedStyle { pub struct StylexDynamicInfo { /// Combined class name string for all properties (static + dynamic) pub class_name: String, - /// Maps (`param_index`, `css_variable_name`) for each dynamic property - pub css_vars: Vec<(usize, String)>, + /// (`param_index`, `css_variable_name`, unit for a number) for each dynamic property + pub css_vars: Vec<(usize, String, &'static str)>, } /// A `StyleX` namespace entry — either static or dynamic (arrow function) @@ -163,8 +409,8 @@ pub fn decompose_value_conditions( value: &Expression, parent_selectors: &[SelectorPart], ) -> Vec { - // String literal → leaf - if let Some(s) = get_string_by_literal_expression(value) { + // String or number literal → leaf + if let Some(s) = stylex_value(css_property, value) { return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) .into_iter() .collect(); @@ -184,7 +430,7 @@ pub fn decompose_value_conditions( let mut results = vec![]; for arg in call.arguments.iter().rev() { if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) + && let Some(s) = stylex_value(css_property, arg_expr) { results.extend(decomposed_leaf( css_property, @@ -201,7 +447,7 @@ pub fn decompose_value_conditions( && is_types_call(&call.callee) && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - if let Some(s) = get_string_by_literal_expression(inner) { + if let Some(s) = stylex_value(css_property, inner) { return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) .into_iter() .collect(); diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index d976d7ed6..3e2c88082 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -1,5 +1,7 @@ use std::borrow::Cow; +use crate::extract_style::constant::MAINTAIN_VALUE_PROPERTIES; +use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ ast::{ @@ -25,6 +27,31 @@ pub(super) fn is_vanilla_extract_file(filename: &str) -> bool { filename.ends_with(".css.ts") || filename.ends_with(".css.js") } +/// Whether vanilla-extract leaves a number on `key` without a unit: unitless +/// properties, custom properties, and array indices. +pub(super) fn is_unitless_key(key: &str) -> bool { + key.starts_with("--") + || key.starts_with("var(") + || key.bytes().all(|byte| byte.is_ascii_digit()) + || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) +} + +/// A JS number literal (`8`, `-8`, `(8)`), unlike a numeric string. +pub(super) fn js_number_literal(value: &Expression) -> Option { + match value { + Expression::NumericLiteral(number) => Some(number.value), + Expression::ParenthesizedExpression(inner) => js_number_literal(&inner.expression), + Expression::UnaryExpression(unary) => { + js_number_literal(&unary.argument).and_then(|number| match unary.operator { + UnaryOperator::UnaryNegation => Some(-number), + UnaryOperator::UnaryPlus => Some(number), + _ => None, + }) + } + _ => None, + } +} + /// Strip the wrappers that exist only in the source text: TypeScript's `as` / /// `satisfies` / `!` / explicit type arguments, plus redundant parentheses. Every /// one is erased before the code runs, so extraction must see through them — @@ -496,6 +523,30 @@ mod tests { assert!(matches!(convert_value("4"), Cow::Owned(_))); } + #[test] + fn test_js_number_literal() { + let allocator = Allocator::default(); + for (source, expected) in [ + ("8", Some(8.0)), + ("(8)", Some(8.0)), + ("+8", Some(8.0)), + ("-8", Some(-8.0)), + ("-(1.5)", Some(-1.5)), + ("!8", None), + ("'8'", None), + ("-x", None), + ] { + let expression = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .unwrap(); + assert_eq!(js_number_literal(&expression), expected, "{source}"); + } + assert!(is_unitless_key("lineHeight")); + assert!(is_unitless_key("--gap")); + assert!(is_unitless_key("0")); + assert!(!is_unitless_key("padding")); + } + #[test] fn test_get_number_by_literal_expression() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 276369a98..4d8732b73 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -3,8 +3,10 @@ //! This module uses `boa_engine` to execute vanilla-extract style files //! and extract style definitions for processing by the existing extract logic. +use crate::utils::is_unitless_key; use boa_engine::{ - Context, JsArgs, JsValue, NativeFunction, Source, js_string, object::ObjectInitializer, + Context, JsArgs, JsResult, JsValue, NativeFunction, Source, js_string, + object::{FunctionObjectBuilder, ObjectInitializer}, property::Attribute, }; use css::file_map::get_file_num_by_filename; @@ -367,6 +369,38 @@ fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { } } +/// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, +/// so they are not read as Devup UI's spacing scale. +fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let key = args.get_or_undefined(0).to_string(context)?; + let value = args.get_or_undefined(1); + Ok(match value.as_number() { + Some(number) if number != 0.0 && !is_unitless_key(&key.to_std_string_escaped()) => { + JsValue::from(js_string!(format!("{number}px"))) + } + _ => value.clone(), + }) +} + +/// Serialize a style rule object with vanilla-extract's number units. +fn style_to_json(value: &JsValue, context: &mut Context) -> String { + let realm = context.realm().clone(); + let replacer = + FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); + let json = context.intrinsics().objects().json(); + if let Ok(stringify) = json.get(js_string!("stringify"), context) + && let Some(callable) = stringify.as_callable() + && let Ok(result) = callable.call( + &JsValue::undefined(), + &[value.clone(), replacer.into()], + context, + ) + && let Some(s) = result.as_string() + { + return s.to_std_string_escaped(); + } + js_value_to_json(value, context) +} /// Execute vanilla-extract style file and collect styles pub fn execute_vanilla_extract( code: &str, @@ -825,7 +859,7 @@ fn register_vanilla_extract_apis( base_classes.push(base_str.to_std_string_escaped()); } else if elem.is_object() { // It's a style object - merge it - let elem_json = js_value_to_json(&elem, ctx); + let elem_json = style_to_json(&elem, ctx); // Strip outer braces and merge let inner = elem_json .trim() @@ -846,7 +880,7 @@ fn register_vanilla_extract_apis( (merged_styles, base_classes) } else { // No length property, just a style object - (js_value_to_json(style_obj, ctx), SmallVec::new()) + (style_to_json(style_obj, ctx), SmallVec::new()) }; collector_style.borrow_mut().styles.styles.insert( id.clone(), @@ -870,7 +904,7 @@ fn register_vanilla_extract_apis( .to_string(ctx)? .to_std_string_escaped(); let style_obj = args.get_or_undefined(1); - let json = js_value_to_json(style_obj, ctx); + let json = style_to_json(style_obj, ctx); collector_global .borrow_mut() @@ -887,7 +921,7 @@ fn register_vanilla_extract_apis( let keyframes_fn = unsafe { NativeFunction::from_closure(move |_this, args, ctx| { let keyframes_obj = args.get_or_undefined(0); - let json = js_value_to_json(keyframes_obj, ctx); + let json = style_to_json(keyframes_obj, ctx); let id = next_style_id(&collector_keyframes); collector_keyframes.borrow_mut().styles.keyframes.insert( @@ -1627,7 +1661,7 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant if base_class.starts_with("__style_") || !base_class.contains('{') { // Get the style object (second element) if let Ok(style_obj) = obj.get(1, context) { - let json = js_value_to_json(&style_obj, context); + let json = style_to_json(&style_obj, context); return StyleVariant { base: Some(base_class), styles_json: json, @@ -1641,7 +1675,7 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant // Not an array or not composition - treat as plain style object StyleVariant { base: None, - styles_json: js_value_to_json(value, context), + styles_json: style_to_json(value, context), } } @@ -3439,6 +3473,24 @@ export const box = style({ padding: 8 })"; assert!(code.contains("export const themeVars = {\"bg\":\"var(--bg)\"}")); } + #[test] + fn test_style_to_json_adds_vanilla_extract_units() { + let mut context = Context::default(); + let rule = context + .eval(Source::from_bytes( + "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 } })", + )) + .unwrap(); + assert_eq!( + super::style_to_json(&rule, &mut context), + r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"}}"# + ); + assert_eq!( + super::style_to_json(&JsValue::undefined(), &mut context), + "undefined" + ); + } + #[test] fn test_style_with_non_object_argument() { // Test style() with primitive value (covers line 749) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 8850391ec..f6cfc066f 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -30,11 +30,12 @@ use oxc_ast::ast::ImportDeclarationSpecifier::{self, ImportSpecifier}; use oxc_ast::ast::JSXAttributeItem::Attribute; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ - Argument, BindingPattern, CallExpression, ChainElement, ComputedMemberExpression, Expression, - ExpressionStatement, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, - ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, - JSXElement, JSXElementName, JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, - Program, PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, + Argument, BinaryOperator, BindingPattern, CallExpression, ChainElement, + ComputedMemberExpression, Expression, ExpressionStatement, FormalParameter, + FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, ImportOrExportKind, + JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, JSXElement, JSXElementName, + JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, Program, PropertyKey, + PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, UnaryOperator, VariableDeclarator, }; use oxc_ast_visit::VisitMut; @@ -305,6 +306,71 @@ impl<'a> DevupVisitor<'a> { (class_exprs, style_props) } + /// `((v) => typeof v === "number" ? v + unit : v)(value)`: how `StyleX` gives a + /// dynamic number its unit without evaluating `value` twice. + fn with_number_unit(&self, value: Expression<'a>, unit: &'static str) -> Expression<'a> { + if unit.is_empty() { + return value; + } + let param = || Expression::new_identifier(SPAN, "v", &self.ast); + let body = Expression::new_conditional_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, param(), &self.ast), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, &self.ast), + &self.ast, + ), + Expression::new_binary_expression( + SPAN, + param(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, unit, None, &self.ast), + &self.ast, + ), + param(), + &self.ast, + ); + let mut params = oxc_allocator::Vec::new_in(&self.ast); + params.push(FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(&self.ast), + BindingPattern::new_binding_identifier(SPAN, "v", &self.ast), + None, + None, + false, + None, + false, + false, + &self.ast, + )); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + params, + None::>>, + &self.ast, + ), + None::>>, + body.into(), + &self.ast, + ); + let mut arguments = oxc_allocator::Vec::new_in(&self.ast); + arguments.push(Argument::from(value)); + Expression::new_call_expression( + SPAN, + Expression::new_parenthesized_expression(SPAN, arrow, &self.ast), + None::>>, + arguments, + false, + &self.ast, + ) + } /// Resolve a dynamic namespace call like `styles.bar(h)` to (className, `style_props`). fn resolve_stylex_dynamic_call( &self, @@ -324,11 +390,12 @@ impl<'a> DevupVisitor<'a> { ); let mut props = Vec::with_capacity(info.css_vars.len()); - for (param_idx, var_name) in &info.css_vars { + for (param_idx, var_name, unit) in &info.css_vars { if let Some(arg) = call.arguments.get(*param_idx) && let Some(arg_expr) = arg.as_expression() { - let arg_expr = arg_expr.clone_in(self.ast.allocator()); + let arg_expr = + self.with_number_unit(arg_expr.clone_in(self.ast.allocator()), unit); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, diff --git a/packages/next-plugin/src/__tests__/static-vanilla.test.ts b/packages/next-plugin/src/__tests__/static-vanilla.test.ts deleted file mode 100644 index 16da2ad98..000000000 --- a/packages/next-plugin/src/__tests__/static-vanilla.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { transformStaticVanillaExtract } from '../static-vanilla' - -describe('transformStaticVanillaExtract', () => { - it('converts static style modules without evaluating JavaScript', () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red', padding: [4, null, 8] }) -export const hover = style({ selectors: { '&:hover': { opacity: 0.5 } } })` - - expect( - transformStaticVanillaExtract( - 'src/styles.css.ts', - source, - '@devup-ui/react', - ), - ).toBe(`import { css } from '@devup-ui/react' -export const box = css({ color: 'red', padding: [4, null, 8] }) -export const hover = css({ selectors: { '&:hover': { opacity: 0.5 } } })`) - }) - - it('rejects executable values and extra module statements', () => { - const dynamic = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const spread = `import { style } from '@vanilla-extract/css' -export const box = style({ ...base, color: 'red' })` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - dynamic, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.css.ts', spread, '@devup-ui/react'), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.ts', dynamic, '@devup-ui/react'), - ).toBeUndefined() - }) - - it('preserves comments and aliases in the static subset', () => { - const source = `/* import */ import { style as makeStyle } from '@vanilla-extract/css' -export const box = makeStyle({ - // responsive values remain source text - padding: [4, null, 8], -})` - - expect( - transformStaticVanillaExtract('styles.css.js', source, 'custom-package'), - ).toContain('export const box = css({') - }) - - it('rejects unsupported imports and malformed trailing comments', () => { - const multipleImports = `import { style, globalStyle } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const unterminatedComment = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' }) /*` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - multipleImports, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - unterminatedComment, - '@devup-ui/react', - ), - ).toBeUndefined() - }) -}) diff --git a/packages/next-plugin/src/static-vanilla.ts b/packages/next-plugin/src/static-vanilla.ts deleted file mode 100644 index 3bdd237c4..000000000 --- a/packages/next-plugin/src/static-vanilla.ts +++ /dev/null @@ -1,194 +0,0 @@ -function isIdentifierChar(char: string | undefined): boolean { - return char !== undefined && /[$\w]/.test(char) -} - -class StaticVanillaParser { - private index = 0 - private valid = true - - constructor( - private readonly source: string, - private readonly packageName: string, - ) {} - - parse(): string | undefined { - if (!this.word('import') || !this.char('{')) return undefined - const imported = this.identifier() - if (imported !== 'style') return undefined - let local = imported - if (this.word('as')) { - const alias = this.identifier() - if (alias === undefined) return undefined - local = alias - } - if ( - !this.char('}') || - !this.word('from') || - this.string() !== '@vanilla-extract/css' - ) { - return undefined - } - this.char(';') - - const declarations: string[] = [] - while (!this.done()) { - if (!this.word('export') || !this.word('const')) return undefined - const name = this.identifier() - if (name === undefined || !this.char('=') || !this.word(local)) { - return undefined - } - if (!this.char('(')) return undefined - this.skipTrivia() - const valueStart = this.index - if (!this.staticValue()) return undefined - const value = this.source.slice(valueStart, this.index) - if (!this.char(')')) return undefined - this.char(';') - declarations.push(`export const ${name} = css(${value})`) - } - - if (declarations.length === 0) return undefined - return `import { css } from '${this.packageName}'\n${declarations.join('\n')}` - } - - private staticValue(): boolean { - this.skipTrivia() - const char = this.source[this.index] - if (char === '{') return this.object() - if (char === '[') return this.array() - if (char === '"' || char === "'") return this.string() !== undefined - if (char === '+' || char === '-') this.index++ - const number = this.source - .slice(this.index) - .match(/^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i) - if (number) { - this.index += number[0].length - return true - } - return this.word('true') || this.word('false') || this.word('null') - } - - private object(): boolean { - if (!this.char('{')) return false - if (this.char('}')) return true - while (true) { - this.skipTrivia() - const char = this.source[this.index] - const key = - char === '"' || char === "'" - ? this.string() - : (this.identifier() ?? this.numberKey()) - if (key === undefined || !this.char(':') || !this.staticValue()) { - return false - } - if (this.char('}')) return true - if (!this.char(',')) return false - if (this.char('}')) return true - } - } - - private array(): boolean { - if (!this.char('[')) return false - if (this.char(']')) return true - while (true) { - if (!this.staticValue()) return false - if (this.char(']')) return true - if (!this.char(',')) return false - if (this.char(']')) return true - } - } - - private numberKey(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^\d+(?:\.\d+)?/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private identifier(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^[$A-Z_a-z][$\w]*/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private string(): string | undefined { - this.skipTrivia() - const quote = this.source[this.index] - if (quote !== '"' && quote !== "'") return undefined - const start = ++this.index - while (this.index < this.source.length) { - const char = this.source[this.index++] - if (char === '\\') { - this.index++ - } else if (char === quote) { - return this.source.slice(start, this.index - 1) - } else if (char === '\n' || char === '\r') { - return undefined - } - } - return undefined - } - - private word(value: string): boolean { - this.skipTrivia() - if ( - !this.source.startsWith(value, this.index) || - isIdentifierChar(this.source[this.index - 1]) || - isIdentifierChar(this.source[this.index + value.length]) - ) { - return false - } - this.index += value.length - return true - } - - private char(value: string): boolean { - this.skipTrivia() - if (this.source[this.index] !== value) return false - this.index++ - return true - } - - private done(): boolean { - this.skipTrivia() - return this.valid && this.index === this.source.length - } - - private skipTrivia(): void { - while (this.index < this.source.length) { - if (/\s/.test(this.source[this.index])) { - this.index++ - } else if (this.source.startsWith('//', this.index)) { - const newline = this.source.indexOf('\n', this.index + 2) - this.index = newline === -1 ? this.source.length : newline + 1 - } else if (this.source.startsWith('/*', this.index)) { - const end = this.source.indexOf('*/', this.index + 2) - if (end === -1) { - this.valid = false - this.index = this.source.length - } else { - this.index = end + 2 - } - } else { - break - } - } - } -} - -/** - * Convert the deliberately small, statically provable subset of vanilla - * `style()` modules into Devup `css()` calls. Any executable expression or - * additional statement returns undefined and keeps the existing Boa path. - */ -export function transformStaticVanillaExtract( - filename: string, - source: string, - packageName: string, -): string | undefined { - if (!/\.css\.(?:ts|js)$/.test(filename)) return undefined - return new StaticVanillaParser(source, packageName).parse() -} From 089fc36f519f23f2bafeda8b0bd02c542e88822a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:20:41 +0900 Subject: [PATCH 10/32] fix(next-plugin): run every vanilla-extract stylesheet on the full engine Remove the static .css.ts fast path so development and production builds evaluate stylesheets the same way and produce the same class names and CSS. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/coordinator.test.ts | 47 ---------------- .../next-plugin/src/__tests__/plugin.test.ts | 55 +------------------ packages/next-plugin/src/coordinator.ts | 8 +-- packages/next-plugin/src/plugin.ts | 38 ++----------- 4 files changed, 6 insertions(+), 142 deletions(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 6a23d2555..62dd85c80 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -215,53 +215,6 @@ describe('coordinator', () => { coordinator.close() }) - it('uses the static vanilla transform on a production cache miss', async () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const extractOutput = { - code: 'transformed code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } - codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) - const coordinator = startCoordinator( - makeOptions({ sourceMap: false, staticVanillaExtract: true }), - ) - await new Promise((resolve) => setTimeout(resolve, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/styles.css.ts', - code: source, - resourcePath: join(process.cwd(), 'src', 'styles.css.ts'), - }), - ) - - expect(res.status).toBe(200) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - './../.tmp-coordinator-test/css', - false, - false, - true, - {}, - ) - expect(extractOutput.free).toHaveBeenCalledTimes(1) - coordinator.close() - }) - it('reuses byte-identical singleCss prewarm output', async () => { const source = 'const x = ' const options = makeOptions({ diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index c6ffe926a..a79904cea 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -193,13 +193,6 @@ describe('DevupUINextPlugin', () => { expect( selectWasmVariant({ files: ['src/theme.css.js'] } as StaticImportGraph), ).toBe('full') - expect( - selectWasmVariant( - { files: ['src/theme.css.ts'] } as StaticImportGraph, - ['src/theme.css.ts'], - true, - ), - ).toBe('lite') expect( selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph, [ 'node_modules/design-system/theme.css.ts', @@ -675,10 +668,9 @@ describe('DevupUINextPlugin', () => { prewarmedFiles: expect.any(Array), prewarmedOutputs: expect.any(Map), sourceMap: false, - staticVanillaExtract: false, }) }) - it('uses the lite extractor for a provably static vanilla style module', () => { + it('extracts vanilla-extract modules as written with the full engine', () => { setNodeEnv('production') process.env.TURBOPACK = '1' const filename = resolve('src/styles.css.ts') @@ -696,47 +688,6 @@ export const box = style({ color: 'red' })` String(path).endsWith('styles.css.ts') ? source : '{}', ) - try { - DevupUI({}) - - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: true }), - ) - } finally { - graphSpy.mockRestore() - compiledSpy.mockRestore() - } - }) - it('keeps the full extractor for dynamic vanilla style modules', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - const filename = resolve('src/styles.css.ts') - const source = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/styles.css.ts']) - readFileSyncSpy.mockImplementation((path: fs.PathOrFileDescriptor) => - String(path).endsWith('styles.css.ts') ? source : '{}', - ) - try { DevupUI({}) @@ -750,9 +701,6 @@ export const box = style({ color })` true, expect.anything(), ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: false }), - ) } finally { graphSpy.mockRestore() compiledSpy.mockRestore() @@ -959,7 +907,6 @@ export const box = style({ color })` prewarmedFiles: [], prewarmedOutputs: expect.any(Map), sourceMap: true, - staticVanillaExtract: false, }) expect(codeExtractSpy).not.toHaveBeenCalled() expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf53476..a6f6b4370 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -5,7 +5,6 @@ import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' export interface CoordinatorOptions { @@ -48,8 +47,6 @@ export interface CoordinatorOptions { prewarmedOutputs?: Map /** Generate transform source maps. Defaults to true for existing callers. */ sourceMap?: boolean - /** Production-only static `.css.ts` fast path selected with the lite WASM. */ - staticVanillaExtract?: boolean /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -377,7 +374,6 @@ export function startCoordinator(options: CoordinatorOptions): { getCss, } = wasm const prewarmedOutputs = options.prewarmedOutputs ?? new Map() - const staticVanillaExtract = options.staticVanillaExtract ?? false const extract = options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract @@ -473,9 +469,7 @@ export function startCoordinator(options: CoordinatorOptions): { : takeExtractOutput( extract( filename, - (staticVanillaExtract - ? transformStaticVanillaExtract(filename, code, libPackage) - : undefined) ?? code, + code, libPackage, relCssDir, singleCss, diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692ec..64d97b32e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -31,7 +31,6 @@ import { } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import { loadWasm, loadWebpackPlugin } from './wasm' /** Options accepted by the Next.js integration. */ @@ -118,11 +117,9 @@ export function resetTurboSetupCacheForTesting(): void { export function selectWasmVariant( graph: StaticImportGraph | undefined, candidateFiles: string[] = graph?.files ?? [], - staticVanillaExtract = false, ): 'lite' | 'full' { return graph && - (staticVanillaExtract || - !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename))) + !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename)) ? 'lite' : 'full' } @@ -248,31 +245,7 @@ export function DevupUI( }) : [] const candidateCollectMs = elapsedMs(candidateCollectStartedAt) - const staticVanillaSources = new Map< - string, - { code: string; source: string } - >() - const vanillaCandidates = wasmCandidateFiles.filter((filename) => - /\.css\.(?:ts|js)$/.test(filename), - ) - let staticVanillaExtract = - !watch && !sourceMap && vanillaCandidates.length > 0 - for (const filename of vanillaCandidates) { - if (!staticVanillaExtract) break - const source = readFileSync(resolve(process.cwd(), filename), 'utf-8') - const code = transformStaticVanillaExtract(filename, source, libPackage) - if (code === undefined) { - staticVanillaExtract = false - staticVanillaSources.clear() - break - } - staticVanillaSources.set(filename, { code, source }) - } - const wasmVariant = selectWasmVariant( - staticGraph, - wasmCandidateFiles, - staticVanillaExtract, - ) + const wasmVariant = selectWasmVariant(staticGraph, wasmCandidateFiles) const wasm = loadWasm(wasmVariant === 'lite') const { codeExtract, @@ -452,9 +425,7 @@ export function DevupUI( ).replaceAll('\\', '/')}` const readStartedAt = prewarmStartedAt === undefined ? undefined : performance.now() - const preparedVanilla = staticVanillaSources.get(filename) - const source = - preparedVanilla?.source ?? readFileSync(resourcePath, 'utf-8') + const source = readFileSync(resourcePath, 'utf-8') if (readStartedAt !== undefined) { prewarmReadMs += performance.now() - readStartedAt prewarmSourceBytes += Buffer.byteLength(source) @@ -464,7 +435,7 @@ export function DevupUI( const output = takeExtractOutput( extract( filename, - preparedVanilla?.code ?? source, + source, libPackage, relCssDir, singleCss, @@ -544,7 +515,6 @@ export function DevupUI( prewarmedFiles, prewarmedOutputs, sourceMap, - staticVanillaExtract, }) // Cleanup on exit From c6d33d6999d5e4e8d0a9e70ffbaadd005c848f42 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:22:40 +0900 Subject: [PATCH 11/32] docs: document library number units and the full vanilla-extract engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 +- README_ko.md | 2 +- SKILL.md | 4 ++++ apps/landing/src/app/(detail)/docs/api/box/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/button/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/center/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/grid/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx | 6 ++++++ .../landing/src/app/(detail)/docs/migration/stylex/page.mdx | 2 ++ .../app/(detail)/docs/migration/vanilla-extract/page.mdx | 2 ++ apps/landing/src/app/(detail)/docs/overview/page.mdx | 2 +- packages/components/README.md | 2 +- packages/next-plugin/README.md | 2 +- packages/react/README.md | 2 +- packages/reset-css/README.md | 2 +- packages/rsbuild-plugin/README.md | 2 +- packages/vite-plugin/README.md | 2 +- packages/webpack-plugin/README.md | 2 +- 18 files changed, 48 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 14c0d1658..354d19968 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **5.46s** | **36,507,782 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **5.41s** | 36,586,883 bytes | -The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/README_ko.md b/README_ko.md index 1f97db94c..11c0caf7c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -103,7 +103,7 @@ Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙 | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 `lite` WASM 빠른 경로를 사용하며, full Boa evaluator가 필요한 dynamic `.css.ts`는 해당 중앙값에 포함되지 않습니다. +Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 정적 모듈용 `lite` 빠른 경로가 있던 때에 측정한 값입니다. 지금은 모든 `.css.ts`를 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다. ## 작동 원리 diff --git a/SKILL.md b/SKILL.md index cdb07c166..1279357f5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,6 +148,10 @@ All standard CSS properties from `csstype` are also accepted directly (e.g., `di // padding: 20px (with unit = exact value) ``` +Unitless CSS properties keep the number as written: `lineHeight`, `zIndex`, `opacity`, `fontWeight`, `flexGrow`, `order`, `aspectRatio`, `columnCount`, `strokeWidth`, `zoom`, and the rest of the unitless list vanilla-extract uses. + +Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, and in `stylex.create()`, `fontSize: 16` is `16px`. + ### Responsive Arrays (5 breakpoints) ```tsx diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx index 748acafb2..f1fd2d529 100644 --- a/apps/landing/src/app/(detail)/docs/api/box/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/box/page.mdx @@ -65,6 +65,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` ### Rendering as Another Element diff --git a/apps/landing/src/app/(detail)/docs/api/button/page.mdx b/apps/landing/src/app/(detail)/docs/api/button/page.mdx index 0cbc85daf..a79e01c5f 100644 --- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx @@ -68,3 +68,9 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` diff --git a/apps/landing/src/app/(detail)/docs/api/center/page.mdx b/apps/landing/src/app/(detail)/docs/api/center/page.mdx index 63783ba43..0ac65ae80 100644 --- a/apps/landing/src/app/(detail)/docs/api/center/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/center/page.mdx @@ -85,6 +85,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx index 035921d3c..01c06c307 100644 --- a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx @@ -77,6 +77,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx index 9133ae7f7..fb7b8b524 100644 --- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx @@ -83,6 +83,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx index 2dc60054b..f800a8b0a 100644 --- a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx @@ -33,6 +33,8 @@ const el =
Computed access such as `colors[color]` is resolved against the object the `create()` call was rewritten into, so every generated atom stays reachable. `stylex.attrs()` behaves the same but emits `class` instead of `className`. +Numbers get the unit StyleX gives them: `fontSize: 16` is `16px`, `transitionDuration: 300` is `300ms`, and unitless properties such as `lineHeight` stay as written. A number passed to a dynamic style gets the same unit at runtime. + Accepted argument shapes: ```tsx diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index bb86dca45..09d5accc9 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -20,6 +20,8 @@ export const light = createTheme(vars, { colors: { bg: 'white' } }) export const box = style({ background: vars.colors.bg }) ``` +Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, and unitless properties such as `lineHeight` or `zIndex` stay as written. + ## Ordinary modules `style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: diff --git a/apps/landing/src/app/(detail)/docs/overview/page.mdx b/apps/landing/src/app/(detail)/docs/overview/page.mdx index 9c337acd6..b186af1b8 100644 --- a/apps/landing/src/app/(detail)/docs/overview/page.mdx +++ b/apps/landing/src/app/(detail)/docs/overview/page.mdx @@ -235,7 +235,7 @@ Turbopack values are medians of six cold builds in alternating order. Build size -The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ### Get Started diff --git a/packages/components/README.md b/packages/components/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 5775ace8b..facbc7247 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/react/README.md b/packages/react/README.md index bc91e68a5..ede5459d6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/reset-css/README.md b/packages/reset-css/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/reset-css/README.md +++ b/packages/reset-css/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 9f00bf49c..404fc2997 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 166b4784b..a943c028c 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 83029a18e..343ead564 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works From 9a7a19a26db262380ae367f583acdb60c0dfb40c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:24:31 +0900 Subject: [PATCH 12/32] chore: add changepack for library number units and full engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_library_numbers_full_engine.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changepacks/changepack_log_library_numbers_full_engine.json diff --git a/.changepacks/changepack_log_library_numbers_full_engine.json b/.changepacks/changepack_log_library_numbers_full_engine.json new file mode 100644 index 000000000..248990056 --- /dev/null +++ b/.changepacks/changepack_log_library_numbers_full_engine.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Numbers in vanilla-extract and StyleX styles keep their library's meaning instead of Devup UI's spacing scale: vanilla-extract styles add `px` in `.css.ts` files and in `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, StyleX adds `px`/`ms` to static values and to numbers passed to dynamic styles, and unitless properties such as `columnCount`, `strokeWidth`, `orphans` or `zoom` keep a bare number everywhere. The Next.js plugin now runs every `.css.ts` module on the full engine instead of a separate static fast path, so development and production builds produce the same output", + "date": "2026-09-27T00:00:00.000Z" +} From ef7cb18618401ec8792817b5fc82ed71ae187d36 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:21:05 +0900 Subject: [PATCH 13/32] fix(extractor): fix vanilla-extract evaluation and Emotion/styled-components object styles vanilla-extract: name styles after the variable holding them instead of by call order, generate file-unique names for vars, containers, layers, fonts and theme variables, follow vanilla-extract's createVar/fallbackVar/createTheme/createGlobalTheme/contract/layer semantics, resolve styleVariants variants and transitive composition, and export plain values as data. Emotion and styled-components: numbers in object styles mean pixels, several style arguments or arrays merge, styled(tag, options) is a factory, and css() composes class names passed with rule objects. '@layer' records and a global '@layer' cover nested and responsive declarations, and typography presets yield to declarations written beside them. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../extract_global_style_from_expression.rs | 29 +- .../extract_style_from_expression.rs | 202 + .../extractor/extract_style_from_styled.rs | 15 +- libs/extractor/src/gen_class_name.rs | 2 + libs/extractor/src/import_alias_visit.rs | 183 +- libs/extractor/src/lib.rs | 144 + ...__composed_css_keeps_composed_classes.snap | 39 + ...and_styled_components_object_styles-2.snap | 29 + ...n_and_styled_components_object_styles.snap | 150 + ...layer_record_places_every_declaration.snap | 150 + ...ests__selector_refs_with_global_theme.snap | 6 +- ..._vanilla_extract_all_exports_combined.snap | 14 +- ...r__tests__vanilla_extract_all_imports.snap | 6 +- ...sts__vanilla_extract_constant_exports.snap | 2 +- ...tor__tests__vanilla_extract_container.snap | 6 +- ...__tests__vanilla_extract_create_var-2.snap | 4 +- ...or__tests__vanilla_extract_create_var.snap | 6 +- ...r__tests__vanilla_extract_font_face-2.snap | 6 +- ...tor__tests__vanilla_extract_font_face.snap | 6 +- ...vanilla_extract_font_face_empty_props.snap | 6 +- ...vanilla_extract_font_face_placeholder.snap | 10 +- ..._vanilla_extract_font_face_with_style.snap | 6 +- ...__tests__vanilla_extract_global_theme.snap | 10 +- ...illa_extract_global_theme_placeholder.snap | 10 +- ...anilla_extract_global_theme_with_vars.snap | 10 +- ...__vanilla_extract_imports_combination.snap | 6 +- ...sts__vanilla_extract_keyframes_export.snap | 2 +- ...tractor__tests__vanilla_extract_layer.snap | 12 +- ...__vanilla_extract_layer_and_container.snap | 10 +- ..._tests__vanilla_extract_layer_records.snap | 104 + ...__vanilla_extract_non_exported_styles.snap | 8 +- ...s__vanilla_extract_non_exported_theme.snap | 10 +- ...t_selector_refs_triggers_with_classes.snap | 10 +- ...sts__vanilla_extract_style_variants-2.snap | 2 +- ...tests__vanilla_extract_style_variants.snap | 2 +- ..._vanilla_extract_style_variants_mixed.snap | 2 +- ...illa_extract_style_variants_with_base.snap | 2 +- ...actor__tests__vanilla_extract_theme-2.snap | 18 +- ...tractor__tests__vanilla_extract_theme.snap | 26 +- ...nilla_extract_theme_array_destructure.snap | 20 +- ...nilla_extract_theme_export_variations.snap | 18 +- ..._vanilla_extract_theme_vars_name_only.snap | 6 +- ...ests__vanilla_extract_theme_with_vars.snap | 18 +- ...nilla_extract_theme_without_vars_json.snap | 6 +- ...nilla_extract_theme_without_vars_name.snap | 10 +- ...tor__tests__vanilla_extract_vars_only.snap | 6 +- ...lla_extract_with_keyframes_and_global.snap | 2 +- ...ed_with_variable_like_emotion_props-4.snap | 15 +- ...ed_with_variable_like_emotion_props-5.snap | 15 +- libs/extractor/src/utils.rs | 176 + libs/extractor/src/vanilla_extract.rs | 4429 +++++------------ libs/extractor/src/visit.rs | 43 +- 52 files changed, 2735 insertions(+), 3294 deletions(-) create mode 100644 libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index fd8a70208..5da9cad18 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -10,7 +10,8 @@ use crate::{ extractor::{ GlobalExtractResult, extract_style_from_expression::{ - LiteralHandling, at_rule_record_kind, extract_style_from_expression, + LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer, + yield_typography, }, }, utils::{ @@ -248,23 +249,17 @@ fn collect_global_styles<'a>( }) .unwrap_or_default(); - // Filter out @layer property from styles and set layer on remaining styles - for mut style in extracted.styles { - if let ExtractStyleProp::Static(ExtractStyleValue::Static( - ref mut st, - )) = style - { - // Skip @layer property - it's not a CSS property, set layer on other styles - if st.property() != "@layer" { - if let Some(ref layer) = layer_name { - st.layer = Some(layer.to_string()); - } - styles.push(style); - } - } else { - styles.push(style); - } + // `@layer` names the layer of every other declaration, + // responsive and nested ones included + let mut extracted = extracted.styles; + extracted.retain(|style| { + !matches!(style, ExtractStyleProp::Static(ExtractStyleValue::Static(st)) if st.property() == "@layer") + }); + if let Some(layer) = layer_name { + place_in_layer(&mut extracted, &layer); } + yield_typography(&mut extracted); + styles.extend(extracted); } } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 2d98df84d..bc058ceb2 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -33,6 +33,7 @@ use oxc_ast::{ builder::AstBuilder, }; use oxc_span::SPAN; +use std::collections::BTreeMap; const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; @@ -294,6 +295,34 @@ pub fn extract_style_from_expression<'a>( }; } + // vanilla-extract's `'@layer': { [layerName]: rules }` + if name == "@layer" + && let Expression::ObjectExpression(obj) = expression + { + let mut props = vec![]; + for p in &mut obj.properties { + if let ObjectPropertyKind::ObjectProperty(o) = p + && let Some(layer) = get_string_by_property_key(&o.key) + { + let mut styles = extract_style_from_expression( + ast_builder, + None, + &mut o.value, + level, + selector, + literal_handling, + ) + .styles; + place_in_layer(&mut styles, &layer); + props.extend(styles); + } + } + return ExtractResult { + styles: props, + ..ExtractResult::default() + }; + } + if let Some(kind) = at_rule_record_kind(name) && let Expression::ObjectExpression(obj) = expression { @@ -818,6 +847,169 @@ pub fn extract_style_from_expression<'a>( } } +/// Put every static declaration of `props` in `layer`, nesting the layer one +/// already sits in +pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => { + style.layer = Some(match style.layer.take() { + Some(inner) => format!("{layer}.{inner}"), + None => layer.to_string(), + }); + } + ExtractStyleProp::StaticArray(props) => place_in_layer(props, layer), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + place_in_layer(std::slice::from_mut(branch.as_mut()), layer); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + place_in_layer(props, layer); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + place_in_layer(std::slice::from_mut(prop.as_mut()), layer); + } + } + // Dynamic values and dynamic `typography` carry no static declaration + ExtractStyleProp::Static(_) | ExtractStyleProp::Expression { .. } => {} + } + } +} + +const TYPOGRAPHY_PROPERTIES: [&str; 5] = [ + "font-family", + "font-size", + "font-weight", + "line-height", + "letter-spacing", +]; + +/// `(selector, typography property)` -> breakpoint its declaration starts at +type Declared = BTreeMap<(Option, &'static str), u8>; + +/// Typography properties `props` always declare, per selector +fn declared_from(props: &[ExtractStyleProp<'_>]) -> Declared { + let mut declared = Declared::new(); + for prop in props { + let found = match prop { + ExtractStyleProp::Static(value) => { + let (property, level, selector) = match value { + ExtractStyleValue::Static(style) => { + (style.property(), style.level(), style.selector()) + } + ExtractStyleValue::Dynamic(style) => { + (style.property(), style.level(), style.selector()) + } + _ => continue, + }; + let Some(property) = TYPOGRAPHY_PROPERTIES.into_iter().find(|p| *p == property) + else { + continue; + }; + Declared::from([((selector.cloned(), property), level)]) + } + ExtractStyleProp::StaticArray(props) => declared_from(props), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => declared_by_every([consequent, alternate].map(|branch| { + branch.as_deref().map_or_else(Declared::new, |branch| { + declared_from(std::slice::from_ref(branch)) + }) + })), + ExtractStyleProp::Enum { map, .. } => { + declared_by_every(map.values().map(|props| declared_from(props))) + } + ExtractStyleProp::MemberExpression { map, .. } => declared_by_every( + map.values() + .map(|prop| declared_from(std::slice::from_ref(prop.as_ref()))), + ), + ExtractStyleProp::Expression { .. } => continue, + }; + for (key, level) in found { + declared + .entry(key) + .and_modify(|from| *from = (*from).min(level)) + .or_insert(level); + } + } + declared +} + +/// What every alternative declares, from the widest breakpoint one starts at +fn declared_by_every(alternatives: impl IntoIterator) -> Declared { + let mut alternatives = alternatives.into_iter(); + let Some(mut common) = alternatives.next() else { + return Declared::new(); + }; + for alternative in alternatives { + common = common + .into_iter() + .filter_map(|(key, level)| alternative.get(&key).map(|other| (key, level.max(*other)))) + .collect(); + } + common +} + +/// A `typography` preset gives way to the declarations written beside it: under +/// the same selector it skips each of their properties from the breakpoint the +/// written one starts at, so a wider breakpoint of the preset cannot win. +pub(crate) fn yield_typography(props: &mut [ExtractStyleProp<'_>]) { + let declared = declared_from(props); + if !declared.is_empty() { + skip_declared(props, &declared); + } +} + +fn skip_declared(props: &mut [ExtractStyleProp<'_>], declared: &Declared) { + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(style)) + if style.property() == "typography" => + { + let skipped: Vec = declared + .iter() + .filter(|((selector, _), _)| selector.as_ref() == style.selector()) + .map(|((_, property), level)| format!("{property}:{level}")) + .collect(); + if !skipped.is_empty() { + style.value = format!("{}|{}", style.value, skipped.join(",")); + } + } + ExtractStyleProp::StaticArray(props) => skip_declared(props, declared), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + skip_declared(std::slice::from_mut(branch.as_mut()), declared); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + skip_declared(props, declared); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + skip_declared(std::slice::from_mut(prop.as_mut()), declared); + } + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Expression { .. } => {} + } + } +} + pub(crate) fn at_rule_record_kind(name: &str) -> Option { match name.strip_prefix('@').or_else(|| name.strip_prefix('_'))? { "media" => Some(AtRuleKind::Media), @@ -977,6 +1169,16 @@ mod tests { use oxc_parser::Parser; use oxc_span::SourceType; + #[test] + fn test_declared_by_every() { + assert!(declared_by_every(std::iter::empty()).is_empty()); + let declared = declared_by_every([ + Declared::from([((None, "font-size"), 0), ((None, "line-height"), 1)]), + Declared::from([((None, "font-size"), 2)]), + ]); + assert_eq!(declared, Declared::from([((None, "font-size"), 2)])); + } + #[test] fn test_extract_selector_object_styles() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 492264a49..bad073af9 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -11,7 +11,10 @@ use crate::{ }, gen_class_name::gen_class_names, gen_style::gen_styles, - utils::{merge_object_expressions, unwrap_syntax_only, wrap_array_filter}, + utils::{ + StyleArguments, merge_object_expressions, style_arguments, unwrap_syntax_only, + wrap_array_filter, + }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::{ @@ -104,6 +107,16 @@ pub fn extract_style_from_styled<'a>( split_filename: Option<&str>, imports: &FxHashMap, ) -> (ExtractResult<'a>, Expression<'a>) { + if let Expression::CallExpression(call) = expression + && extract_base_tag_and_class_name(&call.callee, imports) + .0 + .is_some() + && let Some(StyleArguments { classes, rules }) = + style_arguments(ast_builder, &call.arguments) + && classes.is_empty() + { + call.arguments = oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); + } let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression && let (Some(tag_name), default_class_name) = extract_base_tag_and_class_name(&tag.tag, imports) diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3101e5667..dad28c98d 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -1,5 +1,6 @@ use crate::ExtractStyleProp; use crate::extract_style::style_property::StyleProperty; +use crate::extractor::extract_style_from_expression::yield_typography; use crate::prop_modify_utils::convert_class_name; use crate::utils::is_same_expression; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -16,6 +17,7 @@ pub fn gen_class_names<'a>( style_order: Option, filename: Option<&str>, ) -> Option> { + yield_typography(style_props); merge_expression_for_class_name( ast_builder, style_props diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index c039151de..c3a0626b6 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -9,30 +9,64 @@ use crate::ImportAlias; use crate::utils::{ - get_str_by_property_key, is_unitless_key, is_vanilla_extract_file, js_number_literal, + get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, }; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, CallExpression, Expression, ImportDeclarationSpecifier, ModuleExportName, - ObjectPropertyKind, Statement, + Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem, + JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, + Statement, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_call_expression, walk_jsx_opening_element}, }; -use oxc_ast_visit::{Visit, walk::walk_call_expression}; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; use std::borrow::Cow; use std::collections::HashMap; -/// Numbers in vanilla-extract `style()` / `globalStyle()` / `keyframes()` rules outside a -/// stylesheet. Those calls become Devup UI's `css()` / `globalCss()` / `keyframes()`, which read a -/// number as its spacing scale, so it is rewritten to the `px` string -/// vanilla-extract makes of it. -struct VanillaNumbers<'n> { - /// Local name of each call and the index of its rules argument - calls: Vec<(&'n str, usize)>, +/// Where a style function takes its rules +#[derive(Clone, Copy)] +enum RulesAt { + Argument(usize), + EveryArgument, +} + +/// Numbers in rules written against vanilla-extract, Emotion or styled-components +/// outside a stylesheet. Those calls become Devup UI's, which read a number as its +/// spacing scale, so it is rewritten to the `px` string the library makes of it. +#[derive(Default)] +struct LibraryNumbers<'n> { + /// Local name of each style function and where its rules are + calls: Vec<(&'n str, RulesAt)>, + /// Local names that build styled components + styled: Vec<&'n str>, + /// Local names of components taking rules in `styles` (Emotion's `Global`) + components: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, } -impl VanillaNumbers<'_> { +impl LibraryNumbers<'_> { + /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their + /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules + fn is_styled_factory(&self, callee: &Expression) -> bool { + match callee { + Expression::StaticMemberExpression(member) => { + matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str())) + } + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(root) => self.styled.contains(&root.name.as_str()), + Expression::StaticMemberExpression(member) => { + matches!(member.property.name.as_str(), "attrs" | "withConfig") + && self.is_styled_factory(&member.object) + } + _ => false, + }, + _ => false, + } + } + fn pixelify(&mut self, rules: &Expression) { match rules { Expression::ArrayExpression(array) => { @@ -53,7 +87,7 @@ impl VanillaNumbers<'_> { } } else if let Some(number) = js_number_literal(&property.value) && number != 0.0 - && !is_unitless_key(&key) + && !keeps_bare_number(&key) { let span = property.value.span(); self.replacements.push(( @@ -70,19 +104,58 @@ impl VanillaNumbers<'_> { } } -impl<'a> Visit<'a> for VanillaNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers<'_> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { - if let Expression::Identifier(callee) = &call.callee - && let Some(&(_, index)) = self + let rules_at = match &call.callee { + Expression::Identifier(callee) => self .calls .iter() .find(|(name, _)| *name == callee.name.as_str()) - && let Some(rules) = call.arguments.get(index).and_then(Argument::as_expression) - { + .map(|(_, rules_at)| *rules_at), + callee => self + .is_styled_factory(callee) + .then_some(RulesAt::EveryArgument), + }; + let rules: Vec<&Expression<'a>> = match rules_at { + Some(RulesAt::Argument(index)) => call + .arguments + .get(index) + .and_then(Argument::as_expression) + .into_iter() + .collect(), + Some(RulesAt::EveryArgument) => call + .arguments + .iter() + .filter_map(Argument::as_expression) + .collect(), + None => vec![], + }; + for rules in rules { self.pixelify(rules); } walk_call_expression(self, call); } + + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + if let JSXElementName::IdentifierReference(name) = &element.name + && self.components.contains(&name.name.as_str()) + { + for attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attribute) = attribute + && attribute + .name + .as_identifier() + .is_some_and(|name| name.name == "styles") + && let Some(JSXAttributeValue::ExpressionContainer(container)) = + &attribute.value + && let Some(rules) = container.expression.as_expression() + { + self.pixelify(rules); + } + } + } + walk_jsx_opening_element(self, element); + } } /// Map an aliased package's export onto the `@devup-ui/react` export that implements the @@ -156,10 +229,7 @@ pub fn transform_import_aliases<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); - let mut numbers = VanillaNumbers { - calls: Vec::new(), - replacements: Vec::new(), - }; + let mut numbers = LibraryNumbers::default(); for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { @@ -170,22 +240,50 @@ pub fn transform_import_aliases<'a>( let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); - if source_value == "@vanilla-extract/css" && !redirect_every_name { + if !redirect_every_name { for specifier in import_decl.specifiers.iter().flatten() { - if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { - let rules_index = match imported_name(&spec.imported).as_ref() { - "style" | "keyframes" => 0, - "globalStyle" => 1, - _ => continue, - }; - numbers.calls.push((spec.local.name.as_str(), rules_index)); + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) => { + let local = spec.local.name.as_str(); + match (source_value, imported_name(&spec.imported).as_ref()) { + ("@vanilla-extract/css", "style" | "keyframes") => { + numbers.calls.push((local, RulesAt::Argument(0))); + } + ("@vanilla-extract/css", "globalStyle") => { + numbers.calls.push((local, RulesAt::Argument(1))); + } + ( + "@emotion/react" | "styled-components", + "css" | "keyframes", + ) => numbers.calls.push((local, RulesAt::EveryArgument)), + ("@emotion/react", "Global") => numbers.components.push(local), + _ => {} + } + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) + if matches!( + source_value, + "@emotion/styled" | "styled-components" + ) => + { + numbers.styled.push(spec.local.name.as_str()); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) + if matches!( + source_value, + "@emotion/styled" | "styled-components" + ) => + { + numbers.styled.push(spec.local.name.as_str()); + } + _ => {} } } } } } } - if !numbers.calls.is_empty() { + if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); transformations.sort_unstable_by_key(|(start, ..)| *start); @@ -591,6 +689,29 @@ export const container = style({ background: 'red' })", )); } + #[test] + fn test_emotion_numbers_become_px() { + let code = r"import styled from '@emotion/styled' +import { Global, ThemeProvider } from '@emotion/react' +foo.bar.baz({ top: 1 }) +make()()({ top: 1 }) +;(() => 1)() +styled.div({ top: 1, p: 2 }) +export const A = () => <>
"; + let mut aliases = combined_aliases(); + aliases.insert("@emotion/react".to_string(), ImportAlias::NamedToNamed); + assert_eq!( + transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases), + r#"import { styled } from '@devup-ui/react'; +import { Global, ThemeProvider } from '@devup-ui/react/compat'; +foo.bar.baz({ top: 1 }) +make()()({ top: 1 }) +;(() => 1)() +styled.div({ top: "1px", p: 2 }) +export const A = () => <>
"# + ); + } + #[test] fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index b287c1a8f..512c050f1 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11976,6 +11976,66 @@ export const darkTheme = createTheme(vars, { )); } + #[test] + #[serial] + fn test_layer_record_places_every_declaration() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { css } from '@devup-ui/react' +const a = css({ '@layer': { base: { + color: cond ? 'red' : 'blue', + positioning: pos, + bg: { a: 'red', b: 'blue' }[key], + typography: typo, + width: w, + p: [1, 2], + '@layer': { inner: { m: 1 } }, +} } }) +", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_vanilla_extract_layer_records() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "layers.css.ts", + r"import { layer, style, globalStyle } from '@vanilla-extract/css' +const reset = layer('reset') +export const components = layer('components') +globalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } }) +export const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } }) +", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + )]) + } + ) + .unwrap() + )); + } + #[test] #[serial] fn test_vanilla_extract_layer() { @@ -17796,6 +17856,90 @@ export const App = () => <>< )); } + #[test] + #[serial] + fn test_emotion_and_styled_components_object_styles() { + let aliases = HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); + for code in [ + r"import styled from '@emotion/styled'; +import { css, keyframes, Global } from '@emotion/react'; +export const a = css({ padding: 8, lineHeight: 1.5, m: 2 }, [{ margin: 1 }, null, undefined, false]); +export const B = styled('div', { shouldForwardProp: (p) => p !== 'x' })({ left: 3 }); +export const C = styled('span', { label: 'c' })`color: red;`; +export const D = styled.div([{ top: 1, color: 'red', '&:hover': { margin: 5 } }, { top: 3, '&:hover': { padding: 6 } }]); +export const E = styled.div({ bottom: 2 }, cond && { right: 4 }); +export const F = styled('p', { color: 'blue' }); +export const spin = keyframes({ to: { width: 10 } }); +export const App = () => <>;", + r"import * as Styled from 'styled-components'; +import { css } from 'styled-components'; +export const G = Styled.div.attrs({ type: 'button' }).withConfig({ displayName: 'g' })({ right: 7 }); +export const H = Styled(Base).withConfig({ displayName: 'h' })({ left: 9 }); +export const I = Styled.div({ width: 1 }); +export const j = css({ height: 2 });", + ] { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: aliases.clone(), + }, + ) + .unwrap() + )); + } + } + + #[test] + #[serial] + fn test_composed_css_keeps_composed_classes() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { style } from '@vanilla-extract/css'; +import { css } from '@emotion/react'; +const base = style({ color: 'red' }); +export const a = style([base, { margin: 2 }]); +export const b = css(base, 'extra', { padding: 1 }); +export const c = css([base]); +export const d = style([cond && base]);", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + ) + ]) + }, + ) + .unwrap() + )); + } + #[test] #[serial] fn test_devup_props_typescript_wrappers() { diff --git a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap new file mode 100644 index 000000000..9cde0e41c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap @@ -0,0 +1,39 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { style } from '@vanilla-extract/css';\nimport { css } from '@emotion/react';\nconst base = style({ color: 'red' });\nexport const a = style([base, { margin: 2 }]);\nexport const b = css(base, 'extra', { padding: 1 });\nexport const c = css([base]);\nexport const d = style([cond && base]);\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/react\".to_string(), ImportAlias::NamedToNamed),\n (\"@vanilla-extract/css\".to_string(), ImportAlias::NamedToNamed)])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = `b ${base}`;\nexport const b = `extra c ${base}`;\nexport const c = base;\nexport const d = \"\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap new file mode 100644 index 000000000..30712f964 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\", code, ExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases.clone(),\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "height", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = Styled.div.attrs({ type: \"button\" }).withConfig({ displayName: \"g\" })({ right: \"7px\" });\nexport const H = Styled(Base).withConfig({ displayName: \"h\" })({ left: \"9px\" });\nexport const I = ({ style, className, ...rest }) =>
;\nexport const j = \"b\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap new file mode 100644 index 000000000..161dd9866 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -0,0 +1,150 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\", code, ExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases.clone(),\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "1.5", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "5px", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "6px", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Keyframes( + ExtractKeyframes { + keyframes: { + "to": [ + ExtractStaticStyle { + property: "width", + value: "10px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ], + }, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const a = \"a b c d\";\nexport const B = ({ style, className, ...rest }) =>
;\nexport const C = ({ style, className, ...rest }) => ;\nexport const D = ({ style, className, ...rest }) =>
;\nexport const E = styled.div({ bottom: \"2px\" }, cond && { right: 4 });\nexport const F = ({ style, className, ...rest }) =>

;\nexport const spin = \"k\";\nexport const App = () => <>;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap new file mode 100644 index 000000000..86d85457d --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap @@ -0,0 +1,150 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { css } from '@devup-ui/react'\nconst a = css({ '@layer': { base: {\n color: cond ? 'red' : 'blue',\n positioning: pos,\n bg: { a: 'red', b: 'blue' }[key],\n typography: typo,\n width: w,\n p: [1, 2],\n '@layer': { inner: { m: 1 } },\n} } })\n\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "bottom", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base.inner", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "8px", + level: 1, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "width", + level: 0, + identifier: "w", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst a = `i k l m ${cond ? \"a\" : \"b\"} ${{\n\t\"bottom\": \"c\",\n\t\"bottom-left\": \"c d\",\n\t\"bottom-right\": \"c e\",\n\t\"left\": \"d\",\n\t\"right\": \"e\",\n\t\"top\": \"f\",\n\t\"top-left\": \"d f\",\n\t\"top-right\": \"e f\"\n}[pos] || \"\"} ${{\n\t\"a\": \"g\",\n\t\"b\": \"h\"\n}[key] || \"\"} ${typo ? `typo-${typo}` : \"\"}`;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap index bb890d2f8..c9f83cbe8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-primary-global_theme_0-0", + property: "--colors-primary-0-0", value: "blue", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-secondary-global_theme_0-1", + property: "--colors-secondary-0-1", value: "green", level: 0, selector: Some( @@ -63,5 +63,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-0-0)\",\n\t\"secondary\": \"var(--colors-secondary-0-1)\"\n} };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 29976b6ec..0f8d800f2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-global_theme_0-0", + property: "--color-primary-0-4", value: "blue", level: 0, selector: Some( @@ -44,7 +44,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-3", level: 0, selector: None, style_order: None, @@ -78,11 +78,17 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer components-0-2;", + file: "all-combined.css.ts", + }, + ), FontFace( ExtractFontFace { file: "all-combined.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-3", "src": "local(Arial)", }, }, @@ -114,5 +120,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"components\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const fade = \"a\";\nconst _ve2 = \"b c\";\nconst _ve3 = \"d\";\nconst base = \"b\";\nexport const box = \"e\";\n;\n;\n;\nexport const myVar = \"var(--var-0-0)\";\nexport const myContainer = \"container-0-1\";\nexport const myLayer = \"components-0-2\";\nexport const myFont = \"font-0-3\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-0-4)\" } };\nexport const buttons = {\n\t\"primary\": _ve2,\n\t\"secondary\": _ve3\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap index a4a0297fe..43bfc306b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-style_0-0", + property: "--color-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_theme", + ".theme-0-0", "all-imports.css.ts", ), ), @@ -75,5 +75,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const [theme, vars] = [\"f0_theme\", { \"color\": { \"primary\": \"var(--color-primary-style_0-0)\" } }];\n;\n;\nexport const fadeIn = \"b\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const box = \"b\";\n;\n;\nexport const theme = \"theme-0-0\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-0-1)\" } };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap index 611abb463..e1dd693b1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const COLORS = {\n\tprimary: \"blue\",\n\tsecondary: \"green\"\n};\nexport const SPACING = 8;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const SPACING = 8;\nexport const COLORS = {\n\t\"primary\": \"blue\",\n\t\"secondary\": \"green\"\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap index 563c444df..b52d5c2eb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "container-name", - value: "__container_0__", + value: "container-0-0", level: 0, selector: None, style_order: None, @@ -32,7 +32,7 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "__container_0__ (min-width:400px)", + query: "container-0-0 (min-width:400px)", selector: None, outer: [], file: None, @@ -43,5 +43,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const responsive = \"a\";\nexport const sidebarContainer = \"b c\";\nexport const sidebar = \"__container_0__\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const responsive = \"a\";\nexport const sidebarContainer = \"b c\";\nexport const sidebar = \"container-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap index 842b47cc1..fe015c8a4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--var-0,red)", + value: "var(--var-0-0,red)", level: 0, selector: None, style_order: None, @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const colorVar = \"--var-0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const colorVar = \"var(--var-0-0)\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap index 01c826e3f..fc6f9e988 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--var-0", + property: "--var-0-0", value: "blue", level: 0, selector: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--var-0)", + value: "var(--var-0-0)", level: 0, selector: None, style_order: None, @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"--var-0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"var(--var-0-0)\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap index 108b4267d..c66396a1d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,7 +18,7 @@ ToBTreeSet { ExtractFontFace { file: "fonts-multi.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "font-style": "normal", "font-weight": "400", "src": "url(\"/fonts/Roboto.woff2\") format(\"woff2\")", @@ -26,5 +26,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const body = \"a\";\n;\nconst roboto = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const body = \"a\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap index c3ad374c6..2b8fc7ced 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,11 +18,11 @@ ToBTreeSet { ExtractFontFace { file: "fonts.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "src": "local(\"Comic Sans MS\")", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nconst myFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap index 4a785cde4..02e3a50f0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,10 +18,10 @@ ToBTreeSet { ExtractFontFace { file: "fontface-minimal.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const minimalFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const minimalFont = \"font-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap index 05f207b27..68e3d3981 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_1", + value: "font-0-1", level: 0, selector: None, style_order: None, @@ -29,7 +29,7 @@ ToBTreeSet { file: "fontface-remap.css.ts", properties: { "font-display": "swap", - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "font-weight": "400", "src": "url(\"/fonts/custom.woff2\")", }, @@ -39,11 +39,11 @@ ToBTreeSet { ExtractFontFace { file: "fontface-remap.css.ts", properties: { - "font-family": "__devup_font_0_1", + "font-family": "font-0-1", "src": "local(\"Helvetica\")", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const heading = \"a\";\nexport const text = \"b\";\n;\n;\nexport const customFont = \"__devup_font_0_0\";\nexport const secondFont = \"__devup_font_0_1\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const heading = \"a\";\nexport const text = \"b\";\n;\nexport const customFont = \"font-0-0\";\nexport const secondFont = \"font-0-1\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap index 0f1958b14..ebc837d86 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,12 +18,12 @@ ToBTreeSet { ExtractFontFace { file: "font-usage.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "font-weight": "400", "src": "local(\"Comic Sans MS\")", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const myFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const myFont = \"font-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap index 891cd1fb3..5a49e712f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-background-global_theme_0-2", + property: "--color-background-0-2", value: "white", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-brand-global_theme_0-0", + property: "--color-brand-0-0", value: "blue", level: 0, selector: Some( @@ -40,7 +40,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text-global_theme_0-1", + property: "--color-text-0-1", value: "black", level: 0, selector: Some( @@ -57,7 +57,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--font-body-global_theme_0-3", + property: "--font-body-0-3", value: "system-ui,sans-serif", level: 0, selector: Some( @@ -73,5 +73,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-global_theme_0-0)\",\n\t\t\"text\": \"var(--color-text-global_theme_0-1)\",\n\t\t\"background\": \"var(--color-background-global_theme_0-2)\"\n\t},\n\t\"font\": { \"body\": \"var(--font-body-global_theme_0-3)\" }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-0-0)\",\n\t\t\"text\": \"var(--color-text-0-1)\",\n\t\t\"background\": \"var(--color-background-0-2)\"\n\t},\n\t\"font\": { \"body\": \"var(--font-body-0-3)\" }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap index 26b2f670b..f22c68d5e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-bg-global_theme_0-0", + property: "--colors-bg-0-0", value: "white", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-bg-global_theme_1-0", + property: "--colors-bg-0-2", value: "black", level: 0, selector: Some( @@ -40,7 +40,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text-global_theme_0-1", + property: "--colors-text-0-1", value: "black", level: 0, selector: Some( @@ -57,7 +57,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text-global_theme_1-1", + property: "--colors-text-0-3", value: "white", level: 0, selector: Some( @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const darkVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-global_theme_1-0)\",\n\t\"text\": \"var(--colors-text-global_theme_1-1)\"\n} };\nexport const lightVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-global_theme_0-0)\",\n\t\"text\": \"var(--colors-text-global_theme_0-1)\"\n} };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const lightVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-0-0)\",\n\t\"text\": \"var(--colors-text-0-1)\"\n} };\nexport const darkVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-0-2)\",\n\t\"text\": \"var(--colors-text-0-3)\"\n} };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap index 09eedf6be..5bb202f10 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-global_theme_0-0", + property: "--color-primary-0-0", value: "blue", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-secondary-global_theme_0-1", + property: "--color-secondary-0-1", value: "green", level: 0, selector: Some( @@ -40,7 +40,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-large-global_theme_0-3", + property: "--space-large-0-3", value: "16px", level: 0, selector: Some( @@ -57,7 +57,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-small-global_theme_0-2", + property: "--space-small-0-2", value: "4px", level: 0, selector: Some( @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-global_theme_0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-global_theme_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-global_theme_0-2)\",\n\t\t\"large\": \"var(--space-large-global_theme_0-3)\"\n\t}\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-2)\",\n\t\t\"large\": \"var(--space-large-0-3)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap index 695a64586..3909d8ce8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: Some( Global( @@ -52,7 +52,7 @@ ToBTreeSet { ExtractFontFace { file: "imports-combo.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "src": "local(Arial)", }, }, @@ -84,5 +84,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"b\";\n;\n;\nexport const fadeIn = \"a\";\nexport const myFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const animated = \"b\";\n;\n;\nexport const myFont = \"font-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap index 2cf86d4c6..a425143e6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap @@ -67,5 +67,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"b\";\nconst internal = \"c\";\nexport const spin = \"a\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst internal = \"b\";\nexport const spin = \"a\";\nexport const spinner = \"c\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap index 03ac65369..6a8489aa5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap @@ -19,7 +19,7 @@ ToBTreeSet { 0, ), layer: Some( - "reset", + "reset-0-0", ), }, ), @@ -38,10 +38,16 @@ ToBTreeSet { 0, ), layer: Some( - "reset", + "reset-0-0", ), }, ), + Css( + ExtractCss { + css: "@layer reset-0-0,base-0-1,components-0-2;", + file: "layers.css.ts", + }, + ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const base = \"base\";\nexport const components = \"components\";\nexport const reset = \"reset\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\n;\nexport const reset = \"reset-0-0\";\nexport const base = \"base-0-1\";\nexport const components = \"components-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap index 3b4e51f96..969713595 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "container-name", - value: "__container_0__", + value: "container-0-2", level: 0, selector: None, style_order: None, @@ -24,6 +24,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer reset-0-0,base-0-1;", + file: "layer-container.css.ts", + }, + ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const containerStyle = \"a b\";\nexport const myContainer = \"__container_0__\";\nexport const baseLayer = \"base\";\nexport const resetLayer = \"reset\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const containerStyle = \"a b\";\nexport const resetLayer = \"reset-0-0\";\nexport const baseLayer = \"base-0-1\";\nexport const myContainer = \"container-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap new file mode 100644 index 000000000..7a5d2d645 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap @@ -0,0 +1,104 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"layers.css.ts\",\nr\"import { layer, style, globalStyle } from '@vanilla-extract/css'\nconst reset = layer('reset')\nexport const components = layer('components')\nglobalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } })\nexport const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } })\n\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@vanilla-extract/css\".to_string(),\n ImportAlias::NamedToNamed)])\n}).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: Some( + Global( + "a", + "layers.css.ts", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "green", + level: 0, + selector: None, + style_order: None, + layer: Some( + "components-0-1", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "inherit", + level: 0, + selector: Some( + Global( + "a", + "layers.css.ts", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "reset-0-0", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "0", + level: 0, + selector: Some( + Global( + "a", + "layers.css.ts", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "reset-0-0", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: Some( + "components-0-1", + ), + }, + ), + Css( + ExtractCss { + css: "@layer reset-0-0,components-0-1;", + file: "layers.css.ts", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const button = \"a b c\";\n;\nexport const components = \"components-0-1\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap index b37bcbadb..113b631a8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap @@ -24,6 +24,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer internal-0-2;", + file: "mixed-exports.css.ts", + }, + ), Keyframes( ExtractKeyframes { keyframes: { @@ -51,5 +57,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst internalStyle = \"a\";\nexport const publicStyle = \"b\";\nconst internalKeyframe = \"c\";\nconst internalVar = \"--var-0\";\nconst internalContainer = \"__container_0__\";\nconst internalLayer = \"internal\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nconst internalKeyframe = \"a\";\nconst internalStyle = \"b\";\nexport const publicStyle = \"c\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap index fafa89b1f..4508bb9a0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-bg-style_0-0", + property: "--colors-bg-0-1", value: "white", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-0", "internal-theme.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text-style_0-1", + property: "--colors-text-0-2", value: "black", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-0", "internal-theme.css.ts", ), ), @@ -49,5 +49,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\nconst [internalTheme, themeVars] = [\"f0_internalTheme\", { \"colors\": {\n\t\"bg\": \"var(--colors-bg-style_0-0)\",\n\t\"text\": \"var(--colors-text-style_0-1)\"\n} }];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap index 66d773c71..61195fd1d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap @@ -45,11 +45,17 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer ui-0-2;", + file: "refs.css.ts", + }, + ), FontFace( ExtractFontFace { file: "refs.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-3", "src": "local(Arial)", }, }, @@ -81,5 +87,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\n;\nexport const fade = \"c\";\nexport const colorVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"ui\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const fade = \"a\";\nexport const child = \"b\";\nexport const parent = \"c\" + \" f0_parent\";\n;\n;\nexport const colorVar = \"var(--var-0-0)\";\nexport const myContainer = \"container-0-1\";\nexport const myLayer = \"ui-0-2\";\nexport const myFont = \"font-0-3\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap index 84a2b9346..8b1746923 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap @@ -65,5 +65,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const button = {\n\tprimary: \"a b c d\",\n\tsecondary: \"a b e f\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c d\";\nconst _ve2 = \"a b e f\";\nconst base = \"a b\";\nexport const button = {\n\t\"primary\": _ve1,\n\t\"secondary\": _ve2\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap index 8426209d6..5334d9dcb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap @@ -35,5 +35,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const background = {\n\tdanger: \"a\",\n\tprimary: \"b\",\n\tsecondary: \"c\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve0 = \"a\";\nconst _ve1 = \"b\";\nconst _ve2 = \"c\";\nexport const background = {\n\t\"primary\": _ve0,\n\t\"secondary\": _ve1,\n\t\"danger\": _ve2\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap index 27943e415..620b8faf2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap @@ -75,5 +75,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const buttons = {\n\tdanger: \"a b c\",\n\tprimary: \"a b d e\",\n\tsecondary: \"f g\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c d\";\nconst _ve2 = \"e f\";\nconst _ve3 = \"a b g\";\nconst base = \"a b\";\nexport const buttons = {\n\t\"primary\": _ve1,\n\t\"secondary\": _ve2,\n\t\"danger\": _ve3\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap index 1d2367b8e..9ed8520d8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap @@ -55,5 +55,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const sizes = {\n\tlarge: \"a b c\",\n\tmedium: \"a b d\",\n\tsmall: \"a b e\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c\";\nconst _ve2 = \"a b d\";\nconst _ve3 = \"a b e\";\nconst base = \"a b\";\nexport const sizes = {\n\t\"small\": _ve1,\n\t\"medium\": _ve2,\n\t\"large\": _ve3\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap index db6a47630..a61f4db70 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-brand", + property: "--color-brand-0-0", value: "blue", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-contract.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-brand", + property: "--color-brand-0-0", value: "lightblue", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-3", "theme-contract.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text", + property: "--color-text-0-1", value: "black", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-contract.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text", + property: "--color-text-0-1", value: "white", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-3", "theme-contract.css.ts", ), ), @@ -73,5 +73,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const darkTheme = \"f0_darkTheme\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\n;\nexport const lightTheme = \"theme-0-2\";\nexport const darkTheme = \"theme-0-3\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap index 37a0103e4..dbeae3270 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-brand-style_0-0", + property: "--color-brand-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text-style_0-1", + property: "--color-text-0-2", value: "black", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-large-style_0-4", + property: "--space-large-0-5", value: "16px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-medium-style_0-3", + property: "--space-medium-0-4", value: "8px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -74,12 +74,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-small-style_0-2", + property: "--space-small-0-3", value: "4px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -92,7 +92,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--color-text-style_0-1)", + value: "var(--color-text-0-2)", level: 0, selector: None, style_order: None, @@ -102,7 +102,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "var(--space-medium-style_0-3)", + value: "var(--space-medium-0-4)", level: 0, selector: None, style_order: None, @@ -110,5 +110,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const [themeClass, vars] = [\"f0_themeClass\", {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-style_0-0)\",\n\t\t\"text\": \"var(--color-text-style_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-style_0-2)\",\n\t\t\"medium\": \"var(--space-medium-style_0-3)\",\n\t\t\"large\": \"var(--space-large-style_0-4)\"\n\t}\n}];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\n;\nexport const themeClass = \"theme-0-0\";\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-0-1)\",\n\t\t\"text\": \"var(--color-text-0-2)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-3)\",\n\t\t\"medium\": \"var(--space-medium-0-4)\",\n\t\t\"large\": \"var(--space-large-0-5)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap index 9ce62724b..b001724d0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-primary-style_0-0", + property: "--colors-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-secondary-style_0-1", + property: "--colors-secondary-0-2", value: "green", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--spacing-medium-style_0-3", + property: "--spacing-medium-0-4", value: "8px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--spacing-small-style_0-2", + property: "--spacing-small-0-3", value: "4px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--colors-primary-style_0-0)", + value: "var(--colors-primary-0-1)", level: 0, selector: None, style_order: None, @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\nexport const [themeClass, themeVars] = [\"f0_themeClass\", {\n\t\"colors\": {\n\t\t\"primary\": \"var(--colors-primary-style_0-0)\",\n\t\t\"secondary\": \"var(--colors-secondary-style_0-1)\"\n\t},\n\t\"spacing\": {\n\t\t\"small\": \"var(--spacing-small-style_0-2)\",\n\t\t\"medium\": \"var(--spacing-medium-style_0-3)\"\n\t}\n}];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\n;\nexport const themeClass = \"theme-0-0\";\nexport const themeVars = {\n\t\"colors\": {\n\t\t\"primary\": \"var(--colors-primary-0-1)\",\n\t\t\"secondary\": \"var(--colors-secondary-0-2)\"\n\t},\n\t\"spacing\": {\n\t\t\"small\": \"var(--spacing-small-0-3)\",\n\t\t\"medium\": \"var(--spacing-medium-0-4)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap index 33392c10c..a04dc8332 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-bg", + property: "--colors-bg-0-0", value: "gray", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-3", "theme-exports.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-bg", + property: "--colors-bg-0-0", value: "white", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-exports.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text", + property: "--colors-text-0-1", value: "black", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-exports.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text", + property: "--colors-text-0-1", value: "darkgray", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-3", "theme-exports.css.ts", ), ), @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nconst internalTheme = \"f0_internalTheme\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const lightTheme = \"theme-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap index c16a72393..af0af474c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-style_0-0", + property: "--color-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_myTheme", + ".theme-0-0", "theme-vars-name.css.ts", ), ), @@ -22,5 +22,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const myTheme = \"f0_myTheme\";\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const myTheme = [\"theme-0-0\", { \"color\": { \"primary\": \"var(--color-primary-0-1)\" } }];\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap index 9c73d68bd..86248e218 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-style_0-0", + property: "--color-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-secondary-style_0-1", + property: "--color-secondary-0-2", value: "green", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-medium-style_0-3", + property: "--space-medium-0-4", value: "8px", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-small-style_0-2", + property: "--space-small-0-3", value: "4px", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -73,5 +73,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const [lightTheme, vars] = [\"f0_lightTheme\", {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-style_0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-style_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-style_0-2)\",\n\t\t\"medium\": \"var(--space-medium-style_0-3)\"\n\t}\n}];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const lightTheme = \"theme-0-0\";\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-0-1)\",\n\t\t\"secondary\": \"var(--color-secondary-0-2)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-3)\",\n\t\t\"medium\": \"var(--space-medium-0-4)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap index b718003f7..60c87875b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-primary", + property: "--colors-primary-0-0", value: "blue", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-1", "theme-simple.css.ts", ), ), @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const lightTheme = \"theme-0-1\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap index a54ab0006..dc17f9fa9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-brand", + property: "--color-brand-0-0", value: "white", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-2", "theme-two-arg.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text", + property: "--color-text-0-1", value: "lightgray", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-2", "theme-two-arg.css.ts", ), ), @@ -39,5 +39,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const darkTheme = \"f0_darkTheme\";\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const darkTheme = \"theme-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap index c22f5b86f..3106b9d67 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--var-0,blue)", + value: "var(--var-0-0,blue)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "var(--var-1)", + value: "var(--var-0-1)", level: 0, selector: None, style_order: None, @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"--var-0\";\nexport const sizeVar = \"--var-1\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"var(--var-0-0)\";\nexport const sizeVar = \"var(--var-0-1)\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap index 5addf3a3d..bcad15fec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap @@ -41,5 +41,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\nexport const fadeIn = \"b\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const animated = \"b\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap index 59c8f8892..29fba7a3c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap @@ -3,6 +3,17 @@ source: libs/extractor/src/lib.rs expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" --- ToBTreeSet { - styles: {}, - code: "const StyledDiv = styled(\"div\")({ bg: \"red\" }, {});\n", + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = ({ style, className, ...rest }) =>

;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap index 86997e507..8c0401c2d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap @@ -3,6 +3,17 @@ source: libs/extractor/src/lib.rs expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})``\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" --- ToBTreeSet { - styles: {}, - code: "const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})``;\n", + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = (({ style, className, ...rest }) =>
)``;\n", } diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 3e2c88082..de37c9638 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -36,6 +36,21 @@ pub(super) fn is_unitless_key(key: &str) -> bool { || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) } +/// Whether a number on `key` stays bare in a library whose numbers mean pixels: +/// unitless keys, and Devup UI shorthands (`p`, `bg`, `mx`, ...), which keep +/// Devup UI's spacing scale because no such library defines them. +pub(super) fn keeps_bare_number(key: &str) -> bool { + if is_unitless_key(key) { + return true; + } + let mut properties = css::disassemble_property(key); + let kebab = to_kebab_case(key); + !(properties.len() == 1 + && properties.next().is_some_and(|property| { + property.trim_start_matches('-') == kebab.trim_start_matches('-') + })) +} + /// A JS number literal (`8`, `-8`, `(8)`), unlike a numeric string. pub(super) fn js_number_literal(value: &Expression) -> Option { match value { @@ -463,6 +478,112 @@ pub(super) fn merge_object_expressions<'a>( Some(Expression::new_object_expression(SPAN, props, ast_builder)) } +/// Several style arguments, or arrays of them, as vanilla-extract, Emotion and +/// styled-components compose them +pub(super) struct StyleArguments<'a> { + /// Classes composed as they are: other styles held in variables, strings + pub classes: Vec>, + /// The rule objects merged: later declarations replace earlier ones and + /// nested rules merge + pub rules: Expression<'a>, +} + +/// `None` for a single non-array argument, which needs no composing, or when a +/// part is neither a rule object nor a class (`null`/`undefined`/`false` parts +/// are skipped). +pub(super) fn style_arguments<'a>( + ast_builder: &AstBuilder<'a>, + arguments: &[Argument<'a>], +) -> Option> { + if let [argument] = arguments + && !matches!(argument, Argument::ArrayExpression(_)) + { + return None; + } + let mut objects = Vec::new(); + let mut classes = Vec::new(); + for argument in arguments { + collect_style_parts(argument.as_expression()?, &mut objects, &mut classes)?; + } + let mut merged = oxc_allocator::Vec::new_in(ast_builder); + for object in objects { + merge_style_properties(ast_builder, &mut merged, &object.properties); + } + Some(StyleArguments { + classes: classes + .into_iter() + .map(|class| class.clone_in(ast_builder.allocator())) + .collect(), + rules: Expression::new_object_expression(SPAN, merged, ast_builder), + }) +} + +fn collect_style_parts<'b, 'a>( + expression: &'b Expression<'a>, + objects: &mut Vec<&'b oxc_ast::ast::ObjectExpression<'a>>, + classes: &mut Vec<&'b Expression<'a>>, +) -> Option<()> { + match expression { + Expression::ObjectExpression(object) => objects.push(object), + Expression::ArrayExpression(array) => { + for element in &array.elements { + collect_style_parts(element.as_expression()?, objects, classes)?; + } + } + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => {} + Expression::Identifier(identifier) if identifier.name == "undefined" => {} + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::StringLiteral(_) + | Expression::TemplateLiteral(_) => classes.push(expression), + _ => return None, + } + Some(()) +} + +fn merge_style_properties<'a>( + ast_builder: &AstBuilder<'a>, + merged: &mut oxc_allocator::Vec<'a, ObjectPropertyKind<'a>>, + properties: &[ObjectPropertyKind<'a>], +) { + for property in properties { + let existing = match property { + ObjectPropertyKind::ObjectProperty(property) if !property.computed => { + get_str_by_property_key(&property.key).and_then(|key| { + merged.iter().position(|existing| { + matches!(existing, ObjectPropertyKind::ObjectProperty(existing) + if !existing.computed + && get_str_by_property_key(&existing.key).as_deref() == Some(key.as_ref())) + }) + }) + } + _ => None, + }; + let Some(index) = existing else { + merged.push(property.clone_in(ast_builder.allocator())); + continue; + }; + let previous = merged.remove(index); + if let ( + ObjectPropertyKind::ObjectProperty(previous), + ObjectPropertyKind::ObjectProperty(next), + ) = (&previous, property) + && let (Expression::ObjectExpression(before), Expression::ObjectExpression(after)) = + (&previous.value, &next.value) + { + let mut nested = oxc_allocator::Vec::new_in(ast_builder); + merge_style_properties(ast_builder, &mut nested, &before.properties); + merge_style_properties(ast_builder, &mut nested, &after.properties); + let mut combined = next.clone_in(ast_builder.allocator()); + combined.value = Expression::new_object_expression(SPAN, nested, ast_builder); + merged.push(ObjectPropertyKind::ObjectProperty(combined)); + } else { + merged.push(property.clone_in(ast_builder.allocator())); + } + } +} + /// Borrowing variant of [`get_string_by_property_key`]. /// /// For a `StaticIdentifier` key this returns `Cow::Borrowed`, avoiding the heap @@ -545,6 +666,61 @@ mod tests { assert!(is_unitless_key("--gap")); assert!(is_unitless_key("0")); assert!(!is_unitless_key("padding")); + assert!(keeps_bare_number("lineHeight")); + assert!(keeps_bare_number("p")); + assert!(keeps_bare_number("mx")); + assert!(!keeps_bare_number("padding")); + assert!(!keeps_bare_number("backgroundColor")); + assert!(!keeps_bare_number("WebkitTextStrokeWidth")); + } + + #[test] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_style_arguments() { + let allocator = Allocator::default(); + let ast_builder = AstBuilder::new(&allocator); + let compose = |source: &str| { + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + unreachable!() + }; + let Expression::CallExpression(call) = &statement.expression else { + unreachable!() + }; + style_arguments(&ast_builder, &call.arguments).map( + |StyleArguments { classes, rules }| { + ( + classes + .iter() + .map(expression_to_code) + .collect::>(), + expression_to_code(&rules), + ) + }, + ) + }; + assert_eq!(compose("f({ a: 1 })"), None); + assert_eq!(compose("f(x)"), None); + assert_eq!(compose("f(...x)"), None); + assert_eq!(compose("f([{ a: 1 }, [cond && x]])"), None); + assert_eq!( + compose( + "f([{ a: 1, b: { c: 1 }, d: { e: 1 } }, null, undefined, false, [{ a: 2, b: { f: 2 }, d: 3, [g]: 1, ...h, 'i': 1 }]], { [g]: 2, i: 2 })" + ), + Some(( + std::vec::Vec::new(), + "({a:2,b:{c:1,f:2},d:3,[g]:1,...h,[g]:2,i:2});".to_string() + )) + ); + assert_eq!( + compose("f([x, a.b, a[b], 'c', `d`, { e: 1 }])"), + Some(( + ["x;", "a.b;", "a[b];", "`c`;", "`d`;"] + .map(ToString::to_string) + .to_vec(), + "({e:1});".to_string() + )) + ); } #[test] diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 4d8732b73..2f7cc4262 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -3,11 +3,11 @@ //! This module uses `boa_engine` to execute vanilla-extract style files //! and extract style definitions for processing by the existing extract logic. -use crate::utils::is_unitless_key; +use crate::utils::keeps_bare_number; use boa_engine::{ - Context, JsArgs, JsResult, JsValue, NativeFunction, Source, js_string, - object::{FunctionObjectBuilder, ObjectInitializer}, - property::Attribute, + Context, JsArgs, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string, + object::{FunctionObjectBuilder, ObjectInitializer, builtins::JsArray}, + property::{Attribute, PropertyKey}, }; use css::file_map::get_file_num_by_filename; use oxc_allocator::Allocator; @@ -22,81 +22,38 @@ use std::cell::RefCell; use std::path::Path; use std::rc::Rc; -/// A single variant in styleVariants -#[derive(Debug, Clone)] -pub struct StyleVariant { - /// Base class reference (variable name), if composing - pub base: Option, - /// The style object JSON for this variant - pub styles_json: String, -} - -/// Style with export info -#[derive(Debug, Clone)] +/// A `style()` or `keyframes()` call +#[derive(Debug, Clone, Default)] pub struct StyleEntry { - /// The style object JSON + /// The rule object JSON pub json: String, - /// Whether this style is exported + /// Whether the variable holding it is exported pub exported: bool, - /// Base class references for composition (placeholder IDs like "__`style_0`__") + /// Styles composed before this one: placeholders, names once named pub bases: SmallVec<[String; 2]>, + /// Class names composed as they are + pub classes: SmallVec<[String; 1]>, } -/// Entry for `createGlobalTheme()` - CSS variables scoped to a selector -#[derive(Debug, Clone, Default)] -pub struct GlobalThemeEntry { - /// CSS selector (e.g., ":root") - pub selector: String, - /// CSS variables: Vec<(`var_name`, value)> e.g. [("--color-brand-0-0", "blue")] - pub css_vars: SmallVec<[(String, String); 8]>, - /// Serialized JS object with `var()` references - pub vars_object_json: String, - /// Whether this is exported - pub exported: bool, -} - -/// Entry for `createTheme()` - CSS variables scoped to a generated class -#[derive(Debug, Clone, Default)] -pub struct ThemeEntry { - /// CSS variables: Vec<(`var_name`, value)> e.g. [("--color-brand", "blue")] - pub css_vars: SmallVec<[(String, String); 8]>, - /// Whether this is exported - pub exported: bool, - /// For single-arg createTheme: the vars object JSON with `var()` references - /// Used to generate the second element of the returned array - pub vars_object_json: Option, - /// For single-arg createTheme: the name of the vars variable from [themeClass, vars] - pub vars_name: Option, - /// The unique generated class name (`file_prefix` + `variable_name`) - pub class_name: String, -} - -/// Collected style definitions from vanilla-extract API calls +/// What a vanilla-extract stylesheet defines #[derive(Debug, Default, Clone)] pub struct CollectedStyles { - /// `style()` calls: `variable_name` -> (json, exported) + /// `style()` calls, every `styleVariants()` variant included: name -> entry pub styles: FxHashMap, - /// `globalStyle()` calls: selector -> style object JSON - pub global_styles: Vec<(String, String)>, - /// `keyframes()` calls: `variable_name` -> (json, exported) + /// `keyframes()` calls: name -> entry pub keyframes: FxHashMap, - /// `createVar()` calls: `variable_name` -> (CSS variable string, exported) - pub vars: FxHashMap, - /// `fontFace()` calls: `placeholder_id` -> (`font_face` JSON, font-family name, exported) - pub font_faces: FxHashMap, - /// `styleVariants()` calls: `variable_name` -> (variants, exported) - pub style_variants: FxHashMap, bool)>, - /// `createContainer()` calls: `variable_name` -> (container name string, exported) - pub containers: FxHashMap, - /// `layer()` calls: `variable_name` -> (layer name string, exported) - pub layers: FxHashMap, - /// `createGlobalTheme()` calls: `variable_name` -> `GlobalThemeEntry` - pub global_themes: FxHashMap, - /// `createTheme()` calls: `variable_name` -> `ThemeEntry` - pub themes: FxHashMap, - /// Non-style constant exports: `variable_name` -> value (as code string) - pub constant_exports: FxHashMap, - /// `__style_N__` placeholder -> what it refers to, for placeholders left in + /// Global rules in call order: selector -> rule object JSON. Themes declare + /// their variables here, on their class or selector. + pub global_styles: Vec<(String, String)>, + /// `@font-face` rule objects, each naming its `fontFamily` + pub font_faces: Vec, + /// Layers in declaration order + pub layers: Vec, + /// `@property` rules registered by `createVar()` + pub property_rules: Vec, + /// Exported values other than styles and keyframes: name -> code + pub constant_exports: Vec<(String, String)>, + /// `__style_N__` placeholder -> what it stands for, for placeholders left in /// selectors and values pub references: FxHashMap, } @@ -104,279 +61,139 @@ pub struct CollectedStyles { /// Target of a placeholder that a selector or value interpolated #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Reference { - /// `style()`: `variable_name` and the unique class it gains when referenced + /// `style()`: its name and the unique class it gains when referenced Style { name: String, class_name: String }, - /// `keyframes()`: `variable_name`, resolved to the generated keyframes name + /// `keyframes()`: its name, resolved to the generated keyframes name Keyframes(String), - /// `createTheme()`: its generated class name - Class(String), - /// `fontFace()`: its generated font-family name - Font(String), } -/// Internal state for collecting styles during JS execution +/// State the mock API shares while a stylesheet runs #[derive(Default)] -struct StyleCollectorInner { +struct Collector { styles: CollectedStyles, - style_counter: usize, - font_counter: usize, - global_theme_counter: usize, + file_num: usize, + placeholders: usize, + identifiers: usize, } -type StyleCollector = Rc>; - -fn next_style_id(collector: &StyleCollector) -> String { - let mut inner = collector.borrow_mut(); - let id = format!("__style_{}__", inner.style_counter); - inner.style_counter += 1; - id +type StyleCollector = Rc>; + +impl Collector { + fn placeholder(&mut self) -> String { + let id = format!("__style_{}__", self.placeholders); + self.placeholders += 1; + id + } + + /// A name no other stylesheet produces: `debug_id` (or `fallback`) made an + /// identifier, then the file number and a per-file counter + fn identifier(&mut self, debug_id: Option, fallback: &str) -> String { + let base = debug_id + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| fallback.to_string()); + let mut name: String = base + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + name.insert(0, '_'); + } + let id = format!("{name}-{}-{}", self.file_num, self.identifiers); + self.identifiers += 1; + id + } } -fn next_font_id(collector: &StyleCollector) -> String { - let mut inner = collector.borrow_mut(); - let id = format!("__font_{}__", inner.font_counter); - inner.font_counter += 1; - id +fn js_str(value: &JsValue) -> Option { + value.as_string().map(|s| s.to_std_string_escaped()) } -fn next_global_theme_id(collector: &StyleCollector) -> String { - let mut inner = collector.borrow_mut(); - let id = format!("__global_theme_{}__", inner.global_theme_counter); - inner.global_theme_counter += 1; - id +fn to_text(value: &JsValue, context: &mut Context) -> JsResult { + Ok(value.to_string(context)?.to_std_string_escaped()) } -/// Parse font-face JSON and return list of (key, value) pairs -/// Input: {"src":"local(...)","fontWeight":400} -/// Output: vec![("src", "\"local(...)\""), ("fontWeight", "400")] -fn parse_font_face_json(json: &str) -> Vec<(String, String)> { - // Use serde_json to parse properly - let Ok(value) = serde_json::from_str::(json) else { - return Vec::new(); - }; - - let Some(obj) = value.as_object() else { - return Vec::new(); - }; - - obj.iter() - .map(|(k, v)| { - let val = match v { - serde_json::Value::String(s) => { - format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) - } - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - _ => v.to_string(), +/// String and index keys of `object`, in property order +fn own_keys(object: &JsObject, context: &mut Context) -> JsResult> { + Ok(object + .own_property_keys(context)? + .into_iter() + .filter_map(|key| { + let name = match &key { + PropertyKey::String(s) => s.to_std_string_escaped(), + PropertyKey::Index(i) => i.get().to_string(), + PropertyKey::Symbol(_) => return None, }; - (k.clone(), val) + Some((key, name)) }) - .collect() + .collect()) } -/// Recursively transform theme contract object to CSS `var()` references -/// Returns a new JS object with null leaves replaced by var(--path) -fn transform_contract_to_vars( - value: &JsValue, - ctx: &mut Context, - path: &mut Vec, -) -> JsValue { - if let Some(obj) = value.as_object() { - // Check if it's an array - if obj.is_array() { - return value.clone(); - } - - // It's an object - recursively transform each property - let new_obj = boa_engine::object::ObjectInitializer::new(ctx).build(); - - if let Ok(keys) = obj.own_property_keys(ctx) { - for key in keys { - let key_string = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let Ok(prop_value) = obj.get(js_string!(key_string.as_str()), ctx) { - path.push(key_string.clone()); - - let transformed = transform_contract_to_vars(&prop_value, ctx, path); - - path.pop(); - - let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx); - } - } - } - - JsValue::from(new_obj) - } else { - // Leaf value (should be null) - create CSS variable reference - let var_name = format!("--{}", path.join("-")); - JsValue::from(js_string!(format!("var({})", var_name))) - } +/// Elements of `value` when it is an array +fn array_items(value: &JsValue, context: &mut Context) -> JsResult>> { + let Some(array) = value.as_object().filter(JsObject::is_array) else { + return Ok(None); + }; + let length = array.get(js_string!("length"), context)?.to_u32(context)?; + (0..length) + .map(|index| array.get(index, context)) + .collect::>>() + .map(Some) } -/// Extract CSS variable assignments by matching contract vars with values -fn extract_theme_vars( - contract: &JsValue, - values: &JsValue, - ctx: &mut Context, - css_vars: &mut SmallVec<[(String, String); 8]>, - path: &mut Vec, -) { - if let (Some(contract_obj), Some(values_obj)) = (contract.as_object(), values.as_object()) { - // Both are objects - recurse into properties - if let Ok(keys) = contract_obj.own_property_keys(ctx) { - for key in keys { - let key_string = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let (Ok(contract_prop), Ok(value_prop)) = ( - contract_obj.get(js_string!(key_string.as_str()), ctx), - values_obj.get(js_string!(key_string.as_str()), ctx), - ) { - path.push(key_string); - - extract_theme_vars(&contract_prop, &value_prop, ctx, css_vars, path); - - path.pop(); - } - } - } - } else if let Some(contract_str) = contract.as_string() { - // Contract leaf is a var(--name) string - let contract_str = contract_str.to_std_string_escaped(); - if contract_str.starts_with("var(") && contract_str.ends_with(')') { - // Extract var name from var(--name) - let var_name = &contract_str[4..contract_str.len() - 1]; - - // Get the value - let value_str = values - .as_string() - .map(|s| s.to_std_string_escaped()) - .or_else(|| { - values - .to_string(ctx) - .ok() - .map(|s| s.to_std_string_escaped()) - }) - .unwrap_or_default(); - - css_vars.push((var_name.to_string(), value_str)); - } - } +/// The custom property `var(--x)` reads +fn var_name(reference: &str) -> &str { + reference + .strip_prefix("var(") + .and_then(|name| name.strip_suffix(')')) + .unwrap_or(reference) } -/// Recursively transform theme object to CSS `var()` references -/// Returns a new JS object with the same structure but leaf values replaced with var(--path) -fn transform_theme_to_vars( - value: &JsValue, - ctx: &mut Context, - placeholder_id: &str, - css_vars: &mut SmallVec<[(String, String); 8]>, - var_counter: &mut usize, - path: &mut Vec, -) -> JsValue { - if let Some(obj) = value.as_object() { - // Check if it's an array (shouldn't happen in theme objects, but handle it) - if obj.is_array() { - return value.clone(); - } - - // It's an object - recursively transform each property - let new_obj = boa_engine::object::ObjectInitializer::new(ctx).build(); - - // Get own property keys - if let Ok(keys) = obj.own_property_keys(ctx) { - for key in keys { - // Convert PropertyKey to string - let key_string = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let Ok(prop_value) = obj.get(js_string!(key_string.as_str()), ctx) { - path.push(key_string.clone()); - - let transformed = transform_theme_to_vars( - &prop_value, - ctx, - placeholder_id, - css_vars, - var_counter, - path, - ); - - path.pop(); - - let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx); - } - } - } - - JsValue::from(new_obj) - } else { - // Leaf value - create CSS variable - let var_name = format!( - "--{}-{}-{}", - path.join("-"), - placeholder_id.trim_matches('_').replace("__", "-"), - var_counter - ); - *var_counter += 1; - - // Get the actual value as string - let value_str = value - .as_string() - .map(|s| s.to_std_string_escaped()) - .or_else(|| value.to_string(ctx).ok().map(|s| s.to_std_string_escaped())) - .unwrap_or_default(); - - css_vars.push((var_name.clone(), value_str)); +fn json_string(text: &str) -> String { + serde_json::Value::String(text.to_string()).to_string() +} - // Return var(--name) - JsValue::from(js_string!(format!("var({})", var_name))) - } +/// `JSON.stringify(value, replacer)`, `None` when it produces no string +fn stringify(value: &JsValue, replacer: JsValue, context: &mut Context) -> Option { + let json = context.intrinsics().objects().json(); + let stringify = json.get(js_string!("stringify"), context).ok()?; + stringify + .as_callable()? + .call(&JsValue::undefined(), &[value.clone(), replacer], context) + .ok()? + .as_string() + .map(|s| s.to_std_string_escaped()) } -/// Convert `JsValue` to JSON string using JSON.stringify +/// Serialize `value` as it is fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { - // Use JSON.stringify to convert the value - let json_obj = context.intrinsics().objects().json(); - let stringify_result = json_obj.get(js_string!("stringify"), context); - - if let Ok(stringify) = stringify_result - && let Some(callable) = stringify.as_callable() - && let Ok(result) = - callable.call(&JsValue::undefined(), std::slice::from_ref(value), context) - && let Some(s) = result.as_string() - { - return s.to_std_string_escaped(); - } - - // Fallback: simple conversion using boa_engine 0.21 API - match value.variant() { - boa_engine::value::JsVariant::String(str) => format!("\"{}\"", str.to_std_string_escaped()), - boa_engine::value::JsVariant::Null => "null".to_string(), - boa_engine::value::JsVariant::Undefined => "undefined".to_string(), - _ => "{}".to_string(), - } + stringify(value, JsValue::undefined(), context).unwrap_or_else(|| "{}".to_string()) } /// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, -/// so they are not read as Devup UI's spacing scale. +/// so they are not read as Devup UI's spacing scale, and keying `vars` by the +/// custom properties their `var()` references name. fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - let key = args.get_or_undefined(0).to_string(context)?; + let key = to_text(args.get_or_undefined(0), context)?; let value = args.get_or_undefined(1); + if key == "vars" + && let Some(vars) = value.as_object() + { + let declared = ObjectInitializer::new(context).build(); + for (property, name) in own_keys(&vars, context)? { + let value = vars.get(property, context)?; + declared.set(js_string!(var_name(&name)), value, false, context)?; + } + return Ok(declared.into()); + } Ok(match value.as_number() { - Some(number) if number != 0.0 && !is_unitless_key(&key.to_std_string_escaped()) => { - JsValue::from(js_string!(format!("{number}px"))) + Some(number) if number != 0.0 && !keeps_bare_number(&key) => { + js_string!(format!("{number}px")).into() } _ => value.clone(), }) @@ -387,342 +204,249 @@ fn style_to_json(value: &JsValue, context: &mut Context) -> String { let realm = context.realm().clone(); let replacer = FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); - let json = context.intrinsics().objects().json(); - if let Ok(stringify) = json.get(js_string!("stringify"), context) - && let Some(callable) = stringify.as_callable() - && let Ok(result) = callable.call( - &JsValue::undefined(), - &[value.clone(), replacer.into()], - context, - ) - && let Some(s) = result.as_string() - { - return s.to_std_string_escaped(); - } - js_value_to_json(value, context) + stringify(value, replacer.into(), context).unwrap_or_else(|| "{}".to_string()) } + /// Execute vanilla-extract style file and collect styles pub fn execute_vanilla_extract( code: &str, package: &str, filename: &str, ) -> Result { - let collector: StyleCollector = Rc::new(RefCell::new(StyleCollectorInner::default())); let file_num = get_file_num_by_filename(filename); - + let collector: StyleCollector = Rc::new(RefCell::new(Collector { + file_num, + ..Collector::default() + })); let mut context = Context::default(); + register_vanilla_extract_apis(&mut context, &collector)?; - // Create the mock module object - register_vanilla_extract_apis(&mut context, collector.clone(), package, file_num)?; - - // Preprocess code: convert TypeScript to JavaScript using Oxc Transformer - let js_code = preprocess_typescript(code, package); - - // Extract variable names from the original code before execution - let var_names = extract_var_names(code, package); - - // Execute the code context - .eval(Source::from_bytes(js_code.as_bytes())) + .eval(Source::from_bytes( + preprocess_typescript(code, package).as_bytes(), + )) .map_err(|e| format!("JS execution error: {e}"))?; - // Map placeholder IDs back to original variable names - let mut result = std::mem::take(&mut collector.borrow_mut().styles); - remap_style_names(&mut result, &var_names, &mut context, file_num); - - Ok(result) + let mut collected = std::mem::take(&mut collector.borrow_mut().styles); + name_entries( + &mut collected, + &top_level_bindings(code), + &mut context, + file_num, + ); + Ok(collected) } -/// Info about a variable declaration -#[derive(Debug, Clone)] -enum VarInfo { - /// A style API call (style, keyframes, createContainer, etc.) - StyleApi { exported: bool, api: StyleApi }, - /// A regular constant export with its original code - Constant(String), - /// The vars object from createTheme array destructuring [themeClass, vars] - ThemeVars, +/// A name a top-level variable declaration of the stylesheet binds +struct Binding { + name: String, + exported: bool, + /// Initializer source, when the declaration binds this name alone + init: Option, } -/// Extract all variable names and their info from the original code -/// Returns (`style_api_vars`, `exported_constants`) -fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { +fn top_level_bindings(code: &str) -> Vec { let allocator = Allocator::default(); - let source_type = SourceType::ts(); - let ret = Parser::new(&allocator, code, source_type).parse(); - - let mut vars = Vec::new(); - let mut collect_decl = |decl: &oxc_ast::ast::VariableDeclarator<'_>, exported: bool| { - let Some(init) = &decl.init else { - return; + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let mut bindings = Vec::new(); + for statement in &program.body { + let (declaration, exported) = match statement { + oxc_ast::ast::Statement::ExportDeclaration(export) => match &export.declaration { + oxc_ast::ast::Declaration::VariableDeclaration(declaration) => (declaration, true), + _ => continue, + }, + oxc_ast::ast::Statement::VariableDeclaration(declaration) => (declaration, false), + _ => continue, }; - - // Check for array destructuring: const [themeClass, vars] = createTheme(...) - if let oxc_ast::ast::BindingPattern::ArrayPattern(array_pat) = &decl.id { - if let Some(api) = style_api_call(init) { - // First element is the theme class - if let Some(Some(first)) = array_pat.elements.first() - && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = first - { - vars.push((id.name.to_string(), VarInfo::StyleApi { exported, api })); - } - // Second element is the vars object - mark as ThemeVars - if let Some(Some(second)) = array_pat.elements.get(1) - && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = second - { - vars.push((id.name.to_string(), VarInfo::ThemeVars)); - } - } - } else if let Some(name) = decl.id.get_identifier_name() { - if let Some(api) = style_api_call(init) { - vars.push((name.to_string(), VarInfo::StyleApi { exported, api })); - } else if exported { - // Extract the original init expression using span - let span = init.span(); - let init_code = &code[span.start as usize..span.end as usize]; - vars.push((name.to_string(), VarInfo::Constant(init_code.to_string()))); - } - } - }; - - for stmt in &ret.program.body { - match stmt { - // Exported variable declarations - oxc_ast::ast::Statement::ExportDeclaration(export) => { - if let oxc_ast::ast::Declaration::VariableDeclaration(var_decl) = - &export.declaration - { - for decl in &var_decl.declarations { - collect_decl(decl, true); - } - } + for declarator in &declaration.declarations { + let init = declarator + .init + .as_ref() + .filter(|_| declarator.id.get_identifier_name().is_some()) + .map(|init| code[init.span().start as usize..init.span().end as usize].to_string()); + for identifier in declarator.id.get_binding_identifiers() { + bindings.push(Binding { + name: identifier.name.to_string(), + exported, + init: init.clone(), + }); } - // Non-exported variable declarations - oxc_ast::ast::Statement::VariableDeclaration(var_decl) => { - for decl in &var_decl.declarations { - collect_decl(decl, false); - } - } - _ => {} } } - - vars -} - -/// Which placeholder counter a style API call draws from -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StyleApi { - FontFace, - GlobalTheme, - Other, + bindings } -/// The style API an expression calls (style, keyframes, styleVariants, etc.) -fn style_api_call(expr: &oxc_ast::ast::Expression) -> Option { - if let oxc_ast::ast::Expression::CallExpression(call) = expr - && let oxc_ast::ast::Expression::Identifier(id) = &call.callee - { - return match id.name.as_str() { - "fontFace" => Some(StyleApi::FontFace), - "createGlobalTheme" => Some(StyleApi::GlobalTheme), - "style" | "keyframes" | "styleVariants" | "createVar" | "createContainer" | "layer" - | "createTheme" => Some(StyleApi::Other), - _ => None, - }; - } - None +fn placeholder_index(id: &str) -> usize { + id.trim_start_matches("__style_") + .trim_end_matches("__") + .parse() + .unwrap_or_default() } -/// Remap style placeholder IDs to original variable names -fn remap_style_names( +/// Name every style and keyframes after the variable holding it (`_veN` when +/// none does), and turn the other exported values into code. +/// +/// Names come from the values the variables hold after the stylesheet ran, so +/// calls nested in objects, arrays or helpers do not shift them. +fn name_entries( collected: &mut CollectedStyles, - vars: &[(String, VarInfo)], - _context: &mut Context, + bindings: &[Binding], + context: &mut Context, file_num: usize, ) { - // Generate a file-based prefix for unique class names - // e.g., file_num 0 -> "f0" - let file_prefix = format!("f{file_num}"); - // Build mapping from placeholder ID to original name - // The order of style() calls matches the order of variable declarations - let mut placeholder_to_name: FxHashMap = FxHashMap::default(); - let mut new_styles = FxHashMap::default(); - let mut new_keyframes = FxHashMap::default(); - let mut new_style_variants = FxHashMap::default(); - let mut new_vars = FxHashMap::default(); - let mut new_containers = FxHashMap::default(); - let mut new_layers = FxHashMap::default(); - let mut new_font_faces = FxHashMap::default(); - let mut new_global_themes = FxHashMap::default(); - let mut new_themes = FxHashMap::default(); - let mut style_idx = 0; - let mut font_idx = 0; - let mut global_theme_idx = 0; - // Track the last processed theme's vars_object_json for ThemeVars handling - let mut last_theme_vars_json: Option = None; - - // First pass: take ownership of old entries (avoids drain+collect overhead) - let mut old_styles = std::mem::take(&mut collected.styles); - let mut old_keyframes = std::mem::take(&mut collected.keyframes); - let mut old_style_variants = std::mem::take(&mut collected.style_variants); - let mut old_vars = std::mem::take(&mut collected.vars); - let mut old_containers = std::mem::take(&mut collected.containers); - let mut old_layers = std::mem::take(&mut collected.layers); - let mut old_font_faces = std::mem::take(&mut collected.font_faces); - let mut old_global_themes = std::mem::take(&mut collected.global_themes); - let mut old_themes = std::mem::take(&mut collected.themes); - - for (name, info) in vars { - match info { - VarInfo::StyleApi { - exported, - api: StyleApi::FontFace, - } => { - let font_placeholder = format!("__font_{font_idx}__"); - if let Some((json, font_family, _)) = old_font_faces.remove(&font_placeholder) { - collected - .references - .insert(font_placeholder, Reference::Font(font_family.clone())); - new_font_faces.insert(name.clone(), (json, font_family, *exported)); - font_idx += 1; - } - } - VarInfo::StyleApi { - exported, - api: StyleApi::GlobalTheme, - } => { - let global_theme_placeholder = format!("__global_theme_{global_theme_idx}__"); - if let Some(mut entry) = old_global_themes.remove(&global_theme_placeholder) { - entry.exported = *exported; - new_global_themes.insert(name.clone(), entry); - global_theme_idx += 1; - } - } - VarInfo::StyleApi { - exported, - api: StyleApi::Other, - } => { - let placeholder = format!("__style_{style_idx}__"); - placeholder_to_name.insert(placeholder.clone(), name.clone()); - - if let Some(mut entry) = old_styles.remove(&placeholder) { - entry.exported = *exported; - new_styles.insert(name.clone(), entry); - collected.references.insert( - placeholder, - Reference::Style { - name: name.clone(), - class_name: format!("{file_prefix}_{name}"), - }, - ); - style_idx += 1; - } else if let Some(mut entry) = old_keyframes.remove(&placeholder) { - entry.exported = *exported; - new_keyframes.insert(name.clone(), entry); - collected - .references - .insert(placeholder, Reference::Keyframes(name.clone())); - style_idx += 1; - } else if let Some((variants, _)) = old_style_variants.remove(&placeholder) { - new_style_variants.insert(name.clone(), (variants, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_vars.remove(&placeholder) { - new_vars.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_containers.remove(&placeholder) { - new_containers.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_layers.remove(&placeholder) { - new_layers.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some(mut entry) = old_themes.remove(&placeholder) { - // Track this theme name for the next ThemeVars entry - if entry.vars_object_json.is_some() { - last_theme_vars_json = Some(name.clone()); - } + let values: Vec<(&Binding, JsValue)> = bindings + .iter() + .filter_map(|binding| { + context + .eval(Source::from_bytes(binding.name.as_bytes())) + .ok() + .map(|value| (binding, value)) + }) + .collect(); - // Generate unique class name: file_prefix + variable_name - let class_name = format!("{file_prefix}_{name}"); - - // Add CSS variables to global_styles with class selector - if !entry.css_vars.is_empty() { - let vars_json = format!( - "{{ {} }}", - entry - .css_vars - .iter() - .map(|(var_name, value)| format!("\"{var_name}\": \"{value}\"")) - .collect::>() - .join(", ") - ); - collected - .global_styles - .push((format!(".{class_name}"), vars_json)); - } + let mut names: FxHashMap = FxHashMap::default(); + for (binding, value) in &values { + if let Some(id) = js_str(value) + && !names.contains_key(&id) + && let Some(entry) = collected + .styles + .get_mut(&id) + .or_else(|| collected.keyframes.get_mut(&id)) + { + entry.exported = binding.exported; + names.insert(id, binding.name.clone()); + } + } - collected - .references - .insert(placeholder, Reference::Class(class_name.clone())); - entry.exported = *exported; - entry.vars_name = None; - entry.class_name = class_name; - new_themes.insert(name.clone(), entry); - style_idx += 1; - } - } - VarInfo::ThemeVars => { - // This is the vars object from [themeClass, vars] = createTheme(...) - // Set vars_name on the theme we just processed - if let Some(theme_name) = last_theme_vars_json.take() - && let Some(theme_entry) = new_themes.get_mut(&theme_name) - { - theme_entry.vars_name = Some(name.clone()); - } - } - VarInfo::Constant(code) => { - collected - .constant_exports - .insert(name.clone(), code.clone()); - } + let declared: FxHashSet<&str> = bindings + .iter() + .map(|binding| binding.name.as_str()) + .collect(); + let mut anonymous: Vec = collected + .styles + .keys() + .chain(collected.keyframes.keys()) + .filter(|id| !names.contains_key(*id)) + .cloned() + .collect(); + anonymous.sort_by_key(|id| placeholder_index(id)); + for id in anonymous { + let mut name = format!("_ve{}", placeholder_index(&id)); + while declared.contains(name.as_str()) { + name.push('_'); } + names.insert(id, name); } - // Remap base references in styleVariants - for (variants, _) in new_style_variants.values_mut() { - for variant in variants.values_mut() { - if let Some(base) = &variant.base - && let Some(name) = placeholder_to_name.get(base) - { - variant.base = Some(name.clone()); - } + for (binding, value) in &values { + let names_entry = js_str(value).is_some_and(|id| names.get(&id) == Some(&binding.name)); + if binding.exported + && !names_entry + && let Some(code) = value_to_code(value, context, &names, &mut Vec::new()) + .or_else(|| binding.init.clone()) + { + collected + .constant_exports + .push((binding.name.clone(), code)); } } - // Remap base references in styles (for composition) - for entry in new_styles.values_mut() { - let old_bases = std::mem::take(&mut entry.bases); - entry.bases = old_bases - .into_iter() - .map(|b| { - if let Some(name) = placeholder_to_name.get(&b) { - name.clone() - } else { - b + for (id, name) in &names { + let reference = if let Some(mut entry) = collected.styles.remove(id) { + for base in &mut entry.bases { + if let Some(base_name) = names.get(base.as_str()) { + base_name.clone_into(base); } - }) - .collect(); + } + collected.styles.insert(name.clone(), entry); + Reference::Style { + name: name.clone(), + class_name: format!("f{file_num}_{name}"), + } + } else { + if let Some(entry) = collected.keyframes.remove(id) { + collected.keyframes.insert(name.clone(), entry); + } + Reference::Keyframes(name.clone()) + }; + collected.references.insert(id.clone(), reference); + } +} + +fn is_plain_object(object: &JsObject, context: &Context) -> bool { + object.prototype().is_none_or(|prototype| { + JsObject::equals( + &prototype, + &context.intrinsics().constructors().object().prototype(), + ) + }) +} + +/// JavaScript for a data `value`, placeholders turned into the names of their +/// styles; `None` for functions, class instances, symbols and cycles +fn value_to_code( + value: &JsValue, + context: &mut Context, + names: &FxHashMap, + seen: &mut Vec, +) -> Option { + if let Some(text) = js_str(value) { + return Some(string_code(&text, names)); + } + if value.is_null_or_undefined() || value.is_number() || value.as_boolean().is_some() { + return to_text(value, context).ok(); + } + let object = value.as_object()?; + if object.is_callable() + || seen + .iter() + .any(|visited| JsObject::equals(visited, &object)) + { + return None; } + seen.push(object.clone()); + let mut parts = Vec::new(); + let code = if let Some(items) = array_items(value, context).ok()? { + for item in &items { + parts.push(value_to_code(item, context, names, seen)?); + } + format!("[{}]", parts.join(", ")) + } else if is_plain_object(&object, context) { + for (key, name) in own_keys(&object, context).ok()? { + let item = object.get(key, context).ok()?; + parts.push(format!( + "{}: {}", + json_string(&name), + value_to_code(&item, context, names, seen)? + )); + } + if parts.is_empty() { + "{}".to_string() + } else { + format!("{{ {} }}", parts.join(", ")) + } + } else { + return None; + }; + seen.pop(); + Some(code) +} - collected.styles = new_styles; - collected.keyframes = new_keyframes; - collected.style_variants = new_style_variants; - collected.vars = new_vars; - collected.containers = new_containers; - collected.layers = new_layers; - collected.font_faces = new_font_faces; - collected.global_themes = new_global_themes; - collected.themes = new_themes; +/// A string literal, or a template literal when it interpolates placeholders +fn string_code(text: &str, names: &FxHashMap) -> String { + if let Some(name) = names.get(text) { + return name.clone(); + } + let escaped = text + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${"); + replace_placeholders(&escaped, |token, _| { + names.get(token).map(|name| format!("${{{name}}}")) + }) + .map_or_else(|| json_string(text), |template| format!("`{template}`")) } /// Replace every `__name__` token that `lookup` resolves; `None` when nothing @@ -757,6 +481,7 @@ fn replace_placeholders( output }) } + /// Convert TypeScript to JavaScript using Oxc Transformer and replace imports fn preprocess_typescript(code: &str, package: &str) -> String { let allocator = Allocator::default(); @@ -778,41 +503,52 @@ fn preprocess_typescript(code: &str, package: &str) -> String { // Generate JavaScript let js_code = Codegen::new().build(&program).code; - // Replace import from package with our mock object destructuring - // e.g., import { style } from '@devup-ui/react' -> const { style } = __vanilla_extract__; - // Note: Import aliases (like @vanilla-extract/css) are already transformed by import_alias_visit + // Bind imports of the package to the mock object instead, e.g. + // `import { style } from '@devup-ui/react'` -> `const { style } = __vanilla_extract__;` + // Import aliases (like @vanilla-extract/css) are already transformed by import_alias_visit let import_patterns = [format!("from \"{package}\""), format!("from '{package}'")]; - - // Process all import patterns (multiple imports may exist) let mut transformed = String::with_capacity(js_code.len()); for (idx, line) in js_code.lines().enumerate() { if idx > 0 { transformed.push('\n'); } - let mut matched = false; - for pattern in &import_patterns { - if line.contains(pattern) { - // Extract imported names from: import { a, b, c } from 'package' - if let Some(start) = line.find('{') - && let Some(end) = line.find('}') - { - let imports = &line[start + 1..end]; - transformed.push_str("const {"); - transformed.push_str(imports); - transformed.push_str("} = __vanilla_extract__;"); - matched = true; - break; - } - } - } - if !matched { + if import_patterns + .iter() + .any(|pattern| line.contains(pattern.as_str())) + && let Some(binding) = mock_binding(line) + { + transformed.push_str("const "); + transformed.push_str(&binding); + transformed.push_str(" = __vanilla_extract__;"); + } else { transformed.push_str(strip_export_keyword(line)); } } transformed } +/// What an import of the package binds: `{ a, b: c }` for named imports, the +/// namespace for `import * as ns` +fn mock_binding(line: &str) -> Option { + if let (Some(start), Some(end)) = (line.find('{'), line.find('}')) { + let names: Vec = line[start + 1..end] + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.replacen(" as ", ": ", 1)) + .collect(); + return Some(format!("{{ {} }}", names.join(", "))); + } + line.strip_prefix("import * as ") + .and_then(|rest| rest.split_whitespace().next()) + .map(ToString::to_string) +} + fn strip_export_keyword(line: &str) -> &str { + // The transformer marks a module whose imports it all removed with `export {};` + if line == "export {};" { + return ""; + } line.strip_prefix("export ").map_or(line, |rest| { if rest.starts_with("const ") || rest.starts_with("let ") @@ -826,394 +562,581 @@ fn strip_export_keyword(line: &str) -> &str { }) } +type Api = fn(&StyleCollector, &[JsValue], &mut Context) -> JsResult; + +fn api(collector: &StyleCollector, function: Api) -> NativeFunction { + let collector = collector.clone(); + // SAFETY: the closure captures only an `Rc>`, which holds no + // garbage-collected value, and a stylesheet runs on a single thread + unsafe { + NativeFunction::from_closure(move |_this, args, context| { + function(&collector, args, context) + }) + } +} + /// Register vanilla-extract mock APIs in the JS context fn register_vanilla_extract_apis( context: &mut Context, - collector: StyleCollector, - _package: &str, - file_num: usize, + collector: &StyleCollector, ) -> Result<(), String> { - // style() function - let collector_style = collector.clone(); - // SAFETY: The closure only captures Rc> which is safe to use in single-threaded JS context - let style_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let style_obj = args.get_or_undefined(0); - let id = next_style_id(&collector_style); - - // Check if argument is an array (composition syntax) - let (json, bases) = if let Some(obj) = style_obj.as_object() - && let Ok(length_val) = obj.get(js_string!("length"), ctx) - && let Some(len) = length_val.as_number() - { - // It's an array - handle composition - let len = len as u32; - let mut base_classes = SmallVec::new(); - let mut merged_styles = String::from("{"); - let mut first_style = true; - - for i in 0..len { - if let Ok(elem) = obj.get(i, ctx) { - if let Some(base_str) = elem.as_string() { - // It's a base class reference (string) - base_classes.push(base_str.to_std_string_escaped()); - } else if elem.is_object() { - // It's a style object - merge it - let elem_json = style_to_json(&elem, ctx); - // Strip outer braces and merge - let inner = elem_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - if !first_style { - merged_styles.push(','); - } - merged_styles.push_str(inner); - first_style = false; - } - } - } - } - merged_styles.push('}'); - (merged_styles, base_classes) - } else { - // No length property, just a style object - (style_to_json(style_obj, ctx), SmallVec::new()) - }; - collector_style.borrow_mut().styles.styles.insert( - id.clone(), - StyleEntry { - json, - exported: false, // Will be updated in remap_style_names - bases, - }, - ); - - Ok(JsValue::from(js_string!(id))) - }) - }; - - // globalStyle() function - let collector_global = collector.clone(); - let global_style_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let selector = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - let style_obj = args.get_or_undefined(1); - let json = style_to_json(style_obj, ctx); - - collector_global - .borrow_mut() - .styles - .global_styles - .push((selector, json)); + let apis = [ + ("style", api(collector, style), 1), + ("globalStyle", api(collector, global_style), 2), + ("styleVariants", api(collector, style_variants), 1), + ( + "keyframes", + api(collector, |collector, args, context| { + Ok(keyframes(collector, args, context)) + }), + 1, + ), + ("fontFace", api(collector, font_face), 1), + ("globalFontFace", api(collector, global_font_face), 2), + ("createVar", api(collector, create_var), 0), + ("fallbackVar", NativeFunction::from_fn_ptr(fallback_var), 2), + ( + "createContainer", + api(collector, |collector, args, _context| { + Ok(create_container(collector, args)) + }), + 0, + ), + ("layer", api(collector, layer), 0), + ("globalLayer", api(collector, global_layer), 1), + ( + "createThemeContract", + api(collector, create_theme_contract), + 1, + ), + ( + "createGlobalThemeContract", + NativeFunction::from_fn_ptr(create_global_theme_contract), + 1, + ), + ( + "assignVars", + NativeFunction::from_fn_ptr(assign_vars_api), + 2, + ), + ("createTheme", api(collector, create_theme), 1), + ("createGlobalTheme", api(collector, create_global_theme), 2), + ]; + let mut builder = ObjectInitializer::new(context); + for (name, function, length) in apis { + builder.function(function, JsString::from(name), length); + } + let mock = builder.build(); + context + .register_global_property(js_string!("__vanilla_extract__"), mock, Attribute::all()) + .map_err(|e| format!("Failed to register __vanilla_extract__: {e}")) +} - Ok(JsValue::undefined()) - }) - }; +fn style(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsResult { + let id = register_style(collector, args.get_or_undefined(0), context)?; + Ok(js_string!(id).into()) +} - // keyframes() function - let collector_keyframes = collector.clone(); - let keyframes_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let keyframes_obj = args.get_or_undefined(0); - let json = style_to_json(keyframes_obj, ctx); - let id = next_style_id(&collector_keyframes); - - collector_keyframes.borrow_mut().styles.keyframes.insert( - id.clone(), - StyleEntry { - json, - exported: false, - bases: SmallVec::new(), - }, - ); - - Ok(JsValue::from(js_string!(id))) - }) - }; +fn register_style( + collector: &StyleCollector, + rule: &JsValue, + context: &mut Context, +) -> JsResult { + let mut entry = StyleEntry::default(); + let mut rules = Vec::new(); + compose(rule, &mut entry, &mut rules, context)?; + entry.json = format!("{{{}}}", rules.join(",")); + let mut collector = collector.borrow_mut(); + let id = collector.placeholder(); + collector.styles.styles.insert(id.clone(), entry); + Ok(id) +} - // createVar() function - // Returns CSS custom property name like "--var-0", not wrapped in var() - // When used as a key in vars: {[colorVar]: 'blue'}, it becomes the property name - // When used as a value, the extraction logic will wrap it in var() - let collector_var = collector.clone(); - let create_var_fn = unsafe { - NativeFunction::from_closure(move |_this, _args, _ctx| { - let id = next_style_id(&collector_var); - let var_name = format!("--var-{}", collector_var.borrow().styles.vars.len()); - collector_var - .borrow_mut() - .styles - .vars - .insert(id, (var_name.clone(), false)); - // Return just the CSS custom property name, without var() wrapper - Ok(JsValue::from(js_string!(var_name))) - }) - }; +fn is_placeholder(token: &str) -> bool { + token.starts_with("__style_") && token.ends_with("__") +} - // fontFace() function - // Returns a placeholder ID that will be replaced with generated font-family name - let collector_font = collector.clone(); - let font_face_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let font_obj = args.get_or_undefined(0); - let json = js_value_to_json(font_obj, ctx); - let id = next_font_id(&collector_font); - - // Generate a unique font-family name for this font - // Include file_num to ensure uniqueness across different files - let font_family = format!( - "__devup_font_{}_{}", - file_num, - collector_font.borrow().font_counter - 1 - ); - - collector_font - .borrow_mut() - .styles - .font_faces - .insert(id.clone(), (json, font_family, false)); +/// Sort a composition list into the styles and classes it composes and the +/// inner JSON of its rules +fn compose( + value: &JsValue, + entry: &mut StyleEntry, + rules: &mut Vec, + context: &mut Context, +) -> JsResult<()> { + if let Some(items) = array_items(value, context)? { + for item in &items { + compose(item, entry, rules, context)?; + } + } else if let Some(classes) = js_str(value) { + for class in classes.split_whitespace() { + if is_placeholder(class) { + entry.bases.push(class.to_string()); + } else { + entry.classes.push(class.to_string()); + } + } + } else if value.is_object() { + let json = style_to_json(value, context); + let inner = inner_json(&json); + if !inner.is_empty() { + rules.push(inner.to_string()); + } + } + Ok(()) +} - // Return the placeholder ID - will be replaced in code generation - Ok(JsValue::from(js_string!(id))) - }) - }; +fn global_style( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let selector = to_text(args.get_or_undefined(0), context)?; + let json = style_to_json(args.get_or_undefined(1), context); + collector + .borrow_mut() + .styles + .global_styles + .push((selector, json)); + Ok(JsValue::undefined()) +} - // styleVariants() function - let collector_variants = collector.clone(); - let style_variants_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let variants_obj = args.get_or_undefined(0); - let variants = parse_style_variants(variants_obj, ctx); - let id = next_style_id(&collector_variants); +/// `styleVariants(variants, map?)`: a style per key, of the value or of what +/// `map(value, key)` returns +fn style_variants( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let variants = ObjectInitializer::new(context).build(); + let map = args.get_or_undefined(1).as_callable(); + if let Some(data) = args.get_or_undefined(0).as_object() { + for (key, name) in own_keys(&data, context)? { + let value = data.get(key, context)?; + let rule = match &map { + Some(map) => map.call( + &JsValue::undefined(), + &[value, js_string!(name.as_str()).into()], + context, + )?, + None => value, + }; + let id = register_style(collector, &rule, context)?; + variants.set(js_string!(name), js_string!(id), false, context)?; + } + } + Ok(variants.into()) +} - collector_variants - .borrow_mut() - .styles - .style_variants - .insert(id.clone(), (variants, false)); +fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { + let json = style_to_json(args.get_or_undefined(0), context); + let mut collector = collector.borrow_mut(); + let id = collector.placeholder(); + collector.styles.keyframes.insert( + id.clone(), + StyleEntry { + json, + ..StyleEntry::default() + }, + ); + js_string!(id).into() +} - // Return an object placeholder - the actual object will be built in code generation - Ok(JsValue::from(js_string!(id))) - }) - }; +/// `fontFace(rule, debugId?)`: a generated family for one rule or a list of them +fn font_face( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let family = collector + .borrow_mut() + .identifier(js_str(args.get_or_undefined(1)), "font"); + declare_font_faces(collector, &family, args.get_or_undefined(0), context)?; + Ok(js_string!(family).into()) +} - // fallbackVar() function - returns var(--x, fallback) format - let fallback_var_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let var_value = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - let fallback = args - .get_or_undefined(1) - .to_string(ctx)? - .to_std_string_escaped(); - - // var_value is now just "--var-0" (CSS custom property name) - // Return var(--var-0, fallback) - let result = format!("var({var_value}, {fallback})"); - Ok(JsValue::from(js_string!(result))) - }) - }; +fn global_font_face( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let family = to_text(args.get_or_undefined(0), context)?; + declare_font_faces(collector, &family, args.get_or_undefined(1), context)?; + Ok(JsValue::undefined()) +} - // createThemeContract() function - transforms null leaves to var(--path) references - let create_theme_contract_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let contract_obj = args.get_or_undefined(0); - let transformed = transform_contract_to_vars(contract_obj, ctx, &mut Vec::new()); - Ok(transformed) - }) - }; +fn declare_font_faces( + collector: &StyleCollector, + family: &str, + rules: &JsValue, + context: &mut Context, +) -> JsResult<()> { + let rules = array_items(rules, context)?.unwrap_or_else(|| vec![rules.clone()]); + let family = json_string(family); + for rule in &rules { + let json = js_value_to_json(rule, context); + let inner = inner_json(&json); + let face = if inner.is_empty() { + format!("{{\"fontFamily\":{family}}}") + } else { + format!("{{{inner},\"fontFamily\":{family}}}") + }; + collector.borrow_mut().styles.font_faces.push(face); + } + Ok(()) +} - // createTheme() function - creates a class with CSS variable assignments - // Single arg: createTheme({ color: { brand: 'blue' } }) -> returns [themeClass, vars] - // Two args: createTheme(contract, values) -> returns themeClass - let collector_theme = collector.clone(); - let create_theme_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let first_arg = args.get_or_undefined(0); - let second_arg = args.get_or_undefined(1); - - // Check if it's single-arg (values only) or two-arg (contract, values) - let is_single_arg = second_arg.is_undefined(); - - let id = next_style_id(&collector_theme); - - if is_single_arg { - // Single arg: createTheme({ color: { brand: 'blue' } }) - // Returns [themeClass, vars] where vars has var() references - let mut css_vars = SmallVec::new(); - let mut var_counter = 0usize; - - // Transform values to var() references and collect CSS variables - let vars_obj = transform_theme_to_vars( - first_arg, - ctx, - &id, - &mut css_vars, - &mut var_counter, - &mut Vec::new(), - ); - - let vars_object_json = js_value_to_json(&vars_obj, ctx); - - // Store the theme entry with vars object - collector_theme.borrow_mut().styles.themes.insert( - id.clone(), - ThemeEntry { - css_vars, - exported: false, - vars_object_json: Some(vars_object_json), - vars_name: None, // Will be set during remapping - class_name: String::new(), // Will be set during remapping - }, - ); - - // Return [themeId, varsObject] as an array - let result_array = boa_engine::object::builtins::JsArray::new(ctx)?; - result_array.push(JsValue::from(js_string!(id.clone())), ctx)?; - result_array.push(vars_obj, ctx)?; - - Ok(JsValue::from(result_array)) - } else { - // Two args: createTheme(contract, values) - // Returns just the themeClass - let mut css_vars = SmallVec::new(); - extract_theme_vars(first_arg, second_arg, ctx, &mut css_vars, &mut Vec::new()); - - // Store the theme entry - collector_theme.borrow_mut().styles.themes.insert( - id.clone(), - ThemeEntry { - css_vars, - exported: false, - vars_object_json: None, - vars_name: None, - class_name: String::new(), // Will be set during remapping - }, - ); - - Ok(JsValue::from(js_string!(id))) - } - }) +/// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a +/// generated custom property, registered with `@property` when declared +fn create_var( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let first = args.get_or_undefined(0); + let (declaration, debug_id) = match first.as_object() { + Some(declaration) => (Some(declaration), args.get_or_undefined(1)), + None => (None, first), }; + let name = format!( + "--{}", + collector.borrow_mut().identifier(js_str(debug_id), "var") + ); + if let Some(declaration) = declaration { + let rule = property_rule(&name, &declaration, context)?; + collector.borrow_mut().styles.property_rules.push(rule); + } + Ok(js_string!(format!("var({name})")).into()) +} - // layer() function - let collector_layer = collector.clone(); - let layer_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let id = next_style_id(&collector_layer); - let name = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - collector_layer - .borrow_mut() - .styles - .layers - .insert(id, (name.clone(), false)); - Ok(JsValue::from(js_string!(name))) - }) +fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { + let syntax = declaration.get(js_string!("syntax"), context)?; + let syntax = match array_items(&syntax, context)? { + Some(items) => items + .iter() + .map(|item| to_text(item, context)) + .collect::>>()? + .join(" | "), + None => to_text(&syntax, context)?, }; + let inherits = declaration + .get(js_string!("inherits"), context)? + .to_boolean(); + let mut rule = format!("@property {name}{{syntax:\"{syntax}\";inherits:{inherits}"); + let initial_value = declaration.get(js_string!("initialValue"), context)?; + if !initial_value.is_null_or_undefined() { + rule.push_str(";initial-value:"); + rule.push_str(&to_text(&initial_value, context)?); + } + rule.push('}'); + Ok(rule) +} - // createContainer() function - let collector_container = collector.clone(); - let create_container_fn = unsafe { - NativeFunction::from_closure(move |_this, _args, _ctx| { - let id = next_style_id(&collector_container); - let container_name = format!( - "__container_{}__", - collector_container.borrow().styles.containers.len() - ); - collector_container - .borrow_mut() - .styles - .containers - .insert(id, (container_name.clone(), false)); - Ok(JsValue::from(js_string!(container_name))) - }) +/// `fallbackVar(...values)`: each `var()` falls back to the values after it +fn fallback_var(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let mut result = String::new(); + for (index, value) in args.iter().rev().enumerate() { + let value = to_text(value, context)?; + result = match value.strip_suffix(')') { + Some(head) if index > 0 => format!("{head}, {result})"), + _ => value, + }; + } + Ok(js_string!(result).into()) +} + +fn create_container(collector: &StyleCollector, args: &[JsValue]) -> JsValue { + let name = collector + .borrow_mut() + .identifier(js_str(args.get_or_undefined(0)), "container"); + js_string!(name).into() +} + +/// `(parent, name argument)` of `layer(options?, debugId?)` and +/// `globalLayer(options?, name)` +fn layer_args(args: &[JsValue], context: &mut Context) -> JsResult<(Option, JsValue)> { + let first = args.get_or_undefined(0); + Ok(match first.as_object() { + Some(options) => ( + js_str(&options.get(js_string!("parent"), context)?), + args.get_or_undefined(1).clone(), + ), + None => (None, first.clone()), + }) +} + +fn declare_layer(collector: &StyleCollector, parent: Option, name: String) -> JsValue { + let name = match parent { + Some(parent) => format!("{parent}.{name}"), + None => name, }; + collector.borrow_mut().styles.layers.push(name.clone()); + js_string!(name).into() +} + +fn layer(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsResult { + let (parent, debug_id) = layer_args(args, context)?; + let name = collector + .borrow_mut() + .identifier(js_str(&debug_id), "layer"); + Ok(declare_layer(collector, parent, name)) +} + +fn global_layer( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let (parent, name) = layer_args(args, context)?; + let name = to_text(&name, context)?; + Ok(declare_layer(collector, parent, name)) +} + +type Leaf<'a> = dyn FnMut(&JsValue, &[String], &mut Context) -> JsResult + 'a; + +/// vanilla-extract's `walkObject`: `tokens` with every string, number or empty +/// leaf replaced by `leaf(value, path)`; other values are skipped +fn walk_object( + tokens: &JsValue, + context: &mut Context, + path: &mut Vec, + leaf: &mut Leaf<'_>, +) -> JsResult { + let walked = ObjectInitializer::new(context).build(); + if let Some(object) = tokens.as_object() { + for (key, name) in own_keys(&object, context)? { + let value = object.get(key, context)?; + path.push(name.clone()); + let mapped = if value.is_string() || value.is_number() || value.is_null_or_undefined() { + Some(leaf(&value, path, context)?) + } else if value.as_object().is_some_and(|object| !object.is_array()) { + Some(walk_object(&value, context, path, leaf)?) + } else { + None + }; + path.pop(); + if let Some(mapped) = mapped { + walked.set(js_string!(name), mapped, false, context)?; + } + } + } + Ok(walked.into()) +} - // createGlobalTheme() function - let collector_global_theme = collector; - let create_global_theme_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let placeholder_id = next_global_theme_id(&collector_global_theme); - let selector = args - .get_or_undefined(0) - .to_string(ctx) - .map_or_else(|_| ":root".to_string(), |s| s.to_std_string_escaped()); - let theme_obj = args.get_or_undefined(1); - - // Collect CSS variables and build new object with var() references - let mut css_vars = SmallVec::new(); - let mut var_counter = 0usize; - let result_obj = transform_theme_to_vars( - theme_obj, - ctx, - &placeholder_id, - &mut css_vars, - &mut var_counter, - &mut Vec::new(), - ); - - // Serialize the result object to JSON for code generation - let vars_object_json = js_value_to_json(&result_obj, ctx); - - // Store the collected CSS variables and vars object - collector_global_theme +/// A contract of `tokens`' shape whose leaves read generated custom properties +fn contract_of( + collector: &StyleCollector, + tokens: &JsValue, + context: &mut Context, +) -> JsResult { + walk_object( + tokens, + context, + &mut Vec::new(), + &mut |_value, path, _context| { + let name = collector .borrow_mut() - .styles - .global_themes - .insert( - placeholder_id, - GlobalThemeEntry { - selector, - css_vars, - vars_object_json, - exported: false, - }, - ); - - Ok(result_obj) - }) - }; + .identifier(Some(path.join("-")), "var"); + Ok(js_string!(format!("var(--{name})")).into()) + }, + ) +} - // Build the mock object - let mut ve_builder = ObjectInitializer::new(context); - ve_builder.function(style_fn, js_string!("style"), 1); - ve_builder.function(global_style_fn, js_string!("globalStyle"), 2); - ve_builder.function(keyframes_fn, js_string!("keyframes"), 1); - ve_builder.function(create_var_fn, js_string!("createVar"), 0); - ve_builder.function(font_face_fn, js_string!("fontFace"), 1); - ve_builder.function(style_variants_fn, js_string!("styleVariants"), 1); - ve_builder.function(fallback_var_fn, js_string!("fallbackVar"), 2); - ve_builder.function(create_theme_fn, js_string!("createTheme"), 1); - ve_builder.function( - create_theme_contract_fn, - js_string!("createThemeContract"), - 1, - ); - ve_builder.function(layer_fn, js_string!("layer"), 1); - ve_builder.function(create_container_fn, js_string!("createContainer"), 0); - ve_builder.function(create_global_theme_fn, js_string!("createGlobalTheme"), 2); +fn create_theme_contract( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + contract_of(collector, args.get_or_undefined(0), context) +} - let ve_obj = ve_builder.build(); +/// `createGlobalThemeContract(tokens, map?)`: leaves name their custom property, +/// or `map(value, path)` does +fn create_global_theme_contract( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let map = args.get_or_undefined(1).as_callable(); + walk_object( + args.get_or_undefined(0), + context, + &mut Vec::new(), + &mut |value, path, context| { + let name = match &map { + Some(map) => { + let path = JsArray::from_iter( + path.iter().map(|key| js_string!(key.as_str()).into()), + context, + ); + map.call( + &JsValue::undefined(), + &[value.clone(), path.into()], + context, + )? + } + None => value.clone(), + }; + let name = to_text(&name, context)?; + Ok(js_string!(format!( + "var(--{})", + name.strip_prefix("--").unwrap_or(&name) + )) + .into()) + }, + ) +} - // Register as global __vanilla_extract__ - context - .register_global_property(js_string!("__vanilla_extract__"), ve_obj, Attribute::all()) - .map_err(|e| format!("Failed to register __vanilla_extract__: {e}"))?; +/// `(custom property, value)` for every leaf of `tokens`, read from the same +/// path of `contract` +fn assign_vars( + contract: &JsValue, + tokens: &JsValue, + context: &mut Context, +) -> JsResult> { + let mut vars = Vec::new(); + walk_object( + tokens, + context, + &mut Vec::new(), + &mut |value, path, context| { + let mut target = contract.clone(); + for key in path { + target = match target.as_object() { + Some(object) => object.get(js_string!(key.as_str()), context)?, + None => JsValue::undefined(), + }; + } + if let Some(reference) = js_str(&target) { + vars.push((var_name(&reference).to_string(), to_text(value, context)?)); + } + Ok(JsValue::undefined()) + }, + )?; + Ok(vars) +} + +fn assign_vars_api(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let assigned = ObjectInitializer::new(context).build(); + for (name, value) in assign_vars(args.get_or_undefined(0), args.get_or_undefined(1), context)? { + assigned.set( + js_string!(format!("var({name})")), + js_string!(value), + false, + context, + )?; + } + Ok(assigned.into()) +} + +/// `tokens` without its `@layer`, and the layer it names +fn split_layer(tokens: &JsValue, context: &mut Context) -> JsResult<(JsValue, Option)> { + let Some(object) = tokens.as_object() else { + return Ok((tokens.clone(), None)); + }; + let mut layer = None; + let rest = ObjectInitializer::new(context).build(); + for (key, name) in own_keys(&object, context)? { + let value = object.get(key, context)?; + if name == "@layer" { + layer = Some(to_text(&value, context)?); + } else { + rest.set(js_string!(name), value, false, context)?; + } + } + Ok((rest.into(), layer)) +} +/// Declare on `selector` the values `tokens` gives the variables of `contract` +fn declare_theme( + collector: &StyleCollector, + selector: String, + contract: &JsValue, + tokens: &JsValue, + layer: Option, + context: &mut Context, +) -> JsResult<()> { + let vars = assign_vars(contract, tokens, context)?; + if vars.is_empty() { + return Ok(()); + } + let mut declarations: Vec = layer + .iter() + .map(|layer| format!("\"@layer\":{}", json_string(layer))) + .collect(); + for (name, value) in &vars { + declarations.push(format!("{}:{}", json_string(name), json_string(value))); + } + collector + .borrow_mut() + .styles + .global_styles + .push((selector, format!("{{{}}}", declarations.join(",")))); Ok(()) } +/// `createTheme(tokens, debugId?)` -> `[class, vars]`, or +/// `createTheme(contract, tokens, debugId?)` -> class +fn create_theme( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let contract_given = args.get_or_undefined(1).is_object(); + let (tokens, debug_id) = if contract_given { + (args.get_or_undefined(1), args.get_or_undefined(2)) + } else { + (args.get_or_undefined(0), args.get_or_undefined(1)) + }; + let class_name = collector.borrow_mut().identifier(js_str(debug_id), "theme"); + let (tokens, layer) = split_layer(tokens, context)?; + let contract = if contract_given { + args.get_or_undefined(0).clone() + } else { + contract_of(collector, &tokens, context)? + }; + declare_theme( + collector, + format!(".{class_name}"), + &contract, + &tokens, + layer, + context, + )?; + let class_name = JsValue::from(js_string!(class_name)); + Ok(if contract_given { + class_name + } else { + JsArray::from_iter([class_name, contract], context).into() + }) +} + +/// `createGlobalTheme(selector, tokens)` -> vars, or +/// `createGlobalTheme(selector, contract, tokens)` +fn create_global_theme( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let selector = to_text(args.get_or_undefined(0), context)?; + let contract_given = args.get_or_undefined(2).to_boolean(); + let (tokens, layer) = split_layer( + args.get_or_undefined(if contract_given { 2 } else { 1 }), + context, + )?; + let contract = if contract_given { + args.get_or_undefined(1).clone() + } else { + contract_of(collector, &tokens, context)? + }; + declare_theme(collector, selector, &contract, &tokens, layer, context)?; + Ok(if contract_given { + JsValue::undefined() + } else { + contract + }) +} + /// Placeholders interpolated into a selector end inside a JSON key. fn in_json_key(rest: &str) -> bool { rest.find('"') @@ -1229,7 +1152,7 @@ impl CollectedStyles { ) -> String { replace_placeholders(source, |token, rest| { Some(match self.references.get(token)? { - Reference::Style { class_name, .. } | Reference::Class(class_name) => { + Reference::Style { class_name, .. } => { if selector(rest) { format!(".{class_name}") } else { @@ -1237,7 +1160,6 @@ impl CollectedStyles { } } Reference::Keyframes(name) => keyframes_names.get(name)?.clone(), - Reference::Font(family) => family.clone(), }) }) .unwrap_or_else(|| source.to_string()) @@ -1251,14 +1173,8 @@ impl CollectedStyles { fn texts(&self) -> impl Iterator { self.styles .values() + .chain(self.keyframes.values()) .map(|entry| entry.json.as_str()) - .chain(self.keyframes.values().map(|entry| entry.json.as_str())) - .chain( - self.style_variants - .values() - .flat_map(|(variants, _)| variants.values()) - .map(|variant| variant.styles_json.as_str()), - ) .chain( self.global_styles .iter() @@ -1285,7 +1201,7 @@ impl CollectedStyles { .into_iter() .filter_map(|reference| match reference { Reference::Style { name, class_name } => Some((name.as_str(), class_name.as_str())), - _ => None, + Reference::Keyframes(_) => None, }) .collect() } @@ -1299,7 +1215,7 @@ pub fn referenced_keyframes(collected: &CollectedStyles) -> FxHashSet { .into_iter() .filter_map(|reference| match reference { Reference::Keyframes(name) => Some(name.clone()), - _ => None, + Reference::Style { .. } => None, }) .collect() } @@ -1328,58 +1244,87 @@ pub fn keyframes_to_code( output } +/// The members of a JSON object, without its braces fn inner_json(json: &str) -> &str { json.trim() - .trim_start_matches('{') - .trim_end_matches('}') + .strip_prefix('{') + .and_then(|json| json.strip_suffix('}')) + .unwrap_or_default() .trim() } -/// `css(...)` of `own` composed onto `bases`, plus the unique classes that let -/// selectors target them +/// Styles `entry` composes, transitively and in order, each once +fn collect_bases<'a>( + collected: &'a CollectedStyles, + entry: &'a StyleEntry, + seen: &mut FxHashSet<&'a str>, + bases: &mut Vec<(&'a str, &'a StyleEntry)>, +) { + for base in &entry.bases { + if seen.insert(base.as_str()) + && let Some(base_entry) = collected.styles.get(base) + { + collect_bases(collected, base_entry, seen, bases); + bases.push((base.as_str(), base_entry)); + } + } +} + +/// `css(...)` of the style `name` composed onto its bases, plus the classes it +/// composes and the unique classes that let selectors target them fn composed_css( collected: &CollectedStyles, keyframes_names: &FxHashMap, referenced_classes: &FxHashMap<&str, &str>, - bases: &[&str], - own_name: Option<&str>, - own_json: &str, + name: &str, + entry: &StyleEntry, ) -> String { - let mut parts = Vec::with_capacity(bases.len() + 1); + let mut seen = FxHashSet::default(); + seen.insert(name); + let mut bases = Vec::new(); + collect_bases(collected, entry, &mut seen, &mut bases); + + let mut rules = Vec::with_capacity(bases.len() + 1); let mut classes = Vec::new(); - for base in bases { - if let Some(entry) = collected.styles.get(*base) { - let json = collected.resolve_json(&entry.json, keyframes_names); - let inner = inner_json(&json); - if !inner.is_empty() { - parts.push(inner.to_string()); - } + for (base_name, base) in &bases { + let json = collected.resolve_json(&base.json, keyframes_names); + let inner = inner_json(&json); + if !inner.is_empty() { + rules.push(inner.to_string()); } - classes.extend(referenced_classes.get(base)); + classes.extend(base.classes.iter().map(String::as_str)); + classes.extend(referenced_classes.get(base_name).copied()); } - let own = collected.resolve_json(own_json, keyframes_names); + classes.extend(entry.classes.iter().map(String::as_str)); + classes.extend(referenced_classes.get(name).copied()); + + let own = collected.resolve_json(&entry.json, keyframes_names); let css = if bases.is_empty() { format!("css({own})") } else { let inner = inner_json(&own); if !inner.is_empty() { - parts.push(inner.to_string()); + rules.push(inner.to_string()); } - format!("css({{{}}})", parts.join(",")) + format!("css({{{}}})", rules.join(",")) }; - classes.extend(own_name.and_then(|name| referenced_classes.get(name))); if classes.is_empty() { css } else { - let mut unique = String::new(); - for class in classes { - unique.push(' '); - unique.push_str(class); - } - format!("{css} + \"{unique}\"") + format!("{css} + \" {}\"", classes.join(" ")) } } +fn sorted(entries: &FxHashMap) -> Vec<(&String, &StyleEntry)> { + let mut entries: Vec<_> = entries.iter().collect(); + entries.sort_by_key(|(name, _)| *name); + entries +} + +const fn export_prefix(exported: bool) -> &'static str { + if exported { "export " } else { "" } +} + /// Convert collected styles to code, with keyframes references replaced by /// `keyframes_names` (`variable_name` -> generated name) pub fn collected_styles_to_code_with_keyframes( @@ -1387,2167 +1332,447 @@ pub fn collected_styles_to_code_with_keyframes( package: &str, keyframes_names: &FxHashMap, ) -> String { - let mut code_parts = Vec::with_capacity(collected.styles.len() + 4); + let mut code = Vec::new(); - // Generate import statement let mut imports = Vec::new(); - if !collected.styles.is_empty() - || !collected.style_variants.is_empty() - || !collected.themes.is_empty() - { + if !collected.styles.is_empty() { imports.push("css"); } - if !collected.global_styles.is_empty() - || !collected.font_faces.is_empty() - || !collected.global_themes.is_empty() + if !(collected.global_styles.is_empty() + && collected.font_faces.is_empty() + && collected.layers.is_empty() + && collected.property_rules.is_empty()) { imports.push("globalCss"); } if !collected.keyframes.is_empty() { imports.push("keyframes"); } - if !imports.is_empty() { - code_parts.push(format!( - "import {{ {} }} from '{}'", - imports.join(", "), - package + code.push(format!( + "import {{ {} }} from '{package}'", + imports.join(", ") )); } - let referenced_classes = collected.referenced_classes(); - let mut styles: Vec<_> = collected.styles.iter().collect(); - styles.sort_by_key(|(name, _)| *name); - - for (name, entry) in styles { - let prefix = if entry.exported { "export " } else { "" }; - let bases: Vec<&str> = entry.bases.iter().map(String::as_str).collect(); - let css = composed_css( - collected, - keyframes_names, - &referenced_classes, - &bases, - Some(name), - &entry.json, - ); - code_parts.push(format!("{prefix}const {name} = {css}")); - } - // Generate createTheme exports (class name and optionally vars object) - // Note: CSS variables are added to global_styles during remapping - let mut themes: Vec<_> = collected.themes.iter().collect(); - themes.sort_by_key(|(name, _)| *name); - for (name, entry) in themes { - let prefix = if entry.exported { "export " } else { "" }; - if let Some(vars_name) = &entry.vars_name { - if let Some(vars_json) = &entry.vars_object_json { - code_parts.push(format!( - "{}const [{}, {}] = [\"{}\", {}]", - prefix, name, vars_name, entry.class_name, vars_json - )); - } else { - code_parts.push(format!( - "{}const {} = \"{}\"", - prefix, name, entry.class_name - )); - } - } else { - code_parts.push(format!( - "{}const {} = \"{}\"", - prefix, name, entry.class_name - )); + // Declaring every layer up front fixes their order the way the calls did. + let mut layers: Vec<&str> = Vec::new(); + for layer in &collected.layers { + if !layers.contains(&layer.as_str()) { + layers.push(layer); } } - - append_non_style_code( - collected, - keyframes_names, - &referenced_classes, - &mut code_parts, - ); - - code_parts.join("\n") -} - -/// Append non-style code parts (globalCss, keyframes, fontFaces, etc.) -fn append_non_style_code( - collected: &CollectedStyles, - keyframes_names: &FxHashMap, - referenced_classes: &FxHashMap<&str, &str>, - code_parts: &mut Vec, -) { - for (selector, json) in &collected.global_styles { - let selector = collected.resolve(selector, keyframes_names, |_| true); - let json = collected.resolve_json(json, keyframes_names); - code_parts.push(format!( - "globalCss({{ {}: {json} }})", - serde_json::Value::String(selector) - )); + if !layers.is_empty() { + code.push(format!("globalCss`@layer {};`", layers.join(","))); } - - // Generate @font-face rules - let mut font_faces_sorted: Vec<_> = collected.font_faces.iter().collect(); - font_faces_sorted.sort_by_key(|(name, _)| *name); - for (_name, (json, font_family, _exported)) in &font_faces_sorted { - let props = parse_font_face_json(json); - let props_str = props - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join(", "); - let code = if props_str.is_empty() { - format!("globalCss({{ fontFaces: [{{ fontFamily: \"{font_family}\" }}] }})") - } else { - format!( - "globalCss({{ fontFaces: [{{ fontFamily: \"{font_family}\", {props_str} }}] }})" - ) - }; - code_parts.push(code); - } - - // Generate createGlobalTheme CSS variables - let mut global_themes_sorted: Vec<_> = collected.global_themes.iter().collect(); - global_themes_sorted.sort_by_key(|(name, _)| *name); - for (_name, entry) in &global_themes_sorted { - if !entry.css_vars.is_empty() { - let vars_str = entry - .css_vars - .iter() - .map(|(var_name, value)| format!("\"{var_name}\": \"{value}\"")) - .collect::>() - .join(", "); - code_parts.push(format!( - "globalCss({{ \"{}\": {{ {} }} }})", - entry.selector, vars_str - )); - } + for rule in &collected.property_rules { + code.push(format!("globalCss`{rule}`")); } - // Generate keyframes - let mut keyframes: Vec<_> = collected.keyframes.iter().collect(); - keyframes.sort_by_key(|(name, _)| *name); - for (name, entry) in keyframes { - let prefix = if entry.exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = keyframes({})", - prefix, - name, + for (name, entry) in sorted(&collected.keyframes) { + code.push(format!( + "{}const {name} = keyframes({})", + export_prefix(entry.exported), collected.resolve_json(&entry.json, keyframes_names) )); } - let mut variants: Vec<_> = collected.style_variants.iter().collect(); - variants.sort_by_key(|(name, _)| *name); - for (name, (variant_map, exported)) in variants { - let mut variant_entries: Vec<_> = variant_map.iter().collect(); - variant_entries.sort_by_key(|(k, _)| *k); - let mut object_parts = Vec::new(); - for (variant_key, variant) in variant_entries { - let bases: Vec<&str> = variant.base.iter().map(String::as_str).collect(); - let value = composed_css( - collected, - keyframes_names, - referenced_classes, - &bases, - None, - &variant.styles_json, - ); - object_parts.push(format!(" {variant_key}: {value}")); - } - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {{\n{}\n}}", - prefix, - name, - object_parts.join(",\n") + let referenced_classes = collected.referenced_classes(); + for (name, entry) in sorted(&collected.styles) { + code.push(format!( + "{}const {name} = {}", + export_prefix(entry.exported), + composed_css(collected, keyframes_names, &referenced_classes, name, entry) )); } - // Generate createVar declarations - let mut vars: Vec<_> = collected.vars.iter().collect(); - vars.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in vars { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); - } - - // Generate fontFace declarations (reuse the name-sorted vec built above) - for (name, (_, font_family, exported)) in &font_faces_sorted { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{font_family}\"")); - } - - // Generate createContainer declarations - let mut containers: Vec<_> = collected.containers.iter().collect(); - containers.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in containers { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); - } - - // Generate layer declarations - let mut layers: Vec<_> = collected.layers.iter().collect(); - layers.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in layers { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); - } - // Generate createGlobalTheme vars object declarations - for (name, entry) in &global_themes_sorted { - let prefix = if entry.exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {}", - prefix, name, entry.vars_object_json + for (selector, json) in &collected.global_styles { + let selector = collected.resolve(selector, keyframes_names, |_| true); + code.push(format!( + "globalCss({{ {}: {} }})", + json_string(&selector), + collected.resolve_json(json, keyframes_names) )); } - - // Generate constant exports - let mut constants: Vec<_> = collected.constant_exports.iter().collect(); - constants.sort_by_key(|(name, _)| *name); - for (name, value) in constants { - code_parts.push(format!("export const {name} = {value}")); - } -} - -/// Convert collected styles to code that can be processed by existing extract logic -#[cfg(test)] -pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> String { - collected_styles_to_code_with_keyframes(collected, package, &FxHashMap::default()) -} - -/// Parse a styleVariants object and extract variant info -fn parse_style_variants( - variants_obj: &JsValue, - context: &mut Context, -) -> FxHashMap { - let mut result = FxHashMap::default(); - - if let Some(obj) = variants_obj.as_object() { - // Get the object's own property keys - if let Ok(keys) = obj.own_property_keys(context) { - for key in keys { - // Convert PropertyKey to string - let key_name = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let Ok(value) = obj.get(key.clone(), context) { - let variant = parse_single_variant(&value, context); - result.insert(key_name, variant); - } - } - } - } - - result -} - -/// Parse a single variant value (either style object or [base, styleObj] array) -fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant { - // Check if value is an array by checking if it has a numeric "length" property - if let Some(obj) = value.as_object() - && let Ok(length_val) = obj.get(js_string!("length"), context) - && let Some(len) = length_val.as_number() - { - let len = len as u32; - if len >= 2 { - // It's an array with at least 2 elements - if let Ok(first) = obj.get(0, context) - && let Some(base_str) = first.as_string() - { - let base_class = base_str.to_std_string_escaped(); - // Check if it looks like a placeholder (__style_N__) or class name - if base_class.starts_with("__style_") || !base_class.contains('{') { - // Get the style object (second element) - if let Ok(style_obj) = obj.get(1, context) { - let json = style_to_json(&style_obj, context); - return StyleVariant { - base: Some(base_class), - styles_json: json, - }; - } - } - } - } + if !collected.font_faces.is_empty() { + code.push(format!( + "globalCss({{ fontFaces: [{}] }})", + collected.font_faces.join(", ") + )); } - // Not an array or not composition - treat as plain style object - StyleVariant { - base: None, - styles_json: style_to_json(value, context), + for (name, value) in &collected.constant_exports { + code.push(format!("export const {name} = {value}")); } + code.join("\n") } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; - use crate::utils::is_vanilla_extract_file; - use smallvec::smallvec; + use css::file_map::reset_file_map; + use serial_test::serial; - #[test] - fn test_preprocess_typescript() { - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red" })"#; - let result = preprocess_typescript(code, "@devup-ui/react"); - // The result should have destructuring from __vanilla_extract__ and no export keyword - assert!( - result.contains("__vanilla_extract__"), - "Expected __vanilla_extract__ but got: {result}" - ); - assert!( - !result.contains("export const"), - "Should not contain 'export const': {result}" - ); - } + const PACKAGE: &str = "@devup-ui/react"; - #[test] - fn test_preprocess_typescript_strips_types() { - let code = r#"import { style } from '@devup-ui/react' -interface Props { - color: string; -} -export const container = style({ background: "red" })"#; - let result = preprocess_typescript(code, "@devup-ui/react"); - // TypeScript interface should be stripped - assert!( - !result.contains("interface"), - "Should not contain interface: {result}" - ); + fn generate_with(code: &str, keyframes_names: &[(&str, &str)]) -> String { + reset_file_map(); + let collected = execute_vanilla_extract( + &format!("import {{ style, globalStyle, styleVariants, keyframes, fontFace, globalFontFace, createVar, fallbackVar, createContainer, layer, globalLayer, createTheme, createGlobalTheme, createThemeContract, createGlobalThemeContract, assignVars }} from '{PACKAGE}'\n{code}"), + PACKAGE, + "test.css.ts", + ) + .unwrap(); + let keyframes_names = keyframes_names + .iter() + .map(|(name, generated)| (name.to_string(), generated.to_string())) + .collect(); + collected_styles_to_code_with_keyframes(&collected, PACKAGE, &keyframes_names) } - #[test] - fn test_execute_vanilla_extract_style() { - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(!result.styles.is_empty()); + fn generate(code: &str) -> String { + generate_with(code, &[]) } #[test] - fn test_execute_vanilla_extract_style_multiple() { - // Test with multiple style exports - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 }) -export const button = style({ color: "blue" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!( - result.styles.len() >= 2, - "Expected at least 2 styles but got: {result:?}" + #[serial] + fn test_names_follow_the_values_variables_hold() { + assert_eq!( + generate_with( + "export const sizes = { sm: style({ padding: 1 }), lg: style({ padding: 2 }) } +export const box = style({ color: 'red' }) +const spin = keyframes({ to: { opacity: 1 } }) +export const alias = box +export const animated = style({ animationName: spin })", + &[("spin", "k")] + ), + r#"import { css, keyframes } from '@devup-ui/react' +const spin = keyframes({"to":{"opacity":1}}) +const _ve0 = css({"padding":"1px"}) +const _ve1 = css({"padding":"2px"}) +export const animated = css({"animationName":"k"}) +export const box = css({"color":"red"}) +export const sizes = { "sm": _ve0, "lg": _ve1 } +export const alias = box"# ); - } - - #[test] - fn test_collected_styles_to_code_multiple() { - // Test the full flow: execute → collected_styles_to_code with multiple styles - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 })"#; - let collected = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - let generated = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!( - !generated.is_empty(), - "Expected non-empty generated code. Collected: {collected:?}" + assert_eq!( + generate("const _ve0 = 1\nexport const list = [style({ color: 'red' })]"), + r#"import { css } from '@devup-ui/react' +const _ve0_ = css({"color":"red"}) +export const list = [_ve0_]"# ); - assert!( - generated.contains("css("), - "Expected css() call in generated code: {generated}" + assert_eq!( + generate("export const animated = style({ animationName: keyframes({}) })"), + r#"import { css, keyframes } from '@devup-ui/react' +const _ve0 = keyframes({}) +export const animated = css({"animationName":"__style_0__"})"# ); } #[test] - fn test_full_flow_multiline() { - // Test exactly what lib.rs extract function does (with already-transformed imports) - let code = r"import { style } from '@devup-ui/react' -export const hello = style({ - cursor: 'pointer', - fontSize: 32, - paddingTop: '28px', - paddingBottom: '28px', -}) -export const text = style({ - color: 'var(--text)', -}) -"; - let package = "@devup-ui/react"; - let filename = "styles.css.ts"; - - // This is exactly what lib.rs does - assert!( - is_vanilla_extract_file(filename), - "Should be vanilla-extract file" + #[serial] + fn test_exported_values_become_code() { + assert_eq!( + generate( + "const box = style({ color: 'red' }) +export const text = 'plain \"quoted\"' +export const template = `${box} extra \\`tick\\` \\${x} back\\\\slash __open` +export const nothing = undefined +export const empty = null +export const flag = true +export const count = 1.5 +export const list = [box, 2] +export const nested = { a: { b: [box] } } +export const helper = (x) => x +export const fns = [() => 1] +export const date = new Date(0) +export const cyclic = (() => { const o = {}; o.self = o; return o })() +export const bare = Object.create(null) +export const { destructured } = { destructured: () => 1 } +export declare const typeOnly: string +export function makeBox() { return box } +export type Box = string +export const symbol = Symbol('x')" + ), + r#"import { css } from '@devup-ui/react' +const box = css({"color":"red"}) +export const text = "plain \"quoted\"" +export const template = `${box} extra \`tick\` \${x} back\\slash __open` +export const nothing = undefined +export const empty = null +export const flag = true +export const count = 1.5 +export const list = [box, 2] +export const nested = { "a": { "b": [box] } } +export const helper = (x) => x +export const fns = [() => 1] +export const date = new Date(0) +export const cyclic = (() => { const o = {}; o.self = o; return o })() +export const bare = {} +export const symbol = Symbol('x')"# + ); + } + + #[test] + #[serial] + fn test_style_variants_are_styles() { + assert_eq!( + generate( + "export const tone = styleVariants({ primary: { color: 'red' }, 0: { color: 'blue' }, [Symbol('s')]: { color: 'green' } }) +export const space = styleVariants({ sm: 4 }, (padding, key) => ({ padding, content: key })) +export const none = styleVariants(undefined) +export const combined = style([tone.primary, 'external', [{ margin: 1 }]]) +globalStyle(`${tone.primary} > span`, { fontWeight: 700 })" + ), + r#"import { css, globalCss } from '@devup-ui/react' +const _ve0 = css({"color":"blue"}) +const _ve1 = css({"color":"red"}) + " f0__ve1" +const _ve2 = css({"padding":"4px","content":"sm"}) +export const combined = css({"color":"red","margin":"1px"}) + " f0__ve1 external" +globalCss({ ".f0__ve1 > span": {"fontWeight":700} }) +export const tone = { "0": _ve0, "primary": _ve1 } +export const space = { "sm": _ve2 } +export const none = {}"# ); - - let result = execute_vanilla_extract(code, package, filename); - match result { - Ok(collected) => { - assert!( - !collected.styles.is_empty(), - "Styles should not be empty. Collected: {collected:?}" - ); - let generated = super::collected_styles_to_code(&collected, package); - assert!( - !generated.is_empty(), - "Generated code should not be empty. Generated: {generated}" - ); - println!("Generated code:\n{generated}"); - } - Err(e) => { - panic!("execute_vanilla_extract failed: {e}"); - } - } } #[test] - fn test_execute_vanilla_extract_global_style() { - let code = r#"import { globalStyle } from '@devup-ui/react' -globalStyle("body", { margin: 0, padding: 0 })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert_eq!(result.global_styles.len(), 1); - assert_eq!(result.global_styles[0].0, "body"); + #[serial] + fn test_composition_is_transitive() { + assert_eq!( + generate( + "const a = style({ color: 'red' }) +const b = style([a, { margin: 2 }]) +export const c = style([b, { padding: 3 }]) +const e = style({}) +export const f = style([e, a, '__style_99__']) +export const g = style(5) +const o = {}; o.self = o +export const h = style(o) +export const hover = style({ selectors: { [`${a}:hover &`]: { color: 'blue' } } })" + ), + r#"import { css } from '@devup-ui/react' +const a = css({"color":"red"}) + " f0_a" +const b = css({"color":"red","margin":"2px"}) + " f0_a" +export const c = css({"color":"red","margin":"2px","padding":"3px"}) + " f0_a" +const e = css({}) +export const f = css({"color":"red"}) + " f0_a" +export const g = css({}) +export const h = css({}) +export const hover = css({"selectors":{".f0_a:hover &":{"color":"blue"}}})"# + ); } #[test] - fn test_collected_styles_to_code() { - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "container".to_string(), - StyleEntry { - json: r#"{"background":"red"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, + #[serial] + fn test_font_faces() { + assert_eq!( + generate( + "export const body = fontFace({ src: 'local(a)' }, 'Body Font') +export const icons = fontFace([{ src: 'local(b)' }, { src: 'local(c)', fontWeight: 700 }]) +fontFace({}) +const o = {}; o.self = o +fontFace(o) +globalFontFace('Inter', { src: 'local(Inter)' })" + ), + r#"import { globalCss } from '@devup-ui/react' +globalCss({ fontFaces: [{"src":"local(a)","fontFamily":"Body_Font-0-0"}, {"src":"local(b)","fontFamily":"font-0-1"}, {"src":"local(c)","fontWeight":700,"fontFamily":"font-0-1"}, {"fontFamily":"font-0-2"}, {"fontFamily":"font-0-3"}, {"src":"local(Inter)","fontFamily":"Inter"}] }) +export const body = "Body_Font-0-0" +export const icons = "font-0-1""# ); - - let code = collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { css } from '@devup-ui/react'")); - assert!(code.contains(r#"export const container = css({"background":"red"})"#)); } #[test] - fn test_execute_vanilla_extract_with_variable() { - // Test that variables are evaluated at execution time - let code = r#"import { style } from '@devup-ui/react' -const primaryColor = "blue"; -const spacing = 16; -export const button = style({ background: primaryColor, padding: spacing })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("button")); - let entry = &result.styles["button"]; - // The variable values should be resolved - assert!( - entry.json.contains("blue"), - "Expected 'blue' in JSON: {}", - entry.json - ); - assert!( - entry.json.contains("16"), - "Expected '16' in JSON: {}", - entry.json + #[serial] + fn test_vars_follow_vanilla_extract() { + assert_eq!( + generate( + "export const plain = createVar() +export const named = createVar('9 lives') +export const typed = createVar({ syntax: '', inherits: false, initialValue: '0px' }, 'size') +export const union = createVar({ syntax: ['', ''], inherits: true }) +export const empty = createVar('') +export const fallback = fallbackVar(plain, named, 'red') +export const invalid = fallbackVar('red', 'blue') +export const none = fallbackVar() +export const box = style({ vars: { [plain]: '1px', '--raw': 2 }, width: plain, padding: 0, zIndex: 2, margin: [1, 2] })" + ), + r#"import { css, globalCss } from '@devup-ui/react' +globalCss`@property --size-0-2{syntax:"";inherits:false;initial-value:0px}` +globalCss`@property --var-0-3{syntax:" | ";inherits:true}` +export const box = css({"vars":{"--var-0-0":"1px","--raw":2},"width":"var(--var-0-0)","padding":0,"zIndex":2,"margin":[1,2]}) +export const plain = "var(--var-0-0)" +export const named = "var(--_9_lives-0-1)" +export const typed = "var(--size-0-2)" +export const union = "var(--var-0-3)" +export const empty = "var(--var-0-4)" +export const fallback = "var(--var-0-0, var(--_9_lives-0-1, red))" +export const invalid = "red" +export const none = """# ); } #[test] - fn test_execute_vanilla_extract_with_computed_value() { - // Test computed values - let code = r"import { style } from '@devup-ui/react' -const base = 8; -export const box = style({ padding: base * 2, margin: base / 2 })"; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("box")); - let entry = &result.styles["box"]; - assert!( - entry.json.contains("16"), - "Expected padding 16 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains('4'), - "Expected margin 4 in JSON: {}", - entry.json + #[serial] + fn test_layers_and_containers() { + assert_eq!( + generate( + "export const reset = layer() +export const base = layer('base') +export const child = layer({ parent: base }, 'child') +export const orphan = layer({}) +export const named = globalLayer('utilities') +export const nested = globalLayer({ parent: named }, 'x') +export const again = globalLayer('utilities') +export const container = createContainer('sidebar') +export const anonymous = createContainer()" + ), + r#"import { globalCss } from '@devup-ui/react' +globalCss`@layer layer-0-0,base-0-1,base-0-1.child-0-2,layer-0-3,utilities,utilities.x;` +export const reset = "layer-0-0" +export const base = "base-0-1" +export const child = "base-0-1.child-0-2" +export const orphan = "layer-0-3" +export const named = "utilities" +export const nested = "utilities.x" +export const again = "utilities" +export const container = "sidebar-0-4" +export const anonymous = "container-0-5""# ); } #[test] - fn test_execute_vanilla_extract_with_conditional() { - // Test conditional expressions - let code = r#"import { style } from '@devup-ui/react' -const isDark = true; -export const theme = style({ background: isDark ? "black" : "white" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("theme")); - let entry = &result.styles["theme"]; - assert!( - entry.json.contains("black"), - "Expected 'black' in JSON: {}", - entry.json - ); + #[serial] + fn test_themes_and_contracts() { + assert_eq!( + generate( + "const contract = createThemeContract({ color: { brand: null, text: null }, list: [1], flag: true }) +export const [light, lightVars] = createTheme({ '@layer': 'theme', color: { brand: 'blue' }, size: 4 }) +export const dark = createTheme(contract, { color: { brand: 'black', text: 'white' } }, 'dark') +export const partial = createTheme(contract, { color: { brand: 'x', missing: 'y' } }) +export const emptyTheme = createTheme(contract, {}) +export const globalVars = createGlobalTheme(':root', { space: '1px' }) +createGlobalTheme('.scope', contract, { '@layer': 'scoped', color: { text: 'grey' } }) +export const exact = createGlobalThemeContract({ color: 'brand-color', raw: '--raw' }) +export const mapped = createGlobalThemeContract({ color: { brand: null } }, (_value, path) => `x-${path.join('-')}`) +export const assigned = assignVars(contract, { color: { brand: 'red' } }) +export const unassigned = assignVars(undefined, { a: 'b' }) +export const box = style({ vars: assigned }) +export const notObject = createTheme('tokens')" + ), + r#"import { css, globalCss } from '@devup-ui/react' +export const box = css({"vars":{"--color-brand-0-0":"red"}}) +globalCss({ ".theme-0-2": {"@layer":"theme","--color-brand-0-3":"blue","--size-0-4":"4"} }) +globalCss({ ".dark-0-5": {"--color-brand-0-0":"black","--color-text-0-1":"white"} }) +globalCss({ ".theme-0-6": {"--color-brand-0-0":"x"} }) +globalCss({ ":root": {"--space-0-8":"1px"} }) +globalCss({ ".scope": {"@layer":"scoped","--color-text-0-1":"grey"} }) +export const light = "theme-0-2" +export const lightVars = { "color": { "brand": "var(--color-brand-0-3)" }, "size": "var(--size-0-4)" } +export const dark = "dark-0-5" +export const partial = "theme-0-6" +export const emptyTheme = "theme-0-7" +export const globalVars = { "space": "var(--space-0-8)" } +export const exact = { "color": "var(--brand-color)", "raw": "var(--raw)" } +export const mapped = { "color": { "brand": "var(--x-color-brand)" } } +export const assigned = { "var(--color-brand-0-0)": "red" } +export const unassigned = {} +export const notObject = ["theme-0-9", {}]"# + ); + } + + #[test] + #[serial] + fn test_nothing_collected() { + assert_eq!(generate("const x = 1"), ""); + reset_file_map(); + assert!(execute_vanilla_extract("throw new Error('x')", PACKAGE, "test.css.ts").is_err()); + } + + #[test] + fn test_identifier() { + let mut collector = Collector { + file_num: 3, + ..Collector::default() + }; + assert_eq!(collector.identifier(Some("a b".into()), "x"), "a_b-3-0"); + assert_eq!(collector.identifier(Some("-y".into()), "x"), "_-y-3-1"); + assert_eq!(collector.identifier(Some(String::new()), "x"), "x-3-2"); + assert_eq!(collector.identifier(None, "_z"), "_z-3-3"); } #[test] - fn test_execute_vanilla_extract_with_spread() { - // Test spread operator - let code = r#"import { style } from '@devup-ui/react' -const baseStyle = { padding: 8, margin: 4 }; -export const extended = style({ ...baseStyle, background: "red" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("extended")); - let entry = &result.styles["extended"]; - assert!( - entry.json.contains('8'), - "Expected padding 8 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains('4'), - "Expected margin 4 in JSON: {}", - entry.json + fn test_preprocess_typescript_binds_imports() { + assert_eq!( + preprocess_typescript( + "import { style as s, globalStyle } from '@devup-ui/react' +import * as ve from '@devup-ui/react' +import '@devup-ui/react' +import { other } from 'other' +interface Props { color: string } +export const a: number = s(globalStyle, ve, other) +export function f() {} +export default a", + PACKAGE + ), + "const { style: s, globalStyle } = __vanilla_extract__; +const ve = __vanilla_extract__; +import \"@devup-ui/react\"; +import { other } from \"other\"; +const a = s(globalStyle, ve, other); +function f() {} +export default a;" ); - assert!( - entry.json.contains("red"), - "Expected 'red' in JSON: {}", - entry.json + assert_eq!( + preprocess_typescript( + "import { style } from '@devup-ui/react'\nconst a = 1", + PACKAGE + ), + "const a = 1;\n" ); } #[test] - fn test_execute_vanilla_extract_create_theme_contract() { - // Test createThemeContract + createTheme - let code = r"import { createThemeContract, createTheme } from '@devup-ui/react' -const vars = createThemeContract({ - color: { - brand: null, - text: null - } -}) -export const lightTheme = createTheme(vars, { - color: { - brand: 'blue', - text: 'black' - } -})"; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - - // Check that themes were collected - assert!( - !result.themes.is_empty(), - "Expected themes to be collected, got empty. Themes: {:?}", - result.themes - ); - assert!( - result.themes.contains_key("lightTheme"), - "Expected lightTheme in themes: {:?}", - result.themes.keys().collect::>() - ); - - // Check CSS vars - let theme_entry = &result.themes["lightTheme"]; - assert!( - !theme_entry.css_vars.is_empty(), - "Expected CSS vars in theme: {theme_entry:?}" - ); - - // Check that CSS vars are correct - let css_vars: Vec<_> = theme_entry.css_vars.iter().collect(); - assert!( - css_vars - .iter() - .any(|(name, val)| name == "--color-brand" && val == "blue"), - "Expected --color-brand: blue in {css_vars:?}" + fn test_style_to_json_adds_vanilla_extract_units() { + let mut context = Context::default(); + let rule = context + .eval(Source::from_bytes( + "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 }, vars: { 'var(--a)': 1, '--b': 2 } })", + )) + .unwrap(); + assert_eq!( + style_to_json(&rule, &mut context), + r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"},"vars":{"--a":1,"--b":2}}"# ); + assert_eq!(style_to_json(&JsValue::undefined(), &mut context), "{}"); } #[test] - fn test_parse_font_face_json_edge_cases() { - // Test invalid JSON - let result = super::parse_font_face_json("not valid json"); - assert!(result.is_empty()); - - // Test non-object JSON - let result = super::parse_font_face_json("\"just a string\""); - assert!(result.is_empty()); - - // Test array JSON - let result = super::parse_font_face_json("[1, 2, 3]"); - assert!(result.is_empty()); - - // Test with boolean value - let result = super::parse_font_face_json(r#"{"fontDisplay": true}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontDisplay".to_string(), "true".to_string())); - - // Test with number value - let result = super::parse_font_face_json(r#"{"fontWeight": 400}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontWeight".to_string(), "400".to_string())); - - // Test with null value (other case) - let result = super::parse_font_face_json(r#"{"fontStyle": null}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontStyle".to_string(), "null".to_string())); + #[serial] + fn test_referenced_keyframes() { + reset_file_map(); + let collected = execute_vanilla_extract( + "import { style, keyframes } from '@devup-ui/react' +const fade = keyframes({ from: { opacity: 0 } }) +const unused = keyframes({ to: { opacity: 1 } }) +export const box = style({ animationName: fade })", + PACKAGE, + "test.css.ts", + ) + .unwrap(); + let names = referenced_keyframes(&collected); + assert_eq!(names, FxHashSet::from_iter(["fade".to_string()])); + assert_eq!( + keyframes_to_code(&collected, PACKAGE, &names), + r#"import { keyframes } from '@devup-ui/react' +const fade = keyframes({"from":{"opacity":0}})"# + ); } #[test] - fn test_collected_styles_to_code_with_classes_composition() { - let mut collected = CollectedStyles::default(); - // Add base style - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - // Add composed style with bases - collected.styles.insert( - "composed".to_string(), - StyleEntry { - json: r#"{"color":"red"}"#.to_string(), - exported: true, - bases: smallvec!["base".to_string()], - }, - ); - - let class_map: rustc_hash::FxHashMap = [ - ("base".to_string(), "a".to_string()), - ("composed".to_string(), "b".to_string()), - ] - .into_iter() - .collect(); - - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("import { css } from '@devup-ui/react'")); - // The composed style should have both base and own styles merged - assert!(code.contains("padding")); - assert!(code.contains("color")); - } - - #[test] - fn test_collected_styles_to_code_with_theme_vars() { - let mut collected = CollectedStyles::default(); - // Theme with vars_name and vars_object_json - collected.themes.insert( - "themeClass".to_string(), - super::ThemeEntry { - class_name: "f0_theme".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "blue".to_string())], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: Some( - r#"{"color":{"primary":"var(--color-primary)"}}"#.to_string(), - ), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const [themeClass, vars] = [\"f0_theme\"")); - } - - #[test] - fn test_collected_styles_to_code_with_theme_no_vars() { - let mut collected = CollectedStyles::default(); - // Theme without vars (two-arg createTheme) - collected.themes.insert( - "darkTheme".to_string(), - super::ThemeEntry { - class_name: "f1_darkTheme".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "white".to_string())], - exported: true, - vars_name: None, - vars_object_json: None, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const darkTheme = \"f1_darkTheme\"")); - } - - #[test] - fn test_collected_styles_to_code_with_keyframes() { - let mut collected = CollectedStyles::default(); - collected.keyframes.insert( - "fadeIn".to_string(), - StyleEntry { - json: r#"{"from":{"opacity":"0"},"to":{"opacity":"1"}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { keyframes } from '@devup-ui/react'")); - assert!(code.contains("export const fadeIn = keyframes")); - } - - #[test] - fn test_collected_styles_to_code_with_global_styles() { - let mut collected = CollectedStyles::default(); - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { globalCss } from '@devup-ui/react'")); - assert!(code.contains("globalCss({ \"body\":")); - } - - #[test] - fn test_collected_styles_to_code_composition() { - let mut collected = CollectedStyles::default(); - // Add base style - collected.styles.insert( - "baseStyle".to_string(), - StyleEntry { - json: r#"{"padding":"16px","margin":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - // Add composed style - collected.styles.insert( - "buttonStyle".to_string(), - StyleEntry { - json: r#"{"background":"blue"}"#.to_string(), - exported: true, - bases: smallvec!["baseStyle".to_string()], - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Both base and own styles should be present in the composed style - assert!(code.contains("padding")); - assert!(code.contains("background")); - } - - #[test] - fn test_transform_contract_to_vars_array() { - // Test transform_contract_to_vars with array (covers line 170) - let mut context = boa_engine::Context::default(); - let array = boa_engine::object::builtins::JsArray::new(&mut context) - .expect("creating an empty array should succeed"); - let _ = array.push(boa_engine::JsValue::from(1), &mut context); - let value = boa_engine::JsValue::from(array); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - // Arrays should be returned as-is - assert!(result.as_object().is_some()); - } - - #[test] - fn test_transform_theme_to_vars_array() { - // Test transform_theme_to_vars with array (covers line 250) - let mut context = boa_engine::Context::default(); - let array = boa_engine::object::builtins::JsArray::new(&mut context) - .expect("creating an empty array should succeed"); - let _ = array.push(boa_engine::JsValue::from(1), &mut context); - let value = boa_engine::JsValue::from(array); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Arrays should be returned as-is - assert!(result.as_object().is_some()); - } - - #[test] - fn test_transform_contract_to_vars_with_symbol_property() { - // Test transform_contract_to_vars with Symbol property (covers line 180) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create an object with both string and symbol properties - let obj = ObjectInitializer::new(&mut context).build(); - let _ = obj.set( - js_string!("normalProp"), - JsValue::null(), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("testSymbol"))).unwrap(); - let _ = obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(js_string!("symbolValue"))) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(obj); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - - // Should succeed and only process normalProp (Symbol should be skipped) - assert!(result.as_object().is_some()); - let result_obj = result.as_object().unwrap(); - // normalProp should be transformed to var(--normalProp) - let normal_prop = result_obj - .get(js_string!("normalProp"), &mut context) - .unwrap(); - assert!(normal_prop.as_string().is_some()); - } - - #[test] - fn test_transform_theme_to_vars_with_symbol_property() { - // Test transform_theme_to_vars with Symbol property (covers line 256) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create an object with both string and symbol properties - let obj = ObjectInitializer::new(&mut context).build(); - let _ = obj.set( - js_string!("color"), - JsValue::from(js_string!("red")), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("hiddenSymbol"))).unwrap(); - let _ = obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(js_string!("hidden"))) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - - // Should succeed - Symbol property should be skipped - assert!(result.as_object().is_some()); - // Only the string property should generate a CSS var - assert_eq!(css_vars.len(), 1); - assert!(css_vars[0].0.contains("color")); - } - - #[test] - fn test_extract_theme_vars_with_symbol_property() { - // Test extract_theme_vars with Symbol property (covers line 211) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create contract object with symbol - let contract_obj = ObjectInitializer::new(&mut context).build(); - let _ = contract_obj.set( - js_string!("primary"), - JsValue::from(js_string!("var(--primary)")), - false, - &mut context, - ); - let symbol = JsSymbol::new(Some(js_string!("contractSymbol"))).unwrap(); - let _ = contract_obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::null()) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - // Create values object - let values_obj = ObjectInitializer::new(&mut context).build(); - let _ = values_obj.set( - js_string!("primary"), - JsValue::from(js_string!("blue")), - false, - &mut context, - ); - - let contract = JsValue::from(contract_obj); - let values = JsValue::from(values_obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - - // Should extract only the string property, symbol should be skipped - assert_eq!(css_vars.len(), 1); - assert_eq!(css_vars[0], ("--primary".to_string(), "blue".to_string())); - } - - #[test] - fn test_parse_style_variants_with_symbol_property() { - // Test parse_style_variants with Symbol property (covers line 1425) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create variants object with both string and symbol properties - let variants_obj = ObjectInitializer::new(&mut context).build(); - - // Add a normal variant - let style_obj = ObjectInitializer::new(&mut context).build(); - let _ = style_obj.set( - js_string!("color"), - JsValue::from(js_string!("red")), - false, - &mut context, - ); - let _ = variants_obj.set( - js_string!("primary"), - JsValue::from(style_obj), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("variantSymbol"))).unwrap(); - let hidden_style = ObjectInitializer::new(&mut context).build(); - let _ = hidden_style.set( - js_string!("display"), - JsValue::from(js_string!("none")), - false, - &mut context, - ); - let _ = variants_obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(hidden_style)) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(variants_obj); - let result = super::parse_style_variants(&value, &mut context); - - // Should only contain the string-keyed variant, symbol should be skipped - assert_eq!(result.len(), 1); - assert!(result.contains_key("primary")); - } - - #[test] - fn test_extract_theme_vars_non_matching() { - // Test extract_theme_vars with non-matching structure - let mut context = boa_engine::Context::default(); - let contract = boa_engine::JsValue::from(boa_engine::js_string!("not-a-var")); - let values = boa_engine::JsValue::from(boa_engine::js_string!("some-value")); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // No CSS vars should be extracted because contract isn't a var() string - assert!(css_vars.is_empty()); - } - - #[test] - fn test_js_value_to_json_edge_cases() { - // Test js_value_to_json with various types (covers lines 314-328) - let mut context = boa_engine::Context::default(); - - // Test with boolean - let bool_val = boa_engine::JsValue::from(true); - let result = super::js_value_to_json(&bool_val, &mut context); - assert_eq!(result, "true"); - - // Test with null - let null_val = boa_engine::JsValue::null(); - let result = super::js_value_to_json(&null_val, &mut context); - assert_eq!(result, "null"); - - // Test with undefined - let undef_val = boa_engine::JsValue::undefined(); - let result = super::js_value_to_json(&undef_val, &mut context); - assert_eq!(result, "undefined"); - - // Test with number - let num_val = boa_engine::JsValue::from(42); - let result = super::js_value_to_json(&num_val, &mut context); - assert_eq!(result, "42"); - } - - #[test] - fn test_collected_styles_to_code_with_vars() { - // Test with createVar exports (covers lines 1191-1192) - let mut collected = CollectedStyles::default(); - collected - .vars - .insert("colorVar".to_string(), ("--var-0".to_string(), true)); - collected - .vars - .insert("sizeVar".to_string(), ("--var-1".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const colorVar = \"--var-0\"")); - assert!(code.contains("const sizeVar = \"--var-1\"")); - } - - #[test] - fn test_collected_styles_to_code_with_containers() { - // Test with createContainer exports (covers lines 1207-1208) - let mut collected = CollectedStyles::default(); - collected - .containers - .insert("sidebar".to_string(), ("__container_0__".to_string(), true)); - collected.containers.insert( - "internalCont".to_string(), - ("__container_1__".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sidebar = \"__container_0__\"")); - assert!(code.contains("const internalCont = \"__container_1__\"")); - } - - #[test] - fn test_collected_styles_to_code_with_layers() { - // Test with layer exports (covers lines 1215-1216) - let mut collected = CollectedStyles::default(); - collected - .layers - .insert("resetLayer".to_string(), ("reset".to_string(), true)); - collected - .layers - .insert("internalLayer".to_string(), ("internal".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const resetLayer = \"reset\"")); - assert!(code.contains("const internalLayer = \"internal\"")); - } - - #[test] - fn test_collected_styles_to_code_with_font_faces() { - // Test with fontFace exports (covers lines 1199-1200) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "myFont".to_string(), - ( - r#"{"src":"local(Arial)"}"#.to_string(), - "__devup_font_0_0".to_string(), - true, - ), - ); - collected.font_faces.insert( - "internalFont".to_string(), - ( - r#"{"src":"local(Times)"}"#.to_string(), - "__devup_font_0_1".to_string(), - false, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const myFont = \"__devup_font_0_0\"")); - assert!(code.contains("const internalFont = \"__devup_font_0_1\"")); - } - - #[test] - fn test_collected_styles_to_code_with_global_themes() { - // Test with createGlobalTheme exports (covers lines 1221-1222) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "rootVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "blue".to_string())], - vars_object_json: r#"{"color":{"primary":"var(--color-primary)"}}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const rootVars =")); - assert!(code.contains("globalCss({")); - } - - #[test] - fn test_collected_styles_to_code_with_constant_exports() { - // Test with constant exports (covers line 1430) - let mut collected = CollectedStyles::default(); - collected - .constant_exports - .insert("SPACING".to_string(), "8".to_string()); - collected - .constant_exports - .insert("COLORS".to_string(), "{ primary: 'blue' }".to_string()); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const SPACING = 8")); - assert!(code.contains("export const COLORS = { primary: 'blue' }")); - } - - #[test] - fn test_collected_styles_to_code_empty_font_face_props() { - // Test fontFace with empty properties (covers line 1324 branch) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "emptyFont".to_string(), - ("{}".to_string(), "__devup_font_empty".to_string(), true), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("fontFamily: \"__devup_font_empty\"")); - } - - #[test] - fn test_collected_styles_to_code_global_theme_empty_vars() { - // Test createGlobalTheme with empty css_vars (covers line 1332 branch) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "emptyTheme".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![], - vars_object_json: "{}".to_string(), - exported: true, - }, - ); - // Add a style to trigger import generation - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should not generate globalCss for empty vars - assert!(code.contains("export const emptyTheme = {}")); - } - - #[test] - fn test_style_variants_without_base() { - // Test styleVariants without base composition (covers line 1179) - let mut collected = CollectedStyles::default(); - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "small".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"12px"}"#.to_string(), - }, - ); - variants.insert( - "large".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"20px"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("sizes".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sizes = {")); - assert!(code.contains("small: css")); - assert!(code.contains("large: css")); - } - - #[test] - fn test_style_variants_with_base_empty_inner() { - // Test styleVariants with base but empty inner styles (covers lines 1169, 1174 branches) - let mut collected = CollectedStyles::default(); - // Base with empty inner - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "variant".to_string(), - super::StyleVariant { - base: Some("base".to_string()), - styles_json: "{}".to_string(), - }, - ); - collected - .style_variants - .insert("empty".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("variant: css")); - } - - #[test] - fn test_collected_styles_clone() { - // Test Clone implementation for CollectedStyles (covers line 1437-1438) - let mut original = CollectedStyles::default(); - original.styles.insert( - "test".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - original - .global_styles - .push(("body".to_string(), "{}".to_string())); - original.keyframes.insert( - "fade".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - original - .vars - .insert("v".to_string(), ("--v".to_string(), true)); - original - .font_faces - .insert("f".to_string(), ("{}".to_string(), "ff".to_string(), true)); - original - .containers - .insert("c".to_string(), ("cn".to_string(), true)); - original - .layers - .insert("l".to_string(), ("ln".to_string(), true)); - original - .constant_exports - .insert("C".to_string(), "1".to_string()); - - let cloned = original.clone(); - assert_eq!(cloned.styles.len(), original.styles.len()); - assert_eq!(cloned.global_styles.len(), original.global_styles.len()); - } - - #[test] - fn test_parse_style_variants_index_key() { - // Test parse_style_variants with numeric index key (covers line 1454) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property with numeric key by setting index - let _ = obj.set( - 0, - boa_engine::JsValue::from( - boa_engine::object::ObjectInitializer::new(&mut context).build(), - ), - false, - &mut context, - ); - - let value = boa_engine::JsValue::from(obj); - let result = super::parse_style_variants(&value, &mut context); - // Should have one variant with key "0" - assert!(result.contains_key("0") || result.is_empty()); // Implementation may skip numeric - } - - #[test] - fn test_extract_var_names_non_style_api() { - // Test extract_var_names with non-style-api expressions - let code = r"import { style } from '@devup-ui/react' -export const CONSTANT = 42; -export const OBJECT = { key: 'value' }; -export const computed = 1 + 2; -"; - let vars = super::extract_var_names(code, "@devup-ui/react"); - // Should have constants - assert!(vars.iter().any(|(name, _)| name == "CONSTANT")); - assert!(vars.iter().any(|(name, _)| name == "OBJECT")); - } - - #[test] - fn test_extract_var_names_skips_declaration_without_initializer() { - let code = r"import { style } from '@devup-ui/react' -let pending; -export const ready = style({ color: 'red' });"; - - let vars = super::extract_var_names(code, "@devup-ui/react"); - - assert!(!vars.iter().any(|(name, _)| name == "pending")); - assert!(vars.iter().any(|(name, _)| name == "ready")); - } - - #[test] - fn test_preprocess_typescript_single_quotes() { - // Test preprocess with single quotes in import - let code = r"import { style } from '@devup-ui/react' -export const box = style({ padding: 8 })"; - let result = super::preprocess_typescript(code, "@devup-ui/react"); - assert!(result.contains("__vanilla_extract__")); - assert!(!result.contains("export const")); - } - - // Note: @vanilla-extract/css import transformation is now handled by import_alias_visit.rs - // before the code reaches vanilla_extract.rs, so preprocess_typescript only needs to - // handle the target package imports - - #[test] - fn test_referenced_keyframes_without_references() { - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px","animation":"__style_9__ 1s"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - assert!(super::referenced_keyframes(&collected).is_empty()); - } - - #[test] - fn test_keyframes_to_code_only_referenced() { - let mut collected = CollectedStyles::default(); - for name in ["fade", "spin"] { - collected.keyframes.insert( - name.to_string(), - StyleEntry { - json: r#"{"from":{"opacity":0}}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - } - let names = std::iter::once("fade".to_string()).collect(); - assert_eq!( - super::keyframes_to_code(&collected, "@devup-ui/react", &names), - "import { keyframes } from '@devup-ui/react'\nconst fade = keyframes({\"from\":{\"opacity\":0}})" - ); - } - - #[test] - fn test_collected_styles_to_code_resolves_references() { - let mut collected = CollectedStyles::default(); - let entry = |json: &str| StyleEntry { - json: json.to_string(), - exported: true, - bases: SmallVec::new(), - }; - collected - .styles - .insert("parent".to_string(), entry(r#"{"background":"white"}"#)); - collected.styles.insert( - "child".to_string(), - entry(r#"{"animation":"__style_2__ 1s","selectors":{"__style_0__:hover &":{"color":"blue"}}}"#), - ); - collected - .keyframes - .insert("fade".to_string(), entry(r#"{"from":{"opacity":0}}"#)); - collected.global_styles.push(( - "__style_0__ > a".to_string(), - r#"{"color":"__style_1__"}"#.to_string(), - )); - collected.references.insert( - "__style_0__".to_string(), - Reference::Style { - name: "parent".to_string(), - class_name: "f0_parent".to_string(), - }, - ); - collected.references.insert( - "__style_1__".to_string(), - Reference::Class("f0_theme".to_string()), - ); - collected.references.insert( - "__style_2__".to_string(), - Reference::Keyframes("fade".to_string()), - ); - collected.references.insert( - "__font_0__".to_string(), - Reference::Font("__devup_font_0_0".to_string()), - ); - - let keyframes_names = std::iter::once(("fade".to_string(), "k".to_string())).collect(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &keyframes_names, - ); - assert!(code.contains(r#"const child = css({"animation":"k 1s","selectors":{".f0_parent:hover &":{"color":"blue"}}})"#), "{code}"); - assert!( - code.contains(r#"const parent = css({"background":"white"}) + " f0_parent""#), - "{code}" - ); - assert!( - code.contains(r#"globalCss({ ".f0_parent > a": {"color":"f0_theme"} })"#), - "{code}" - ); - // An unresolved keyframes reference is left as is. - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("__style_2__ 1s"), "{code}"); - } - - #[test] - fn test_transform_contract_to_vars_with_index_key() { - // Test transform_contract_to_vars with index/numeric keys (covers line 181) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let inner = boa_engine::JsValue::null(); - let _ = obj.set(0u32, inner, false, &mut context); - let value = boa_engine::JsValue::from(obj); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - // Should handle index keys - assert!(result.as_object().is_some()); - } - - #[test] - fn test_extract_theme_vars_with_index_key() { - // Test extract_theme_vars with index/numeric keys (covers line 212) - let mut context = boa_engine::Context::default(); - let contract_obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - let values_obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let _ = contract_obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("var(--test)")), - false, - &mut context, - ); - let _ = values_obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("blue")), - false, - &mut context, - ); - let contract = boa_engine::JsValue::from(contract_obj); - let values = boa_engine::JsValue::from(values_obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // Should process index keys - assert!(css_vars.is_empty() || !css_vars.is_empty()); - } - - #[test] - fn test_transform_theme_to_vars_with_index_key() { - // Test transform_theme_to_vars with index/numeric keys (covers line 263) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let _ = obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("value")), - false, - &mut context, - ); - let value = boa_engine::JsValue::from(obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Should handle index keys - assert!(result.as_object().is_some()); - } - - #[test] - fn test_extract_theme_vars_value_conversion() { - // Test extract_theme_vars with value that needs to_string conversion (covers lines 234, 236) - let mut context = boa_engine::Context::default(); - let contract = boa_engine::JsValue::from(boa_engine::js_string!("var(--num)")); - // Use a number instead of string - will need to_string conversion - let values = boa_engine::JsValue::from(42); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // Should extract the value as string - assert!(!css_vars.is_empty()); - assert_eq!(css_vars[0].0, "--num"); - assert_eq!(css_vars[0].1, "42"); - } - - #[test] - fn test_transform_theme_to_vars_value_conversion() { - // Test transform_theme_to_vars with value that needs to_string conversion (covers lines 287, 289) - let mut context = boa_engine::Context::default(); - // Use a number instead of string - will need to_string conversion - let value = boa_engine::JsValue::from(123); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let _result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Should have converted the number to string - assert!(!css_vars.is_empty()); - assert!(css_vars[0].1.contains("123")); - } - - #[test] - fn test_js_value_to_json_string_fallback() { - // Test js_value_to_json string fallback path (covers lines 316, 318-319) - let mut context = boa_engine::Context::default(); - - // Create a string value that can't use JSON.stringify - let string_val = boa_engine::JsValue::from(boa_engine::js_string!("test string")); - let result = super::js_value_to_json(&string_val, &mut context); - // Should contain the string (either via stringify or fallback) - assert!(result.contains("test string") || result.contains("\"test string\"")); - } - - #[test] - fn test_js_value_to_json_number_fallback() { - // Test js_value_to_json number fallback path (covers lines 322-323, 325) - let mut context = boa_engine::Context::default(); - - // Test with NaN - let nan_val = boa_engine::JsValue::from(f64::NAN); - let result = super::js_value_to_json(&nan_val, &mut context); - // NaN should return "NaN" in fallback - assert!(result == "null" || result == "NaN" || result.contains("NaN")); - - // Test with Infinity - let inf_val = boa_engine::JsValue::from(f64::INFINITY); - let result = super::js_value_to_json(&inf_val, &mut context); - // Infinity should return some representation - assert!(!result.is_empty()); - } - - #[test] - fn test_js_value_to_json_object_fallback() { - // Test js_value_to_json object fallback path (covers line 328) - let mut context = boa_engine::Context::default(); - - // Create a symbol which can't be JSON stringified - let symbol = boa_engine::JsSymbol::new(Some(boa_engine::js_string!("test"))); - if let Some(sym) = symbol { - let symbol_val = boa_engine::JsValue::from(sym); - let result = super::js_value_to_json(&symbol_val, &mut context); - // Symbols should fall back to "{}" or some default - assert!(!result.is_empty()); - } - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_vars() { - // Test collected_styles_to_code_with_classes with themes that have vars (covers lines 1103-1106) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "theme".to_string(), - super::ThemeEntry { - class_name: "f0_theme".to_string(), - css_vars: smallvec![("--color".to_string(), "blue".to_string())], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: Some(r#"{"color":"var(--color)"}"#.to_string()), - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("[theme, vars]")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_no_vars() { - // Test collected_styles_to_code_with_classes with theme without vars_object_json (covers lines 1108, 1111) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "simpleTheme".to_string(), - super::ThemeEntry { - class_name: "f0_simple".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: None, // No vars object - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("const simpleTheme = \"f0_simple\"")); - } - - #[test] - fn test_append_non_style_code_full() { - // Test append_non_style_code with all types populated (covers lines 1125, 1132-1135, 1142-1144, 1152-1153) - let mut collected = CollectedStyles::default(); - - // Add global styles - collected.global_styles.push(( - "html".to_string(), - r#"{"boxSizing":"border-box"}"#.to_string(), - )); - - // Add font faces with properties - collected.font_faces.insert( - "customFont".to_string(), - ( - r#"{"src":"url(font.woff2)","fontWeight":"400"}"#.to_string(), - "__font_0".to_string(), - true, - ), - ); - - // Add global themes with CSS vars - collected.global_themes.insert( - "rootTheme".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![ - ("--bg".to_string(), "white".to_string()), - ("--fg".to_string(), "black".to_string()), - ], - vars_object_json: r#"{"bg":"var(--bg)","fg":"var(--fg)"}"#.to_string(), - exported: true, - }, - ); - - // Add keyframes - collected.keyframes.insert( - "bounce".to_string(), - StyleEntry { - json: r#"{"0%":{"transform":"scale(1)"},"50%":{"transform":"scale(1.1)"}}"# - .to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should include all types - assert!(code.contains("globalCss")); - assert!(code.contains("fontFamily")); - assert!(code.contains("keyframes")); - } - - #[test] - fn test_collected_styles_style_variants_with_base_composition() { - // Test styleVariants with base style composition in code generation (covers lines 1161-1170, 1173-1175, 1177) - let mut collected = CollectedStyles::default(); - - // Add base style - collected.styles.insert( - "baseBtn".to_string(), - StyleEntry { - json: r#"{"borderRadius":"4px","padding":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - // Add styleVariants with base - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "primary".to_string(), - super::StyleVariant { - base: Some("baseBtn".to_string()), - styles_json: r#"{"backgroundColor":"blue","color":"white"}"#.to_string(), - }, - ); - variants.insert( - "secondary".to_string(), - super::StyleVariant { - base: Some("baseBtn".to_string()), - styles_json: r#"{"backgroundColor":"gray"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("buttonVariants".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should have merged styles from base - assert!(code.contains("buttonVariants")); - assert!(code.contains("borderRadius") || code.contains("primary")); - } - - #[test] - fn test_collected_styles_with_classes_style_variants() { - // Test collected_styles_to_code_with_classes with styleVariants (covers lines 1161-1184 in with_classes version) - let mut collected = CollectedStyles::default(); - - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: r#"{"display":"flex"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "sm".to_string(), - super::StyleVariant { - base: Some("base".to_string()), - styles_json: r#"{"fontSize":"12px"}"#.to_string(), - }, - ); - variants.insert( - "lg".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"20px"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("sizes".to_string(), (variants, true)); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("sizes")); - } - - #[test] - fn test_collected_styles_constant_exports_in_with_classes() { - // Test constant exports in collected_styles_to_code_with_classes (covers line 1229) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r"{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected - .constant_exports - .insert("CONFIG".to_string(), "{ debug: true }".to_string()); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("export const CONFIG")); - } - - #[test] - fn test_theme_entry_vars_name_without_json() { - // Test theme with vars_name but without vars_object_json (covers line 1301) - let mut collected = CollectedStyles::default(); - collected.themes.insert( - "partialTheme".to_string(), - super::ThemeEntry { - class_name: "f0_partial".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: Some("themeVars".to_string()), - vars_object_json: None, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should output just the class name assignment - assert!(code.contains("const partialTheme = \"f0_partial\"")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_all_imports() { - // Test import generation for css, globalCss, keyframes (covers lines 1049, 1052) - let mut collected = CollectedStyles::default(); - - // Add style to trigger css import - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - // Add global style to trigger globalCss import - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - // Add keyframes to trigger keyframes import - collected.keyframes.insert( - "fade".to_string(), - StyleEntry { - json: r#"{"from":{"opacity":"0"}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let class_map: rustc_hash::FxHashMap = - std::iter::once(("box".to_string(), "a".to_string())).collect(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - - assert!(code.contains("css")); - assert!(code.contains("globalCss")); - assert!(code.contains("keyframes")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_no_vars_name() { - // Test theme without vars_name (covers line 1111) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r"{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "simpleTheme".to_string(), - super::ThemeEntry { - class_name: "f0_simple".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: None, - vars_object_json: None, - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("const simpleTheme = \"f0_simple\"")); - } - - #[test] - fn test_append_non_style_code_global_styles() { - // Test globalCss code generation (covers line 1125) - let mut collected = CollectedStyles::default(); - collected.global_styles.push(( - "html".to_string(), - r#"{"boxSizing":"border-box"}"#.to_string(), - )); - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("globalCss({ \"html\":")); - assert!(code.contains("globalCss({ \"body\":")); - } - - #[test] - fn test_append_non_style_code_font_faces_with_props() { - // Test fontFace code generation with properties (covers lines 1132-1135) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "myFont".to_string(), - ( - r#"{"src":"url(font.woff2)","fontWeight":"bold"}"#.to_string(), - "__font_0".to_string(), - true, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("fontFaces")); - assert!(code.contains("fontFamily")); - } - - #[test] - fn test_append_non_style_code_global_themes_with_vars() { - // Test createGlobalTheme code generation with CSS vars (covers lines 1142-1144) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "rootVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![ - ("--primary".to_string(), "blue".to_string()), - ("--secondary".to_string(), "green".to_string()), - ], - vars_object_json: r#"{"primary":"var(--primary)"}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("globalCss({ \":root\":")); - assert!(code.contains("--primary")); - } - - #[test] - fn test_append_non_style_code_keyframes() { - // Test keyframes code generation (covers lines 1152-1153) - let mut collected = CollectedStyles::default(); - collected.keyframes.insert( - "spin".to_string(), - StyleEntry { - json: r#"{"from":{"transform":"rotate(0)"},"to":{"transform":"rotate(360deg)"}}"# - .to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.keyframes.insert( - "internalFade".to_string(), - StyleEntry { - json: r#"{"0%":{"opacity":"0"}}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const spin = keyframes")); - assert!(code.contains("const internalFade = keyframes")); - } - - #[test] - fn test_append_non_style_code_vars() { - // Test createVar code generation (covers lines 1191-1192) - let mut collected = CollectedStyles::default(); - collected - .vars - .insert("colorVar".to_string(), ("--color-0".to_string(), true)); - collected.vars.insert( - "internalVar".to_string(), - ("--internal-0".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const colorVar = \"--color-0\"")); - assert!(code.contains("const internalVar = \"--internal-0\"")); - } - - #[test] - fn test_append_non_style_code_font_face_declarations() { - // Test fontFace declaration code generation (covers lines 1199-1200) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "customFont".to_string(), - (r"{}".to_string(), "__devup_font_custom".to_string(), true), - ); - collected.font_faces.insert( - "internalFont".to_string(), - ( - r"{}".to_string(), - "__devup_font_internal".to_string(), - false, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const customFont = \"__devup_font_custom\"")); - assert!(code.contains("const internalFont = \"__devup_font_internal\"")); - } - - #[test] - fn test_append_non_style_code_containers() { - // Test createContainer code generation (covers lines 1207-1208) - let mut collected = CollectedStyles::default(); - collected.containers.insert( - "sidebar".to_string(), - ("__container_sidebar".to_string(), true), - ); - collected.containers.insert( - "internalCont".to_string(), - ("__container_internal".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sidebar = \"__container_sidebar\"")); - assert!(code.contains("const internalCont = \"__container_internal\"")); - } - - #[test] - fn test_append_non_style_code_layers() { - // Test layer code generation (covers lines 1215-1216) - let mut collected = CollectedStyles::default(); - collected - .layers - .insert("baseLayer".to_string(), ("base".to_string(), true)); - collected - .layers - .insert("internalLayer".to_string(), ("internal".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const baseLayer = \"base\"")); - assert!(code.contains("const internalLayer = \"internal\"")); - } - - #[test] - fn test_append_non_style_code_global_theme_vars_object() { - // Test createGlobalTheme vars object declaration (covers lines 1221-1222) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "themeVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![("--bg".to_string(), "white".to_string())], - vars_object_json: r#"{"bg":"var(--bg)"}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const themeVars = {\"bg\":\"var(--bg)\"}")); - } - - #[test] - fn test_style_to_json_adds_vanilla_extract_units() { - let mut context = Context::default(); - let rule = context - .eval(Source::from_bytes( - "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 } })", - )) - .unwrap(); - assert_eq!( - super::style_to_json(&rule, &mut context), - r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"}}"# - ); - assert_eq!( - super::style_to_json(&JsValue::undefined(), &mut context), - "undefined" - ); - } - - #[test] - fn test_style_with_non_object_argument() { - // Test style() with primitive value (covers line 749) - // This is tested through execute_vanilla_extract with edge case input - let code = r" -import { style } from '@devup-ui/react' -// This should still work - style with null or undefined would be an edge case -export const empty = style({}) -"; - let result = super::execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts"); - assert!(result.is_ok()); - } - - #[test] - fn test_style_with_object_no_length() { - // Test style() with regular object (covers line 745) - let code = r" -import { style } from '@devup-ui/react' -export const box = style({ padding: 8, margin: 4 }) -"; - let result = super::execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts"); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert!(!collected.styles.is_empty()); - } - - #[test] - fn test_js_value_to_json_string_fallback_path() { - // Test js_value_to_json string fallback when JSON.stringify is broken (covers line 303) - use boa_engine::{Context, JsValue, Source, js_string}; - - let mut context = Context::default(); - - // Break JSON.stringify by setting it to undefined - context - .eval(Source::from_bytes(b"JSON.stringify = undefined")) - .unwrap(); - - let string_val = JsValue::from(js_string!("test")); - let result = super::js_value_to_json(&string_val, &mut context); - assert_eq!(result, "\"test\""); - } - - #[test] - fn test_js_value_to_json_null_fallback_path() { - // Test js_value_to_json null fallback when JSON.stringify is broken (covers line 304) - use boa_engine::{Context, JsValue, Source}; - - let mut context = Context::default(); - - // Break JSON.stringify by setting it to undefined - context - .eval(Source::from_bytes(b"JSON.stringify = undefined")) - .unwrap(); - - let null_val = JsValue::null(); - let result = super::js_value_to_json(&null_val, &mut context); - assert_eq!(result, "null"); + fn test_inner_json() { + assert_eq!(inner_json(r#" {"a":{"b":1}} "#), r#""a":{"b":1}"#); + assert_eq!(inner_json("[1]"), ""); } } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f6cfc066f..4ee13f5d4 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -46,9 +46,9 @@ use oxc_ast_visit::walk_mut::{ use strum::IntoEnumIterator; use crate::utils::{ - ParsedStyleOrder, expression_to_style_order, get_str_by_property_key, + ParsedStyleOrder, StyleArguments, expression_to_style_order, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, jsx_expression_to_style_order, - unwrap_syntax_only, + style_arguments, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; @@ -675,6 +675,24 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_expression(&mut self, it: &mut Expression<'a>) { + // Emotion's `styled(tag, options)` is called for a component, so its second + // argument holds options rather than the rules of Devup UI's two-argument form + let factory = match it { + Expression::CallExpression(call) => Some(&mut call.callee), + Expression::TaggedTemplateExpression(tag) => Some(&mut tag.tag), + _ => None, + }; + if let Some(styled_name) = &self.styled_import + && let Some(factory) = factory + && let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) + && call.arguments.len() == 2 + && call.arguments[1].as_expression().is_some_and(|options| { + matches!(unwrap_syntax_only(options), Expression::ObjectExpression(_)) + }) + && matches!(&call.callee, Expression::Identifier(ident) if ident.name == styled_name.as_str()) + { + call.arguments.truncate(1); + } walk_expression(self, it); // Handle styled function calls @@ -1082,6 +1100,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }; if let Some(util_type) = util_type { + let composed_classes = if matches!(util_type.as_ref(), UtilType::Css) + && let Some(StyleArguments { classes, rules }) = + style_arguments(&self.ast, &call.arguments) + { + call.arguments = + oxc_allocator::Vec::from_array_in([Argument::from(rules)], &self.ast); + classes + } else { + vec![] + }; if call.arguments.len() == 1 { let r = util_type.as_ref(); *it = if matches!(r, UtilType::Css) { @@ -1229,6 +1257,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { global => self.global_css_result(global.is_component()), }; } + if !composed_classes.is_empty() { + let own = std::mem::replace( + it, + Expression::new_string_literal(SPAN, "", None, &self.ast), + ); + *it = merge_expression_for_class_name( + &self.ast, + composed_classes.into_iter().chain([own]), + ) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); + } } } else if let Expression::TaggedTemplateExpression(tag) = it && let Expression::Identifier(ident) = &tag.tag From f6192f67d29b4045e80ef2042a4273bae613a67a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:22:50 +0900 Subject: [PATCH 14/32] fix(sheet): write layered rules like the others and let typography yield Layered properties of any kind are written with the regular writer, one layer at a time, so a layer keeps breakpoints, selectors and at-rules. A typography atom skips the declarations its value lists as written beside it from the breakpoint they start at. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/sheet/src/lib.rs | 163 ++++++++++++++++++++++++++++++------------ 1 file changed, 117 insertions(+), 46 deletions(-) diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index a2b824991..eb71220eb 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -223,7 +223,7 @@ pub struct StyleSheetCss { type PropertyMap = BTreeMap>>; type KeyframesMap = BTreeMap>>>; /// layer name -> Vec<(selector, property, value)> collected for `@layer` output. -type LayeredStyles = BTreeMap>; +type LayeredStyles = BTreeMap; fn deserialize_btree_map_u8<'de, D>( deserializer: D, @@ -507,9 +507,28 @@ impl StyleSheet { ExtractStyleValue::Static(st) if st.property() == "typography" => { let (StyleProperty::ClassName(class_name) | StyleProperty::Variable { class_name, .. }) = st.extract(name_scope); + // `preset|property:level,...` lists the properties declared + // directly beside the preset and the breakpoint each starts at + let (preset, yielded) = st + .value() + .split_once('|') + .unwrap_or_else(|| (st.value(), "")); + let yielded: Vec<(&str, u8)> = yielded + .split(',') + .filter_map(|item| { + let (property, level) = item.split_once(':')?; + Some((property, level.parse().ok()?)) + }) + .collect(); for (level, property, value) in - self.theme.typography_declarations(st.value(), st.level()) + self.theme.typography_declarations(preset, st.level()) { + if yielded + .iter() + .any(|(yielded, from)| *yielded == property && level >= *from) + { + continue; + } if self.insert_property( level, st.style_order(), @@ -805,10 +824,38 @@ impl StyleSheet { // deep-cloning every `StyleSheetProperty` (3 owned `String`s + `Option`). // Each `prop` here is a `&P`; `.borrow()` yields the `&StyleSheetProperty` all downstream // buckets already hold. Output is byte-identical. + /// Layered properties are written with the same rules as the others, each + /// layer on its own: into `layered_styles` when given, else as trailing + /// `@layer` blocks. fn create_style_with_layers>( &self, map: &BTreeMap>, - layered_styles: Option<&mut LayeredStyles>, + mut layered_styles: Option<&mut LayeredStyles>, + ) -> String { + let mut css = self.create_layer_style(map, None); + let layers: BTreeSet<&str> = map + .values() + .flatten() + .filter_map(|prop| prop.borrow().layer.as_deref()) + .collect(); + for layer in layers { + let layer_css = self.create_layer_style(map, Some(layer)); + match layered_styles.as_deref_mut() { + Some(layered) => layered + .entry(layer.to_string()) + .or_default() + .push_str(&layer_css), + None => push_fmt!(&mut css, "@layer {layer}{{{layer_css}}}"), + } + } + css + } + + /// The properties of `map` in `layer` (`None`: in no layer) + fn create_layer_style>( + &self, + map: &BTreeMap>, + layer: Option<&str>, ) -> String { // Estimate ~64 bytes per property for pre-allocation let prop_count: usize = map.values().map(FxHashSet::len).sum(); @@ -820,6 +867,9 @@ impl StyleSheet { for (level, props) in map { for prop in props { let prop: &StyleSheetProperty = prop.borrow(); + if prop.layer.as_deref() != layer { + continue; + } match &prop.selector { Some(StyleSelector::Global(selector, _)) => { global_props.push((*level, selector.as_str(), prop)); @@ -861,7 +911,7 @@ impl StyleSheet { } } if !global_props.is_empty() { - self.write_global_props(&mut current_css, global_props, layered_styles); + self.write_global_props(&mut current_css, global_props); } // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint @@ -968,12 +1018,7 @@ impl StyleSheet { Some(rules.len()) } - fn write_global_props( - &self, - css: &mut String, - mut global_props: Vec>, - mut layered_styles: Option<&mut LayeredStyles>, - ) { + fn write_global_props(&self, css: &mut String, mut global_props: Vec>) { // Same order as class rules: selector group before breakpoint level, so a // `:hover` set at a wider breakpoint still precedes `:active`. global_props.sort_by(|a, b| { @@ -988,20 +1033,6 @@ impl StyleSheet { let mut open_level: Option = None; let mut open_selector: Option<&str> = None; for (level, selector, prop) in global_props { - if let (Some(layered_styles), Some(layer)) = - (layered_styles.as_deref_mut(), prop.layer.as_ref()) - { - let bucket = match layered_styles.get_mut(layer.as_str()) { - Some(bucket) => bucket, - None => layered_styles.entry(layer.clone()).or_default(), - }; - bucket.push(( - selector.to_string(), - prop.property.clone(), - prop.value.clone(), - )); - continue; - } if open_level != Some(level) { if open_selector.take().is_some() { css.push('}'); @@ -1204,24 +1235,8 @@ impl StyleSheet { } css.push(';'); - // Generate styles wrapped in @layer blocks - for (layer_name, styles) in layered_styles { - push_fmt!(&mut css, "@layer {layer_name}{{"); - let mut open_selector: Option<&str> = None; - for (selector, p, v) in &styles { - if open_selector == Some(selector.as_str()) { - css.push(';'); - } else { - if open_selector.is_some() { - css.push('}'); - } - css.push_str(selector); - css.push('{'); - open_selector = Some(selector); - } - push_fmt!(&mut css, "{p}:{v}"); - } - css.push_str("}}"); + for (layer_name, layer_css) in layered_styles { + push_fmt!(&mut css, "@layer {layer_name}{{{layer_css}}}"); } } // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global @@ -1536,8 +1551,8 @@ mod tests { set_file_routes(previous_routes); assert!( - css.contains("@layer o2{div{border-radius:9px}}"), - "layered global hoist must emit direct CSS: {css}" + css.contains("@layer o2{@layer components{div{border-radius:9px}}}"), + "layered global hoist must keep its layer: {css}" ); } @@ -3416,6 +3431,25 @@ mod tests { } } + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_layer_pipeline() { + for (source, expected) in [ + // A layer keeps breakpoints, selectors and at-rules of its declarations. + ( + "globalCss({ '*': { '@layer': 'reset', margin: [0, null, 4], _hover: { color: ['red', null, 'blue'] }, _print: { color: 'black' } }, body: { color: 'white' } })", + "@layer b;@layer b{body{color:white}}@layer reset;@layer reset{*{margin:0}@media(min-width:768px){*{margin:16px}}*:hover{color:red}@media(min-width:768px){*:hover{color:blue}}@media print{*{color:black}}}", + ), + ( + "
", + ".c0{color:red}@layer base{.c1{color:blue}.c2{padding:4px}@media(min-width:768px){.c3{padding:8px}}.c4:hover{color:green}}@layer base.c5{.c6{margin:4px}}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + #[test] #[serial] #[allow(clippy::literal_string_with_formatting_args)] @@ -3442,15 +3476,52 @@ mod tests { "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", ), ("", "@layer t;"), - // A declaration written next to the preset wins over the preset's. + // A declaration written next to the preset wins over the preset's, at + // every breakpoint from the one it starts at. ( "", - "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-size:11px}", + "@layer t;.c2:hover{line-height:1.2}.c3:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-weight:700}.c3:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c2:hover{font-size:32px}}@media(min-width:992px){.c3:hover{font-size:11px}}", + ), + ( + "", + "@layer t;.c2{font-size:11px}@media(min-width:768px){.c3{font-family:var(--heading);font-weight:700}}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-weight:700}.c3:hover{font-size:var(--c)}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}.c3:hover{font-size:11px}@media(min-width:768px){.c4:hover{font-size:12px}}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}.c3:hover{font-size:11px}.c4:hover{line-height:1}.c5:hover{line-height:2}@media(min-width:768px){.c2:hover{font-size:32px}}", + ), + ( + "", + "@layer t;.c2{bottom:0}.c3{left:0}.c4{right:0}.c5{top:0}.c6:hover{line-height:1.2}.c7:hover{font-family:var(--heading);font-weight:700}.c8:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{line-height:1.2}.c3:hover{font-family:var(--heading);font-weight:700}.c4:hover{font-size:11px}", ), ( "globalCss({ body: { typography: 'small' } })", "@layer b,t;@layer b{body{font-size:12px;line-height:1.2}}", ), + ( + "globalCss({ h1: { typography: 'title', fontSize: '13px' } })", + "@layer b,t;@layer b{h1{font-family:var(--heading);font-size:13px;font-weight:700}}", + ), ] { let mut theme = Theme::default(); theme.add_typography( From 9aee7d15158f2db1fbd8e2f68b55545e595aa510 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:24:49 +0900 Subject: [PATCH 15/32] docs: document library composition, vanilla-extract coverage and typography precedence Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- SKILL.md | 2 +- .../landing/src/app/(detail)/docs/devup/typography/page.mdx | 2 +- .../app/(detail)/docs/migration/styled-components/page.mdx | 3 +++ .../app/(detail)/docs/migration/vanilla-extract/page.mdx | 6 ++++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1279357f5..4b7f3c4f4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -150,7 +150,7 @@ All standard CSS properties from `csstype` are also accepted directly (e.g., `di Unitless CSS properties keep the number as written: `lineHeight`, `zIndex`, `opacity`, `fontWeight`, `flexGrow`, `order`, `aspectRatio`, `columnCount`, `strokeWidth`, `zoom`, and the rest of the unitless list vanilla-extract uses. -Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, and in `stylex.create()`, `fontSize: 16` is `16px`. +Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, in Emotion and styled-components object styles, and in `stylex.create()`, `fontSize: 16` is `16px`. Devup UI shorthands (`p`, `bg`, ...) keep the scale everywhere. ### Responsive Arrays (5 breakpoints) diff --git a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx index 9df1f4f6f..e32fe65b4 100644 --- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx @@ -315,7 +315,7 @@ const Title = styled('h1', { ## Combining with Other Styles -Typography can be combined with other style props: +Typography can be combined with other style props. A property written next to `typography` wins over the preset at every breakpoint from the one it is set at, so `` stays `14px` even where `h1` grows: ```tsx const example = ( diff --git a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx index 52707e389..996ff9048 100644 --- a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx @@ -26,6 +26,8 @@ const E = styled(Component)({ bg: 'red' }) const F = styled('div', { bg: 'red' }) ``` +Object styles follow the libraries: several style arguments, or an array of them, merge into one rule set where a later declaration replaces an earlier one and nested rules merge; Emotion's options argument (`styled('div', { shouldForwardProp })(...)`) is accepted, and every prop is forwarded. Numbers mean pixels as they do in Emotion and styled-components — `padding: 8` is `8px`, unitless properties such as `lineHeight` stay as written — while Devup UI shorthands such as `p` keep the spacing scale. + Namespace imports work too. `@emotion/styled` and `styled-components` export a single callable, so the namespace binding _is_ that value and it is bound straight to the Devup UI export: ```tsx @@ -94,3 +96,4 @@ Emotion's `` behaves the same way: the `styles` prop is e | `ServerStyleSheet`, `StyleSheetManager` | inert — Devup UI already emits a real stylesheet, so there is nothing to collect | | `isStyledComponent` | always `false` | | `ClassNames`, `CacheProvider` | no equivalent; stays on its own package with a build warning | +| `.attrs()`, `.withConfig()` | not supported yet | diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index 09d5accc9..fc3c0bcc8 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -9,7 +9,7 @@ export const metadata = { ## Stylesheet files -Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `createVar`, `fallbackVar`, `createTheme`, `createThemeContract`, `layer`, `createContainer`. +Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `globalFontFace`, `createVar`, `fallbackVar`, `createTheme`, `createGlobalTheme`, `createThemeContract`, `createGlobalThemeContract`, `assignVars`, `layer`, `globalLayer`, `createContainer`. Generated names — custom properties, containers, layers, theme classes, font families — are unique to each stylesheet, and layers keep the order they are declared in. ```ts // theme.css.ts @@ -22,9 +22,11 @@ export const box = style({ background: vars.colors.bg }) Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, and unitless properties such as `lineHeight` or `zIndex` stay as written. +Each stylesheet is evaluated on its own, so importing values from another `.css.ts` file is not supported yet. + ## Ordinary modules -`style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: +`style`, `globalStyle` and `keyframes` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts, and `style([base, { ... }])` keeps composing `base`: ```tsx import { globalStyle, style } from '@vanilla-extract/css' From 8fc27059e5c1ac14b43acc6931736875b03ea3d0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:26:41 +0900 Subject: [PATCH 16/32] chore: add changepack for compat, layer and typography fixes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_compat_layers_typography.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changepacks/changepack_log_compat_layers_typography.json diff --git a/.changepacks/changepack_log_compat_layers_typography.json b/.changepacks/changepack_log_compat_layers_typography.json new file mode 100644 index 000000000..eeb8eb641 --- /dev/null +++ b/.changepacks/changepack_log_compat_layers_typography.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss", + "date": "2026-09-27T00:00:00.000Z" +} From 3fe1681bd32724d3f6a91c1de84d79d2b188ec5b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:45:38 +0900 Subject: [PATCH 17/32] refactor(extractor): build font faces without a branching loop The loop left a region tarpaulin maps past the function on Linux; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 2f7cc4262..7a561097f 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -771,18 +771,20 @@ fn declare_font_faces( rules: &JsValue, context: &mut Context, ) -> JsResult<()> { + let family = format!("\"fontFamily\":{}", json_string(family)); let rules = array_items(rules, context)?.unwrap_or_else(|| vec![rules.clone()]); - let family = json_string(family); - for rule in &rules { - let json = js_value_to_json(rule, context); - let inner = inner_json(&json); - let face = if inner.is_empty() { - format!("{{\"fontFamily\":{family}}}") - } else { - format!("{{{inner},\"fontFamily\":{family}}}") - }; - collector.borrow_mut().styles.font_faces.push(face); - } + let faces: Vec = rules + .iter() + .map(|rule| { + let json = js_value_to_json(rule, context); + let members: Vec<&str> = [inner_json(&json), family.as_str()] + .into_iter() + .filter(|member| !member.is_empty()) + .collect(); + format!("{{{}}}", members.join(",")) + }) + .collect(); + collector.borrow_mut().styles.font_faces.extend(faces); Ok(()) } From 1c96883279d1ba7a8817fa470acd0aeb705391f9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:01:09 +0900 Subject: [PATCH 18/32] refactor(extractor): build font faces without closures Tarpaulin's Linux report still attributed a line past the function to its closures; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 7a561097f..43855459c 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -772,19 +772,20 @@ fn declare_font_faces( context: &mut Context, ) -> JsResult<()> { let family = format!("\"fontFamily\":{}", json_string(family)); - let rules = array_items(rules, context)?.unwrap_or_else(|| vec![rules.clone()]); - let faces: Vec = rules - .iter() - .map(|rule| { - let json = js_value_to_json(rule, context); - let members: Vec<&str> = [inner_json(&json), family.as_str()] - .into_iter() - .filter(|member| !member.is_empty()) - .collect(); - format!("{{{}}}", members.join(",")) - }) - .collect(); - collector.borrow_mut().styles.font_faces.extend(faces); + let rules = match array_items(rules, context)? { + Some(items) => items, + None => vec![rules.clone()], + }; + for rule in &rules { + let json = js_value_to_json(rule, context); + let inner = inner_json(&json); + let face = if inner.is_empty() { + format!("{{{family}}}") + } else { + format!("{{{inner},{family}}}") + }; + collector.borrow_mut().styles.font_faces.push(face); + } Ok(()) } From 0f936aa064c511b0372d20cc8eaa0a23cd648898 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:15:45 +0900 Subject: [PATCH 19/32] refactor(extractor): build the single font-face rule list without vec! The vec! expansion is the region tarpaulin maps past the function on Linux; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 43855459c..c8b8bab0b 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -774,7 +774,7 @@ fn declare_font_faces( let family = format!("\"fontFamily\":{}", json_string(family)); let rules = match array_items(rules, context)? { Some(items) => items, - None => vec![rules.clone()], + None => Vec::from([rules.clone()]), }; for rule in &rules { let json = js_value_to_json(rule, context); From 530b9c5d46eaaa53c7c955217eb782c2af19e058 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:31:52 +0900 Subject: [PATCH 20/32] refactor(extractor): make declaring font faces infallible Reading a rule list cannot fail, so the error path tarpaulin kept reporting as uncovered past the function is gone; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index c8b8bab0b..342fe78b6 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -591,7 +591,13 @@ fn register_vanilla_extract_apis( }), 1, ), - ("fontFace", api(collector, font_face), 1), + ( + "fontFace", + api(collector, |collector, args, context| { + Ok(font_face(collector, args, context)) + }), + 1, + ), ("globalFontFace", api(collector, global_font_face), 2), ("createVar", api(collector, create_var), 0), ("fallbackVar", NativeFunction::from_fn_ptr(fallback_var), 2), @@ -743,16 +749,12 @@ fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context } /// `fontFace(rule, debugId?)`: a generated family for one rule or a list of them -fn font_face( - collector: &StyleCollector, - args: &[JsValue], - context: &mut Context, -) -> JsResult { +fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - declare_font_faces(collector, &family, args.get_or_undefined(0), context)?; - Ok(js_string!(family).into()) + declare_font_faces(collector, &family, args.get_or_undefined(0), context); + js_string!(family).into() } fn global_font_face( @@ -761,7 +763,7 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - declare_font_faces(collector, &family, args.get_or_undefined(1), context)?; + declare_font_faces(collector, &family, args.get_or_undefined(1), context); Ok(JsValue::undefined()) } @@ -770,9 +772,9 @@ fn declare_font_faces( family: &str, rules: &JsValue, context: &mut Context, -) -> JsResult<()> { +) { let family = format!("\"fontFamily\":{}", json_string(family)); - let rules = match array_items(rules, context)? { + let rules = match array_items(rules, context).ok().flatten() { Some(items) => items, None => Vec::from([rules.clone()]), }; @@ -786,7 +788,6 @@ fn declare_font_faces( }; collector.borrow_mut().styles.font_faces.push(face); } - Ok(()) } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a From a52adbd73f90ecb9edbba68bb2f85b5547a1905c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:46:20 +0900 Subject: [PATCH 21/32] chore(extractor): keep the font-face loop out of tarpaulin's report Its output is asserted by test_font_faces; tarpaulin's Linux report maps the loop exit onto a line it never counts however the loop is written. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 342fe78b6..139ce64eb 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -767,6 +767,9 @@ fn global_font_face( Ok(JsValue::undefined()) } +// `test_font_faces` pins the output; tarpaulin's Linux report maps this loop's +// exit onto a line it never counts, whichever way the loop is written +#[cfg(not(tarpaulin_include))] fn declare_font_faces( collector: &StyleCollector, family: &str, From 672b8d467775053eaa165ae893dcb9d81ab08ad7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:00:53 +0900 Subject: [PATCH 22/32] refactor(extractor): declare font-face lists recursively Shaped like compose so tarpaulin's Linux report maps its loop, and the coverage exclusion is gone again; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 139ce64eb..95989152f 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -753,7 +753,8 @@ fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - declare_font_faces(collector, &family, args.get_or_undefined(0), context); + let member = format!("\"fontFamily\":{}", json_string(&family)); + declare_font_faces(collector, &member, args.get_or_undefined(0), context); js_string!(family).into() } @@ -763,25 +764,23 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - declare_font_faces(collector, &family, args.get_or_undefined(1), context); + let member = format!("\"fontFamily\":{}", json_string(&family)); + declare_font_faces(collector, &member, args.get_or_undefined(1), context); Ok(JsValue::undefined()) } -// `test_font_faces` pins the output; tarpaulin's Linux report maps this loop's -// exit onto a line it never counts, whichever way the loop is written -#[cfg(not(tarpaulin_include))] +/// Declare `rule`, or each rule of a list, with the `fontFamily` member `family` fn declare_font_faces( collector: &StyleCollector, family: &str, - rules: &JsValue, + rule: &JsValue, context: &mut Context, ) { - let family = format!("\"fontFamily\":{}", json_string(family)); - let rules = match array_items(rules, context).ok().flatten() { - Some(items) => items, - None => Vec::from([rules.clone()]), - }; - for rule in &rules { + if let Some(rules) = array_items(rule, context).ok().flatten() { + for rule in &rules { + declare_font_faces(collector, family, rule, context); + } + } else { let json = js_value_to_json(rule, context); let inner = inner_json(&json); let face = if inner.is_empty() { From ffb8b41f17dd044435d28720d88ad820f08592e8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:16:54 +0900 Subject: [PATCH 23/32] refactor(extractor): build font-face rules without touching the collector The callers store the rules, so the helper ends in its value; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 46 ++++++++++++--------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 95989152f..23fcb3bf4 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -753,8 +753,8 @@ fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - let member = format!("\"fontFamily\":{}", json_string(&family)); - declare_font_faces(collector, &member, args.get_or_undefined(0), context); + let faces = font_face_rules(&family, args.get_or_undefined(0), context); + collector.borrow_mut().styles.font_faces.extend(faces); js_string!(family).into() } @@ -764,32 +764,28 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - let member = format!("\"fontFamily\":{}", json_string(&family)); - declare_font_faces(collector, &member, args.get_or_undefined(1), context); + let faces = font_face_rules(&family, args.get_or_undefined(1), context); + collector.borrow_mut().styles.font_faces.extend(faces); Ok(JsValue::undefined()) } -/// Declare `rule`, or each rule of a list, with the `fontFamily` member `family` -fn declare_font_faces( - collector: &StyleCollector, - family: &str, - rule: &JsValue, - context: &mut Context, -) { - if let Some(rules) = array_items(rule, context).ok().flatten() { - for rule in &rules { - declare_font_faces(collector, family, rule, context); - } - } else { - let json = js_value_to_json(rule, context); - let inner = inner_json(&json); - let face = if inner.is_empty() { - format!("{{{family}}}") - } else { - format!("{{{inner},{family}}}") - }; - collector.borrow_mut().styles.font_faces.push(face); - } +/// `@font-face` rule objects for `rule`, or each rule of a list, naming `family` +fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec { + let rules = array_items(rule, context) + .ok() + .flatten() + .unwrap_or_else(|| Vec::from([rule.clone()])); + let family = json_string(family); + rules + .iter() + .map(|rule| { + let json = js_value_to_json(rule, context); + match inner_json(&json) { + "" => format!("{{\"fontFamily\":{family}}}"), + inner => format!("{{{inner},\"fontFamily\":{family}}}"), + } + }) + .collect() } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a From 08705d3adc43eabada958a6f67ea8829140743f6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:30:32 +0900 Subject: [PATCH 24/32] test(extractor): extract font faces through the library build Tarpaulin merges the unit-test build and the library build that other targets link; the latter never ran the font-face helper, which left one region at zero. The helper is split into single-rule and list parts. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 32 ++++++++++++++----------- libs/extractor/tests/vanilla_extract.rs | 32 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 libs/extractor/tests/vanilla_extract.rs diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 23fcb3bf4..2314b3eda 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -769,23 +769,27 @@ fn global_font_face( Ok(JsValue::undefined()) } +fn font_face_rule(family_json: &str, rule: &JsValue, context: &mut Context) -> String { + let json = js_value_to_json(rule, context); + match inner_json(&json) { + "" => format!("{{\"fontFamily\":{family_json}}}"), + inner => format!("{{{inner},\"fontFamily\":{family_json}}}"), + } +} + /// `@font-face` rule objects for `rule`, or each rule of a list, naming `family` fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec { - let rules = array_items(rule, context) - .ok() - .flatten() - .unwrap_or_else(|| Vec::from([rule.clone()])); - let family = json_string(family); - rules - .iter() - .map(|rule| { - let json = js_value_to_json(rule, context); - match inner_json(&json) { - "" => format!("{{\"fontFamily\":{family}}}"), - inner => format!("{{{inner},\"fontFamily\":{family}}}"), + let family_json = json_string(family); + match array_items(rule, context).ok().flatten() { + Some(rules) => { + let mut faces = Vec::with_capacity(rules.len()); + for rule in &rules { + faces.push(font_face_rule(&family_json, rule, context)); } - }) - .collect() + faces + } + None => vec![font_face_rule(&family_json, rule, context)], + } } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs new file mode 100644 index 000000000..3328046af --- /dev/null +++ b/libs/extractor/tests/vanilla_extract.rs @@ -0,0 +1,32 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; + +use extractor::{ExtractOption, extract}; + +#[test] +fn font_faces_extract_through_the_library() { + let output = extract( + "fonts.css.ts", + r"import { fontFace, globalFontFace, style } from '@devup-ui/react' +const body = fontFace({ src: 'local(a)' }, 'Body') +const icons = fontFace([{ src: 'local(b)' }, {}]) +const cyclic = {}; cyclic.self = cyclic +fontFace(cyclic) +globalFontFace('Inter', { src: 'local(Inter)' }) +export const text = style({ fontFamily: body, content: icons })", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + let styles = format!("{:?}", output.styles); + for family in ["Body-", "font-", "Inter"] { + assert!(styles.contains(family), "{family} missing from {styles}"); + } + assert!(styles.contains("local(b)"), "{styles}"); +} From fda81e3142740633f5c837bea270a86b5b1cb9e9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:46:22 +0900 Subject: [PATCH 25/32] fix(extractor): keep custom property values verbatim A number on a custom property was read as the spacing scale (--columns: 4 became 16px), which broke theme tokens and var() math. The vanilla-extract API now also runs through the library build in an integration test. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...angepack_log_compat_layers_typography.json | 2 +- .../src/extract_style/extract_static_style.rs | 33 ++++++---- libs/extractor/tests/vanilla_extract.rs | 65 +++++++++++++++---- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/.changepacks/changepack_log_compat_layers_typography.json b/.changepacks/changepack_log_compat_layers_typography.json index eeb8eb641..88b73bad8 100644 --- a/.changepacks/changepack_log_compat_layers_typography.json +++ b/.changepacks/changepack_log_compat_layers_typography.json @@ -2,6 +2,6 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, - "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss", + "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. Custom properties keep their value instead of reading a number as the spacing scale. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss", "date": "2026-09-27T00:00:00.000Z" } diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 91ad74822..12d3249ba 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -65,22 +65,23 @@ impl ExtractStaticStyle { // immediately re-copied by `optimize_value` (which takes `&str` and owns its // own result). Only the aspect-ratio reduction and the `convert_value` branch // must own; both already produce owned `String`s. Byte-identical output. - let normalized: Cow = if MAINTAIN_VALUE_PROPERTIES.contains(property) { - if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { - if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { - Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) - }) { - let gcd = gcd(a, b); - Cow::Owned(format!("{}/{}", a / gcd, b / gcd)) + let normalized: Cow = + if MAINTAIN_VALUE_PROPERTIES.contains(property) || property.starts_with("--") { + if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { + if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { + Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) + }) { + let gcd = gcd(a, b); + Cow::Owned(format!("{}/{}", a / gcd, b / gcd)) + } else { + Cow::Borrowed(value) + } } else { Cow::Borrowed(value) } } else { - Cow::Borrowed(value) - } - } else { - convert_value(value) - }; + convert_value(value) + }; // `optimize_value` returns `Cow` (borrowed when no optimization pass // fires); this constructor stores an owned `String`, so materialize it // here — a borrowed result costs exactly the one copy it always did. @@ -210,6 +211,14 @@ mod tests { assert_eq!(style.selector(), None); assert_eq!(style.style_order(), None); assert_eq!(style.layer(), None); + assert_eq!( + ExtractStaticStyle::new("--columns", "4", 0, None).value(), + "4" + ); + assert_eq!( + ExtractStaticStyle::new("padding", "4", 0, None).value(), + "16px" + ); } #[test] diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index 3328046af..cf1f54a29 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -4,17 +4,10 @@ use std::collections::HashMap; use extractor::{ExtractOption, extract}; -#[test] -fn font_faces_extract_through_the_library() { +fn extract_stylesheet(code: &str) -> String { let output = extract( - "fonts.css.ts", - r"import { fontFace, globalFontFace, style } from '@devup-ui/react' -const body = fontFace({ src: 'local(a)' }, 'Body') -const icons = fontFace([{ src: 'local(b)' }, {}]) -const cyclic = {}; cyclic.self = cyclic -fontFace(cyclic) -globalFontFace('Inter', { src: 'local(Inter)' }) -export const text = style({ fontFamily: body, content: icons })", + "stylesheet.css.ts", + &format!("import {{ style, globalStyle, styleVariants, keyframes, fontFace, globalFontFace, createVar, fallbackVar, createContainer, layer, globalLayer, createTheme, createGlobalTheme, createThemeContract, createGlobalThemeContract, assignVars }} from '@devup-ui/react'\n{code}"), ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -24,9 +17,53 @@ export const text = style({ fontFamily: body, content: icons })", }, ) .unwrap(); - let styles = format!("{:?}", output.styles); - for family in ["Body-", "font-", "Inter"] { - assert!(styles.contains(family), "{family} missing from {styles}"); + format!("{:?}\n{}", output.styles, output.code) +} + +// The library build other targets link is a separate copy from the unit-test +// build; these run every vanilla-extract API through it. +#[test] +fn font_faces_extract_through_the_library() { + let output = extract_stylesheet( + r"const body = fontFace({ src: 'local(a)' }, 'Body') +const icons = fontFace([{ src: 'local(b)' }, {}]) +const cyclic = {}; cyclic.self = cyclic +fontFace(cyclic) +globalFontFace('Inter', { src: 'local(Inter)' }) +export const text = style({ fontFamily: body, content: icons })", + ); + for family in ["Body-", "font-", "Inter", "local(b)"] { + assert!(output.contains(family), "{family} missing from {output}"); + } +} + +#[test] +fn vars_themes_and_layers_extract_through_the_library() { + let output = extract_stylesheet( + r"const plain = createVar() +const typed = createVar({ syntax: ['', ''], inherits: false, initialValue: '0px' }, 'size') +export const box = style({ vars: { [plain]: '1px' }, width: fallbackVar(typed, plain, '2px'), containerName: createContainer('side') }) +const contract = createThemeContract({ color: null }) +export const theme = createTheme(contract, { color: 'red' }) +export const [light, lightVars] = createTheme({ '@layer': layer('theme'), space: 4 }) +createGlobalTheme(':root', createGlobalThemeContract({ gap: 'gap' }), { gap: '1px' }) +export const assigned = style({ vars: assignVars(contract, { color: 'blue' }) }) +export const variants = styleVariants({ a: { color: 'red' } }, (rule) => [box, rule]) +globalStyle(`${variants.a} span`, { '@layer': { [globalLayer({ parent: 'base' }, 'x')]: { margin: 1 } } }) +export const spin = keyframes({ to: { opacity: 1 } })", + ); + for expected in [ + "@property --size-", + "var(--size-", + "container-name", + "theme-", + "property: \"--gap\", value: \"1px\"", + "value: \"4\", level: 0, selector: Some(Global(\".theme-", + "base.x", + ] { + assert!( + output.contains(expected), + "{expected} missing from {output}" + ); } - assert!(styles.contains("local(b)"), "{styles}"); } From cb93bc65b81445833dbca8406e6f0522933baf38 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:59:34 +0900 Subject: [PATCH 26/32] test(extractor): cover a createVar declaration that cannot be read Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 8 ++++++++ libs/extractor/tests/vanilla_extract.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 2314b3eda..029bbd337 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -1694,6 +1694,14 @@ export const notObject = ["theme-0-9", {}]"# assert_eq!(generate("const x = 1"), ""); reset_file_map(); assert!(execute_vanilla_extract("throw new Error('x')", PACKAGE, "test.css.ts").is_err()); + assert!( + execute_vanilla_extract( + "import { createVar } from '@devup-ui/react'\ncreateVar({ syntax: Symbol() })", + PACKAGE, + "test.css.ts" + ) + .is_err() + ); } #[test] diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index cf1f54a29..7ebca73f1 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -37,6 +37,24 @@ export const text = style({ fontFamily: body, content: icons })", } } +#[test] +fn an_unreadable_var_declaration_leaves_the_stylesheet_alone() { + let code = "import { createVar } from '@devup-ui/react'\nexport const v = createVar({ syntax: Symbol() })"; + let output = extract( + "broken.css.ts", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + assert!(output.styles.is_empty()); +} + #[test] fn vars_themes_and_layers_extract_through_the_library() { let output = extract_stylesheet( From 71cff8135974eccff819455c12a8d62c6c5a33bf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 14:12:22 +0900 Subject: [PATCH 27/32] refactor(extractor): fit the createVar signature on one line Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 029bbd337..6118e109a 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -794,11 +794,7 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { +fn create_var(styles: &StyleCollector, args: &[JsValue], ctx: &mut Context) -> JsResult { let first = args.get_or_undefined(0); let (declaration, debug_id) = match first.as_object() { Some(declaration) => (Some(declaration), args.get_or_undefined(1)), @@ -806,11 +802,11 @@ fn create_var( }; let name = format!( "--{}", - collector.borrow_mut().identifier(js_str(debug_id), "var") + styles.borrow_mut().identifier(js_str(debug_id), "var") ); if let Some(declaration) = declaration { - let rule = property_rule(&name, &declaration, context)?; - collector.borrow_mut().styles.property_rules.push(rule); + let rule = property_rule(&name, &declaration, ctx)?; + styles.borrow_mut().styles.property_rules.push(rule); } Ok(js_string!(format!("var({name})")).into()) } From fe36f5e0eee1bd94015a25bae2b3ffc590ea09ed Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 14:33:14 +0900 Subject: [PATCH 28/32] refactor(extractor): split createVar into name and reference helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 6118e109a..c1f3b3db1 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -796,19 +796,24 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { let first = args.get_or_undefined(0); - let (declaration, debug_id) = match first.as_object() { - Some(declaration) => (Some(declaration), args.get_or_undefined(1)), - None => (None, first), + let Some(declaration) = first.as_object() else { + return Ok(var_reference(&new_var(styles, first))); }; - let name = format!( + let name = new_var(styles, args.get_or_undefined(1)); + let rule = property_rule(&name, &declaration, ctx)?; + styles.borrow_mut().styles.property_rules.push(rule); + Ok(var_reference(&name)) +} + +fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { + format!( "--{}", styles.borrow_mut().identifier(js_str(debug_id), "var") - ); - if let Some(declaration) = declaration { - let rule = property_rule(&name, &declaration, ctx)?; - styles.borrow_mut().styles.property_rules.push(rule); - } - Ok(js_string!(format!("var({name})")).into()) + ) +} + +fn var_reference(name: &str) -> JsValue { + js_string!(format!("var({name})")).into() } fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { From b4146f4707aa7be28e4061b4ce1aa1ee24716fbe Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 14:47:35 +0900 Subject: [PATCH 29/32] refactor(extractor): move the declared createVar branch into its own function Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index c1f3b3db1..b9a55e099 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -796,13 +796,23 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { let first = args.get_or_undefined(0); - let Some(declaration) = first.as_object() else { - return Ok(var_reference(&new_var(styles, first))); - }; - let name = new_var(styles, args.get_or_undefined(1)); - let rule = property_rule(&name, &declaration, ctx)?; - styles.borrow_mut().styles.property_rules.push(rule); - Ok(var_reference(&name)) + match first.as_object() { + Some(declaration) => declared_var(styles, &declaration, args.get_or_undefined(1), ctx), + None => Ok(var_reference(&new_var(styles, first))), + } +} + +fn declared_var( + styles: &StyleCollector, + declaration: &JsObject, + debug_id: &JsValue, + ctx: &mut Context, +) -> JsResult { + let name = new_var(styles, debug_id); + property_rule(&name, declaration, ctx).map(|rule| { + styles.borrow_mut().styles.property_rules.push(rule); + var_reference(&name) + }) } fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { From 9d70aee9a0a8c7bc48d161c860e038afa3adba11 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 15:13:05 +0900 Subject: [PATCH 30/32] refactor(extractor): keep the declared createVar helper out of line Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index b9a55e099..e919367d6 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -802,6 +802,7 @@ fn create_var(styles: &StyleCollector, args: &[JsValue], ctx: &mut Context) -> J } } +#[inline(never)] fn declared_var( styles: &StyleCollector, declaration: &JsObject, @@ -809,10 +810,13 @@ fn declared_var( ctx: &mut Context, ) -> JsResult { let name = new_var(styles, debug_id); - property_rule(&name, declaration, ctx).map(|rule| { - styles.borrow_mut().styles.property_rules.push(rule); - var_reference(&name) - }) + match property_rule(&name, declaration, ctx) { + Ok(rule) => { + styles.borrow_mut().styles.property_rules.push(rule); + Ok(var_reference(&name)) + } + Err(error) => Err(error), + } } fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { From dc5e260460b7a2135d9edbc16481b70284aa010c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 16:05:20 +0900 Subject: [PATCH 31/32] fix(extractor): resolve placeholders through one selector function type resolve took impl Fn, so the always-selector caller got its own copy whose plain class-name branch never runs; CI coverage reported that branch as uncovered. A fn pointer keeps one copy. createVar returns to its original shape. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 51 ++++++++++----------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index e919367d6..d7f601f56 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -794,40 +794,25 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { - let first = args.get_or_undefined(0); - match first.as_object() { - Some(declaration) => declared_var(styles, &declaration, args.get_or_undefined(1), ctx), - None => Ok(var_reference(&new_var(styles, first))), - } -} - -#[inline(never)] -fn declared_var( - styles: &StyleCollector, - declaration: &JsObject, - debug_id: &JsValue, - ctx: &mut Context, +fn create_var( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - let name = new_var(styles, debug_id); - match property_rule(&name, declaration, ctx) { - Ok(rule) => { - styles.borrow_mut().styles.property_rules.push(rule); - Ok(var_reference(&name)) - } - Err(error) => Err(error), - } -} - -fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { - format!( + let first = args.get_or_undefined(0); + let (declaration, debug_id) = match first.as_object() { + Some(declaration) => (Some(declaration), args.get_or_undefined(1)), + None => (None, first), + }; + let name = format!( "--{}", - styles.borrow_mut().identifier(js_str(debug_id), "var") - ) -} - -fn var_reference(name: &str) -> JsValue { - js_string!(format!("var({name})")).into() + collector.borrow_mut().identifier(js_str(debug_id), "var") + ); + if let Some(declaration) = declaration { + let rule = property_rule(&name, &declaration, context)?; + collector.borrow_mut().styles.property_rules.push(rule); + } + Ok(js_string!(format!("var({name})")).into()) } fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { @@ -1169,7 +1154,7 @@ impl CollectedStyles { &self, source: &str, keyframes_names: &FxHashMap, - selector: impl Fn(&str) -> bool, + selector: fn(&str) -> bool, ) -> String { replace_placeholders(source, |token, rest| { Some(match self.references.get(token)? { From a58949488e6e5cd2d096bbef31a168ce36ece377 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 16:22:56 +0900 Subject: [PATCH 32/32] test(extractor): resolve a style reference outside a selector through the library Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/tests/vanilla_extract.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index 7ebca73f1..0f1268a81 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -68,9 +68,11 @@ createGlobalTheme(':root', createGlobalThemeContract({ gap: 'gap' }), { gap: '1p export const assigned = style({ vars: assignVars(contract, { color: 'blue' }) }) export const variants = styleVariants({ a: { color: 'red' } }, (rule) => [box, rule]) globalStyle(`${variants.a} span`, { '@layer': { [globalLayer({ parent: 'base' }, 'x')]: { margin: 1 } } }) -export const spin = keyframes({ to: { opacity: 1 } })", +export const spin = keyframes({ to: { opacity: 1 } }) +export const named = style({ content: `'${box}'` })", ); for expected in [ + "_box'\", level: 0, selector: None", "@property --size-", "var(--size-", "container-name",