diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json new file mode 100644 index 000000000..6aa4c50d9 --- /dev/null +++ b/.changepacks/changepack_log_compose_known_styles.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json new file mode 100644 index 000000000..4d05b2838 --- /dev/null +++ b/.changepacks/changepack_log_compose_styled.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_dead_css_property_types.json b/.changepacks/changepack_log_dead_css_property_types.json new file mode 100644 index 000000000..b63675779 --- /dev/null +++ b/.changepacks/changepack_log_dead_css_property_types.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/react/package.json": "Patch" + }, + "note": "Reject the 18 unprefixed legacy flexbox and scroll-snap properties rejected by the compiler in component and StyleX property types. Keep vendor-prefixed compatibility properties, stroke-color and ime-mode.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_class_names.json b/.changepacks/changepack_log_emotion_class_names.json new file mode 100644 index 000000000..9317e2ecf --- /dev/null +++ b/.changepacks/changepack_log_emotion_class_names.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's now compiles at build time with no runtime: the element becomes what its child function ({ css, cx, theme }) => ... renders, each css and cx call becomes the classes it composes (later styles replace earlier ones; css reads strings as CSS text and its tagged templates split around mixins; cx keeps unknown classes, reads { name: condition } objects as conditional classes and composes nested css and cx calls), and theme.a.b in their values becomes var(--a-b). Numbers in css rules are px lengths, as Emotion reads them, and top-level constants are inlined. New build errors, each at file:line:col: a without exactly one child function taking { css, cx, theme } that gives what it renders at once (attributes, other children, rest or default parameters, async or generator functions, several statements); reading the theme other than as theme.a.b in a css value; using css or cx other than by calling them; a cx object with a spread, getter or method; parts css and cx cannot compose (runtime calls, spreads, a condition choosing between styles and classes). @devup-ui/react/compat exports ClassNames with the ClassNamesContent and ClassNamesArg types, and the @emotion/react declarations include them", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_component_selectors.json b/.changepacks/changepack_log_emotion_component_selectors.json new file mode 100644 index 000000000..98c3f7259 --- /dev/null +++ b/.changepacks/changepack_log_emotion_component_selectors.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Emotion and styled-components component selectors now compile at build time: a styled component the file defines that another style selects (${Child} { ... } in CSS text, [Child] or [`&:hover ${Child}`] as a rule key) gets a short marker class, which only such components get, and the selector reads it as .marker; a component extending it, or an element inlining it for its css prop, keeps the marker. Selectors written without & now nest under the component as stylis nests them (ul { ... } is & ul, a selector list nests each selector, :hover applies to the component itself) instead of becoming global rules, and rule-object keys starting with ., #, [, *, >, + or ~ are read as nested selectors instead of properties", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_css_prop.json b/.changepacks/changepack_log_emotion_css_prop.json new file mode 100644 index 000000000..71e82e1df --- /dev/null +++ b/.changepacks/changepack_log_emotion_css_prop.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's css prop compiles at build time while @emotion/react is aliased: on tags and Devup UI components always, and on every element once the file imports @emotion/react or @emotion/styled or names @emotion/react in a @jsxImportSource pragma; jsx, jsxs and jsxDEV from @emotion/react/jsx-runtime (or jsx-dev-runtime) and jsx from @emotion/react compile it too, so libraries built with Emotion's JSX runtime do as well. The prop becomes the element's className, with CSS variables in its style for values only the runtime gives: arrays and conditions compose with a later part replacing what an earlier one sets, css() classes the file knows compose by their styles, strings and templates are CSS text, css`` mixins split the text around them, a function of the theme reads theme.a.b as var(--a-b), unitless numbers are px as in Emotion (constants included), and a className holding known css() classes overrides the prop as Emotion's registered classes do. A styled component the file defines renders its tag in the element's place when the prop overrides its styles and it renders a tag with no attrs or props read and the element has no spread, as or forwardedAs. Emotion's JSX runtime imports become react/jsx-runtime, the pragma names react, and jsx comes from @devup-ui/react/compat (React's createElement); @devup-ui/react/compat/css-prop types the prop on React.Attributes. New build errors, each with file:line:column and the code: a css prop part the build cannot read (a call, an element), a style object declared inside a function or with let, a binding only running the module gives or code changes, a theme function that does more than return rules or reads the theme other than as theme.a.b in a value, an interpolation CSS text cannot place or a mixin inside a nested rule, and a css prop overriding a styled component's styles where its tag cannot be rendered in place. Composing css() with a part that reads a known binding (such as a keyframes name) now reads its value", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_jsx_import_source.json b/.changepacks/changepack_log_emotion_jsx_import_source.json new file mode 100644 index 000000000..ceb232995 --- /dev/null +++ b/.changepacks/changepack_log_emotion_jsx_import_source.json @@ -0,0 +1,14 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/react/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "A project whose tsconfig.json (or jsconfig.json) builds JSX with Emotion (compilerOptions.jsxImportSource of @emotion/react, through extends and project references too) now compiles the css prop on every element of every file, and its .tsx and .jsx files are built with React's JSX runtime through a /** @jsxImportSource react */ pragma, which a file's own JSX pragma overrides, so the bundler no longer imports Emotion's runtime. The plugins read the setting into a new @emotion/react/jsx-runtime alias (importAliases can turn it off with false, and it is left out when @emotion/react is not aliased); @devup-ui/react/compat/emotion-jsx-runtime declares Emotion's JSX runtimes as React's so the project type-checks without Emotion installed. plugin-utils exports readJsxImportSource, and mergeImportAliases takes the JSX import source", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_eslint_alias_rules.json b/.changepacks/changepack_log_eslint_alias_rules.json new file mode 100644 index 000000000..d5c9a818b --- /dev/null +++ b/.changepacks/changepack_log_eslint_alias_rules.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "ESLint rules now check exactly what the build reads. The array, typography, media and styleOrder rules and css-utils-literal-only follow the import aliases the build compiles by default (@emotion/react, @emotion/styled, styled-components, @vanilla-extract/css) and the rules of styled (styled.div({...}), styled(\"div\", {...}), styled(Base)({...}, {...}); not .attrs()), createGlobalStyle({...}) and the styles of Emotion Global, and check and fix a module-level const object that only styles read. Vanilla-extract stylesheets (.css.ts/.css.js) are no longer read as Devup UI. New rule no-runtime-read reports css/keyframes/styled/Box read as a value (\"`X` is read at runtime, where it does not exist\"), and css-utils-literal-only reports globals that do not exist where a .css.ts is evaluated (window, document, process, ...). No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_eslint_composition_arrays.json b/.changepacks/changepack_log_eslint_composition_arrays.json new file mode 100644 index 000000000..c17f4db47 --- /dev/null +++ b/.changepacks/changepack_log_eslint_composition_arrays.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Do not treat composition arrays as responsive property values in the duplicate, trailing-null or single-value array rules. Preserve composition arrays and their nested responsive property diagnostics.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_shorthand_target_validation.json b/.changepacks/changepack_log_shorthand_target_validation.json new file mode 100644 index 000000000..5bae9f6be --- /dev/null +++ b/.changepacks/changepack_log_shorthand_target_validation.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Custom shorthand targets are constrained to supported camelCase/kebab-case CSS properties, built-in aliases and valid custom properties. All five build adapters normalize options at creation before registration or extraction, including Next.js production cache reuse. Unsupported targets fail registration atomically with the original shorthand name, target and zero-based index; previously registered shorthands remain unchanged. New devup-ui option errors expose structured logical shorthands paths without inventing source coordinates. Eighteen dead unprefixed property names are rejected.", + "date": "2026-10-05T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_style_order_contract.json b/.changepacks/changepack_log_style_order_contract.json new file mode 100644 index 000000000..cee54744f --- /dev/null +++ b/.changepacks/changepack_log_style_order_contract.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Validate explicit static styleOrder as an integer from 1 to 254 or its canonical decimal digit string, consistently in extraction and lint, including composed Emotion css props. Keep order metadata out of library px conversion. Invalid static values and conditional branches now produce located build errors instead of silently changing cascade order.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_forward_ref.json b/.changepacks/changepack_log_styled_forward_ref.json new file mode 100644 index 000000000..7dd26ffbb --- /dev/null +++ b/.changepacks/changepack_log_styled_forward_ref.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components the build generates are wrapped in React's forwardRef, so a ref passed to them reaches the element they render on React 18 as well as React 19; the file imports forwardRef from react when it defines one", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_props.json b/.changepacks/changepack_log_styled_props.json new file mode 100644 index 000000000..8dfbb440e --- /dev/null +++ b/.changepacks/changepack_log_styled_props.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components no longer pass every prop to the tag they render: `$` props, `theme` and the props their style functions or attrs read are kept away from a tag unless it takes them as attributes, and `shouldForwardProp` (Emotion options or styled-components withConfig) is evaluated at build time. A `shouldForwardProp` the build cannot evaluate is a build error naming the file, line and code, with the forms it accepts. An element using a styled component the file defines drops props the component neither reads nor passes on; spread props are passed as written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_targets.json b/.changepacks/changepack_log_styled_targets.json new file mode 100644 index 000000000..a51d45fe7 --- /dev/null +++ b/.changepacks/changepack_log_styled_targets.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components render what their `as` prop names, defaulting to the tag or component they were defined with, and pass `forwardedAs` on as `as`, as styled-components and Emotion do; `as` used to reach the rendered tag as an attribute. `Component.withComponent(target)` on a styled component the file binds to a const builds a component rendering the same styles and attrs as `target` (a tag name or a component JSX can name), where it used to call a method the generated function does not have and throw", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_stylex_public_contracts.json b/.changepacks/changepack_log_stylex_public_contracts.json new file mode 100644 index 000000000..8953d6e55 --- /dev/null +++ b/.changepacks/changepack_log_stylex_public_contracts.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/react/package.json": "Patch" + }, + "note": "Type every compiled StyleX API with opaque styles, dynamic function arguments, conditional variables, partial themes and static null/string theme contract placeholders. Unsupported runtime shapes are rejected by TypeScript; no new build errors in this change.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_types_match_build.json b/.changepacks/changepack_log_types_match_build.json new file mode 100644 index 000000000..2865577c2 --- /dev/null +++ b/.changepacks/changepack_log_types_match_build.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/react/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Types now accept what the build compiles and nothing it rejects. css() takes classes, style objects, arrays and falsy parts, and the css/keyframes/globalCss tags take the static interpolations the build evaluates. styled gets .attrs(), .withConfig() and .withComponent(), and its result renders another tag. styleVars takes numbers; styleOrder is 1 to 254; ResponsiveValue is exported for hand-written custom shorthands. The compat declarations name what Emotion, styled-components and vanilla-extract give (SerializedStyles, Keyframes, RuleSet, an augmentable Theme and DefaultTheme, CSSObject and the other styled-components types) and follow the vanilla-extract APIs the evaluator registers (style arrays, the styleVariants mapper, createGlobalTheme, createGlobalThemeContract; globalKeyframes, composeStyles and generateIdentifier are gone). DevupTheme takes shadow as devup.json documents it. No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0e6edbd21..cd3b62a9f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -138,65 +138,6 @@ jobs: path: playwright-report/ retention-days: 1 - # @devup-ui/vite-plugin must keep the CSS every server page needs when the - # build is vinext/RSC. The fixture shares one Devup UI header between server - # list pages and a client page, and the tests read the styles the browser - # actually applies when each route is entered directly, so a dropped - # stylesheet dependency fails here whatever the CSS files are called. - vinext-rsc-css-e2e: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v7 - - - uses: actions-rust-lang/setup-rust-toolchain@v2 - - - name: Cache cargo registry + target - uses: actions/cache@v6 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: cargo-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.4.0" - name: Install bun - - - name: Cache bun dependencies - uses: actions/cache@v6 - with: - path: ~/.bun/install/cache - key: bun-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: "latest" - - name: Install Node.js - uses: actions/setup-node@v7 - with: - node-version: 24 - - run: bun install - - run: bun run build - - name: Cache Playwright Browsers - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} - - name: Install Playwright Browsers - run: bunx playwright install chromium --with-deps - - name: Build fixture (per-file CSS) - run: bun run --filter vinext-rsc-css-repro build - - name: Run E2E Tests (per-file CSS) - run: bun run test:e2e:rsc-css - - name: Build fixture (singleCss) - run: | - rm -rf apps/vinext-rsc-css-repro/dist apps/vinext-rsc-css-repro/.vinext apps/vinext-rsc-css-repro/df - DEVUP_SINGLE_CSS=1 bun run --filter vinext-rsc-css-repro build - - name: Run E2E Tests (singleCss) - run: bun run test:e2e:rsc-css publish: runs-on: ubuntu-latest permissions: diff --git a/apps/vinext-rsc-css-repro/.gitignore b/apps/vinext-rsc-css-repro/.gitignore deleted file mode 100644 index 8a6f6f50f..000000000 --- a/apps/vinext-rsc-css-repro/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/dist/ -/.vinext/ -/.next/ -/df/ -/node_modules/ -next-env.d.ts -*.tsbuildinfo diff --git a/apps/vinext-rsc-css-repro/devup.json b/apps/vinext-rsc-css-repro/devup.json deleted file mode 100644 index ed9fe70ff..000000000 --- a/apps/vinext-rsc-css-repro/devup.json +++ /dev/null @@ -1 +0,0 @@ -{ "theme": {} } diff --git a/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts b/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts deleted file mode 100644 index 6e9187c1f..000000000 --- a/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { expect, type Page, test } from '@playwright/test' - -const ROUTES = ['/notice/', '/admins/', '/users/', '/users/edit/'] as const -const EDIT_ROUTE = '/users/edit/' - -function permutations(items: readonly T[]): T[][] { - if (items.length <= 1) return [[...items]] - return items.flatMap((item, index) => - permutations([...items.slice(0, index), ...items.slice(index + 1)]).map( - (rest) => [item, ...rest], - ), - ) -} - -/** - * The users list links to the edit page and so prefetches it, which also loads - * the CSS its client bundle references. Refuse every request to the edit page - * except its own document, so a page can only get the header CSS from what the - * server rendered for it. - */ -async function blockEditPrefetch(page: Page) { - await page.route('**/users/edit/**', (route) => { - if (route.request().resourceType() === 'document') return route.continue() - return route.abort() - }) -} - -async function expectHeaderStyled(page: Page, route: string) { - const header = page.locator('[data-page-title]') - await expect(header, route).toHaveCount(1) - - const applied = await header.evaluate((element) => { - const read = (target: Element) => { - const style = getComputedStyle(target) - return { - display: style.display, - justifyContent: style.justifyContent, - alignItems: style.alignItems, - marginBottom: style.marginBottom, - columnGap: style.columnGap, - rowGap: style.rowGap, - } - } - const group = element.firstElementChild! - return { header: read(element), group: read(group) } - }) - expect(applied.header, `${route} header`).toMatchObject({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '20px', - }) - expect(applied.group, `${route} title group`).toMatchObject({ - display: 'flex', - alignItems: 'center', - columnGap: '12px', - rowGap: '12px', - }) - - const layout = await header.evaluate((element) => { - const bounds = element.getBoundingClientRect() - const group = element.firstElementChild!.getBoundingClientRect() - const right = element.lastElementChild!.getBoundingClientRect() - return { bounds, group, right, hasRight: element.childElementCount > 1 } - }) - expect(layout.group.left, `${route} title group is at the start`).toBe( - layout.bounds.left, - ) - if (layout.hasRight) { - expect(layout.right.right, `${route} right slot is at the end`).toBe( - layout.bounds.right, - ) - } -} - -/** - * Every class the header renders must have a rule in a stylesheet the page - * really loaded (a `` the browser fetched), whatever the files are - * called. - */ -async function expectHeaderCssLoaded(page: Page, route: string) { - const result = await page.locator('[data-page-title]').evaluate((element) => { - const classes = [ - ...element.classList, - ...element.firstElementChild!.classList, - ] - const linked = [...document.styleSheets].filter( - (sheet) => sheet.href !== null, - ) - const collect = (rules: CSSRuleList): string[] => - [...rules].flatMap((rule) => - rule instanceof CSSStyleRule - ? [rule.selectorText] - : 'cssRules' in rule - ? collect(rule.cssRules as CSSRuleList) - : [], - ) - const selectors = linked.flatMap((sheet) => collect(sheet.cssRules)) - return { - linkedSheets: linked.length, - missing: classes.filter( - (name) => - !selectors.some((selector) => - selector.includes(`.${CSS.escape(name)}`), - ), - ), - classes: classes.length, - } - }) - expect(result.classes, `${route} header has atomic classes`).toBeGreaterThan( - 0, - ) - expect(result.linkedSheets, `${route} loaded stylesheets`).toBeGreaterThan(0) - expect(result.missing, `${route} classes without a loaded rule`).toEqual([]) -} - -async function expectRoute(page: Page, route: string) { - await expectHeaderCssLoaded(page, route) - await expectHeaderStyled(page, route) -} - -test.describe('shared Devup UI header in the static vinext build', () => { - for (const route of ROUTES) { - test(`direct entry and reload: ${route}`, async ({ browser }) => { - const context = await browser.newContext() - const page = await context.newPage() - await blockEditPrefetch(page) - - await page.goto(route) - await expectRoute(page, route) - - await page.reload() - await expectRoute(page, route) - await context.close() - }) - } - - for (const order of permutations(ROUTES)) { - test(`entered in order ${order.join(' -> ')}`, async ({ browser }) => { - const context = await browser.newContext() - const page = await context.newPage() - await blockEditPrefetch(page) - - for (const route of order) { - await page.goto(route) - await expectRoute(page, route) - } - await context.close() - }) - } - - test('the edit page itself renders the same header', async ({ page }) => { - await page.goto(EDIT_ROUTE) - await expect(page.getByRole('textbox', { name: 'name' })).toBeVisible() - await expectRoute(page, EDIT_ROUTE) - }) -}) diff --git a/apps/vinext-rsc-css-repro/next.config.ts b/apps/vinext-rsc-css-repro/next.config.ts deleted file mode 100644 index d989d7d9d..000000000 --- a/apps/vinext-rsc-css-repro/next.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - output: 'export', - trailingSlash: true, -} diff --git a/apps/vinext-rsc-css-repro/package.json b/apps/vinext-rsc-css-repro/package.json deleted file mode 100644 index 08e528ec5..000000000 --- a/apps/vinext-rsc-css-repro/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "vinext-rsc-css-repro", - "version": "0.0.0", - "type": "module", - "private": true, - "scripts": { - "build": "vinext build", - "lint": "eslint" - }, - "dependencies": { - "@devup-ui/react": "workspace:^", - "react": "^19.3", - "react-dom": "^19.3", - "react-server-dom-webpack": "^19.3", - "vinext": "1.0.0" - }, - "devDependencies": { - "@devup-ui/vite-plugin": "workspace:^", - "@types/react": "^19", - "@types/react-dom": "^19", - "@vitejs/plugin-react": "^6.1", - "@vitejs/plugin-rsc": "0.5.35", - "typescript": "^7.0.2", - "vite": "8.3.1" - } -} diff --git a/apps/vinext-rsc-css-repro/src/app/admins/page.tsx b/apps/vinext-rsc-css-repro/src/app/admins/page.tsx deleted file mode 100644 index 8e7dd4ad3..000000000 --- a/apps/vinext-rsc-css-repro/src/app/admins/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { PageTitle } from '../../components/PageTitle' - -export default function AdminsPage() { - return ( -
- Create}> - Admins - -
- ) -} diff --git a/apps/vinext-rsc-css-repro/src/app/layout.tsx b/apps/vinext-rsc-css-repro/src/app/layout.tsx deleted file mode 100644 index 07e2db241..000000000 --- a/apps/vinext-rsc-css-repro/src/app/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactNode } from 'react' - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} diff --git a/apps/vinext-rsc-css-repro/src/app/notice/page.tsx b/apps/vinext-rsc-css-repro/src/app/notice/page.tsx deleted file mode 100644 index 1ac64dcb8..000000000 --- a/apps/vinext-rsc-css-repro/src/app/notice/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { PageTitle } from '../../components/PageTitle' - -export default function NoticePage() { - return ( -
- Create}> - Notice - -
- ) -} diff --git a/apps/vinext-rsc-css-repro/src/app/page.tsx b/apps/vinext-rsc-css-repro/src/app/page.tsx deleted file mode 100644 index 89fb876f5..000000000 --- a/apps/vinext-rsc-css-repro/src/app/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Link from 'next/link' - -export default function HomePage() { - return ( -
-

Repro

- notice -
- ) -} diff --git a/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx b/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx deleted file mode 100644 index d23d7c9f5..000000000 --- a/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client' - -import { useState } from 'react' - -import { PageTitle } from '../../../components/PageTitle' - -export default function UserEditPage() { - const [name, setName] = useState('') - return ( -
- Edit user - setName(event.target.value)} - value={name} - /> -
- ) -} diff --git a/apps/vinext-rsc-css-repro/src/app/users/page.tsx b/apps/vinext-rsc-css-repro/src/app/users/page.tsx deleted file mode 100644 index 74a0aaaf1..000000000 --- a/apps/vinext-rsc-css-repro/src/app/users/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Link from 'next/link' - -import { PageTitle } from '../../components/PageTitle' - -export default function UsersPage() { - return ( -
- Create}> - Users - -
- ) -} diff --git a/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx b/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx deleted file mode 100644 index 94b8d3845..000000000 --- a/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, Flex, Text } from '@devup-ui/react' -import type { ReactNode } from 'react' - -export function PageTitle({ - children, - right, -}: { - children: ReactNode - right?: ReactNode -}) { - return ( - - - - {children} - - {right} - - ) -} diff --git a/apps/vinext-rsc-css-repro/tsconfig.json b/apps/vinext-rsc-css-repro/tsconfig.json deleted file mode 100644 index c0b46b24f..000000000 --- a/apps/vinext-rsc-css-repro/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "module": "esnext", - "moduleResolution": "bundler", - "isolatedModules": true, - "jsx": "react-jsx", - "paths": { "@/*": ["./src/*"] } - }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] -} diff --git a/apps/vinext-rsc-css-repro/vite-env.d.ts b/apps/vinext-rsc-css-repro/vite-env.d.ts deleted file mode 100644 index 6a5a1ad59..000000000 --- a/apps/vinext-rsc-css-repro/vite-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/apps/vinext-rsc-css-repro/vite.config.ts b/apps/vinext-rsc-css-repro/vite.config.ts deleted file mode 100644 index 9f163aa1a..000000000 --- a/apps/vinext-rsc-css-repro/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DevupUI } from '@devup-ui/vite-plugin' -import vinext from 'vinext' -import { defineConfig } from 'vite' - -export default defineConfig({ - plugins: [ - DevupUI({ singleCss: process.env.DEVUP_SINGLE_CSS === '1' }), - vinext(), - ], -}) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0a2347ac2..9c775c7b7 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -610,16 +610,17 @@ pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { } /// Internal function to register custom style-property shorthands. -pub fn register_shorthands_internal(shorthands: BTreeMap>) { - css::set_custom_shorthands(shorthands); +pub fn register_shorthands_internal( + shorthands: BTreeMap>, +) -> Result<(), css::shorthand::InvalidShorthandTarget> { + css::set_custom_shorthands(shorthands) } #[wasm_bindgen(js_name = "registerShorthands")] #[cfg(not(tarpaulin_include))] pub fn register_shorthands(shorthands: JsValue) -> Result<(), JsValue> { let shorthands = serde_wasm_bindgen::from_value(shorthands).map_err(js_error)?; - register_shorthands_internal(shorthands); - Ok(()) + register_shorthands_internal(shorthands).map_err(js_error) } #[wasm_bindgen(js_name = "getDefaultTheme")] @@ -1401,7 +1402,7 @@ mod tests { #[test] #[serial] fn test_get_theme_interface() { - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); let sheet = StyleSheet::default(); assert_eq!( sheet.create_interface( @@ -1423,7 +1424,8 @@ mod tests { register_shorthands_internal(BTreeMap::from([( "insetX".to_string(), vec!["left".to_string(), "right".to_string()], - )])); + )])) + .unwrap(); assert_eq!( get_theme_interface( "package", @@ -1437,7 +1439,7 @@ mod tests { ); // test wrong case - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); let mut sheet = StyleSheet::default(); let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); @@ -2021,7 +2023,7 @@ mod tests { )]); register_theme_internal(theme); - register_shorthands_internal(shorthands); + register_shorthands_internal(shorthands).unwrap(); // Verify the theme was registered let default_theme = GLOBAL_STYLE_SHEET.lock().unwrap().theme.get_default_theme(); @@ -2030,6 +2032,45 @@ mod tests { css::disassemble_property("insetX").collect::>(), ["left", "right"] ); - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); + } + + #[test] + #[serial] + fn test_register_shorthands_internal_rejects_targets_atomically() { + register_shorthands_internal(BTreeMap::from([("old".into(), vec!["py".into()])])).unwrap(); + let error = register_shorthands_internal(BTreeMap::from([ + ("aValid".into(), vec!["width".into()]), + ("bad".into(), vec!["height".into(), "widht".into()]), + ])) + .unwrap_err(); + assert_eq!(error.alias, "bad"); + assert_eq!(error.target, "widht"); + assert_eq!(error.index, 1); + assert_eq!(css::get_custom_shorthand_names(), ["old"]); + assert_eq!( + css::disassemble_property("old").collect::>(), + ["padding-top", "padding-bottom"] + ); + register_shorthands_internal(BTreeMap::new()).unwrap(); + } + + #[test] + #[serial] + fn test_register_shorthands_internal_normalizes_vendor_targets() { + register_shorthands_internal(BTreeMap::from([( + "maskAlias".into(), + vec![ + "WebkitMaskImage".into(), + "-webkit-mask-image".into(), + "--Mask".into(), + ], + )])) + .unwrap(); + assert_eq!( + css::disassemble_property("maskAlias").collect::>(), + ["-webkit-mask-image", "-webkit-mask-image", "--Mask"] + ); + register_shorthands_internal(BTreeMap::new()).unwrap(); } } diff --git a/bun.lock b/bun.lock index 906ce66d6..298725c65 100644 --- a/bun.lock +++ b/bun.lock @@ -92,26 +92,6 @@ "@rsbuild/plugin-react": "^2.1", }, }, - "apps/vinext-rsc-css-repro": { - "name": "vinext-rsc-css-repro", - "version": "0.0.0", - "dependencies": { - "@devup-ui/react": "workspace:^", - "react": "^19.3", - "react-dom": "^19.3", - "react-server-dom-webpack": "^19.3", - "vinext": "1.0.0", - }, - "devDependencies": { - "@devup-ui/vite-plugin": "workspace:^", - "@types/react": "^19", - "@types/react-dom": "^19", - "@vitejs/plugin-react": "^6.1", - "@vitejs/plugin-rsc": "0.5.35", - "typescript": "^7.0.2", - "vite": "8.3.1", - }, - }, "apps/vite": { "name": "vite-example", "version": "0.1.0", @@ -2285,7 +2265,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.7.3", "", {}, "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw=="], + "fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="], "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], @@ -2333,8 +2313,6 @@ "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], - "harfbuzzjs": ["harfbuzzjs@0.10.0", "", {}, "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], @@ -3115,8 +3093,6 @@ "vinext-devup-ui-benchmark": ["vinext-devup-ui-benchmark@workspace:benchmark/vinext-devup-ui"], - "vinext-rsc-css-repro": ["vinext-rsc-css-repro@workspace:apps/vinext-rsc-css-repro"], - "vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], "vite-example": ["vite-example@workspace:apps/vite"], @@ -3645,8 +3621,6 @@ "@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@shuding/opentype.js/fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="], - "@stylexjs/babel-plugin/@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], "@stylexjs/babel-plugin/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], @@ -3807,8 +3781,6 @@ "vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "vinext-rsc-css-repro/vinext": ["vinext@1.0.0", "", { "dependencies": { "@unpic/react": "^1.0.2", "@vercel/og": "1.0.3", "@vinext/types": "^1.0.0", "ipaddr.js": "^2.1.0", "magic-string": "^0.30.21", "vite-plugin-commonjs": "^0.10.4", "web-vitals": "^4.2.4" }, "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", "@vitejs/plugin-rsc": "^0.5.34", "react": "^19.2.6", "react-dom": "^19.2.6", "react-server-dom-webpack": "^19.2.6", "vite": "^8.0.0" }, "optionalPeers": ["@mdx-js/rollup", "@vitejs/plugin-rsc", "react-server-dom-webpack"], "bin": { "vinext": "dist/cli.js" } }, "sha512-0PeWLqMoJO6mkPvprneY1mLY12nxxhaGTdr3+sTfVTD9xncwaIXQyhVWy+vrvgMlTOkg2e8MMgOiI3qnEdMp2w=="], - "vite-node/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "vite-node/obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], @@ -5659,12 +5631,6 @@ "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "vinext-rsc-css-repro/vinext/@vercel/og": ["@vercel/og@1.0.3", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.1", "satori": "0.33.5" }, "optionalDependencies": { "sharp": "^0.35.3" } }, "sha512-/iYJ34PDu4lty1TXdL86qHEw8viveHTllG1PWLuJX8tSFfqKL7kyVJeUORJmQ250bHB0h+alcalXUlza3c/NSA=="], - - "vinext-rsc-css-repro/vinext/@vinext/types": ["@vinext/types@1.0.1", "", {}, "sha512-zH4WbVl47rNGxG8Y7XoWJAc8e0VQFQ+r8Cuh0crv1L2SuNovDD3jMtxQDjzoS1b9hYaZXbds0CjRo5tSm7F9Zw=="], - - "vinext-rsc-css-repro/vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@babel/helper-create-class-features-plugin/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -6611,10 +6577,6 @@ "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - "vinext-rsc-css-repro/vinext/@vercel/og/@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.1", "", {}, "sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg=="], - - "vinext-rsc-css-repro/vinext/@vercel/og/satori": ["satori@0.33.5", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "fflate": "0.7.3", "harfbuzzjs": "0.10.0", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-PNW6ktrQ8SM6kk7u4aOuTkkUewkzyTOrj+QeEzXSsnNMAdff4ire9CAqIz8+4C3rCiBtK8QTELv52AZEd1PbRw=="], - "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], @@ -6631,8 +6593,6 @@ "unified-engine/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "vinext-rsc-css-repro/vinext/@vercel/og/satori/css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], - "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e22b6085..e5314c0b3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,4 +62,11 @@ export default [ ignores: ['**/*.md'], }, ...devupUi.configs.recommended, + // the snippets of this page are .css.ts stylesheets, which the build runs as they are + { + files: ['apps/landing/src/app/(detail)/docs/migration/vanilla-extract/**'], + rules: { + '@devup-ui/css-utils-literal-only': 'off', + }, + }, ] diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..3ab3627ad 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -9,8 +9,10 @@ pub mod is_special_property; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; +mod property_names; pub mod rm_css_comment; mod selector_separator; +pub mod shorthand; pub mod style_selector; pub mod theme_tokens; pub mod utils; @@ -257,26 +259,18 @@ static CUSTOM_SHORTHANDS: LazyLock>>> = static HAS_CUSTOM_SHORTHANDS: AtomicBool = AtomicBool::new(false); /// Replace the custom shorthand registry used by style extraction. -pub fn set_custom_shorthands(shorthands: BTreeMap>) { - if let Ok(mut registry) = CUSTOM_SHORTHANDS.write() { - let shorthands: BTreeMap> = shorthands - .into_iter() - .map(|(name, properties)| { - let properties = properties - .into_iter() - .flat_map(|property| { - GLOBAL_STYLE_PROPERTY.get(property.as_str()).map_or_else( - || vec![to_kebab_case(&property).into_owned()], - |mapped| mapped.iter().map(|value| (*value).to_string()).collect(), - ) - }) - .collect(); - (name, properties) - }) - .collect(); - HAS_CUSTOM_SHORTHANDS.store(!shorthands.is_empty(), Ordering::Relaxed); - *registry = shorthands; - } +pub fn set_custom_shorthands( + shorthands: BTreeMap>, +) -> Result<(), shorthand::InvalidShorthandTarget> { + let shorthands = shorthand::normalize_shorthands(shorthands)?; + let mut registry = CUSTOM_SHORTHANDS.write().unwrap_or_else(|poisoned| { + CUSTOM_SHORTHANDS.clear_poison(); + poisoned.into_inner() + }); + HAS_CUSTOM_SHORTHANDS.store(!shorthands.is_empty(), Ordering::Relaxed); + *registry = shorthands; + drop(registry); + Ok(()) } #[must_use] @@ -429,6 +423,25 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S }) } +/// The class marking the component `name` defined in `filename`, which other +/// styles select it by +#[must_use] +pub fn component_marker(name: &str, filename: &str) -> String { + with_prefix(|prefix| { + if is_debug() { + format!("{prefix}c-{name}") + } else { + let class_num = class_num_for_key("", |key| { + key.push_str("c-"); + key.push_str(filename); + key.push('-'); + key.push_str(name); + }); + format!("{prefix}{class_num}") + } + }) +} + /// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`) /// or fall through to the Unicode escape path. const SELECTOR_ENCODE: [Option<&str>; 128] = { @@ -753,6 +766,25 @@ mod tests { ); } + #[test] + #[serial] + fn test_component_marker() { + set_debug(false); + reset_class_map(); + let child = component_marker("Child", "a.tsx"); + assert_eq!(child, "a"); + assert_eq!(component_marker("Child", "a.tsx"), child); + assert_eq!(component_marker("Child", "b.tsx"), "b"); + assert_eq!(component_marker("Other", "a.tsx"), "c"); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, None), + "d" + ); + set_debug(true); + assert_eq!(component_marker("Child", "a.tsx"), "c-Child"); + set_debug(false); + } + #[test] #[serial] fn test_sheet_to_classname() { @@ -1359,7 +1391,7 @@ mod tests { #[test] #[serial] - fn test_custom_shorthand() { + fn test_custom_shorthand() -> Result<(), shorthand::InvalidShorthandTarget> { set_custom_shorthands(BTreeMap::from([( "insetX".to_string(), vec![ @@ -1367,17 +1399,18 @@ mod tests { "marginRight".to_string(), "py".to_string(), ], - )])); + )]))?; assert_eq!( disassemble_property("insetX").collect::>(), ["left", "margin-right", "padding-top", "padding-bottom"] ); - set_custom_shorthands(BTreeMap::new()); + set_custom_shorthands(BTreeMap::new())?; assert_eq!( disassemble_property("insetX").collect::>(), ["inset-x"] ); + Ok(()) } } diff --git a/libs/css/src/property_names.rs b/libs/css/src/property_names.rs new file mode 100644 index 000000000..4b220880e --- /dev/null +++ b/libs/css/src/property_names.rs @@ -0,0 +1,174 @@ +//! Generated from csstype-extra 0.1.37 Properties; excludes the 18 unprefixed dead names. +//! Source SHA-256: ed9937009a84b96e5699789a3d437e06b00972ea2fb05b7900dd076983946ade + +pub(super) static CSS_PROPERTY_NAMES: phf::Set<&str> = phf::phf_set! { + "-moz-appearance", "-moz-binding", "-moz-border-bottom-colors", "-moz-border-left-colors", + "-moz-border-right-colors", "-moz-border-top-colors", "-moz-box-align", "-moz-box-direction", + "-moz-box-flex", "-moz-box-flex-group", "-moz-box-lines", "-moz-box-ordinal-group", + "-moz-box-orient", "-moz-box-pack", "-moz-context-properties", "-moz-float-edge", + "-moz-force-broken-image-icon", "-moz-orient", "-moz-outline-radius", "-moz-outline-radius-bottomleft", + "-moz-outline-radius-bottomright", "-moz-outline-radius-topleft", "-moz-outline-radius-topright", "-moz-overflow-clip-box", + "-moz-stack-sizing", "-moz-text-blink", "-moz-user-focus", "-moz-user-input", + "-moz-user-modify", "-moz-window-dragging", "-moz-window-shadow", "-ms-accelerator", + "-ms-block-progression", "-ms-content-zoom-chaining", "-ms-content-zoom-limit", "-ms-content-zoom-limit-max", + "-ms-content-zoom-limit-min", "-ms-content-zoom-snap", "-ms-content-zoom-snap-points", "-ms-content-zoom-snap-type", + "-ms-content-zooming", "-ms-filter", "-ms-flow-from", "-ms-flow-into", + "-ms-grid-columns", "-ms-grid-rows", "-ms-high-contrast-adjust", "-ms-hyphenate-limit-chars", + "-ms-hyphenate-limit-lines", "-ms-hyphenate-limit-zone", "-ms-ime-align", "-ms-overflow-style", + "-ms-scroll-chaining", "-ms-scroll-limit", "-ms-scroll-limit-x-max", "-ms-scroll-limit-x-min", + "-ms-scroll-limit-y-max", "-ms-scroll-limit-y-min", "-ms-scroll-rails", "-ms-scroll-snap-points-x", + "-ms-scroll-snap-points-y", "-ms-scroll-snap-type", "-ms-scroll-snap-x", "-ms-scroll-snap-y", + "-ms-scroll-translation", "-ms-scrollbar-arrow-color", "-ms-scrollbar-base-color", "-ms-scrollbar-darkshadow-color", + "-ms-scrollbar-face-color", "-ms-scrollbar-highlight-color", "-ms-scrollbar-shadow-color", "-ms-scrollbar-track-color", + "-ms-scrollbar3dlight-color", "-ms-text-autospace", "-ms-touch-select", "-ms-user-select", + "-ms-wrap-flow", "-ms-wrap-margin", "-ms-wrap-through", "-webkit-appearance", + "-webkit-border-after", "-webkit-border-after-color", "-webkit-border-after-style", "-webkit-border-after-width", + "-webkit-border-before", "-webkit-border-before-color", "-webkit-border-before-style", "-webkit-border-before-width", + "-webkit-border-end", "-webkit-border-end-color", "-webkit-border-end-style", "-webkit-border-end-width", + "-webkit-border-start", "-webkit-border-start-color", "-webkit-border-start-style", "-webkit-border-start-width", + "-webkit-box-align", "-webkit-box-direction", "-webkit-box-flex", "-webkit-box-flex-group", + "-webkit-box-lines", "-webkit-box-ordinal-group", "-webkit-box-orient", "-webkit-box-pack", + "-webkit-box-reflect", "-webkit-line-clamp", "-webkit-mask", "-webkit-mask-attachment", + "-webkit-mask-clip", "-webkit-mask-composite", "-webkit-mask-image", "-webkit-mask-origin", + "-webkit-mask-position", "-webkit-mask-position-x", "-webkit-mask-position-y", "-webkit-mask-repeat", + "-webkit-mask-repeat-x", "-webkit-mask-repeat-y", "-webkit-mask-size", "-webkit-overflow-scrolling", + "-webkit-tap-highlight-color", "-webkit-text-fill-color", "-webkit-text-stroke", "-webkit-text-stroke-color", + "-webkit-text-stroke-width", "-webkit-touch-callout", "-webkit-user-modify", "-webkit-user-select", + "accent-color", "align-content", "align-items", "align-self", + "align-tracks", "alignment-baseline", "all", "anchor-name", + "anchor-scope", "animation", "animation-composition", "animation-delay", + "animation-direction", "animation-duration", "animation-fill-mode", "animation-iteration-count", + "animation-name", "animation-play-state", "animation-range", "animation-range-end", + "animation-range-start", "animation-timeline", "animation-timing-function", "animation-trigger", + "appearance", "aspect-ratio", "backdrop-filter", "backface-visibility", + "background", "background-attachment", "background-blend-mode", "background-clip", + "background-color", "background-image", "background-origin", "background-position", + "background-position-x", "background-position-y", "background-repeat", "background-size", + "baseline-shift", "baseline-source", "block-size", "border", + "border-block", "border-block-color", "border-block-end", "border-block-end-color", + "border-block-end-style", "border-block-end-width", "border-block-start", "border-block-start-color", + "border-block-start-style", "border-block-start-width", "border-block-style", "border-block-width", + "border-bottom", "border-bottom-color", "border-bottom-left-radius", "border-bottom-right-radius", + "border-bottom-style", "border-bottom-width", "border-collapse", "border-color", + "border-end-end-radius", "border-end-start-radius", "border-image", "border-image-outset", + "border-image-repeat", "border-image-slice", "border-image-source", "border-image-width", + "border-inline", "border-inline-color", "border-inline-end", "border-inline-end-color", + "border-inline-end-style", "border-inline-end-width", "border-inline-start", "border-inline-start-color", + "border-inline-start-style", "border-inline-start-width", "border-inline-style", "border-inline-width", + "border-left", "border-left-color", "border-left-style", "border-left-width", + "border-radius", "border-right", "border-right-color", "border-right-style", + "border-right-width", "border-shape", "border-spacing", "border-start-end-radius", + "border-start-start-radius", "border-style", "border-top", "border-top-color", + "border-top-left-radius", "border-top-right-radius", "border-top-style", "border-top-width", + "border-width", "bottom", "box-decoration-break", "box-shadow", + "box-sizing", "break-after", "break-before", "break-inside", + "caption-side", "caret", "caret-animation", "caret-color", + "caret-shape", "clear", "clip", "clip-path", + "clip-rule", "color", "color-interpolation-filters", "color-scheme", + "column-count", "column-fill", "column-gap", "column-height", + "column-rule", "column-rule-color", "column-rule-style", "column-rule-width", + "column-span", "column-width", "column-wrap", "columns", + "contain", "contain-intrinsic-block-size", "contain-intrinsic-height", "contain-intrinsic-inline-size", + "contain-intrinsic-size", "contain-intrinsic-width", "container", "container-name", + "container-type", "content", "content-visibility", "corner-block-end-shape", + "corner-block-start-shape", "corner-bottom-left-shape", "corner-bottom-right-shape", "corner-bottom-shape", + "corner-end-end-shape", "corner-end-start-shape", "corner-inline-end-shape", "corner-inline-start-shape", + "corner-left-shape", "corner-right-shape", "corner-shape", "corner-start-end-shape", + "corner-start-start-shape", "corner-top-left-shape", "corner-top-right-shape", "corner-top-shape", + "counter-increment", "counter-reset", "counter-set", "cursor", + "cx", "cy", "d", "direction", + "display", "dominant-baseline", "dynamic-range-limit", "empty-cells", + "field-sizing", "fill", "fill-opacity", "fill-rule", + "filter", "flex", "flex-basis", "flex-direction", + "flex-flow", "flex-grow", "flex-line-count", "flex-shrink", + "flex-wrap", "float", "flood-color", "flood-opacity", + "font", "font-family", "font-feature-settings", "font-kerning", + "font-language-override", "font-optical-sizing", "font-palette", "font-size", + "font-size-adjust", "font-smooth", "font-stretch", "font-style", + "font-synthesis", "font-synthesis-position", "font-synthesis-small-caps", "font-synthesis-style", + "font-synthesis-weight", "font-variant", "font-variant-alternates", "font-variant-caps", + "font-variant-east-asian", "font-variant-emoji", "font-variant-ligatures", "font-variant-numeric", + "font-variant-position", "font-variation-settings", "font-weight", "font-width", + "forced-color-adjust", "frame-sizing", "gap", "grid", + "grid-area", "grid-auto-columns", "grid-auto-flow", "grid-auto-rows", + "grid-column", "grid-column-end", "grid-column-gap", "grid-column-start", + "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", + "grid-row-start", "grid-template", "grid-template-areas", "grid-template-columns", + "grid-template-rows", "hanging-punctuation", "height", "hyphenate-character", + "hyphenate-limit-chars", "hyphens", "image-orientation", "image-rendering", + "image-resolution", "ime-mode", "initial-letter", "initial-letter-align", + "inline-size", "inset", "inset-block", "inset-block-end", + "inset-block-start", "inset-inline", "inset-inline-end", "inset-inline-start", + "interactivity", "interest-delay", "interest-delay-end", "interest-delay-start", + "interpolate-size", "isolation", "justify-content", "justify-items", + "justify-self", "justify-tracks", "left", "letter-spacing", + "lighting-color", "line-break", "line-clamp", "line-height", + "line-height-step", "link-parameters", "list-style", "list-style-image", + "list-style-position", "list-style-type", "margin", "margin-block", + "margin-block-end", "margin-block-start", "margin-bottom", "margin-inline", + "margin-inline-end", "margin-inline-start", "margin-left", "margin-right", + "margin-top", "margin-trim", "marker", "marker-end", + "marker-mid", "marker-start", "mask", "mask-border", + "mask-border-mode", "mask-border-outset", "mask-border-repeat", "mask-border-slice", + "mask-border-source", "mask-border-width", "mask-clip", "mask-composite", + "mask-image", "mask-mode", "mask-origin", "mask-position", + "mask-repeat", "mask-size", "mask-type", "masonry-auto-flow", + "math-depth", "math-shift", "math-style", "max-block-size", + "max-height", "max-inline-size", "max-lines", "max-width", + "min-block-size", "min-height", "min-inline-size", "min-width", + "mix-blend-mode", "object-fit", "object-position", "object-view-box", + "offset", "offset-anchor", "offset-distance", "offset-path", + "offset-position", "offset-rotate", "opacity", "order", + "orphans", "outline", "outline-color", "outline-offset", + "outline-style", "outline-width", "overflow", "overflow-anchor", + "overflow-block", "overflow-clip-box", "overflow-clip-margin", "overflow-inline", + "overflow-wrap", "overflow-x", "overflow-y", "overlay", + "overscroll-behavior", "overscroll-behavior-block", "overscroll-behavior-inline", "overscroll-behavior-x", + "overscroll-behavior-y", "padding", "padding-block", "padding-block-end", + "padding-block-start", "padding-bottom", "padding-inline", "padding-inline-end", + "padding-inline-start", "padding-left", "padding-right", "padding-top", + "page", "page-break-after", "page-break-before", "page-break-inside", + "paint-order", "path-length", "perspective", "perspective-origin", + "place-content", "place-items", "place-self", "pointer-events", + "position", "position-anchor", "position-area", "position-try", + "position-try-fallbacks", "position-try-order", "position-visibility", "print-color-adjust", + "quotes", "r", "reading-flow", "reading-order", + "resize", "right", "rotate", "row-gap", + "ruby-align", "ruby-merge", "ruby-overhang", "ruby-position", + "rx", "ry", "scale", "scroll-axis-lock", + "scroll-behavior", "scroll-initial-target", "scroll-margin", "scroll-margin-block", + "scroll-margin-block-end", "scroll-margin-block-start", "scroll-margin-bottom", "scroll-margin-inline", + "scroll-margin-inline-end", "scroll-margin-inline-start", "scroll-margin-left", "scroll-margin-right", + "scroll-margin-top", "scroll-marker-group", "scroll-padding", "scroll-padding-block", + "scroll-padding-block-end", "scroll-padding-block-start", "scroll-padding-bottom", "scroll-padding-inline", + "scroll-padding-inline-end", "scroll-padding-inline-start", "scroll-padding-left", "scroll-padding-right", + "scroll-padding-top", "scroll-snap-align", "scroll-snap-stop", "scroll-snap-type", + "scroll-target-group", "scroll-timeline", "scroll-timeline-axis", "scroll-timeline-name", + "scrollbar-color", "scrollbar-gutter", "scrollbar-width", "shape-image-threshold", + "shape-margin", "shape-outside", "shape-rendering", "speak-as", + "stop-color", "stop-opacity", "stroke", "stroke-color", + "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", + "stroke-miterlimit", "stroke-opacity", "stroke-width", "tab-size", + "table-layout", "text-align", "text-align-last", "text-anchor", + "text-autospace", "text-box", "text-box-edge", "text-box-trim", + "text-combine-upright", "text-decoration", "text-decoration-color", "text-decoration-inset", + "text-decoration-line", "text-decoration-skip", "text-decoration-skip-ink", "text-decoration-style", + "text-decoration-thickness", "text-emphasis", "text-emphasis-color", "text-emphasis-position", + "text-emphasis-style", "text-fit", "text-indent", "text-justify", + "text-orientation", "text-overflow", "text-rendering", "text-shadow", + "text-size-adjust", "text-spacing-trim", "text-transform", "text-underline-offset", + "text-underline-position", "text-wrap", "text-wrap-mode", "text-wrap-style", + "timeline-scope", "timeline-trigger", "timeline-trigger-activation-range", "timeline-trigger-activation-range-end", + "timeline-trigger-activation-range-start", "timeline-trigger-active-range", "timeline-trigger-active-range-end", "timeline-trigger-active-range-start", + "timeline-trigger-name", "timeline-trigger-source", "top", "touch-action", + "transform", "transform-box", "transform-origin", "transform-style", + "transition", "transition-behavior", "transition-delay", "transition-duration", + "transition-property", "transition-timing-function", "translate", "trigger-scope", + "unicode-bidi", "user-select", "vector-effect", "vertical-align", + "view-timeline", "view-timeline-axis", "view-timeline-inset", "view-timeline-name", + "view-transition-class", "view-transition-name", "view-transition-scope", "visibility", + "white-space", "white-space-collapse", "widows", "width", + "will-change", "window-drag", "word-break", "word-spacing", + "word-wrap", "writing-mode", "x", "y", + "z-index", "zoom", +}; diff --git a/libs/css/src/shorthand.rs b/libs/css/src/shorthand.rs new file mode 100644 index 000000000..a77f6f6bd --- /dev/null +++ b/libs/css/src/shorthand.rs @@ -0,0 +1,237 @@ +use std::{collections::BTreeMap, fmt}; + +use crate::{ + constant::GLOBAL_STYLE_PROPERTY, + property_names::CSS_PROPERTY_NAMES, + utils::{to_camel_case, to_kebab_case}, +}; + +/// A rejected target and its logical position in shorthand configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidShorthandTarget { + /// The configured shorthand name. + pub alias: String, + /// The original target spelling, before normalization. + pub target: String, + /// The zero-based position in the target list. + pub index: usize, +} + +impl fmt::Display for InvalidShorthandTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "shorthands[{:?}][{}]: shorthand `{}` cannot use `{}` at build time: its targets must be supported CSS properties, built-in aliases or custom properties", + self.alias, self.index, self.alias, self.target + ) + } +} + +impl std::error::Error for InvalidShorthandTarget {} + +fn is_custom_property(property: &str) -> bool { + property.strip_prefix("--").is_some_and(|name| { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-') || c >= '\u{80}') + }) +} + +fn normalize_property(property: &str) -> Option { + if is_custom_property(property) || CSS_PROPERTY_NAMES.contains(property) { + return Some(property.to_string()); + } + let kebab = to_kebab_case(property); + let canonical = if ["Webkit", "Moz", "ms", "O"].iter().any(|prefix| { + property.strip_prefix(prefix).is_some_and(|suffix| { + suffix + .as_bytes() + .first() + .is_some_and(u8::is_ascii_uppercase) + }) + }) { + format!("-{kebab}") + } else { + kebab.into_owned() + }; + if !CSS_PROPERTY_NAMES.contains(canonical.as_str()) { + return None; + } + let camel = to_camel_case(&canonical); + let expected = camel + .strip_prefix("Ms") + .map_or_else(|| camel.to_string(), |suffix| format!("ms{suffix}")); + (property == expected).then_some(canonical) +} + +/// Validate every target before producing a normalized registry. +/// +/// # Errors +/// Returns the original alias, target and index of the first unsupported target. +pub fn normalize_shorthands( + shorthands: BTreeMap>, +) -> Result>, InvalidShorthandTarget> { + shorthands + .into_iter() + .map(|(alias, targets)| { + let mut normalized = Vec::new(); + for (index, target) in targets.into_iter().enumerate() { + if let Some(properties) = GLOBAL_STYLE_PROPERTY.get(target.as_str()) { + normalized.extend(properties.iter().map(|property| (*property).to_string())); + } else { + normalized.push(normalize_property(&target).ok_or_else(|| { + InvalidShorthandTarget { + alias: alias.clone(), + target, + index, + } + })?); + } + } + Ok((alias, normalized)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use serial_test::serial; + + #[rstest] + #[case("marginRight", Some("margin-right"))] + #[case("margin-right", Some("margin-right"))] + #[case("left", Some("left"))] + #[case("WebkitMaskImage", Some("-webkit-mask-image"))] + #[case("-webkit-mask-image", Some("-webkit-mask-image"))] + #[case("MozAppearance", Some("-moz-appearance"))] + #[case("msUserSelect", Some("-ms-user-select"))] + #[case("-ms-user-select", Some("-ms-user-select"))] + #[case("OTransform", None)] + #[case("--My-color", Some("--My-color"))] + #[case("--1", Some("--1"))] + #[case("--색", Some("--색"))] + #[case("--", None)] + #[case("--bad name", None)] + #[case("width ", None)] + #[case("Width", None)] + #[case("widht", None)] + #[case("scrollMargnLeft", None)] + #[case("webkitMaskImage", None)] + #[case("MsUserSelect", None)] + #[case("styleOrder", None)] + #[case("_hover", None)] + #[case("positioning", None)] + #[case("", None)] + fn validates_spelling(#[case] property: &str, #[case] expected: Option<&str>) { + assert_eq!(normalize_property(property).as_deref(), expected); + } + + #[test] + fn rejects_unprefixed_dead_properties() { + for property in [ + "box-align", + "box-pack", + "box-flex", + "box-flex-group", + "box-orient", + "box-ordinal-group", + "box-direction", + "box-lines", + "flex-order", + "flex-positive", + "flex-negative", + "flex-preferred-size", + "scroll-snap-coordinate", + "scroll-snap-destination", + "scroll-snap-points-x", + "scroll-snap-points-y", + "scroll-snap-type-x", + "scroll-snap-type-y", + ] { + assert_eq!(normalize_property(property), None); + assert_eq!(normalize_property(&to_camel_case(property)), None); + } + } + + #[test] + fn preserves_builtin_order_and_empty_lists() -> Result<(), InvalidShorthandTarget> { + let normalized = normalize_shorthands(BTreeMap::from([ + ( + "edges".into(), + vec![ + "left".into(), + "py".into(), + "margin-right".into(), + "--Gap".into(), + ], + ), + ("empty".into(), vec![]), + ]))?; + assert_eq!( + normalized["edges"], + [ + "left", + "padding-top", + "padding-bottom", + "margin-right", + "--Gap" + ] + ); + assert_eq!(normalized["empty"], Vec::::new()); + Ok(()) + } + + #[test] + #[serial] + fn invalid_map_preserves_previous_registry() -> Result<(), InvalidShorthandTarget> { + crate::set_custom_shorthands(BTreeMap::from([("old".into(), vec!["left".into()])]))?; + let error = match crate::set_custom_shorthands(BTreeMap::from([ + ("aValid".into(), vec!["right".into()]), + ("bad".into(), vec!["width".into(), "widht".into()]), + ])) { + Err(error) => error, + Ok(()) => panic!("expected invalid shorthand registration to fail"), + }; + assert_eq!( + error, + InvalidShorthandTarget { + alias: "bad".into(), + target: "widht".into(), + index: 1 + } + ); + assert_eq!( + error.to_string(), + "shorthands[\"bad\"][1]: shorthand `bad` cannot use `widht` at build time: its targets must be supported CSS properties, built-in aliases or custom properties" + ); + assert_eq!(crate::get_custom_shorthand_names(), ["old"]); + assert_eq!( + crate::disassemble_property("old").collect::>(), + ["left"] + ); + crate::set_custom_shorthands(BTreeMap::new())?; + Ok(()) + } + + #[test] + #[serial] + fn registration_recovers_a_poisoned_registry() -> Result<(), InvalidShorthandTarget> { + match std::panic::catch_unwind(|| -> Result<(), Box> { + let _registry = crate::CUSTOM_SHORTHANDS.write()?; + panic!("poison the shorthand registry"); + }) { + Err(_) => {} + Ok(_) => panic!("expected the registry poisoning closure to panic"), + } + crate::set_custom_shorthands(BTreeMap::from([("recovered".into(), vec!["right".into()])]))?; + assert_eq!( + crate::disassemble_property("recovered").collect::>(), + ["right"] + ); + crate::set_custom_shorthands(BTreeMap::new())?; + Ok(()) + } +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..f9d85cdf9 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -57,14 +57,14 @@ pub(crate) fn has_build_time_values( option: &ExtractOption, resolver: Option<&ModuleResolver>, ) -> bool { - let (code, _) = crate::import_alias_visit::transform_import_aliases_with_edits( + let aliased = crate::import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); let allocator = Allocator::default(); - let Some(mut program) = parse(&allocator, filename, &code) else { + let Some(mut program) = parse(&allocator, filename, &aliased.code) else { return false; }; let inlined = crate::imported_constants::inline_constants( @@ -73,6 +73,7 @@ pub(crate) fn has_build_time_values( filename, option, resolver, + aliased.css_prop, ); let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs new file mode 100644 index 000000000..b59abb6c6 --- /dev/null +++ b/libs/extractor/src/composition.rs @@ -0,0 +1,402 @@ +//! Styles composed in one place, such as `css(base, cond && danger)`: when the +//! build knows the parts, a later part's declaration replaces an earlier one's +//! for the same property, selector, breakpoint and layer. Atomic classes alone +//! cannot express that, because which of two classes wins depends on their +//! order in the stylesheet, not on the order they were composed in. + +use css::style_selector::StyleSelector; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; + +use crate::{ExtractStyleProp, ExtractStyleValue}; + +/// What a declaration competes on: property, selector, breakpoint and layer +#[derive(Clone, PartialEq, Eq)] +struct CascadeKey { + property: String, + selector: Option, + level: u8, + layer: Option, +} + +impl CascadeKey { + fn of(value: &ExtractStyleValue) -> Self { + let (property, selector, level, layer) = match value { + ExtractStyleValue::Static(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + ExtractStyleValue::Dynamic(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + // A preset: the only other value a style prop holds + _ => ("typography", None, 0, None), + }; + Self { + property: property.to_string(), + selector: selector.cloned(), + level, + layer: layer.map(ToString::to_string), + } + } +} + +/// What a key resolves to +enum Choice<'a> { + Empty, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// What a part does to a key +enum Overlay<'a> { + /// Leaves what came before + Keep, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// The parts composed so far, in source order: a key a later part sets moves +/// to the end, as when merging objects +#[derive(Default)] +pub struct Composition<'a> { + entries: Vec<(CascadeKey, Choice<'a>)>, + /// Styles whose properties the build cannot pair up, kept as they are + unkeyed: Vec>, +} + +impl<'a> Composition<'a> { + /// A part the build knows completely + pub fn apply(&mut self, ast_builder: &AstBuilder<'a>, props: Vec>) { + let mut overlays: Vec<(CascadeKey, Overlay<'a>)> = Vec::new(); + for prop in props { + self.overlays(ast_builder, prop, &mut overlays); + } + for (key, overlay) in overlays { + self.overlay(ast_builder, key, overlay); + } + } + + /// A part applying only while `test` holds, or `alternate` otherwise + pub fn apply_conditional( + &mut self, + ast_builder: &AstBuilder<'a>, + test: &Expression<'a>, + consequent: Vec>, + alternate: Vec>, + ) { + let prop = ExtractStyleProp::Conditional { + condition: test.clone_in(ast_builder.allocator()), + consequent: Some(Box::new(ExtractStyleProp::StaticArray(consequent))), + alternate: Some(Box::new(ExtractStyleProp::StaticArray(alternate))), + }; + self.apply(ast_builder, vec![prop]); + } + + /// Each key a prop sets, with what it sets it to + fn overlays( + &mut self, + ast_builder: &AstBuilder<'a>, + prop: ExtractStyleProp<'a>, + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + ) { + match prop { + ExtractStyleProp::Static(value) => { + push_overlay(overlays, CascadeKey::of(&value), Overlay::Atom(value)); + } + ExtractStyleProp::StaticArray(props) => { + for prop in props { + self.overlays(ast_builder, prop, overlays); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } if [&consequent, &alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)) => + { + let mut sides = [Vec::new(), Vec::new()]; + for (side, prop) in sides.iter_mut().zip([consequent, alternate]) { + if let Some(prop) = prop { + self.overlays(ast_builder, *prop, side); + } + } + let [mut consequent, mut alternate] = sides; + let mut keys: Vec = Vec::new(); + for (key, _) in consequent.iter().chain(&alternate) { + if !keys.contains(key) { + keys.push(key.clone()); + } + } + for key in keys { + let consequent = take_overlay(&mut consequent, &key); + let alternate = take_overlay(&mut alternate, &key); + push_overlay( + overlays, + key, + Overlay::Conditional { + test: condition.clone_in(ast_builder.allocator()), + consequent: Box::new(consequent), + alternate: Box::new(alternate), + }, + ); + } + } + prop => self.unkeyed.push(prop), + } + } + + fn overlay(&mut self, ast_builder: &AstBuilder<'a>, key: CascadeKey, overlay: Overlay<'a>) { + let previous = self + .entries + .iter() + .position(|(existing, _)| *existing == key) + .map_or(Choice::Empty, |index| self.entries.remove(index).1); + let choice = resolve(ast_builder, overlay, previous); + self.entries.push((key, choice)); + } + + /// A class applying after the composed parts, whose styles `values` the + /// build knows: what it sets replaces what they set + pub fn cover(&mut self, values: &[ExtractStyleValue]) { + let keys: Vec = values.iter().map(CascadeKey::of).collect(); + self.entries.retain(|(key, _)| !keys.contains(key)); + } + + /// The composed styles, for class names and the stylesheet + #[must_use] + pub fn into_props(self) -> Vec> { + self.entries + .into_iter() + .filter_map(|(_, choice)| into_prop(choice)) + .chain(self.unkeyed) + .collect() + } + + /// The composed atoms when no condition chooses between them + #[must_use] + pub fn unconditional(&self) -> Option> { + if !self.unkeyed.is_empty() { + return None; + } + let mut values = Vec::new(); + for (_, choice) in &self.entries { + match choice { + Choice::Atom(value) => values.push(value.clone()), + // A key is empty only under a condition + Choice::Empty | Choice::Conditional { .. } => return None, + } + } + Some(values) + } +} + +/// Styles a part of `css(...)` gives +pub enum KnownStyles<'a> { + /// The styles of a `css()` class the file binds + Known(Vec), + /// A rule object + Rules(Expression<'a>), +} + +/// A side of a condition among the parts of `css(...)` +pub enum KnownSide<'a> { + Styles(Vec>), + /// A class the build does not know the styles of + Class(Expression<'a>), + Empty, +} + +/// A part of `css(...)`, in source order +pub enum KnownPart<'a> { + Styles(Vec>), + Conditional { + test: Expression<'a>, + consequent: Vec>, + alternate: Vec>, + }, + Class(Expression<'a>), +} + +/// `prop`'s styles at `order`, unless one sets its own +pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { + match prop { + ExtractStyleProp::Static(value) => value.set_style_order(order), + ExtractStyleProp::StaticArray(props) => { + for prop in props { + set_prop_order(prop, order); + } + } + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + set_prop_order(side, order); + } + } + ExtractStyleProp::Enum { map, .. } => { + for prop in map.values_mut().flatten() { + set_prop_order(prop, order); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + set_prop_order(prop, order); + } + } + // Class names the code gives, and styles reported as errors + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + } +} + +/// Whether `later` may set what `earlier` sets, so the order of their classes +/// decides which applies: a style whose key the build cannot tell may +pub fn overlaps(earlier: &[ExtractStyleProp<'_>], later: &[ExtractStyleProp<'_>]) -> bool { + match (keys(earlier), keys(later)) { + (Some(earlier), Some(later)) => later.iter().any(|key| earlier.contains(key)), + _ => true, + } +} + +/// The keys `props` set, `None` when a style chosen at runtime has none; +/// classes only the runtime gives compose at runtime, so they set none here +fn keys(props: &[ExtractStyleProp<'_>]) -> Option> { + let mut keys = Vec::new(); + for prop in props { + match prop { + ExtractStyleProp::Static(value) => keys.push(CascadeKey::of(value)), + ExtractStyleProp::StaticArray(props) => keys.extend(self::keys(props)?), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + keys.extend(self::keys(std::slice::from_ref(side.as_ref()))?); + } + } + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::Enum { .. } | ExtractStyleProp::MemberExpression { .. } => { + return None; + } + } + } + Some(keys) +} + +/// Whether every style `prop` holds has a key, so a condition around it can be +/// applied key by key +fn keyed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(_) => true, + ExtractStyleProp::StaticArray(props) => props.iter().all(keyed), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => [consequent, alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)), + _ => false, + } +} + +/// `overlay` replacing what `overlays` already sets for the key: a part +/// declaring a property twice keeps the later declaration +fn push_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: CascadeKey, + overlay: Overlay<'a>, +) { + overlays.retain(|(existing, _)| *existing != key); + overlays.push((key, overlay)); +} + +fn take_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: &CascadeKey, +) -> Overlay<'a> { + overlays + .iter() + .position(|(existing, _)| existing == key) + .map_or(Overlay::Keep, |index| overlays.remove(index).1) +} + +/// What `overlay` makes of `previous` +fn resolve<'a>( + ast_builder: &AstBuilder<'a>, + overlay: Overlay<'a>, + previous: Choice<'a>, +) -> Choice<'a> { + match overlay { + Overlay::Keep => previous, + Overlay::Atom(value) => Choice::Atom(value), + Overlay::Conditional { + test, + consequent, + alternate, + } => { + let copy = copy_choice(ast_builder, &previous); + Choice::Conditional { + test, + consequent: Box::new(resolve(ast_builder, *consequent, previous)), + alternate: Box::new(resolve(ast_builder, *alternate, copy)), + } + } + } +} + +fn copy_choice<'a>(ast_builder: &AstBuilder<'a>, choice: &Choice<'a>) -> Choice<'a> { + match choice { + Choice::Empty => Choice::Empty, + Choice::Atom(value) => Choice::Atom(value.clone()), + Choice::Conditional { + test, + consequent, + alternate, + } => Choice::Conditional { + test: test.clone_in(ast_builder.allocator()), + consequent: Box::new(copy_choice(ast_builder, consequent)), + alternate: Box::new(copy_choice(ast_builder, alternate)), + }, + } +} + +fn into_prop(choice: Choice<'_>) -> Option> { + match choice { + Choice::Empty => None, + Choice::Atom(value) => Some(ExtractStyleProp::Static(value)), + Choice::Conditional { + test, + consequent, + alternate, + } => { + let (consequent, alternate) = (into_prop(*consequent), into_prop(*alternate)); + (consequent.is_some() || alternate.is_some()).then(|| ExtractStyleProp::Conditional { + condition: test, + consequent: consequent.map(Box::new), + alternate: alternate.map(Box::new), + }) + } + } +} diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs new file mode 100644 index 000000000..96cb8c59e --- /dev/null +++ b/libs/extractor/src/css_prop.rs @@ -0,0 +1,937 @@ +//! Emotion's `css` prop: styles an element takes in place of a class name. +//! The build composes them like `css()` and gives the element the classes and +//! CSS variables they compile to, so no runtime reads the prop. + +use std::collections::HashMap; + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, + FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, + JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, + TemplateElementValue, TemplateLiteral, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_span::SPAN; +use rustc_hash::FxHashSet; + +use crate::ImportAlias; +use crate::css_utils::{Place, interpolation_place}; +use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; + +/// The package whose alias makes elements take the `css` prop +pub(crate) const EMOTION_REACT: &str = "@emotion/react"; + +/// Emotion's JSX runtime, whose alias tells that the project builds JSX with +/// it, as a `jsxImportSource` of `@emotion/react` in tsconfig does +pub(crate) const EMOTION_JSX_RUNTIME: &str = "@emotion/react/jsx-runtime"; + +/// The pragma building a file's JSX with React, as its `css` props compile +pub(crate) const REACT_JSX_PRAGMA: &str = "/** @jsxImportSource react */\n"; + +/// Whether the project builds JSX with Emotion's runtime, which then builds it +/// with React's as every `css` prop compiles +pub(crate) fn builds_jsx_with_emotion(import_aliases: &HashMap) -> bool { + import_aliases.contains_key(EMOTION_REACT) && import_aliases.contains_key(EMOTION_JSX_RUNTIME) +} + +/// Whether `filename` is written in JSX, which the project's JSX settings build +pub(crate) fn is_jsx_file(filename: &str) -> bool { + filename + .rsplit_once('.') + .is_some_and(|(_, extension)| matches!(extension, "tsx" | "jsx")) +} + +/// Which elements of a file take Emotion's `css` prop +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CssProp { + /// No element: Emotion is not aliased, or the file writes no `css` prop + #[default] + Off, + /// Tags and Devup UI components, which take no `css` prop of their own + Elements, + /// Every element, as the file shows it uses Emotion + Everywhere, +} + +impl CssProp { + /// Which elements of `code` take the prop, with the aliases the build + /// applies, and whether the file shows it uses Emotion + pub(crate) fn of( + import_aliases: &HashMap, + code: &str, + uses_emotion: bool, + ) -> Self { + if !import_aliases.contains_key(EMOTION_REACT) || !may_take_css_prop(code) { + Self::Off + } else if uses_emotion { + Self::Everywhere + } else { + Self::Elements + } + } + + /// Whether the element `name` takes the prop; `devup` tells a Devup UI + /// component + pub(crate) fn takes(self, name: &JSXElementName<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match name { + JSXElementName::Identifier(_) | JSXElementName::NamespacedName(_) => true, + name => crate::imported_constants::jsx_root(name).is_some_and(devup), + }, + } + } + + /// Whether the element a `jsx()` call builds from `element` takes the prop + pub(crate) fn takes_type(self, element: &Expression<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match unwrap_syntax_only(element) { + Expression::StringLiteral(_) => true, + element => binding_root(element).is_some_and(devup), + }, + } + } +} + +/// What in a program takes the `css` prop: the elements `css_prop` tells, and +/// the `jsx()` calls building them +pub(crate) struct CssTakers<'s> { + pub css_prop: CssProp, + /// Local names of the functions building elements from a type and props + jsx: FxHashSet<&'s str>, + /// Local names of Emotion's `ClassNames` + class_names: FxHashSet<&'s str>, +} + +impl<'s> CssTakers<'s> { + /// The elements `program` gives a `css` prop, as `css_prop` tells; + /// `compat` is the entry absorbing Emotion's own `jsx` + pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + let mut jsx = FxHashSet::default(); + let mut class_names = FxHashSet::default(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + let source = import.source.value.as_str(); + for specifier in import.specifiers.iter().flatten() { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + continue; + }; + let imported = specifier.imported.name(); + if is_jsx_function(source, &imported, compat) { + jsx.insert(specifier.local.name.as_str()); + } else if imported == "ClassNames" + && (source == compat || source == EMOTION_REACT) + { + class_names.insert(specifier.local.name.as_str()); + } + } + } + } + Self { + css_prop, + jsx, + class_names, + } + } + + /// The names a `` child function `element` gives takes `css` + /// and `cx` by; empty for any other element + pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { + match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(name.name.as_str()) => + { + class_names_calls(element) + } + _ => Vec::new(), + } + } + + /// Whether `attribute` of the element `name` is a `css` prop it takes; + /// `devup` tells a Devup UI component + pub(crate) fn attribute( + &self, + name: &JSXElementName<'_>, + attribute: &JSXAttributeItem<'_>, + devup: impl Fn(&str) -> bool, + ) -> bool { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) + && self.css_prop.takes(name, devup) + } + + /// Where among the props a `jsx()` call gives is the `css` prop the + /// element takes + pub(crate) fn property( + &self, + call: &CallExpression<'_>, + devup: impl Fn(&str) -> bool, + ) -> Option { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { + return None; + }; + (self.jsx.contains(callee.name.as_str()) + && self.css_prop.takes_type(element.as_expression()?, devup)) + .then(|| { + props.properties.iter().rposition(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed && property.key.static_name().is_some_and(|key| key == "css")) + }) + }) + .flatten() + } +} + +/// Whether `code` may give an element a `css` prop: `css` written as a JSX +/// attribute or as the key of an object +pub(crate) fn may_take_css_prop(code: &str) -> bool { + code.match_indices("css").any(|(index, _)| { + let before = code[..index].chars().next_back(); + let after = code[index + 3..].trim_start(); + let attribute = before.is_some_and(char::is_whitespace) + && after.starts_with('=') + && !after[1..].starts_with('='); + let key = before.is_some_and(|c| c.is_whitespace() || matches!(c, '{' | ',' | '"' | '\'')) + && after + .strip_prefix(['"', '\'']) + .unwrap_or(after) + .trim_start() + .starts_with(':'); + attribute || key + }) +} + +/// Whether importing `source` shows a file uses Emotion +pub(crate) fn is_emotion(source: &str) -> bool { + [EMOTION_REACT, "@emotion/styled"].iter().any(|package| { + source + .strip_prefix(package) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) +} + +/// Where the JSX pragma in the comment text `comment` names `@emotion/react` +/// as the module JSX is built with +pub(crate) fn emotion_pragma(comment: &str) -> Option { + let at = comment.find("@jsxImportSource")? + "@jsxImportSource".len(); + let value = comment[at..].trim_start(); + let rest = value.strip_prefix(EMOTION_REACT)?; + rest.chars() + .next() + .is_none_or(|c| c.is_whitespace() || c == '*') + .then(|| comment.len() - value.len()) +} + +/// The React module an Emotion JSX runtime stands in for +pub(crate) fn react_runtime(source: &str) -> Option<&'static str> { + match source { + "@emotion/react/jsx-runtime" => Some("react/jsx-runtime"), + "@emotion/react/jsx-dev-runtime" => Some("react/jsx-dev-runtime"), + _ => None, + } +} + +/// Whether `imported` from `source` builds an element from a type and props; +/// `compat` is the entry absorbing Emotion's own `jsx` +pub(crate) fn is_jsx_function(source: &str, imported: &str, compat: &str) -> bool { + match source { + "react/jsx-runtime" + | "react/jsx-dev-runtime" + | "@emotion/react/jsx-runtime" + | "@emotion/react/jsx-dev-runtime" => matches!(imported, "jsx" | "jsxs" | "jsxDEV"), + source => { + (source == EMOTION_REACT || source == compat) + && matches!(imported, "jsx" | "createElement") + } + } +} + +/// What a function of the theme requires to give rules at build time +pub(crate) const THEME_FUNCTION: &str = + "a function of the theme must give its rules at once, as `theme => ({ ... })`"; + +/// What a function of the theme requires of its reads of the theme +pub(crate) const THEME_READ: &str = "it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets"; + +/// What CSS text requires of an interpolation it cannot place +pub(crate) const UNPLACED: &str = + "an interpolation in CSS text must be a value, or a mixin standing where a declaration would"; + +/// What CSS text requires of a mixin +pub(crate) const NESTED_MIXIN: &str = + "a mixin must stand outside nested rules, where the parts it composes with can be split"; + +/// `function`, a function of the theme, as the rules it gives, each read of +/// the theme written as the CSS variable the `ThemeProvider` sets for it. +/// `Err` holds the code the build cannot write so, with what it requires. +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + let unsupported = || (function.clone_in(ast.allocator()), THEME_FUNCTION); + let (params, body) = match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let body = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + (&arrow.params, body) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => ( + &function.params, + function.body.as_deref().and_then(returned), + ), + _ => return Err(unsupported()), + }; + let (Some(roots), Some(body)) = (theme_roots(params), body) else { + return Err(unsupported()); + }; + let mut rules = body.clone_in_with_semantic_ids(ast.allocator()); + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(&mut rules, false); + match reads.unread { + Some(unread) => Err((unread, THEME_READ)), + None => Ok(rules), + } +} + +/// What a function body gives when it is a single `return` +pub(crate) fn returned<'b, 'a>(body: &'b FunctionBody<'a>) -> Option<&'b Expression<'a>> { + match body.statements.as_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_ref(), + _ => None, + } +} + +fn returned_mut<'b, 'a>(body: &'b mut FunctionBody<'a>) -> Option<&'b mut Expression<'a>> { + match body.statements.as_mut_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_mut(), + _ => None, + } +} + +/// The parameters of `function` and what it gives at once, when it is a +/// plain function giving one value +pub(crate) fn render_function<'b, 'a>( + function: &'b mut Expression<'a>, +) -> Option<(&'b FormalParameters<'a>, &'b mut Expression<'a>)> { + match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let arrow = &mut **arrow; + let body = match &mut arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned_mut(body)?, + body => body.as_expression_mut()?, + }; + Some((&arrow.params, body)) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => { + let function = &mut **function; + Some(( + &function.params, + returned_mut(function.body.as_deref_mut()?)?, + )) + } + _ => None, + } +} + +/// The names Emotion's `` child function takes `css`, `cx` and +/// the theme by +#[derive(Default, Clone, Copy)] +pub(crate) struct ClassNamesParams<'a> { + pub css: Option<&'a str>, + pub cx: Option<&'a str>, + pub theme: Option<&'a str>, +} + +/// What `params` take of `{ css, cx, theme }`; `None` when they take it in a +/// way the build cannot follow +pub(crate) fn class_names_params<'a>( + params: &FormalParameters<'a>, +) -> Option> { + let mut names = ClassNamesParams::default(); + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => {} + [param] if param.initializer.is_none() => { + let BindingPattern::ObjectPattern(object) = ¶m.pattern else { + return None; + }; + if object.rest.is_some() { + return None; + } + for property in &object.properties { + let (BindingPattern::BindingIdentifier(local), Some(key), false) = ( + &property.value, + property.key.static_name(), + property.computed, + ) else { + return None; + }; + let slot = match key.as_ref() { + "css" => &mut names.css, + "cx" => &mut names.cx, + "theme" => &mut names.theme, + _ => return None, + }; + *slot = Some(local.name.as_str()); + } + } + _ => return None, + } + Some(names) +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child<'a>(element: &JSXElement<'a>) -> Option> { + let mut children = element + .children + .iter() + .filter(|child| !matches!(child, JSXChild::Text(text) if text.value.trim().is_empty())); + let (Some(JSXChild::ExpressionContainer(container)), None) = (children.next(), children.next()) + else { + return None; + }; + let params = match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => &arrow.params, + Expression::FunctionExpression(function) => &function.params, + _ => return None, + }; + class_names_params(params) +} + +/// The names the child function of the `` element `element` +/// takes `css` and `cx` by, which read styles +pub(crate) fn class_names_calls(element: &JSXElement<'_>) -> Vec { + class_names_child(element) + .map(|names| { + [names.css, names.cx] + .into_iter() + .flatten() + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Write the reads of the theme bound to `theme` in `expression`, a CSS value +/// when `value`, as the CSS variables the `ThemeProvider` sets; `Err` holds +/// the first read the build cannot write so +pub(crate) fn read_theme<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + theme: &'a str, + value: bool, +) -> Result<(), Expression<'a>> { + let roots = [(theme, None)]; + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(expression, value); + reads.unread.map_or(Ok(()), Err) +} + +/// The names a function of the theme reads it by, each with the key of the +/// theme it holds when destructured; `None` for parameters the build cannot +/// follow +fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => Some(Vec::new()), + [param] if param.initializer.is_none() => match ¶m.pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(vec![(identifier.name.as_str(), None)]) + } + BindingPattern::ObjectPattern(object) if object.rest.is_none() => object + .properties + .iter() + .map( + |property| match (&property.value, property.key.static_name()) { + (BindingPattern::BindingIdentifier(local), Some(key)) + if !property.computed => + { + Some((local.name.as_str(), Some(key.to_string()))) + } + _ => None, + }, + ) + .collect(), + _ => None, + }, + _ => None, + } +} + +/// Writes each read of the theme in a value as the CSS variable it becomes, +/// keeping the first read that is not one +struct ThemeReads<'r, 'a> { + ast: &'r AstBuilder<'a>, + roots: &'r [(&'a str, Option)], + unread: Option>, +} + +/// How an expression reads the theme +enum ThemeRead { + /// By the path of keys to a value + Path(Vec), + /// Otherwise, as a whole or through a call or a key only the runtime gives + Other, +} + +impl<'a> ThemeReads<'_, 'a> { + /// How `expression` reads the theme, when it does + fn path(&self, expression: &Expression<'a>) -> Option { + let mut path = Vec::new(); + let mut exact = true; + let mut cursor = expression; + loop { + match cursor { + Expression::StaticMemberExpression(member) => { + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match get_string_by_literal_expression(&member.expression) { + Some(key) => path.push(key.into_owned()), + None => exact = false, + } + cursor = &member.object; + } + Expression::CallExpression(call) => { + exact = false; + cursor = &call.callee; + } + Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, + Expression::Identifier(identifier) => { + let (_, key) = self + .roots + .iter() + .find(|(local, _)| *local == identifier.name.as_str())?; + path.extend(key.clone()); + path.reverse(); + return Some(if exact && !path.is_empty() { + ThemeRead::Path(path) + } else { + ThemeRead::Other + }); + } + _ => return None, + } + } + } + + /// Visit `expression`, which stands where a CSS value does when `value` + fn visit(&mut self, expression: &mut Expression<'a>, value: bool) { + match self.path(expression) { + Some(ThemeRead::Path(path)) if value => { + let variable = format!("var(--{})", path.join("-")); + *expression = Expression::new_string_literal( + SPAN, + Str::from_in(variable.as_str(), self.ast.allocator()), + None, + self.ast, + ); + return; + } + Some(_) => { + if self.unread.is_none() { + self.unread = Some(expression.clone_in(self.ast.allocator())); + } + return; + } + None => {} + } + match expression { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = property.key.as_expression_mut() { + self.visit(key, false); + } + self.visit(&mut property.value, true); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.visit(&mut spread.argument, false); + } + } + } + } + Expression::TemplateLiteral(template) => { + for expression in &mut template.expressions { + self.visit(expression, true); + } + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(element) = element.as_expression_mut() { + self.visit(element, value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.visit(&mut conditional.test, false); + self.visit(&mut conditional.consequent, value); + self.visit(&mut conditional.alternate, value); + } + Expression::LogicalExpression(logical) => { + self.visit(&mut logical.left, false); + self.visit(&mut logical.right, value); + } + // Text joined to a string stays text + Expression::BinaryExpression(binary) + if binary.operator == BinaryOperator::Addition + && [&binary.left, &binary.right].into_iter().any(|side| { + matches!( + side, + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) + ) + }) => + { + self.visit(&mut binary.left, value); + self.visit(&mut binary.right, value); + } + Expression::ParenthesizedExpression(inner) => self.visit(&mut inner.expression, value), + expression => walk_mut::walk_expression(self, expression), + } + } +} + +impl<'a> VisitMut<'a> for ThemeReads<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + self.visit(expression, false); + } +} + +/// CSS text as the parts of a `css` prop: when `mixins`, an interpolation +/// standing where a declaration would is a mixin composed there, splitting the +/// text around it. `Err` holds an interpolation the parts cannot place, with +/// what the text requires of it. +pub(crate) fn template_parts<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + mixins: bool, +) -> Result>, (Expression<'a>, &'static str)> { + let mut parts = Vec::new(); + let mut text = String::new(); + let mut depth = 0usize; + let mut from = 0; + for (index, expression) in template.expressions.iter().enumerate() { + let quasi = template.quasis[index].value.raw.as_str(); + text.push_str(quasi); + depth = (depth + quasi.matches('{').count()).saturating_sub(quasi.matches('}').count()); + let place = interpolation_place(&text, &template.quasis[index + 1..]); + if matches!(place, Place::Value) || get_string_by_literal_expression(expression).is_some() { + continue; + } + let requirement = match place { + Place::Statement if mixins && depth == 0 => { + parts.extend(segment(ast, template, from, index)); + parts.push(expression.clone_in_with_semantic_ids(ast.allocator())); + from = index + 1; + continue; + } + Place::Statement if mixins => NESTED_MIXIN, + _ => UNPLACED, + }; + return Err((expression.clone_in(ast.allocator()), requirement)); + } + parts.extend(segment(ast, template, from, template.expressions.len())); + Ok(parts) +} + +/// The text of `template` from the quasi `from` to the quasi `to`, with the +/// values between them; `None` when it holds nothing +fn segment<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + from: usize, + to: usize, +) -> Option> { + let quasis = &template.quasis[from..=to]; + if from == to && quasis[0].value.raw.trim().is_empty() { + return None; + } + let allocator = ast.allocator(); + let quasis = quasis.iter().enumerate().map(|(index, quasi)| { + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: quasi.value.raw, + cooked: quasi.value.cooked, + }, + index == to - from, + ast, + ) + }); + let expressions = template.expressions[from..to] + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(allocator)); + Some(Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_iter_in(quasis, ast), + oxc_allocator::Vec::from_iter_in(expressions, ast), + ast, + )) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::literal_string_with_formatting_args +)] +mod tests { + use super::*; + use crate::utils::expression_to_code; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + fn parsed<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap() + } + + #[test] + fn test_css_prop_needles() { + for code in [ + "
", + "", + "jsx('div', { css: a })", + "jsx('div', {css:a})", + "jsx('div', {\"css\": a})", + "jsx('div', {'css' : a})", + "f(a,css:b)", + ] { + assert!(may_take_css_prop(code), "{code}"); + } + for code in [ + "import './a.css'", + "a.css = b", + "if (a css == b) {}", + "css(a)", + "
", + "css", + ] { + assert!(!may_take_css_prop(code), "{code}"); + } + } + + #[test] + fn test_css_prop_of_aliases() { + let mut aliases = HashMap::new(); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + assert_eq!( + CssProp::of(&aliases, "
", false), + CssProp::Elements + ); + assert_eq!( + CssProp::of(&aliases, "
", true), + CssProp::Everywhere + ); + } + + #[test] + fn test_emotion_sources_and_pragmas() { + assert!(is_emotion("@emotion/react")); + assert!(is_emotion("@emotion/react/jsx-runtime")); + assert!(is_emotion("@emotion/styled")); + assert!(!is_emotion("@emotion/reactive")); + assert!(!is_emotion("@emotion/css")); + + assert_eq!( + emotion_pragma("* @jsxImportSource @emotion/react "), + Some(19) + ); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/react*"), Some(17)); + assert_eq!( + emotion_pragma(" @jsxImportSource\t@emotion/react"), + Some(18) + ); + assert_eq!(emotion_pragma("@jsxImportSource react"), None); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/reactive"), None); + assert_eq!(emotion_pragma("@jsx jsx"), None); + + assert_eq!( + react_runtime("@emotion/react/jsx-runtime"), + Some("react/jsx-runtime") + ); + assert_eq!( + react_runtime("@emotion/react/jsx-dev-runtime"), + Some("react/jsx-dev-runtime") + ); + assert_eq!(react_runtime("@emotion/react"), None); + + assert!(is_jsx_file("src/App.tsx")); + assert!(is_jsx_file("App.jsx")); + assert!(!is_jsx_file("App.ts")); + assert!(!is_jsx_file("Makefile")); + + let mut aliases = + HashMap::from([(EMOTION_JSX_RUNTIME.to_string(), ImportAlias::NamedToNamed)]); + assert!(!builds_jsx_with_emotion(&aliases)); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert!(builds_jsx_with_emotion(&aliases)); + + let compat = "@devup-ui/react/compat"; + assert!(is_jsx_function("react/jsx-runtime", "jsxs", compat)); + assert!(is_jsx_function( + "@emotion/react/jsx-dev-runtime", + "jsxDEV", + compat + )); + assert!(!is_jsx_function("react/jsx-runtime", "Fragment", compat)); + assert!(is_jsx_function("@emotion/react", "createElement", compat)); + assert!(is_jsx_function(compat, "jsx", compat)); + assert!(!is_jsx_function("react", "createElement", compat)); + assert!(!is_jsx_function(EMOTION_REACT, "css", compat)); + } + + #[test] + fn test_takes() { + let allocator = Allocator::default(); + let devup = |name: &str| name == "Box"; + for (code, off, elements, everywhere) in [ + ("
", false, true, true), + ("", false, true, true), + ("", false, true, true), + ("", false, false, true), + ("", false, false, true), + ] { + let Expression::JSXElement(element) = parsed(&allocator, code) else { + panic!("{code}"); + }; + let name = &element.opening_element.name; + assert_eq!(CssProp::Off.takes(name, devup), off, "{code}"); + assert_eq!(CssProp::Elements.takes(name, devup), elements, "{code}"); + assert_eq!(CssProp::Everywhere.takes(name, devup), everywhere, "{code}"); + } + for (code, elements) in [ + ("'div'", true), + ("Box", true), + ("Custom", false), + ("f()", false), + ] { + let element = parsed(&allocator, code); + assert!(!CssProp::Off.takes_type(&element, devup), "{code}"); + assert_eq!( + CssProp::Elements.takes_type(&element, devup), + elements, + "{code}" + ); + assert!(CssProp::Everywhere.takes_type(&element, devup), "{code}"); + } + } + + #[test] + fn test_theme_rules() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + for (code, expected) in [ + ( + "theme => ({ color: theme.colors.primary })", + "({color:`var(--colors-primary)`});", + ), + ( + "({ colors, space: s }) => ({ color: colors.text, margin: s[2] })", + "({color:`var(--colors-text)`,margin:`var(--space-2)`});", + ), + ( + "function (t) { return [{ border: `1px solid ${t.line}` }, a && { color: '#' + t.a }]; }", + "[{border:`1px solid ${`var(--line)`}`},a&&{color:`#`+`var(--a)`}];", + ), + ( + "t => c ? { color: (t.a) } : { [k]: t['b'], ...rest }", + "c?{color:`var(--a)`}:{[k]:`var(--b)`,...rest};", + ), + ("() => ({ color: 'red' })", "({color:`red`});"), + ] { + let rules = theme_rules(&ast, &parsed(&allocator, code)).unwrap(); + assert_eq!(expression_to_code(&rules), expected, "{code}"); + } + for (code, unread) in [ + ("t => ({ color: t })", "t"), + ("t => ({ ...t.mixins })", "t.mixins"), + ("t => ({ margin: t.spacing(2) })", "t.spacing(2)"), + ("t => ({ color: t.colors[name] })", "t.colors[name]"), + ("t => t.dark ? {} : {}", "t.dark"), + ("t => ({ width: t.a * 2 })", "t.a"), + ("t => ({ color: t.a || 'red' })", "t.a"), + ("t => ({ color: f(t.a), border: t.b() })", "t.a"), + ] { + let (read, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_READ, "{code}"); + assert_eq!(expression_to_code(&read), format!("{unread};"), "{code}"); + } + for code in [ + "async t => ({})", + "function* (t) { return {}; }", + "async function (t) { return {}; }", + "(t, u) => ({})", + "(...t) => ({})", + "(t = {}) => ({})", + "([t]) => ({})", + "({ a: { b } }) => ({})", + "({ ...t }) => ({})", + "({ [k]: t }) => ({})", + "t => { const a = 1; return {}; }", + "t => {}", + "function (t) {}", + "a", + ] { + let (_, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_FUNCTION, "{code}"); + } + } + + #[test] + fn test_template_parts() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parts = |code: &'static str, mixins: bool| { + let Expression::TemplateLiteral(template) = parsed(&allocator, code) else { + panic!("{code}"); + }; + template_parts(&ast, &template, mixins) + .map(|parts| parts.iter().map(expression_to_code).collect::()) + }; + assert_eq!( + parts("`color: ${c}; ${'margin: 0'};`", false).unwrap(), + "`color: ${c}; ${`margin: 0`};`;" + ); + assert_eq!( + parts("`${base}; color: ${c}; ${other}`", true).unwrap(), + "base;`; color: ${c}; `;other;" + ); + assert_eq!(parts("``", true).unwrap(), ""); + let (_, requirement) = parts("`${base}; color: red;`", false).unwrap_err(); + assert_eq!(requirement, UNPLACED); + let (_, requirement) = parts("`&:hover { ${base}; }`", true).unwrap_err(); + assert_eq!(requirement, NESTED_MIXIN); + let (code, requirement) = parts("`${selector} { color: red; }`", true).unwrap_err(); + assert_eq!(requirement, UNPLACED); + assert_eq!(expression_to_code(&code), "selector;"); + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200b..ab3239a64 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -146,14 +146,14 @@ pub struct TemplateStyles { pub unplaced: Vec, } -enum Place { +pub(crate) enum Place { Value, Statement, Other, } /// Where an interpolation stands, from the CSS written before and after it -fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { +pub(crate) fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..]; let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); let end = rest.find([';', '{', '}']); @@ -587,19 +587,41 @@ fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { - Some(selector) + Some(StyleSelector::nest_selector(parent, &descendants(prelude))) +} + +/// Each selector of the list `selectors` written without `&` as a descendant +/// of it, as stylis nests one +pub(crate) fn descendants(selectors: &str) -> String { + let mut depth = 0usize; + let mut from = 0; + let mut parts = Vec::new(); + for (index, c) in selectors.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(&selectors[from..index]); + from = index + 1; + } + _ => {} } - 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)) + } + parts.push(&selectors[from..]); + parts + .iter() + .map(|part| { + let part = part.trim(); + if part.contains('&') { + part.to_string() + } else if part.starts_with(':') { + format!("&{part}") + } else { + format!("& {part}") + } + }) + .collect::>() + .join(",") } /// Optimize a declaration's value only when its property warrants multi-value @@ -1171,7 +1193,7 @@ mod tests { #[case( "`ul { font-family: 'Roboto Hello', sans-serif; }`", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( @@ -1610,14 +1632,23 @@ mod tests { #[case( "ul { font-family: 'Roboto Hello', sans-serif; }", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( "div { color: red; ; { background: blue; } }", vec![ - ("color", "red", Some(StyleSelector::Selector("div".to_string()))), - ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ("color", "red", Some(StyleSelector::Selector("& div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), + ] + )] + // A selector written without `&` selects within the element, each one of + // a list on its own, and a pseudo-class applies to the element itself + #[case( + "a, :is(b, c) d { color: red; } :hover { color: blue; }", + vec![ + ("color", "red", Some(StyleSelector::Selector("& a,&:is(b,c) d".to_string()))), + ("color", "blue", Some(StyleSelector::Selector("&:hover".to_string()))), ] )] // As in CSS nesting, only the text after the last `;` is the nested rule's @@ -1626,7 +1657,7 @@ mod tests { "color:red;background:blue { width: 1px; }", vec![ ("color", "red", None), - ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))), ] )] #[case( @@ -1634,7 +1665,7 @@ mod tests { vec![( "width", "1px", - Some(StyleSelector::Selector("color:red".to_string())) + Some(StyleSelector::Selector("& color:red".to_string())) )] )] #[case( diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27b..5ab24b05d 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -10,9 +10,9 @@ use crate::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, utils::{ - expression_to_code, get_number_by_literal_expression, get_str_by_property_key, - get_string_by_literal_expression, get_string_by_property_key, is_same_expression, - readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, + expression_to_code, get_str_by_property_key, get_string_by_literal_expression, + get_string_by_property_key, is_same_expression, readable_code, unwrap_syntax_only, + unwrap_syntax_only_mut, }, }; use css::{ @@ -239,8 +239,7 @@ pub fn extract_style_from_expression<'a>( for disassembled in disassemble_property(&name) { let disassembled: &str = &disassembled; if name == "styleOrder" { - style_order = get_number_by_literal_expression(&prop.value) - .map(|v| v as u8); + style_order = crate::style_order::static_order(&prop.value); } else if name == "styleVars" { style_vars = Some(prop.value.clone_in(ast_builder.allocator())); @@ -1257,9 +1256,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option li'`) rather +/// than CSS properties. fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed0245..c1ceb13df 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -11,6 +11,7 @@ use crate::{ }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, + styled_reads::{Forward, Reads, withheld}, utils::{ STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, merge_object_expressions, readable_code, reads_directly, style_arguments, uncomposable_error, unplaced_error, @@ -46,7 +47,291 @@ struct Base<'a> { bound: Option>, } +/// A styled component the file defines, which a component extending it +/// renders in its place: what it renders, its classes and styles, and its +/// attrs, as the extension applies them before its own +pub struct StyledDefinition<'a> { + name: String, + bound: Option>, + classes: Vec>, + styles: Vec>, + attrs: Vec>, + /// The props its styles and attrs read + reads: Reads, + /// Its `shouldForwardProp`, as the build evaluates it + forward: Option, + /// The class other styles select it by, which a component extending it + /// gives too, as it renders this one + marker: Option, +} + +/// `marker` as a class among those a component gives +fn marker_class<'a>(ast_builder: &AstBuilder<'a>, marker: &str) -> Expression<'a> { + Expression::new_string_literal( + SPAN, + Str::from_in(marker, ast_builder.allocator()), + None, + ast_builder, + ) +} + +/// How the classes of a styled component are named: the file its atoms are +/// split into, and the class other styles select it by +#[derive(Clone, Copy, Default)] +pub struct Naming<'s> { + pub split_filename: Option<&'s str>, + pub marker: Option<&'s str>, +} + +/// Whether a styled component renders a tag, which takes only valid +/// attributes, rather than a component +fn renders_tag(name: &str, bound: Option<&Expression<'_>>) -> bool { + bound.is_none() && name.starts_with(|c: char| c.is_ascii_lowercase()) && !name.contains('.') +} + +/// `own` applying after `inherited`: a prop passes only when both pass it +fn combine_forward(inherited: Option<&Forward>, own: Option) -> Option { + match (inherited.cloned(), own) { + (Some(inherited), Some(own)) => Some(Forward::And(Box::new(inherited), Box::new(own))), + (inherited, own) => own.or(inherited), + } +} + +impl StyledDefinition<'_> { + /// Whether an element using this component keeps passing the prop `name`: + /// what its styles read, what it passes on, and what React or the + /// component itself takes + #[must_use] + pub fn takes(&self, name: &str) -> bool { + self.reads.whole + || self.reads.names.iter().any(|read| read == name) + || matches!( + name, + "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children" + ) + || crate::styled_reads::passes( + name, + renders_tag(&self.name, self.bound.as_ref()), + self.forward.as_ref(), + ) + } + + /// Whether an extension can render what this definition renders: a value + /// only the runtime gives is read again only when it is a binding + #[must_use] + pub fn extendable(&self) -> bool { + self.bound + .as_ref() + .is_none_or(|bound| matches!(bound, Expression::Identifier(_))) + } +} + +impl<'a> StyledDefinition<'a> { + /// The tag an element using this component can render in its place, with + /// the styles the component gives it: a tag, with no attrs, no props read + /// and no value only the runtime gives + #[must_use] + pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { + (renders_tag(&self.name, self.bound.as_ref()) + && self.attrs.is_empty() + && self.reads == Reads::default() + && self.classes.is_empty() + && self.styles.iter().all(fixed)) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + /// The class other styles select the component by + #[must_use] + pub fn marker(&self) -> Option<&str> { + self.marker.as_deref() + } + + /// The styles the component gives what it renders + #[must_use] + pub fn styles(&self) -> &[ExtractStyleProp<'a>] { + &self.styles + } +} + +/// Whether `prop` holds only values the build knows +fn fixed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(value) => !matches!(value, ExtractStyleValue::Dynamic(_)), + ExtractStyleProp::StaticArray(props) => props.iter().all(fixed), + _ => false, + } +} + +/// `Component.withComponent(target)`: the styles and attrs of `definition` +/// rendering `target`, a tag name or a component JSX can name; `None` for any +/// other target +pub fn with_component<'a>( + ast_builder: &AstBuilder<'a>, + definition: &StyledDefinition<'a>, + target: &Expression<'a>, + split_filename: Option<&str>, +) -> Option<(Expression<'a>, StyledDefinition<'a>)> { + let name = match unwrap_syntax_only(target) { + Expression::StringLiteral(literal) => literal.value.to_string(), + target => jsx_name(target)?, + }; + let base = Base::named(name); + let allocator = ast_builder.allocator(); + let mut styles: Vec> = definition + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(); + let classes: Vec> = clone_all(ast_builder, &definition.classes).collect(); + let class_name = merge_expression_for_class_name( + ast_builder, + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), + ); + let withheld = withheld( + &definition.reads, + renders_tag(&base.name, None), + definition.forward.as_ref(), + ); + let component = create_styled_component( + ast_builder, + &base.name, + &class_name, + &gen_styles(ast_builder, &styles, None), + &withheld, + ); + let new_definition = base.definition( + ast_builder, + classes, + &styles, + &definition.attrs, + definition.reads.clone(), + definition.forward.clone(), + ); + Some(( + apply_attrs(ast_builder, component, &definition.attrs), + new_definition, + )) +} + +/// What extracting a styled component gives +pub struct StyledExtraction<'a> { + pub result: ExtractResult<'a>, + pub expression: Expression<'a>, + pub errors: Vec<(u32, String)>, + pub definition: Option>, +} + +/// The styles a component renders: those it extends or its base component +/// brings, then its own, a later declaration replacing an earlier one +fn compose_styles<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + defaults: Option>, + own: Vec>, +) -> Vec> { + let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| { + inherited + .styles + .iter() + .map(|style| style.clone_in(ast_builder.allocator())) + .collect() + }); + earlier.extend(defaults.into_iter().flatten().map(ExtractStyleProp::Static)); + if earlier.is_empty() { + return own; + } + let mut composition = crate::composition::Composition::default(); + composition.apply(ast_builder, earlier); + composition.apply(ast_builder, own); + composition.into_props() +} + +/// The classes and attrs a component extending `inherited` starts from +fn inherited_parts<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + attrs: &[Expression<'a>], +) -> (Vec>, Vec>) { + let allocator = ast_builder.allocator(); + let (classes, mut all_attrs) = inherited.map_or_else( + || (Vec::new(), Vec::new()), + |inherited| { + ( + inherited + .marker + .as_deref() + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(inherited.classes.iter().map(|c| c.clone_in(allocator))) + .collect(), + inherited + .attrs + .iter() + .map(|a| a.clone_in(allocator)) + .collect(), + ) + }, + ); + all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + (classes, all_attrs) +} + impl<'a> Base<'a> { + /// What `inherited` renders, in place of the component defining it + fn extending( + self, + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + ) -> Self { + match inherited { + Some(inherited) => Self { + name: inherited.name.clone(), + styles: None, + bound: inherited + .bound + .as_ref() + .map(|bound| bound.clone_in(ast_builder.allocator())), + }, + None => self, + } + } + + /// The definition of the component rendering this base + fn definition( + &self, + ast_builder: &AstBuilder<'a>, + classes: Vec>, + styles: &[ExtractStyleProp<'a>], + attrs: &[Expression<'a>], + reads: Reads, + forward: Option, + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + StyledDefinition { + name: self.name.clone(), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes, + styles: styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + reads, + forward, + marker: None, + } + } + + /// The props a component rendering this base keeps away from it + fn withheld(&self, reads: &Reads, forward: Option<&Forward>) -> Vec { + withheld(reads, renders_tag(&self.name, self.bound.as_ref()), forward) + } + const fn named(name: String) -> Self { Self { name, @@ -65,6 +350,29 @@ impl<'a> Base<'a> { } } +/// The component `styled(Component)` extends, as written: the argument naming +/// it in a tagged template `styled(Component)`, `styled(Component)({...})` or +/// `styled(Component, {...})` +#[must_use] +pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> { + let factory = match expression { + Expression::TaggedTemplateExpression(tag) => &tag.tag, + Expression::CallExpression(call) + if matches!( + unwrap_syntax_only(&call.callee), + Expression::CallExpression(_) + ) => + { + &call.callee + } + expression => expression, + }; + let Expression::CallExpression(call) = unwrap_syntax_only(factory) else { + return None; + }; + unwrap_syntax_only(call.arguments.first()?.as_expression()?).into() +} + fn extract_base_tag_and_class_name<'a>( ast_builder: &AstBuilder<'a>, input: &Expression<'a>, @@ -180,10 +488,21 @@ fn resolve_styled_call_target<'a>( pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - split_filename: Option<&str>, + naming: Naming<'_>, imports: &FxHashMap, attrs: &[Expression<'a>], -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + inherited: Option<&StyledDefinition<'a>>, + forward: Option, +) -> StyledExtraction<'a> { + let Naming { + split_filename, + marker, + } = naming; + let forward = combine_forward(inherited.and_then(|i| i.forward.as_ref()), forward); + let mut reads = inherited.map_or_else(Reads::default, |inherited| inherited.reads.clone()); + for attr in attrs { + reads.read_in(attr); + } let mut composed_classes = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -209,13 +528,17 @@ pub fn extract_style_from_styled<'a>( None => {} } } - let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression + let (result, new_expr, definition) = if let Expression::TaggedTemplateExpression(tag) = + expression && let Some(mut base) = extract_base_tag_and_class_name(ast_builder, &tag.tag, imports) { // Case 1: styled.div`css` or styled("div")`css` // Check if tag is styled.div or styled(...) // Extract CSS from template literal + for interpolation in &tag.quasi.expressions { + reads.read_in(interpolation); + } let TemplateStyles { styles, statements, @@ -225,16 +548,16 @@ pub fn extract_style_from_styled<'a>( let expression = &tag.quasi.expressions[index]; errors.push((expression.span().start, unplaced_error(expression))); } - let mut props_styles: Vec> = styles + let own: Vec> = styles .into_iter() .map(|ex| ExtractStyleProp::Static(ex.into())) .collect(); + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut props_styles = compose_styles(ast_builder, inherited, defaults, own); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); - if let Some(default_class_name) = base.styles.take() { - props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); - } - - let mixins = statements.into_iter().map(|index| { + classes.extend(statements.into_iter().map(|index| { let mixin = &tag.quasi.expressions[index]; if matches!( unwrap_syntax_only(mixin), @@ -255,12 +578,13 @@ pub fn extract_style_from_styled<'a>( } else { mixin.clone_in(ast_builder.allocator()) } - }); + })); let class_name = merge_expression_for_class_name( ast_builder, - mixins - .collect::>() + marker + .map(|marker| marker_class(ast_builder, marker)) .into_iter() + .chain(clone_all(ast_builder, &classes)) .chain(gen_class_names( ast_builder, &mut props_styles, @@ -279,8 +603,13 @@ pub fn extract_style_from_styled<'a>( &base.name, &class_name, &gen_styles(ast_builder, &props_styles, None), + &base.withheld(&reads, forward.as_ref()), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let mut definition = + base.definition(ast_builder, classes, &props_styles, &attrs, reads, forward); + definition.marker = marker.map(str::to_string); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles: props_styles, @@ -290,7 +619,7 @@ pub fn extract_style_from_styled<'a>( props: None, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else if let Expression::CallExpression(call) = expression && let Some((mut base, style_index)) = resolve_styled_call_target(ast_builder, call, imports) @@ -298,6 +627,13 @@ pub fn extract_style_from_styled<'a>( // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), // or styled("div", { bg: "red" }) + reads.read_in( + if let Argument::SpreadElement(spread) = &call.arguments[style_index] { + &spread.argument + } else { + call.arguments[style_index].to_expression() + }, + ); // Extract styles from object expression let ExtractResult { mut styles, @@ -324,26 +660,41 @@ pub fn extract_style_from_styled<'a>( .into_iter() .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), ); - if let Some(default_class_name) = base.styles.take() { - styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + if let Some(order) = style_order { + for style in &mut styles { + crate::composition::set_prop_order(style, order); + } } + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut styles = compose_styles(ast_builder, inherited, defaults, styles); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); + classes.extend(composed_classes); let class_name = merge_expression_for_class_name( ast_builder, - composed_classes.into_iter().chain(gen_class_names( - ast_builder, - &mut styles, - style_order, - split_filename, - )), + marker + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(clone_all(ast_builder, &classes)) + .chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), ); let component = create_styled_component( ast_builder, &base.name, &class_name, &gen_styles(ast_builder, &styles, None), + &base.withheld(&reads, forward.as_ref()), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let mut definition = base.definition(ast_builder, classes, &styles, &attrs, reads, forward); + definition.marker = marker.map(str::to_string); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles, @@ -353,7 +704,7 @@ pub fn extract_style_from_styled<'a>( props, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else { // Left as written it would call `styled` at runtime, which only the build runs let code = match &*expression { @@ -364,13 +715,23 @@ pub fn extract_style_from_styled<'a>( expression.span().start, build_time_error("styled", &code, STYLED_FACTORY), )); - (None, None) + (None, None, None) }; - ( - result.unwrap_or_else(ExtractResult::default), - new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + StyledExtraction { + result: result.unwrap_or_else(ExtractResult::default), + expression: new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), errors, - ) + definition, + } +} + +fn clone_all<'s, 'a>( + ast_builder: &'s AstBuilder<'a>, + expressions: &'s [Expression<'a>], +) -> impl Iterator> + 's { + expressions + .iter() + .map(|expression| expression.clone_in(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -379,15 +740,16 @@ const ATTRS_PROPS: &str = "__devupProps"; /// Strip styled-components' `.attrs()` / `.withConfig()` off a styled factory /// such as `styled.div.attrs(a).withConfig(c)`, returning the attrs in the -/// order they apply. `withConfig` only tunes runtime behavior, so it is dropped. +/// order they apply, and the `withConfig` options in the order they apply pub fn take_styled_modifiers<'a>( ast_builder: &AstBuilder<'a>, factory: &mut Expression<'a>, is_styled: impl Fn(&str) -> bool, -) -> Vec> { +) -> (Vec>, Vec>) { let mut attrs = Vec::new(); + let mut configs = Vec::new(); if !is_modified_styled(factory, is_styled) { - return attrs; + return (attrs, configs); } while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) && let CallExpression { @@ -397,18 +759,62 @@ pub fn take_styled_modifiers<'a>( && matches!(member.property.name.as_str(), "attrs" | "withConfig") { let placeholder = || Expression::new_null_literal(SPAN, ast_builder); - if member.property.name == "attrs" - && let Some(argument) = arguments[0].as_expression_mut() - { - attrs.push(std::mem::replace(argument, placeholder())); + if let Some(argument) = arguments[0].as_expression_mut() { + let argument = std::mem::replace(argument, placeholder()); + if member.property.name == "attrs" { + attrs.push(argument); + } else { + configs.push(argument); + } } let object = std::mem::replace(&mut member.object, placeholder()); *factory = object; } attrs.reverse(); - attrs + configs.reverse(); + (attrs, configs) } +/// The `shouldForwardProp` the options objects give, the later applying after +/// the earlier, with the offset and code of one the build cannot evaluate +pub fn read_forward(options: &[&Expression<'_>]) -> (Option, Option<(u32, String)>) { + let mut forward = None; + for options in options { + let Expression::ObjectExpression(object) = unwrap_syntax_only(options) else { + continue; + }; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + if property.computed + || property.key.static_name().as_deref() != Some("shouldForwardProp") + { + continue; + } + match Forward::read(&property.value) { + Some(own) => forward = combine_forward(forward.as_ref(), Some(own)), + None => { + return ( + forward, + Some(( + property.value.span().start, + build_time_error( + "styled", + &readable_code(&property.value), + SHOULD_FORWARD_PROP, + ), + )), + ); + } + } + } + } + (forward, None) +} + +const SHOULD_FORWARD_PROP: &str = "`shouldForwardProp` must be a function of the prop name that compares it with strings, `[...].includes(prop)`, `prop.startsWith(...)` or `isPropValid(prop)`, joined by `!`, `&&` and `||`"; + fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { let mut expression = unwrap_syntax_only(expression); let mut modified = false; @@ -452,44 +858,215 @@ fn apply_attrs<'a>( if attrs.is_empty() { return component; } - let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder); - let mut merged = props(); + let merged = if attrs.iter().all(sets_plain_props) { + spread_attrs(ast_builder, attrs) + } else { + merge_attrs(ast_builder, attrs) + }; + props_arrow( + ast_builder, + wrap_direct_call(ast_builder, &component, &[merged]), + ) +} + +/// The binding each attrs step reads the props merged so far through +const ATTRS_CONTEXT: &str = "__devupContext"; +/// The binding each attrs step reads what its attrs give through +const ATTRS_VALUE: &str = "__devupAttrs"; + +/// Whether `attr` is an object setting props other than `className` and +/// `style` by name, which spreading over the props merges as attrs do +fn sets_plain_props(attr: &Expression<'_>) -> bool { + let Expression::ObjectExpression(object) = unwrap_syntax_only(attr) else { + return false; + }; + object.properties.iter().all(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed + && property + .key + .static_name() + .is_some_and(|name| name != "className" && name != "style")) + }) +} + +/// The props with each attrs spread over them in order +fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = identifier(ast_builder, ATTRS_PROPS); + for attr in attrs { + merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator())); + } + merged +} + +/// The props merged as styled-components merges attrs: each attrs, or what +/// calling it with the props merged so far gives, joins its `className` to +/// theirs, merges its `style` over theirs and replaces their other props; the +/// caller's `className` comes last +fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = with_property( + ast_builder, + identifier(ast_builder, ATTRS_PROPS), + "className", + identifier(ast_builder, "undefined"), + ); for attr in attrs { - let attr = attr.clone_in(ast_builder.allocator()); - if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { - merged = spread_objects(ast_builder, merged, attr); - continue; - } - let called = wrap_direct_call(ast_builder, &attr, &[props()]); - let resolved = if matches!( - unwrap_syntax_only(&attr), - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) { - called - } else { - let is_function = Expression::new_binary_expression( + let resolved = spread_objects( + ast_builder, + Expression::new_object_expression( SPAN, - Expression::new_unary_expression( - SPAN, - UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), - ast_builder, - ), - BinaryOperator::StrictEquality, - Expression::new_string_literal(SPAN, "function", None, ast_builder), + oxc_allocator::Vec::new_in(ast_builder), ast_builder, - ); - Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) - }; - let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved)); + ), + resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + ); + let merge = call_with_values( + ast_builder, + vec![(ATTRS_VALUE.to_string(), resolved)], + merge_attrs_value(ast_builder), + ); + let step = named_arrow(ast_builder, ATTRS_CONTEXT, merge); merged = wrap_direct_call(ast_builder, &step, &[merged]); } - props_arrow( + let caller_class = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_PROPS, "className"), + ); + let last = named_arrow( + ast_builder, + ATTRS_CONTEXT, + with_property( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + "className", + caller_class, + ), + ); + wrap_direct_call(ast_builder, &last, &[merged]) +} + +/// What `attr` gives: itself when it is an object, or what calling it with +/// the props merged so far gives when it is a function +fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expression<'a> { + if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { + return attr; + } + let called = wrap_direct_call( + ast_builder, + &attr, + &[identifier(ast_builder, ATTRS_CONTEXT)], + ); + if matches!( + unwrap_syntax_only(&attr), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + return called; + } + let is_function = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Typeof, + attr.clone_in(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) +} + +/// `{ ...context, ...attrs, className, style }` with the classes joined and +/// the styles merged +fn merge_attrs_value<'a>(ast_builder: &AstBuilder<'a>) -> Expression<'a> { + let merged = spread_objects( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + identifier(ast_builder, ATTRS_VALUE), + ); + let class_name = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_VALUE, "className"), + ); + let style = spread_objects( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "style"), + member(ast_builder, ATTRS_VALUE, "style"), + ); + let merged = with_property(ast_builder, merged, "className", class_name); + with_property(ast_builder, merged, "style", style) +} + +fn identifier<'a>(ast_builder: &AstBuilder<'a>, name: &'static str) -> Expression<'a> { + Expression::new_identifier(SPAN, name, ast_builder) +} + +fn member<'a>( + ast_builder: &AstBuilder<'a>, + object: &'static str, + name: &'static str, +) -> Expression<'a> { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + identifier(ast_builder, object), + oxc_ast::ast::IdentifierName::new(SPAN, name, ast_builder), + false, + ast_builder, + )) +} + +/// `[first, second].filter(Boolean).join(" ") || undefined` +fn joined_class<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let joined = wrap_array_filter(ast_builder, &[first, second]) + .unwrap_or_else(|| identifier(ast_builder, "undefined")); + Expression::new_logical_expression( + SPAN, + joined, + LogicalOperator::Or, + identifier(ast_builder, "undefined"), ast_builder, - wrap_direct_call(ast_builder, &component, &[merged]), ) } +/// `object` with `name` set to `value`: an object spreading it, then setting +/// the property +fn with_property<'a>( + ast_builder: &AstBuilder<'a>, + object: Expression<'a>, + name: &'static str, + value: Expression<'a>, +) -> Expression<'a> { + let mut properties = match object { + Expression::ObjectExpression(object) => object.unbox().properties, + object => oxc_allocator::Vec::from_array_in( + [ObjectPropertyKind::new_spread_property( + SPAN, + object, + ast_builder, + )], + ast_builder, + ), + }; + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + PropertyKey::new_static_identifier(SPAN, name, ast_builder), + value, + false, + false, + false, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + fn spread_objects<'a>( ast_builder: &AstBuilder<'a>, first: Expression<'a>, @@ -510,10 +1087,19 @@ fn spread_objects<'a>( } fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + named_arrow(ast_builder, ATTRS_PROPS, body) +} + +/// `(name) => body` +fn named_arrow<'a>( + ast_builder: &AstBuilder<'a>, + name: &'static str, + body: Expression<'a>, +) -> Expression<'a> { let parameter = FormalParameter::new( SPAN, oxc_allocator::Vec::new_in(ast_builder), - BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), None::>>, None::>>, false, @@ -540,11 +1126,180 @@ fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expres ) } +/// The binding the generated styled components forward refs through +pub const FORWARD_REF: &str = "__devupForwardRef"; + +/// `__devupForwardRef((p, ref) => component({ ...p, ref }))`: React 18 gives a +/// function component no `ref` prop, so the component takes it as one and +/// passes it to what it renders with the rest of its props +pub fn forward_ref<'a>(ast_builder: &AstBuilder<'a>, component: Expression<'a>) -> Expression<'a> { + let parameter = |name: &'static str| { + FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ) + }; + let props = with_property( + ast_builder, + identifier(ast_builder, "__devupRefProps"), + "ref", + identifier(ast_builder, "__devupRef"), + ); + let body = wrap_direct_call( + ast_builder, + &Expression::new_parenthesized_expression(SPAN, component, ast_builder), + &[props], + ); + let render = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in( + [parameter("__devupRefProps"), parameter("__devupRef")], + ast_builder, + ), + None::>>, + ast_builder, + ), + None::>>, + body.into(), + ast_builder, + ); + wrap_direct_call( + ast_builder, + &identifier(ast_builder, FORWARD_REF), + &[render], + ) +} + +/// `rest` without the props in `withheld`, as `(({ a: _0, ...p }) => p)(rest)` +fn without_props<'a>(ast_builder: &AstBuilder<'a>, withheld: &[String]) -> Expression<'a> { + let rest = Expression::new_identifier(SPAN, "rest", ast_builder); + if withheld.is_empty() { + return rest; + } + let properties = withheld.iter().enumerate().map(|(index, name)| { + BindingProperty::new( + SPAN, + PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(name.as_str(), ast_builder.allocator()), + None, + ast_builder, + )), + BindingPattern::new_binding_identifier( + SPAN, + Str::from_in( + format!("__devupOmit{index}").as_str(), + ast_builder.allocator(), + ), + ast_builder, + ), + false, + false, + ast_builder, + ) + }); + let pattern = BindingPattern::new_object_pattern( + SPAN, + oxc_allocator::Vec::from_iter_in(properties, ast_builder), + Some(BindingRestElement::boxed( + SPAN, + BindingPattern::new_binding_identifier(SPAN, "__devupDom", ast_builder), + ast_builder, + )), + ast_builder, + ); + let parameter = FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + pattern, + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in([parameter], ast_builder), + None::>>, + ast_builder, + ), + None::>>, + Expression::new_identifier(SPAN, "__devupDom", ast_builder).into(), + ast_builder, + ); + wrap_direct_call( + ast_builder, + &Expression::new_parenthesized_expression(SPAN, arrow, ast_builder), + &[rest], + ) +} + +/// The binding a styled component renders through: what `as` names, or the +/// tag or component it was defined with +const RENDERED: &str = "DevupAs"; + +/// What a styled component renders when `as` names nothing: a tag name as a +/// string, a component as the binding or member JSX names it by +fn tag_expression<'a>(ast_builder: &AstBuilder<'a>, tag_name: &str) -> Expression<'a> { + if tag_name.starts_with(|c: char| c.is_ascii_lowercase()) && !tag_name.contains('.') { + return Expression::new_string_literal( + SPAN, + Str::from_in(tag_name, ast_builder.allocator()), + None, + ast_builder, + ); + } + let mut parts = tag_name.split('.'); + let first = parts.next().unwrap_or(tag_name); + parts.fold( + Expression::new_identifier( + SPAN, + Str::from_in(first, ast_builder.allocator()), + ast_builder, + ), + |object, property| { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + object, + oxc_ast::ast::IdentifierName::new( + SPAN, + Str::from_in(property, ast_builder.allocator()), + ast_builder, + ), + false, + ast_builder, + )) + }, + ) +} + fn create_styled_component<'a>( ast_builder: &AstBuilder<'a>, tag_name: &str, class_name: &Option>, style_vars: &Option>, + withheld: &[String], ) -> Expression<'a> { let params = FormalParameters::boxed( SPAN, @@ -577,6 +1332,39 @@ fn create_styled_component<'a>( false, ast_builder, ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier(SPAN, "as", ast_builder), + BindingPattern::new_assignment_pattern( + SPAN, + BindingPattern::new_binding_identifier( + SPAN, + RENDERED, + ast_builder, + ), + tag_expression(ast_builder, tag_name), + ast_builder, + ), + false, + false, + ast_builder, + ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + BindingPattern::new_binding_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + true, + false, + ast_builder, + ), ], ast_builder, ), @@ -604,17 +1392,23 @@ fn create_styled_component<'a>( SPAN, JSXOpeningElement::boxed( SPAN, - JSXElementName::new_identifier( - SPAN, - Str::from_in(tag_name, ast_builder.allocator()), - ast_builder, - ), + JSXElementName::new_identifier(SPAN, RENDERED, ast_builder), None::>>, oxc_allocator::Vec::from_iter_in( vec![ JSXAttributeItem::new_spread_attribute( SPAN, - Expression::new_identifier(SPAN, "rest", ast_builder), + without_props(ast_builder, withheld), + ast_builder, + ), + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, "as", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + Expression::new_identifier(SPAN, "forwardedAs", ast_builder).into(), + ast_builder, + )), ast_builder, ), JSXAttributeItem::new_attribute( diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..3e5ffe940 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,18 +8,22 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; +use crate::css_prop::{ + CssProp, EMOTION_REACT, REACT_JSX_PRAGMA, builds_jsx_with_emotion, class_names_child, + emotion_pragma, is_emotion, is_jsx_file, is_jsx_function, react_runtime, returned, +}; use crate::utils::{ 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, JSXAttributeItem, - JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, - Statement, + Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, + JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, + LogicalOperator, ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, - walk::{walk_call_expression, walk_jsx_opening_element}, + walk::{walk_call_expression, walk_jsx_element, walk_jsx_opening_element}, }; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; @@ -44,6 +48,15 @@ struct LibraryNumbers<'n> { styled: Vec<&'n str>, /// Local names of components taking rules in `styles` (Emotion's `Global`) components: Vec<&'n str>, + /// Which elements take the `css` prop, whose rules are Emotion's + css_prop: CssProp, + /// Local names of Devup UI's exports, which take the `css` prop as tags do + devup: Vec<&'n str>, + /// Local names of the functions building elements from a type and props + jsx: Vec<&'n str>, + /// Local names of Emotion's `ClassNames`, whose child function takes a + /// `css` taking rules + class_names: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, } @@ -95,7 +108,70 @@ impl LibraryNumbers<'_> { } } + /// The rules a `css` prop composes: a function of the theme gives them + fn pixelify_css(&mut self, value: &Expression) { + match value { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify_css(element); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.pixelify_css(&conditional.consequent); + self.pixelify_css(&conditional.alternate); + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify_css(&logical.left); + } + self.pixelify_css(&logical.right); + } + Expression::ParenthesizedExpression(inner) => self.pixelify_css(&inner.expression), + Expression::ArrowFunctionExpression(arrow) => { + let rules = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + if let Some(rules) = rules { + self.pixelify_css(rules); + } + } + Expression::FunctionExpression(function) => { + if let Some(rules) = function.body.as_deref().and_then(returned) { + self.pixelify_css(rules); + } + } + rules => self.pixelify(rules), + } + } + + /// The value of the attribute `name` on `element`, pixelified as `css` + /// prop rules when `css` + fn pixelify_attribute(&mut self, element: &JSXOpeningElement, name: &str) { + for attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attribute) = attribute + && attribute + .name + .as_identifier() + .is_some_and(|attribute| attribute.name == name) + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value + && let Some(rules) = container.expression.as_expression() + { + if name == "css" { + self.pixelify_css(rules); + } else { + self.pixelify(rules); + } + } + } + } + fn pixelify_value(&mut self, key: &str, value: &Expression) { + if key == "styleOrder" { + return; + } if let Some(number) = js_number_literal(value) { if number != 0.0 && !keeps_bare_number(key) { let span = value.span(); @@ -126,7 +202,25 @@ impl LibraryNumbers<'_> { } } -impl<'a> Visit<'a> for LibraryNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers<'a> { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let css = match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(&name.name.as_str()) => + { + class_names_child(element).and_then(|names| names.css) + } + _ => None, + }; + if let Some(css) = css { + self.calls.push((css, RulesAt::EveryArgument)); + } + walk_jsx_element(self, element); + if css.is_some() { + self.calls.pop(); + } + } + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { Expression::Identifier(callee) => self @@ -155,6 +249,23 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { for rules in rules { self.pixelify(rules); } + if let Expression::Identifier(callee) = &call.callee + && self.jsx.contains(&callee.name.as_str()) + && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() + && let Some(element) = element.as_expression() + && self + .css_prop + .takes_type(element, |root| self.devup.contains(&root)) + { + for property in &props.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && !property.computed + && property.key.static_name().is_some_and(|key| key == "css") + { + self.pixelify_css(&property.value); + } + } + } walk_call_expression(self, call); } @@ -162,19 +273,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { 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); - } - } + self.pixelify_attribute(element, "styles"); + } + if self + .css_prop + .takes(&element.name, |root| self.devup.contains(&root)) + { + self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); } @@ -207,6 +312,9 @@ fn devup_equivalent(source: &str, imported: &str) -> Option ("@vanilla-extract/css", "globalStyle") => Some(DevupTarget::Main("globalCss")), (_, "keyframes") => Some(DevupTarget::Main("keyframes")), (_, "styled") => Some(DevupTarget::Main("styled")), + // The `css` props it builds compile away, leaving React's own element + ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), + ("@emotion/react", "ClassNames") => Some(DevupTarget::Compat("ClassNames")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -226,7 +334,18 @@ pub fn transform_import_aliases<'a>( package: &str, import_aliases: &HashMap, ) -> Cow<'a, str> { - transform_import_aliases_with_edits(code, filename, package, import_aliases).0 + transform_import_aliases_with_edits(code, filename, package, import_aliases).code +} + +/// What rewriting aliased imports gives +pub struct Aliased<'a> { + /// The code, or the original code when nothing was rewritten + pub code: Cow<'a, str>, + /// The replacements made in order, so a position in the code maps back to + /// the source + pub edits: Vec, + /// Which elements take Emotion's `css` prop + pub css_prop: CssProp, } /// A replacement of `code[start..end]` by text of `length` bytes @@ -242,17 +361,28 @@ pub type Edit = (usize, usize, usize); /// /// # Returns /// The transformed source code, or the original code if no transformations were -/// needed, and the replacements made in order, so a position in the result maps -/// back to the source +/// needed, the replacements made in order, so a position in the result maps +/// back to the source, and which elements take Emotion's `css` prop pub fn transform_import_aliases_with_edits<'a>( code: &'a str, filename: &str, package: &str, import_aliases: &HashMap, -) -> (Cow<'a, str>, Vec) { - // Quick check: if no aliases match, return original code - if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { - return (Cow::Borrowed(code), Vec::new()); +) -> Aliased<'a> { + let unchanged = |css_prop| Aliased { + code: Cow::Borrowed(code), + edits: Vec::new(), + css_prop, + }; + let emotion_aliased = import_aliases.contains_key(EMOTION_REACT); + let emotion_jsx = builds_jsx_with_emotion(import_aliases); + // Quick check: if no aliases match, no element takes a `css` prop and the + // project builds no JSX with Emotion, return original code + if !(emotion_jsx && is_jsx_file(filename)) + && CssProp::of(import_aliases, code, false) == CssProp::Off + && (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias))) + { + return unchanged(CssProp::Off); } let allocator = Allocator::default(); @@ -267,12 +397,59 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); let mut numbers = LibraryNumbers::default(); + let mut uses_emotion = emotion_jsx; + let mut jsx_pragma = false; + let compat = format!("{package}/compat"); + + // A pragma building JSX with Emotion builds it with React once the `css` + // props are compiled + for comment in &program.comments { + let span = comment.content_span(); + let text = &code[span.start as usize..span.end as usize]; + jsx_pragma |= text.contains("@jsx"); + if let Some(at) = emotion_pragma(text) { + uses_emotion = true; + if emotion_aliased { + let start = span.start as usize + at; + transformations.push((start, start + EMOTION_REACT.len(), "react".to_string())); + } + } + } + // So does a project building every file's JSX with Emotion, through a + // pragma, which a file's own one overrides + if emotion_jsx && is_jsx_file(filename) && !jsx_pragma { + transformations.push((0, 0, REACT_JSX_PRAGMA.to_string())); + } for stmt in &program.body { 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) { + uses_emotion |= is_emotion(source_value); + for specifier in import_decl.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) + if is_jsx_function( + source_value, + &imported_name(&spec.imported), + &compat, + ) => + { + numbers.jsx.push(spec.local.name.as_str()); + } + specifier if source_value == package => { + numbers.devup.push(specifier.local().name.as_str()); + } + _ => {} + } + } + if emotion_aliased && let Some(runtime) = react_runtime(source_value) { + let span = import_decl.source.span; + transformations.push(( + span.start as usize, + span.end as usize, + format!("\"{runtime}\""), + )); + } else if let Some(alias) = import_aliases.get(source_value) { let span = import_decl.span; let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); @@ -294,6 +471,9 @@ pub fn transform_import_aliases_with_edits<'a>( "css" | "keyframes", ) => numbers.calls.push((local, RulesAt::EveryArgument)), ("@emotion/react", "Global") => numbers.components.push(local), + ("@emotion/react", "ClassNames") => { + numbers.class_names.push(local); + } _ => {} } } @@ -320,15 +500,22 @@ pub fn transform_import_aliases_with_edits<'a>( } } } - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { + numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); + if !(numbers.calls.is_empty() + && numbers.styled.is_empty() + && numbers.components.is_empty() + && numbers.class_names.is_empty()) + || numbers.css_prop != CssProp::Off + { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); - transformations.sort_unstable_by_key(|(start, ..)| *start); } + // An insertion comes before a replacement starting where it does + transformations.sort_unstable_by_key(|(start, end, _)| (*start, *end)); // Apply transformations in reverse order to preserve positions if transformations.is_empty() { - return (Cow::Borrowed(code), Vec::new()); + return unchanged(numbers.css_prop); } let edits = transformations @@ -340,7 +527,11 @@ pub fn transform_import_aliases_with_edits<'a>( result.replace_range(start..end, &replacement); } - (Cow::Owned(result), edits) + Aliased { + code: Cow::Owned(result), + edits, + css_prop: numbers.css_prop, + } } /// The source offset of `offset` in code `edits` made; an offset inside a @@ -1027,4 +1218,86 @@ const x = 1;", ); } } + + #[test] + fn test_css_prop_numbers_runtimes_and_pragmas() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + let outputs: Vec<(String, CssProp)> = [ + "export const a =
;", + "export const a =
;", + "export const a =
({ top: 1 }), t => { return { left: 2 }; }, t => { f(); return { right: 3 }; }, function (t) { return { bottom: 4 }; }, function (t) { f(); }]} />;", + "import { Box } from '@devup-ui/react';\nexport const a = <>;", + "import styled from '@emotion/styled';\nexport const a = ;", + "/** @jsxImportSource @emotion/react */\nexport const a = ;", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nimport { jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 }, id: 1, ['css']: { top: 1 } }), jsxDEV(Custom, { css: { top: 3 } }), _jsx('div', props), f('div', { css: { top: 4 } })];", + "import { jsx, createElement } from '@emotion/react';\nexport const a = [jsx('div', { css: { padding: 2 } }), createElement('div', null)];", + "import { jsx as _jsx } from 'react/jsx-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 } }), _jsx(Custom, { css: { padding: 2 } })];", + "export const a = { css: 1 };", + ] + .iter() + .map(|code| { + let aliased = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let without_css_prop = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &emotion_alias(), + ); + assert_eq!(without_css_prop.css_prop, CssProp::Off); + assert!(matches!(without_css_prop.code, Cow::Borrowed(_))); + } + + #[test] + fn test_project_building_jsx_with_emotion() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + aliases.insert( + crate::css_prop::EMOTION_JSX_RUNTIME.to_string(), + ImportAlias::NamedToNamed, + ); + let outputs: Vec<(String, CssProp, Vec)> = [ + ("test.tsx", "export const a = ;"), + ( + "test.jsx", + "import styled from '@emotion/styled';\nexport const a =
;", + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const a =
;", + ), + ("test.tsx", "/** @jsx h */\nexport const a =
;"), + ("test.ts", "export const a = 1;"), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const a = _jsx(Custom, { css: { top: 1 } });", + ), + ] + .iter() + .map(|(filename, code)| { + let aliased = + transform_import_aliases_with_edits(code, filename, "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop, aliased.edits) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let runtime_alone = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &HashMap::from([( + crate::css_prop::EMOTION_JSX_RUNTIME.to_string(), + ImportAlias::NamedToNamed, + )]), + ); + assert_eq!(runtime_alone.css_prop, CssProp::Off); + assert!(matches!(runtime_alone.code, Cow::Borrowed(_))); + } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..03cb5c9b5 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -21,8 +21,14 @@ use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::composition::{Composition, set_prop_order}; +use crate::css_prop::{CssProp, CssTakers}; +use crate::extractor::ExtractResult; +use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, +}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +50,9 @@ enum Constant { /// The class a `StyleX` theme applies Theme(String), /// What another style API gives: a class, a component or a keyframes - /// name, never rules - Style, + /// name, never rules; for a `css()` class, the styles behind it when + /// they are known + Style(Option>>), /// An object or array code changes, or a value read from one Changed(Rc), } @@ -154,6 +161,8 @@ pub(crate) struct Inlined { pub dependencies: BTreeSet, pub stylex_vars: FxHashMap>, pub stylex_themes: FxHashMap, + /// The styles behind imported `css()` classes + pub css_styles: FxHashMap>, pub unknown: Unknown, pub changed: Changed, } @@ -312,11 +321,13 @@ pub(crate) fn inline_constants<'a>( filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, + css_prop: CssProp, ) -> Inlined { let is_style_package = |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; let mut style_roots = FxHashSet::default(); let mut apis = StyleApis::default(); + let css_props = CssTakers::new(program, css_prop, &format!("{}/compat", option.package)); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement && is_style_package(&import.source.value) @@ -338,13 +349,15 @@ pub(crate) fn inline_constants<'a>( } } } - if style_roots.is_empty() { + if style_roots.is_empty() && css_prop == CssProp::Off { return Inlined::default(); } let mut read = StyleReads { style_roots: &style_roots, + css_props: &css_props, names: FxHashSet::default(), depth: 0, + class_names: Vec::new(), }; read.visit_program(program); if read.names.is_empty() { @@ -363,6 +376,7 @@ pub(crate) fn inline_constants<'a>( scope .style_names .extend(style_roots.iter().map(ToString::to_string)); + scope.css_props = Some(&css_props); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { @@ -471,6 +485,11 @@ pub(crate) fn inline_constants<'a>( Constant::Theme(class) => { inlined.stylex_themes.insert(name.clone(), class.clone()); } + Constant::Style(Some(styles)) => { + inlined + .css_styles + .insert(name.clone(), styles.as_ref().clone()); + } _ => {} } for symbol in bindings.get(name.as_str()).into_iter().flatten() { @@ -486,9 +505,12 @@ pub(crate) fn inline_constants<'a>( scoping: &scoping, symbols: &symbols, style_roots: &style_roots, + css_props: &css_props, apis: &apis, objects: false, styles: false, + px: false, + class_names: Vec::new(), } .visit_program(program); } @@ -620,8 +642,12 @@ impl StyleApis<'_> { /// of its functions struct StyleReads<'s> { style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } /// Whether `expression` is a style API: a root the package gives, or a member @@ -655,12 +681,33 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } + fn visit_jsx_element(&mut self, element: &oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element); + let taken = calls.len(); + self.class_names.extend(calls); + oxc_ast_visit::walk::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); - let style = self.is_style_root(&call.callee); + let style = + self.is_style_root(&call.callee) || calls_one_of(&self.class_names, &call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading(style, |reads| { - for argument in &call.arguments { - reads.visit_argument(argument); + for (index, argument) in call.arguments.iter().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter().enumerate() { + reads.reading(at == css, |reads| { + reads.visit_object_property_kind(property); + }); + } + } + _ => reads.visit_argument(argument), + } } }); } @@ -670,29 +717,36 @@ impl<'a> Visit<'a> for StyleReads<'_> { tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&tagged.tag); - let style = self.is_style_root(&tagged.tag); + let style = self.is_style_root(&tagged.tag) || calls_one_of(&self.class_names, &tagged.tag); self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); } fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); - self.reading(style, |reads| { - for attribute in &element.attributes { - match attribute { - JSXAttributeItem::Attribute(attribute) => { - if let Some(value) = &attribute.value { - reads.visit_jsx_attribute_value(value); - } - } - JSXAttributeItem::SpreadAttribute(spread) => { - reads.visit_expression(&spread.argument); + for attribute in &element.attributes { + let style = style + || self.css_props.attribute(&element.name, attribute, |root| { + self.style_roots.contains(root) + }); + self.reading(style, |reads| match attribute { + JSXAttributeItem::Attribute(attribute) => { + if let Some(value) = &attribute.value { + reads.visit_jsx_attribute_value(value); } } - } - }); + JSXAttributeItem::SpreadAttribute(spread) => { + reads.visit_expression(&spread.argument); + } + }); + } } } +/// Whether `callee` is one of `names` +fn calls_one_of(names: &[String], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) if names.iter().any(|name| name == callee.name.as_str())) +} + /// The name `` or `` starts with pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { match name { @@ -994,6 +1048,8 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, + /// The `css` props of the file extracted, which never run what they hold + css_props: Option<&'p CssTakers<'p>>, uses: Option>>>, changes: FxHashMap>>, } @@ -1009,6 +1065,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), + css_props: None, uses: None, changes: FxHashMap::default(), } @@ -1032,6 +1089,55 @@ impl<'p, 'a> ModuleScope<'p, 'a> { }) } + /// The styles behind `css(rules)`, the package's own `css` given one + /// rule object every value of which is known, as the module's class names + /// do not tell them + fn css_styles( + &mut self, + modules: &mut Modules<'_>, + call: &oxc_ast::ast::CallExpression<'_>, + ) -> Option>> { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else { + return None; + }; + if export != "css" || !source.starts_with(modules.option.package.as_str()) { + return None; + } + let [argument] = call.arguments.as_slice() else { + return None; + }; + let rules = self.evaluate(modules, argument.as_expression()?)?; + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut rules = match constant_literal(&builder, &rules, true)? { + rules @ Expression::ObjectExpression(_) => rules, + _ => return None, + }; + let ExtractResult { + mut styles, + style_order, + .. + } = extract_style_from_expression( + &builder, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + if let Some(order) = style_order { + for prop in &mut styles { + set_prop_order(prop, order); + } + } + let mut composition = Composition::default(); + composition.apply(&builder, styles); + composition.unconditional().map(Rc::new) + } + fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool { self.style_imports.contains(name) || self.style_names.contains(name) @@ -1066,9 +1172,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { uses.clone() } else { let option = modules.option; - let uses = Rc::new(crate::mutations::uses(self.program, &|name| { - self.is_style_import(option, name) - })); + let uses = Rc::new(crate::mutations::uses( + self.program, + &|name| self.is_style_import(option, name), + self.css_props, + )); self.uses = Some(uses.clone()); uses }; @@ -1465,13 +1573,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } - callee if self.is_style_api(modules, callee) => Some(Constant::Style), + callee if self.is_style_api(modules, callee) => { + Some(Constant::Style(self.css_styles(modules, call))) + } _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) if self.is_style_api(modules, &tagged.tag) => { - Some(Constant::Style) + Some(Constant::Style(None)) } Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), @@ -1694,11 +1804,17 @@ struct Inline<'s, 'a> { scoping: &'s Scoping, symbols: &'s FxHashMap, style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, apis: &'s StyleApis<'s>, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop styles: bool, + /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths + px: bool, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -1837,55 +1953,7 @@ impl<'a> Inline<'_, 'a> { } fn literal(&self, constant: &Constant) -> Option> { - let builder = self.ast_builder; - match constant { - Constant::String(value) => Some(Expression::new_string_literal( - SPAN, - Str::from_in(value.as_str(), builder.allocator()), - None, - builder, - )), - Constant::Number(value) => Some(Expression::new_numeric_literal( - SPAN, - *value, - None, - NumberBase::Decimal, - builder, - )), - Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), - Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), - Constant::Record(entries) if self.objects => { - let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); - for (key, value) in entries.iter() { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - oxc_ast::ast::PropertyKind::Init, - oxc_ast::ast::PropertyKey::StringLiteral( - oxc_ast::ast::StringLiteral::boxed( - SPAN, - Str::from_in(key.as_str(), builder.allocator()), - None, - builder, - ), - ), - self.literal(value)?, - false, - false, - false, - builder, - )); - } - Some(Expression::new_object_expression(SPAN, properties, builder)) - } - Constant::Array(values) if self.objects => { - let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); - for value in values.iter() { - elements.push(self.literal(value)?.into()); - } - Some(Expression::new_array_expression(SPAN, elements, builder)) - } - _ => None, - } + constant_literal(self.ast_builder, constant, self.objects) } fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T { @@ -1902,6 +1970,99 @@ impl<'a> Inline<'_, 'a> { self.styles = outer; result } + + /// `visit` reading a `css` prop when `css` + fn reading_css(&mut self, css: bool, visit: impl FnOnce(&mut Self) -> T) -> T { + let outer = std::mem::replace(&mut self.px, css); + let result = self.reading_styles(css, |inline| inline.reading_objects(css, visit)); + self.px = outer; + result + } +} + +/// `property` holding a number as the `px` length Emotion reads it as +fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + if let Some(number) = crate::utils::js_number_literal(&property.value) + && number != 0.0 + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + property.value = Expression::new_string_literal( + SPAN, + Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), + None, + ast_builder, + ); + } +} + +/// The rules `rules` with their numbers as the `px` lengths Emotion reads them +/// as, nested rules included +fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { + if let Expression::ObjectExpression(object) = rules { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + px_value(ast_builder, property); + px_rules(ast_builder, &mut property.value); + } + } + } +} + +/// `constant` written as a literal, objects and arrays too when `objects` +fn constant_literal<'a>( + builder: &AstBuilder<'a>, + constant: &Constant, + objects: bool, +) -> Option> { + match constant { + Constant::String(value) => Some(Expression::new_string_literal( + SPAN, + Str::from_in(value.as_str(), builder.allocator()), + None, + builder, + )), + Constant::Number(value) => Some(Expression::new_numeric_literal( + SPAN, + *value, + None, + NumberBase::Decimal, + builder, + )), + Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Record(entries) if objects => { + let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); + for (key, value) in entries.iter() { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(key.as_str(), builder.allocator()), + None, + builder, + )), + constant_literal(builder, value, objects)?, + false, + false, + false, + builder, + )); + } + Some(Expression::new_object_expression(SPAN, properties, builder)) + } + Constant::Array(values) if objects => { + let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); + for value in values.iter() { + elements.push(constant_literal(builder, value, objects)?.into()); + } + Some(Expression::new_array_expression(SPAN, elements, builder)) + } + _ => None, + } } impl<'a> VisitMut<'a> for Inline<'_, 'a> { @@ -1912,6 +2073,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { .and_then(|constant| self.literal(&constant)) { *expression = literal; + if self.px { + px_rules(self.ast_builder, expression); + } return; } if let Some(chosen) = self.chosen(expression) { @@ -1928,6 +2092,12 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); + if calls_one_of(&self.class_names, &tagged.tag) { + self.reading_css(true, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + return; + } let styles = is_style_root(self.style_roots, &tagged.tag); self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); @@ -1940,14 +2110,42 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); } + fn visit_jsx_element(&mut self, element: &mut oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element); + let taken = calls.len(); + self.class_names.extend(calls); + walk_mut::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&mut call.callee); + if calls_one_of(&self.class_names, &call.callee) { + self.reading_css(true, |inline| { + for argument in &mut call.arguments { + inline.visit_argument(argument); + } + }); + return; + } let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { - for argument in &mut call.arguments { - inline.visit_argument(argument); + for (index, argument) in call.arguments.iter_mut().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter_mut().enumerate() { + inline.reading_css(at == css, |inline| { + inline.visit_object_property_kind(property); + }); + } + } + (_, argument) => inline.visit_argument(argument), + } } }); }); @@ -1955,7 +2153,14 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let element_name = &element.name; for attribute in &mut element.attributes { + if self.css_props.attribute(element_name, attribute, |root| { + self.style_roots.contains(root) + }) { + self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + continue; + } let objects = styled && match attribute { JSXAttributeItem::Attribute(attribute) => { @@ -1973,9 +2178,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + let inlined_number = self.px + && self.styles + && matches!(self.constant(&property.value), Some(Constant::Number(_))); walk_mut::walk_object_property(self, property); if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { property.shorthand = false; } + if inlined_number { + px_value(self.ast_builder, property); + } } } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..f8a9a5369 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,6 +1,8 @@ mod as_visit; mod build_time_values; mod component; +mod composition; +mod css_prop; mod css_utils; pub mod extract_style; mod extractor; @@ -11,8 +13,18 @@ mod imported_constants; mod module_loader; mod mutations; mod prop_modify_utils; +mod prop_valid; +#[cfg(test)] +mod shorthand_tests; mod source_map; +mod style_order; +#[cfg(test)] +mod style_order_css_prop_tests; +#[cfg(test)] +mod style_order_tests; +mod style_order_validation; mod style_values; +mod styled_reads; mod stylex; mod tailwind; mod util_type; @@ -297,26 +309,32 @@ fn extract_source( // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` - let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( + let import_alias_visit::Aliased { + code: transformed_code, + edits: alias_edits, + css_prop, + } = import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); - // Step 2: Check if code contains the target package (after transformation) + // Step 2: Check if code contains the target package (after transformation), + // gives an element a `css` prop, or had an import rewritten let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let unchanged = || ExtractOutput { + styles: FxHashSet::default(), + code: code.to_string(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; - if !has_relevant_import { + if !has_relevant_import && css_prop == css_prop::CssProp::Off && alias_edits.is_empty() { // skip if not using package - return Ok(ExtractOutput { - styles: FxHashSet::default(), - code: code.to_string(), - map: None, - css_file: None, - dependencies: Vec::new(), - }); + return Ok(unchanged()); } let mut dependencies = std::collections::BTreeSet::new(); @@ -424,6 +442,7 @@ fn extract_source( filename, &option, resolver, + css_prop, ) } else { imported_constants::Inlined::default() @@ -437,9 +456,15 @@ fn extract_source( if global { None } else { Some(bucket) }, ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); + visitor.import_css(inlined.css_styles); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); + visitor.takes_css_prop(css_prop); visitor.visit_program(&mut program); + if !has_relevant_import && alias_edits.is_empty() && !visitor.compiled_css_prop { + // No element took the `css` prop the text seemed to give + return Ok(unchanged()); + } if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) { @@ -14553,6 +14578,449 @@ const Button = styled.button({ bg: 'red' }) )); } + /// `code` extracted with readable class names + fn readable_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ); + css::debug::set_debug(false); + match output { + Ok(output) => output.code, + Err(error) => error.to_string(), + } + } + + // A later part of `css(...)` replaces an earlier part's declaration of the + // same property, selector, breakpoint and layer, whatever the stylesheet + // order of their classes + #[rstest] + #[case( + "css(yellow, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(azure, yellow)", + r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css({ color: 'green' }, yellow)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: 'green' })", + r#""color-0-red-_a__c_hover-255 color-0-green--255""# + )] + #[case( + "css([yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(...[yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, null, undefined, false, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on && azure)", + r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(on ? yellow : azure)", + r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"# + )] + #[case( + "css(yellow, on ? { color: 'pink' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, { color: on ? 'pink' : 'teal' })", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"# + )] + #[case( + "css(yellow, ext, azure)", + r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`" + )] + #[case( + "css(yellow, on ? 'plain' : azure)", + r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(azure, wide)", + r#""background-0-black--255 color-0-a1--255 color-1-b1--255""# + )] + #[case( + "css(wide, { color: [null, 'x2'] })", + r#""color-0-a1--255 color-1-x2--255""# + )] + #[case( + "css(yellow, ordered)", + r#""color-0-red-_a__c_hover-255 color-0-navy--3""# + )] + #[case("css(heading, body)", r#""typo-body""#)] + #[case( + "css(chained, { color: 'gold' })", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""# + )] + #[case( + "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{" + )] + #[case( + "css(yellow, on ? { color: { a: 'red' }[size] } : null)", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"# + )] + #[case( + "css(yellow, on || azure)", + r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, ext ?? azure)", + r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, null ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(azure || yellow)", + r#""background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on || 'plain')", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"# + )] + #[case( + "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"# + )] + #[case( + "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"# + )] + #[case( + "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, fade)", + r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", + "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" + )] + #[case( + "css(yellow, { color: 'pink' } || azure)", + r#""color-0-red-_a__c_hover-255 color-0-pink--255""# + )] + #[case( + "css(yellow, undefined ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[serial] + fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{css, keyframes}} from '@devup-ui/core' +const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) +const azure = css({{ color: 'azure', bg: 'black' }}) +const wide = css({{ color: ['a1', 'b1'] }}) +const ordered = css({{ color: 'navy', styleOrder: 3 }}) +const heading = css({{ typography: 'heading' }}) +const body = css({{ typography: 'body' }}) +const chained = css(yellow, azure) +const fade = keyframes({{ from: {{ opacity: 0 }} }}) +export const result = {call} +" + )); + assert!( + code.contains(&format!("export const result = {expected}")), + "{code}" + ); + } + + #[test] + #[serial] + fn test_styled_extension_composes_base() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.button`color: red; background: white; &:hover { color: red; }` +const Ext = styled(Base)`color: blue; &:hover { color: blue; }` +const Obj = styled.div({ color: 'red', p: 2 }) +const ObjExt = styled(Obj)({ color: 'blue' }) +const Twice = styled(ObjExt, { m: 1 }) +const Runtime = styled(make())({ color: 'red' }) +const FromRuntime = styled(Runtime)({ color: 'blue' }) +let Changing = styled.div({ color: 'red' }) +const FromChanging = styled(Changing)({ color: 'blue' }) +const Ordered = styled.div({ color: 'red', styleOrder: 3 })", + ); + for expected in [ + "const Ext = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue-_a__c_hover-255 color-0-blue--255 background-0-white--255\", className]", + "const ObjExt = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-blue--255 padding-0-8px--255\", className]", + "const Twice = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"margin-0-4px--255 color-0-blue--255 padding-0-8px--255\", className]", + "const FromRuntime = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Runtime, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = Changing, forwardedAs, ...rest }) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--3\", className]", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // `withComponent` renders the same styles as another tag or component, and + // `as` changes what a styled component renders while `forwardedAs` passes on + #[test] + #[serial] + fn test_styled_with_component_and_as() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Section = styled.section.attrs({ role: 'region' })({ color: 'red' }) +export const Aside = Section.withComponent('aside') +export const Linked = Section.withComponent(Link) +export const Nested = Section.withComponent(motion.div) +export const Again = Aside.withComponent('nav') +export const Kept = Section.withComponent(make()) +export const Other = other.withComponent('aside') +export const a =
", + ); + for expected in [ + "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"color-0-red--255\", className]", + "as: DevupAs = Link,", + "as: DevupAs = motion.div,", + "export const Again = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"nav\",", + "export const Kept = Section.withComponent(make());", + "export const Other = other.withComponent(\"aside\");", + "...{ role: \"region\" }", + "
", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + // A styled component keeps the props its styles read, `$` props and `theme` + // away from the tag, follows `shouldForwardProp` as the build evaluates it, + // and an element using it drops what it neither reads nor passes on + #[test] + #[serial] + fn test_styled_prop_forwarding() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +import isPropValid from '@emotion/is-prop-valid' +const A = styled('h1', { shouldForwardProp: (prop) => prop !== 'tone' })({ color: 'red' }) +const B = styled.div`color: ${(p) => p.$c}; background: ${(p) => p.tone};` +const C = styled.div.withConfig({ shouldForwardProp: (prop, valid) => valid(prop) && !['x', 'y'].includes(prop) })({ color: 'red' }) +const D = styled('p', { shouldForwardProp: (prop) => isPropValid(prop) || prop.startsWith('data-') })({ color: 'red' }) +const E = styled('p', { shouldForwardProp: () => true })({ color: 'red' }) +const F = styled(Link)`color: ${(p) => p.$c};` +const G = styled('p', { shouldForwardProp: function (prop) { return prop === 'id' } })({ color: 'red' }) +const H = styled.div`color: ${function (p) { return p.c }};` +const I = styled.div`color: ${({ tone, ...more }) => tone};` +const J = styled.div`color: ${(p) => p[key]}; margin: ${([a]) => a}; padding: ${() => 1};` +const K = styled(A, { shouldForwardProp: (prop) => 'id' === prop || prop === 'title' })({ color: 'blue' }) +const L = styled.div.withConfig(config)`color: ${(p) => { return p.level }}; margin: ${({ [k]: v }) => v};` +const M = styled('p', { shouldForwardProp: (prop) => { return prop === 'id' } })({ color: 'red' }) +export const a = +export const b = +export const j = +export const f = +export const k = ", + ); + for expected in [ + "{...(({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, \"tone\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"x\": __devupOmit0, \"y\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)}", + "{...(({ \"$c\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)}", + "export const a = ", + "export const b = ", + "export const j = ", + "export const f = ", + "export const k = ", + "{...(({ \"level\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)}", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + let error = readable_code( + "import {styled} from '@devup-ui/core' +const A = styled('h1', { shouldForwardProp: (prop) => allowed.has(prop) })({ color: 'red' }) +const B = styled('h1', { shouldForwardProp: (prop) => prop === other })({ color: 'red' }) +const C = styled('h1', { shouldForwardProp: (prop) => prop > 'a' })({ color: 'red' }) +const D = styled('h1', { shouldForwardProp: (prop) => prop ?? 'a' })({ color: 'red' }) +const E = styled('h1', { shouldForwardProp: (prop) => { const x = 1; return x } })({ color: 'red' }) +const F = styled('h1', { shouldForwardProp: check })({ color: 'red' }) +const G = styled('h1', { shouldForwardProp: ({ a }) => a })({ color: 'red' }) +const H = styled('h1', { shouldForwardProp: (prop) => [x].includes(prop) })({ color: 'red' }) +const I = styled('h1', { shouldForwardProp: (prop) => list.includes(prop) })({ color: 'red' }) +const J = styled('h1', { shouldForwardProp: (prop) => prop.endsWith('a') })({ color: 'red' }) +const K = styled('h1', { [k]: 1, ...o, shouldForwardProp: (prop) => valid(prop, 1) })({ color: 'red' }) +const L = styled('h1', { shouldForwardProp: (prop) => prop === 'a' ?? prop === 'b' })({ color: 'red' }) +const M = styled('h1', { shouldForwardProp: (prop) => x === y })({ color: 'red' }) +const N = styled('h1', { shouldForwardProp: (prop) => { prop === 'a' } })({ color: 'red' })", + ); + assert!( + error.contains("`shouldForwardProp` must be a function"), + "{error}" + ); + assert!(error.contains("allowed.has(prop)"), "{error}"); + } + + #[test] + #[serial] + fn test_styled_attrs_merge_in_order() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.input.attrs({ type: 'text', title: 'base' })({ color: 'red' }) +const Ext = styled(Base).attrs({ type: 'password' })({ color: 'blue' }) +const X = styled.div.attrs({ className: 'from-attrs', style: { color: 'green' } })({ color: 'red' }) +const F = styled.div.attrs((p) => ({ id: p.id }))({ color: 'red' }) +const V = styled.div.attrs(extra)({ color: 'red' })", + ); + for expected in [ + "...{\n\t\t...__devupProps,\n\t\t...{\n\t\t\ttype: \"text\",\n\t\t\ttitle: \"base\"\n\t\t}\n\t},\n\t...{ type: \"password\" }", + "className: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined", + "className: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined", + "...__devupContext.style,\n\t\t...__devupAttrs.style", + "((p) => ({ id: p.id }))(__devupContext)", + "typeof extra === \"function\" ? extra(__devupContext) : extra", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + #[rstest] + #[case( + "", + "className=\"spread\" style={{ opacity: 2 }}" + )] + #[case( + "", + "className=\"direct\" style={{ opacity: 1 }}" + )] + #[case( + "", + "className={(\"className\" in Object(rest) ? rest.className : \"direct\") || \"\"} style={\"style\" in Object(rest) ? rest.style : { opacity: 1 }}" + )] + #[case( + "", + "className=\"last\"" + )] + #[case( + "", + "className={{\n\tclassName: \"first\",\n\t...rest\n}.className || \"\"}" + )] + #[case( + " color=\"red\" />", + "
" + )] + #[case( + "", + "className={(\"className\" in Object(more) ? more.className : rest?.className) || \"\"}" + )] + #[serial] + fn test_jsx_props_written_later_win(#[case] element: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{Box}} from '@devup-ui/core' +export const a = {element}" + )); + assert!(code.contains(expected), "{expected}\n{code}"); + } + + #[test] + #[serial] + fn test_css_composing_reports_runtime_values() { + let code = readable_code( + "import {css} from '@devup-ui/core' +const base = css({ color: 'red' }) +export const a = css(base, { color: tone }) +export const b = css(base, { [key]: 'x', styleOrder: 2 }) +export const c = css(base, getStyles()) +export const d = css(base, { positioning: side, styleOrder: 2 })", + ); + assert!(!code.contains("test.tsx:6:"), "{code}"); + assert!(code.contains("`css()` cannot use `tone`"), "{code}"); + assert!( + code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"), + "{code}" + ); + } + + // The styles of a `css()` class another module exports compose as well + #[test] + #[serial] + fn test_css_composes_imported_classes() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/styles.ts", + "import { css, keyframes } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +const brand = 'teal'; +export const base = css({ color: brand, _hover: { color: 'red' } }); +export const danger = css({ color: 'crimson' }); +export const twice = css({ color: 'a' }, { m: 1 }); +export const text = css`color: blue;`; +export const runtime = css({ color: globalThis.tone }); +export const listed = css([{ color: 'b' }]); +export const spaced = Devup.css({ color: 'c' }); +export const fade = keyframes({ from: { opacity: 0 } }); +export const called = Devup({ color: 'e' }); +export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });", + )]; + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react'; +import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles'; +export const a = css(base, danger); +export const b = css(danger, base, { m: 1 }); +export const c = css(twice, text, runtime, listed, spaced, fade); +export const d = css(ordered, danger, called);", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap(); + css::debug::set_debug(false); + for expected in [ + r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, + r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, + "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", + ] { + assert!( + output.code.contains(expected), + "{expected} in {}", + output.code + ); + } + } + // Each Tailwind class becomes the classes of its styles; every other class, // and every class that runs into an interpolation, stays as written #[rstest] @@ -20836,4 +21304,346 @@ export const App = () => ExtractOption { + 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), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + } + } + + fn emotion_outputs(files: &[(&str, &str)]) -> Vec<(String, ToBTreeSet)> { + files + .iter() + .map(|(filename, code)| { + reset_class_map(); + reset_file_map(); + ( + (*code).to_string(), + ToBTreeSet::from(extract(filename, code, emotion_option()).unwrap()), + ) + }) + .collect() + } + + #[test] + #[serial] + fn test_emotion_css_prop_in_every_form() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "export const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ danger }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ active }) =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst style = css`color: red;`;\nexport const App = () =>
;" + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const App = () =>
;" + ), + ( + "test.ts", + "import { jsx } from '@emotion/react';\nexport const App = () => jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsxs as _jsxs } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsxs('div', { css: { color: 'red' }, children: ['a', 'b'] });" + ), + ( + "test.ts", + "import { jsxDEV as _jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const App = () => _jsxDEV('div', { css: { padding: 4 } }, void 0, false);" + ), + ( + "test.tsx", + "/** @jsx jsx */\nimport { jsx } from '@emotion/react';\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = () =>
({ color: theme.colors.primary, padding: 8 })} />;" + ), + ( + "test.tsx", + "export const App = () =>
{ return { color: colors.text }; }} />;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst base = css({ color: 'blue', margin: 0 });\nexport const App = ({ cls }) => <>
;" + ), + ( + "test.tsx", + "export const App = ({ c, w }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ c }) => <>
;" + ), + ( + "test.tsx", + "const base = { color: 'red', padding: 4 };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "const SPACE = 4;\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ cls, s }) => <>
css={{ color: 'red' }} />
;" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nconst base = { padding: 2 };\nconst SPACE = 3;\nexport const App = ({ c, s }) => [_jsx('div', { id: 1 }), _jsx('div', { style: s, css: [base, { margin: SPACE, color: c }] })];" + ), + ])); + } + + #[test] + #[serial] + fn test_emotion_css_prop_on_every_element() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "const Custom = ({ className }) =>
;\nexport const App = () => ;" + ), + ("test.tsx", "export const a = { css: 1 };"), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Custom = ({ className }) =>
;\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import { Box } from '@devup-ui/react';\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Button = styled.button({ color: 'blue', margin: 1 });\nexport const App = ({ f, rest, cls }) => <> -
-
+
+
Option 1
-
+
Option 2
-
+
-
+
Option 3
-
+
Option 4
-
-