From c4ab2374ef771c51974b38b3747ef15cd6b976f9 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Fri, 13 Jun 2025 20:31:01 +0200 Subject: [PATCH 01/21] WIP --- README.md | 47 ++++ example/.gitignore | 1 + example/package.json | 3 +- example/src/App.tsx | 33 ++- example/src/index.tsx | 2 + example/src/theme.ts | 15 ++ example/src/useSheetLogger.ts | 5 +- example/vite.config.ts | 8 +- package.json | 30 ++- pnpm-lock.yaml | 446 ++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/sheet.ts | 46 +++- src/vite-plugin.ts | 168 +++++++++++++ tsup.config.ts | 2 +- 14 files changed, 788 insertions(+), 19 deletions(-) create mode 100644 example/src/theme.ts create mode 100644 src/vite-plugin.ts diff --git a/README.md b/README.md index 03b76b0..39c1078 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,41 @@ const sheet = css.make({ }); ``` +### css.extend + +```tsx +import { css } from "@swan-io/css"; + +const input = css.extend({ + colors: { + red: "#fa2c37", + blue: "#2c7fff", + green: "#00c950", + }, +}); + +type CustomInput = typeof input; + +declare module "@swan-io/css" { + export interface Input extends CustomInput {} +} +``` + +```tsx +import "./theme"; + +import { createRoot } from "react-dom/client"; +// … +``` + +```tsx +const sheet = css.make(({ colors }) => ({ + box: { + backgroundColor: colors.blue, + }, +})); +``` + ### cx Concatenate the generated classes from left to right, with subsequent styles overwriting the property values of earlier ones. @@ -119,6 +154,18 @@ const Component = ({ inline }: { inline: boolean }) => ( ); ``` +## CSS extraction + +```tsx +import swanCss from "@swan-io/css/vite-plugin"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; + +export default defineConfig(({ command }) => ({ + plugins: [react(), swanCss()], +})); +``` + ## Links - ⚖️ [**License**](./LICENSE) diff --git a/example/.gitignore b/example/.gitignore index 3209677..b219d5d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,5 +1,6 @@ *.local .DS_Store +.vite-inspect dist dist-ssr node_modules diff --git a/example/package.json b/example/package.json index bc6182e..cc1d2a4 100644 --- a/example/package.json +++ b/example/package.json @@ -17,6 +17,7 @@ "@types/react-dom": "19.1.7", "@vitejs/plugin-react-swc": "4.0.0", "typescript": "5.9.2", - "vite": "7.1.2" + "vite": "7.1.2", + "vite-plugin-inspect": "11.3.2" } } diff --git a/example/src/App.tsx b/example/src/App.tsx index a72c3ba..09a7a73 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,13 +1,36 @@ -import { css } from "@swan-io/css"; +import { css, cx } from "@swan-io/css"; +import { useState } from "react"; import { useSheetLogger } from "./useSheetLogger"; -const sheet = css.make({ +const sheet = css.make(({ colors }) => ({ title: { - color: "red", + color: colors.red, + ":hover": { + color: colors.blue, + }, }, -}); + extra: { + color: colors.green, + }, +})); export const App = () => { + const [checked, setChecked] = useState(false); + useSheetLogger(); // log sheet on "l" keypress - return

Hello world

; + + const handleOnChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + }; + + return ( + <> +

Hello world

+ + + + Add extra className + + + ); }; diff --git a/example/src/index.tsx b/example/src/index.tsx index 46d7811..666df1c 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -1,3 +1,5 @@ +import "./theme"; + import { createRoot } from "react-dom/client"; import { App } from "./App"; diff --git a/example/src/theme.ts b/example/src/theme.ts new file mode 100644 index 0000000..af024bd --- /dev/null +++ b/example/src/theme.ts @@ -0,0 +1,15 @@ +import { css } from "@swan-io/css"; + +const input = css.extend({ + colors: { + red: "#fa2c37", + blue: "#2c7fff", + green: "#00c950", + }, +}); + +type CustomInput = typeof input; + +declare module "@swan-io/css" { + export interface Input extends CustomInput {} +} diff --git a/example/src/useSheetLogger.ts b/example/src/useSheetLogger.ts index c7d868a..a9e1eba 100644 --- a/example/src/useSheetLogger.ts +++ b/example/src/useSheetLogger.ts @@ -6,8 +6,9 @@ export const useSheetLogger = () => { if (event.code === "KeyL") { const id = "swan-stylesheet"; - const sheet = document.querySelector( - `style[id="${id}"]`, + const sheet = ( + document.querySelector(`link[id="${id}"]`) ?? + document.querySelector(`style[id="${id}"]`) )?.sheet; if (sheet != null) { diff --git a/example/vite.config.ts b/example/vite.config.ts index 794d7c6..5947907 100644 --- a/example/vite.config.ts +++ b/example/vite.config.ts @@ -1,7 +1,13 @@ +import swanCss from "@swan-io/css/vite-plugin"; import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "vite"; +import inspect from "vite-plugin-inspect"; export default defineConfig(({ command }) => ({ build: { sourcemap: true }, - plugins: [react()], + plugins: [ + react(), + command === "build" && inspect({ build: true }), + command === "build" && swanCss(), + ], })); diff --git a/package.json b/package.json index ade2b19..c4894f9 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,21 @@ "url": "git+https://github.com/swan-io/css.git" }, "packageManager": "pnpm@10.14.0", - "source": "src/index.ts", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "default": "./dist/index.js" + }, + "./vite-plugin": { + "types": "./dist/vite-plugin.d.ts", + "import": "./dist/vite-plugin.mjs", + "default": "./dist/vite-plugin.js" + } + }, "files": [ "dist" ], @@ -51,10 +62,21 @@ "prettier-plugin-organize-imports" ] }, + "peerDependencies": { + "vite": ">=5" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, "dependencies": { "@emotion/hash": "^0.9.2", "@react-native/normalize-colors": "^0.81.0", "csstype": "^3.1.3", + "magic-string": "^0.30.17", + "oxc-parser": "^0.82.1", + "oxc-walker": "^0.4.0", "postcss-value-parser": "^4.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1044794..1b77105 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,21 @@ importers: csstype: specifier: ^3.1.3 version: 3.1.3 + magic-string: + specifier: ^0.30.17 + version: 0.30.17 + oxc-parser: + specifier: ^0.82.1 + version: 0.82.1 + oxc-walker: + specifier: ^0.4.0 + version: 0.4.0(oxc-parser@0.82.1) postcss-value-parser: specifier: ^4.2.0 version: 4.2.0 + vite: + specifier: '>=5' + version: 7.1.2(@types/node@24.3.0) devDependencies: '@types/node': specifier: ^24.3.0 @@ -88,6 +100,9 @@ importers: vite: specifier: 7.1.2 version: 7.1.2(@types/node@24.3.0) + vite-plugin-inspect: + specifier: 11.3.2 + version: 11.3.2(vite@7.1.2(@types/node@24.3.0)) packages: @@ -103,6 +118,15 @@ packages: resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} engines: {node: '>=6.9.0'} + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + + '@emnapi/runtime@1.4.5': + resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + + '@emnapi/wasi-threads@1.0.4': + resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} @@ -269,6 +293,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -279,6 +306,101 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@napi-rs/wasm-runtime@1.0.3': + resolution: {integrity: sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==} + + '@oxc-parser/binding-android-arm64@0.82.1': + resolution: {integrity: sha512-Vph9abEKcjDm1qypjgvvHzrMcjIC5Nhi5kVO/GQ9WTRIbVEq5yS7vWp3VYh6TQ405DxAX2z8g2o67Ovdh3r1hA==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.82.1': + resolution: {integrity: sha512-0biUTb+VBpbNG3begg5e2FW9DlOW/7wLLr/sF9JLXLKiCQnYMTTQ/FTkHMqxkDJBON+FTiHpnvp4aS2eUv3lkA==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.82.1': + resolution: {integrity: sha512-VGIJSzPsWAK501FOOW44TspbZ8eWIOhY1FfBNIsn0JTN3Ve9uk/waSVN/lysQzMJOX3S3mIhtrVGdKQ3fum8GQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.82.1': + resolution: {integrity: sha512-5oLRbNxkNQz8bRuvr07xOTJIzsmaRg6pLHxH2HqlRvhhpo9sXJN4yROSGKgoQSFCSWZyDylg18Aw5cxvSxfJgw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.82.1': + resolution: {integrity: sha512-lhbZaoDoxbxNp83GOvcXw02R+qJk3ckUPmHmbxkTCjSD/BttrjTLsZ30HJdH2rDB9kmBtfsbg3/Mnz/bU2D6ow==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.82.1': + resolution: {integrity: sha512-0dLHzsC22O+9/diTCjDrTDSEaTUsnvbvq2jTGpHNaVg7903Jieb3Eftqut3MpBea9764a/IZkGoKP3btCdQnYQ==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.82.1': + resolution: {integrity: sha512-ftux8M4nPbYj/6lEO9PbfZ8knacHx/o4JfwueDbcrWOf53otJ9jHNITbZooIgl7zwGCW+JSZgJis2Ts4u9feQw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.82.1': + resolution: {integrity: sha512-dvtBGwTsW2bUHB0c6lVj0KIm7NT00xcsATWTCXiwGoDIGl/FPJctudpj+nMwXyjdPk3rlKRDjJ3pHcd7pYR1DQ==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.82.1': + resolution: {integrity: sha512-y2A2lXUyppruU5AE6MforoqAvptwRGSRV8rO32Xv+vdflJH3rKxqKhWxW2bfRc14YhKRbQUcwPaW4nEqPPvwag==} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.82.1': + resolution: {integrity: sha512-2oajEj8l0TGyWawVl+cuFjn7mcVBCR2fTO2EFsCf9WH7KEG/gyU86G5XDLN6tnl1E3Gqe88A09s0J8UUj+qUKA==} + engines: {node: '>=20.0.0'} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.82.1': + resolution: {integrity: sha512-r0XCtdH36uXlwH504O2zRXqjhD8NjBQaPgInnwGMFskgPKPQBJIAYqWqrBzTEOJEPQEuhksfjsGNn7TAOQKdNQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.82.1': + resolution: {integrity: sha512-CW6Rw6RME+cp1AmS2GFT7wdg/7nCxL/pHGbwhq7RumO6ITBKzicd6YH2rkQrMYy0x8ZTzSdS4YjbPBHT78E4jA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-wasm32-wasi@0.82.1': + resolution: {integrity: sha512-0Vy/d8iBwFxrXldnc5GfXFmF8Pxgrqv14d/htz5u2kb02bhCCWIk+GjI2gKEcQfOgl2Bn4oOK3tL5rUrFNPRPg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.82.1': + resolution: {integrity: sha512-qfWIjAPt7ljozHkIH8sa155yH4rLrG8w36GRRhSZ1ltQT6HFgNZO56HSyZShSw3++3zBb5AkHVVnBWvBTo5zjQ==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.82.1': + resolution: {integrity: sha512-xiMVlP38bsq/7FHR6e+pZQ8XJetPhNToPy5mNh227pIybSWWjcdPTHo0LAJmIrsqrx5+/msIkZ+Wm/E+SXBkww==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.82.1': + resolution: {integrity: sha512-MCPtxtmHRmCqMI+DZyADBtW7QrFQ6OtQvHVAu576LWu6Y5zshLNabDc6RDJE/+uKVdypd9ZU1r05J/547VooPQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -477,6 +599,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.0': + resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -575,6 +700,10 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + ansis@4.1.0: + resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} + engines: {node: '>=14'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -588,9 +717,16 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + birpc@2.5.0: + resolution: {integrity: sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -651,6 +787,18 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + default-browser-id@5.0.0: + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} + engines: {node: '>=18'} + + default-browser@5.2.1: + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -667,6 +815,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -712,10 +863,24 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -756,6 +921,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -789,6 +957,22 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + oxc-parser@0.82.1: + resolution: {integrity: sha512-2bBrazc/0wpA/+XECTwLA6dvp2Swp9vm/psgvwDz4CcxwfvYyQN0/ghw9km0LFpPIDSrxhltJSsfajhb2NZq0A==} + engines: {node: '>=20.0.0'} + + oxc-walker@0.4.0: + resolution: {integrity: sha512-x5TJAZQD3kRnRBGZ+8uryMZUwkTYddwzBftkqyJIcmpBOXmoK/fwriRKATjZroR2d+aS7+2w1B0oz189bBTwfw==} + peerDependencies: + oxc-parser: '>=0.72.0' + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -807,6 +991,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -895,6 +1082,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -904,6 +1095,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.0.0: + resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} + engines: {node: '>=18'} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -1008,6 +1203,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.0: resolution: {integrity: sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==} engines: {node: '>=18'} @@ -1027,6 +1225,9 @@ packages: typescript: optional: true + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1038,11 +1239,39 @@ packages: undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + unplugin-utils@0.2.5: + resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==} + engines: {node: '>=18.12.0'} + + unplugin@2.3.6: + resolution: {integrity: sha512-+/MdXl8bLTXI2lJF22gUBeCFqZruEpL/oM9f8wxCuKh9+Mw9qeul3gTqgbKpMeOFlusCzc0s7x2Kax2xKW+FQg==} + engines: {node: '>=18.12.0'} + + vite-dev-rpc@1.1.0: + resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 + + vite-hot-client@2.1.0: + resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==} + peerDependencies: + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-inspect@11.3.2: + resolution: {integrity: sha512-nzwvyFQg58XSMAmKVLr2uekAxNYvAbz1lyPmCAFVIBncCgN9S/HPM+2UM9Q9cvc4JEbC5ZBgwLAdaE2onmQuKg==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + vite@7.1.2: resolution: {integrity: sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1130,6 +1359,9 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -1163,6 +1395,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + snapshots: '@babel/code-frame@7.27.1': @@ -1175,6 +1411,22 @@ snapshots: '@babel/runtime@7.28.3': {} + '@emnapi/core@1.4.5': + dependencies: + '@emnapi/wasi-threads': 1.0.4 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.4.5': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.0.4': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/hash@0.9.2': {} '@esbuild/aix-ppc64@0.25.9': @@ -1269,6 +1521,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1278,6 +1535,62 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.0.3': + dependencies: + '@emnapi/core': 1.4.5 + '@emnapi/runtime': 1.4.5 + '@tybys/wasm-util': 0.10.0 + optional: true + + '@oxc-parser/binding-android-arm64@0.82.1': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.82.1': + optional: true + + '@oxc-parser/binding-darwin-x64@0.82.1': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.82.1': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.82.1': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.82.1': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.82.1': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.82.1': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.82.1': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.82.1': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.82.1': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.82.1': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.82.1': + dependencies: + '@napi-rs/wasm-runtime': 1.0.3 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.82.1': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.82.1': + optional: true + + '@oxc-project/types@0.82.1': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -1414,6 +1727,11 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.2': @@ -1519,6 +1837,8 @@ snapshots: ansi-styles@6.2.1: {} + ansis@4.1.0: {} + any-promise@1.3.0: {} aria-query@5.3.0: @@ -1529,10 +1849,16 @@ snapshots: balanced-match@1.0.2: {} + birpc@2.5.0: {} + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.0.0 + bundle-require@5.1.0(esbuild@0.25.9): dependencies: esbuild: 0.25.9 @@ -1580,6 +1906,15 @@ snapshots: deep-eql@5.0.2: {} + default-browser-id@5.0.0: {} + + default-browser@5.2.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + + define-lazy-prop@3.0.0: {} + dequal@2.0.3: {} dom-accessibility-api@0.5.16: {} @@ -1590,6 +1925,8 @@ snapshots: emoji-regex@9.2.2: {} + error-stack-parser-es@1.0.5: {} + es-module-lexer@1.7.0: {} esbuild@0.25.9: @@ -1657,8 +1994,18 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + is-docker@3.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} jackspeak@3.4.3: @@ -1687,6 +2034,16 @@ snapshots: lz-string@1.5.0: {} + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.17 + mlly: 1.7.4 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.1 + unplugin: 2.3.6 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1718,6 +2075,41 @@ snapshots: object-assign@4.1.1: {} + ohash@2.0.11: {} + + open@10.2.0: + dependencies: + default-browser: 5.2.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + oxc-parser@0.82.1: + dependencies: + '@oxc-project/types': 0.82.1 + optionalDependencies: + '@oxc-parser/binding-android-arm64': 0.82.1 + '@oxc-parser/binding-darwin-arm64': 0.82.1 + '@oxc-parser/binding-darwin-x64': 0.82.1 + '@oxc-parser/binding-freebsd-x64': 0.82.1 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.82.1 + '@oxc-parser/binding-linux-arm-musleabihf': 0.82.1 + '@oxc-parser/binding-linux-arm64-gnu': 0.82.1 + '@oxc-parser/binding-linux-arm64-musl': 0.82.1 + '@oxc-parser/binding-linux-riscv64-gnu': 0.82.1 + '@oxc-parser/binding-linux-s390x-gnu': 0.82.1 + '@oxc-parser/binding-linux-x64-gnu': 0.82.1 + '@oxc-parser/binding-linux-x64-musl': 0.82.1 + '@oxc-parser/binding-wasm32-wasi': 0.82.1 + '@oxc-parser/binding-win32-arm64-msvc': 0.82.1 + '@oxc-parser/binding-win32-x64-msvc': 0.82.1 + + oxc-walker@0.4.0(oxc-parser@0.82.1): + dependencies: + estree-walker: 3.0.3 + magic-regexp: 0.10.0 + oxc-parser: 0.82.1 + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -1731,6 +2123,8 @@ snapshots: pathval@2.0.1: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -1791,6 +2185,8 @@ snapshots: readdirp@4.1.2: {} + regexp-tree@0.1.27: {} + resolve-from@5.0.0: {} rollup@4.46.2: @@ -1819,6 +2215,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.46.2 fsevents: 2.3.3 + run-applescript@7.0.0: {} + scheduler@0.26.0: {} shebang-command@2.0.0: @@ -1914,6 +2312,9 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: + optional: true + tsup@8.5.0(@swc/core@1.13.3)(postcss@8.5.6)(typescript@5.9.2): dependencies: bundle-require: 5.1.0(esbuild@0.25.9) @@ -1943,12 +2344,36 @@ snapshots: - tsx - yaml + type-level-regexp@0.1.17: {} + typescript@5.9.2: {} ufo@1.6.1: {} undici-types@7.10.0: {} + unplugin-utils@0.2.5: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.3 + + unplugin@2.3.6: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.15.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + + vite-dev-rpc@1.1.0(vite@7.1.2(@types/node@24.3.0)): + dependencies: + birpc: 2.5.0 + vite: 7.1.2(@types/node@24.3.0) + vite-hot-client: 2.1.0(vite@7.1.2(@types/node@24.3.0)) + + vite-hot-client@2.1.0(vite@7.1.2(@types/node@24.3.0)): + dependencies: + vite: 7.1.2(@types/node@24.3.0) + vite-node@3.2.4(@types/node@24.3.0): dependencies: cac: 6.7.14 @@ -1970,6 +2395,21 @@ snapshots: - tsx - yaml + vite-plugin-inspect@11.3.2(vite@7.1.2(@types/node@24.3.0)): + dependencies: + ansis: 4.1.0 + debug: 4.4.1 + error-stack-parser-es: 1.0.5 + ohash: 2.0.11 + open: 10.2.0 + perfect-debounce: 1.0.0 + sirv: 3.0.1 + unplugin-utils: 0.2.5 + vite: 7.1.2(@types/node@24.3.0) + vite-dev-rpc: 1.1.0(vite@7.1.2(@types/node@24.3.0)) + transitivePeerDependencies: + - supports-color + vite@7.1.2(@types/node@24.3.0): dependencies: esbuild: 0.25.9 @@ -2036,6 +2476,8 @@ snapshots: webidl-conversions@4.0.2: {} + webpack-virtual-modules@0.6.2: {} + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -2064,3 +2506,7 @@ snapshots: strip-ansi: 7.1.0 ws@8.18.3: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 diff --git a/src/index.ts b/src/index.ts index 0ac147d..ef1ee7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ import { createSheet } from "./sheet"; export const { css, cx } = createSheet(); +export type { Input } from "./types"; diff --git a/src/sheet.ts b/src/sheet.ts index 1e94e53..c49a7bc 100644 --- a/src/sheet.ts +++ b/src/sheet.ts @@ -14,7 +14,9 @@ const getSheet = (id: string): CSSStyleSheet | null => { return null; } - const current = document.querySelector(`style[id="${id}"]`); + const current = + document.querySelector(`link[id="${id}"]`) ?? + document.querySelector(`style[id="${id}"]`); if (current != null) { return current.sheet; @@ -33,12 +35,23 @@ const getMediaRule = ( media: string, ): { cssRules: CSSRuleList | []; + toString: () => string; insertRule: (rule: string) => void; } => { + const cssRules = new Set(); + + const toString = () => + cssRules.size > 0 + ? `@media ${media}{${[...cssRules].join("")}}` + : `@media ${media}{}`; // Keep an empty media sheet to preserve the index (hydratation) + if (sheet == null) { return { cssRules: [], - insertRule: () => {}, + toString, + insertRule: (rule) => { + cssRules.add(rule); + }, }; } @@ -52,7 +65,10 @@ const getMediaRule = ( return { cssRules: [], - insertRule: () => {}, + toString, + insertRule: (rule) => { + cssRules.add(rule); + }, }; } } @@ -61,9 +77,11 @@ const getMediaRule = ( return { cssRules: mediaRule.cssRules, + toString, insertRule: (rule) => { try { mediaRule.insertRule(rule, mediaRule.cssRules.length); + cssRules.add(rule); } catch (error) { if (process.env.NODE_ENV === "development") { console.error(error); @@ -278,19 +296,28 @@ export const createSheet = () => { return classNames; }; - const input: Input = { + const _input: Input = { keyframes: (keyframes) => insertKeyframes(preprocessKeyframes(keyframes)), }; return { + input: _input, css: { + extend: >(input: T) => { + forEach(input, (key, value) => { + // @ts-expect-error keep initial object instance reference + _input[key] = value; + }); + + return input; + }, make: ( styles: Record | ((input: Input) => Record), ): Record => { const output = {} as Record; forEach( - typeof styles === "function" ? styles(input) : styles, + typeof styles === "function" ? styles(_input) : styles, (key, value) => { output[key] = key[0] === "$" @@ -368,5 +395,14 @@ export const createSheet = () => { return output; }, + toString: () => + [ + keyframesSheet.toString(), + resetSheet.toString(), + atomicSheet.toString(), + hoverSheet.toString(), + focusSheet.toString(), + activeSheet.toString(), + ].join("\n"), }; }; diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts new file mode 100644 index 0000000..a9f5cb2 --- /dev/null +++ b/src/vite-plugin.ts @@ -0,0 +1,168 @@ +import MagicString from "magic-string"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import type { Node } from "oxc-parser"; +import { parseAndWalk } from "oxc-walker"; +import type { Plugin } from "vite"; +import { createSheet } from "./sheet"; + +type PluginOptions = { + fileName?: string; +}; + +const isCssMethodNode = ( + importName: string, + methodName: "extend" | "make", + node: Node, +) => + importName !== "" && + node.type === "MemberExpression" && + node.object.type === "Identifier" && + node.object.name === importName && + node.property.type === "Identifier" && + node.property.name === methodName; + +const plugin = (options: PluginOptions = {}): Plugin => { + const packageName = "@swan-io/css"; + const packageAliases = new Set([packageName]); + + let assetsDir = "assets"; + let emittedFileName = ""; + + const sheet = createSheet(); + + return { + name: packageName, + enforce: "post", + + configResolved({ build, resolve }) { + assetsDir = build.assetsDir; + const alias = resolve.alias.find((item) => item.find === packageName); + + if (alias != null) { + packageAliases.add(alias.replacement); + } + }, + + transform(code, id) { + let cssImportName = ""; + const magicString = new MagicString(code); + + parseAndWalk(code, id, (node) => { + if ( + node.type === "ImportDeclaration" && + packageAliases.has(node.source.value) + ) { + const specifier = node.specifiers.find( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === "css", + ); + + if (specifier != null) { + cssImportName = specifier.local.name; + } + } else if ( + node.type === "CallExpression" && + isCssMethodNode(cssImportName, "extend", node.callee) + ) { + const fn = node.arguments[0]; + + if (fn != null && fn.type === "ObjectExpression") { + const result = sheet.css.extend( + new Function(`return ${magicString.slice(fn.start, fn.end)};`)(), + ); + + magicString.overwrite( + node.start, + node.end, + JSON.stringify(result, null, 2), + ); + } else { + magicString.overwrite(node.start, node.end, "{}"); + } + } else if ( + node.type === "CallExpression" && + isCssMethodNode(cssImportName, "make", node.callee) + ) { + const fn = node.arguments[0]; + + if (fn != null && fn.type === "ObjectExpression") { + const result = sheet.css.make( + new Function(`return ${magicString.slice(fn.start, fn.end)};`)(), + ); + + magicString.overwrite( + node.start, + node.end, + JSON.stringify(result, null, 2), + ); + } else if ( + fn != null && + (fn.type === "ArrowFunctionExpression" || + fn.type === "FunctionExpression") + ) { + const result = sheet.css.make( + new Function( + "input", + `return (${magicString.slice(fn.start, fn.end)})(input);`, + )(sheet.input), + ); + + magicString.overwrite( + node.start, + node.end, + JSON.stringify(result, null, 2), + ); + } else { + magicString.overwrite(node.start, node.end, "{}"); + } + } + }); + + if (cssImportName !== "") { + return { + code: magicString.toString(), + map: magicString.generateMap({ hires: true }), + }; + } + }, + + generateBundle(_options, _bundle, _isWrite) { + const source = sheet.toString(); + + const hash = createHash("sha256") + .update(source) + .digest("hex") + .slice(0, 8); + + const fileName = options.fileName ?? "styles"; + emittedFileName = join(assetsDir, `${fileName}-${hash}.css`); + + this.emitFile({ + type: "asset", + source, + fileName: emittedFileName, + }); + }, + + transformIndexHtml(html, _context) { + if (emittedFileName !== "") { + const attrs = { + rel: "stylesheet", + id: "swan-stylesheet", + crossorigin: true, + href: "/" + emittedFileName, + }; + + return { + html, + tags: [{ tag: "link", injectTo: "head", attrs }], + }; + } + }, + }; +}; + +export default plugin; diff --git a/tsup.config.ts b/tsup.config.ts index eafcf7a..2b811ad 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; const config = { - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/vite-plugin.ts"], target: "es2019", tsconfig: "./tsconfig.build.json", bundle: true, From d41318a47ec6824c7b0275229760a6b0bba8e83c Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Sun, 17 Aug 2025 22:07:37 +0200 Subject: [PATCH 02/21] WIP with sync resolver --- package.json | 2 + pnpm-lock.yaml | 274 ++++++++++++++++++++++++++++++ src/css.ts | 302 +++++++++++++++++++++++++++++++++ src/cx.ts | 106 ++++++++++++ src/index.ts | 5 +- src/sheet.ts | 408 --------------------------------------------- src/utils.ts | 3 + src/vite-plugin.ts | 314 +++++++++++++++++++++++++++++++--- tsup.config.ts | 2 +- 9 files changed, 985 insertions(+), 431 deletions(-) create mode 100644 src/css.ts create mode 100644 src/cx.ts delete mode 100644 src/sheet.ts diff --git a/package.json b/package.json index c4894f9..7b0182b 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,9 @@ "@react-native/normalize-colors": "^0.81.0", "csstype": "^3.1.3", "magic-string": "^0.30.17", + "node-html-parser": "^7.0.1", "oxc-parser": "^0.82.1", + "oxc-resolver": "^11.6.1", "oxc-walker": "^0.4.0", "postcss-value-parser": "^4.2.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b77105..366e9ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,15 @@ importers: magic-string: specifier: ^0.30.17 version: 0.30.17 + node-html-parser: + specifier: ^7.0.1 + version: 7.0.1 oxc-parser: specifier: ^0.82.1 version: 0.82.1 + oxc-resolver: + specifier: ^11.6.1 + version: 11.6.1 oxc-walker: specifier: ^0.4.0 version: 0.4.0(oxc-parser@0.82.1) @@ -401,6 +407,101 @@ packages: '@oxc-project/types@0.82.1': resolution: {integrity: sha512-MCPtxtmHRmCqMI+DZyADBtW7QrFQ6OtQvHVAu576LWu6Y5zshLNabDc6RDJE/+uKVdypd9ZU1r05J/547VooPQ==} + '@oxc-resolver/binding-android-arm-eabi@11.6.1': + resolution: {integrity: sha512-Ma/kg29QJX1Jzelv0Q/j2iFuUad1WnjgPjpThvjqPjpOyLjCUaiFCCnshhmWjyS51Ki1Iol3fjf1qAzObf8GIA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.6.1': + resolution: {integrity: sha512-xjL/FKKc5p8JkFWiH7pJWSzsewif3fRf1rw2qiRxRvq1uIa6l7Zoa14Zq2TNWEsqDjdeOrlJtfWiPNRnevK0oQ==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.6.1': + resolution: {integrity: sha512-u0yrJ3NHE0zyCjiYpIyz4Vmov21MA0yFKbhHgixDU/G6R6nvC8ZpuSFql3+7C8ttAK9p8WpqOGweepfcilH5Bw==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.6.1': + resolution: {integrity: sha512-2lox165h1EhzxcC8edUy0znXC/hnAbUPaMpYKVlzLpB2AoYmgU4/pmofFApj+axm2FXpNamjcppld8EoHo06rw==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.6.1': + resolution: {integrity: sha512-F45MhEQ7QbHfsvZtVNuA/9obu3il7QhpXYmCMfxn7Zt9nfAOw4pQ8hlS5DroHVp3rW35u9F7x0sixk/QEAi3qQ==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.6.1': + resolution: {integrity: sha512-r+3+MTTl0tD4NoWbfTIItAxJvuyIU7V0fwPDXrv7Uj64vZ3OYaiyV+lVaeU89Bk/FUUQxeUpWBwdKNKHjyRNQw==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.6.1': + resolution: {integrity: sha512-TBTZ63otsWZ72Z8ZNK2JVS0HW1w9zgOixJTFDNrYPUUW1pXGa28KAjQ1yGawj242WLAdu3lwdNIWtkxeO2BLxQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.6.1': + resolution: {integrity: sha512-SjwhNynjSG2yMdyA0f7wz7Yvo3ppejO+ET7n2oiI7ApCXrwxMzeRWjBzQt+oVWr2HzVOfaEcDS9rMtnR83ulig==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.6.1': + resolution: {integrity: sha512-f4EMidK6rosInBzPMnJ0Ri4RttFCvvLNUNDFUBtELW/MFkBwPTDlvbsmW0u0Mk/ruBQ2WmRfOZ6tT62kWMcX2Q==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.6.1': + resolution: {integrity: sha512-1umENVKeUsrWnf5IlF/6SM7DCv8G6CoKI2LnYR6qhZuLYDPS4PBZ0Jow3UDV9Rtbv5KRPcA3/uXjI88ntWIcOQ==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.6.1': + resolution: {integrity: sha512-Hjyp1FRdJhsEpIxsZq5VcDuFc8abC0Bgy8DWEa31trCKoTz7JqA7x3E2dkFbrAKsEFmZZ0NvuG5Ip3oIRARhow==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.6.1': + resolution: {integrity: sha512-ODJOJng6f3QxpAXhLel3kyWs8rPsJeo9XIZHzA7p//e+5kLMDU7bTVk4eZnUHuxsqsB8MEvPCicJkKCEuur5Ag==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.6.1': + resolution: {integrity: sha512-hCzRiLhqe1ZOpHTsTGKp7gnMJRORlbCthawBueer2u22RVAka74pV/+4pP1tqM07mSlQn7VATuWaDw9gCl+cVg==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.6.1': + resolution: {integrity: sha512-JansPD8ftOzMYIC3NfXJ68tt63LEcIAx44Blx6BAd7eY880KX7A0KN3hluCrelCz5aQkPaD95g8HBiJmKaEi2w==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.6.1': + resolution: {integrity: sha512-R78ES1rd4z2x5NrFPtSWb/ViR1B8wdl+QN2X8DdtoYcqZE/4tvWtn9ZTCXMEzUp23tchJ2wUB+p6hXoonkyLpA==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-wasm32-wasi@11.6.1': + resolution: {integrity: sha512-qAR3tYIf3afkij/XYunZtlz3OH2Y4ni10etmCFIJB5VRGsqJyI6Hl+2dXHHGJNwbwjXjSEH/KWJBpVroF3TxBw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.6.1': + resolution: {integrity: sha512-QqygWygIuemGkaBA48POOTeinbVvlamqh6ucm8arGDGz/mB5O00gXWxed12/uVrYEjeqbMkla/CuL3fjL3EKvw==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-ia32-msvc@11.6.1': + resolution: {integrity: sha512-N2+kkWwt/bk0JTCxhPuK8t8JMp3nd0n2OhwOkU8KO4a7roAJEa4K1SZVjMv5CqUIr5sx2CxtXRBoFDiORX5oBg==} + cpu: [ia32] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.6.1': + resolution: {integrity: sha512-DfMg3cU9bJUbN62Prbp4fGCtLgexuwyEaQGtZAp8xmi1Ii26uflOGx0FJkFTF6lVMSFoIRFvIL8gsw5/ZdHrMw==} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -720,6 +821,9 @@ packages: birpc@2.5.0: resolution: {integrity: sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -771,6 +875,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -806,6 +917,19 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -815,6 +939,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -863,6 +991,10 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -953,6 +1085,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-postinstall@0.3.3: + resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + node-html-parser@7.0.1: + resolution: {integrity: sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -968,6 +1111,9 @@ packages: resolution: {integrity: sha512-2bBrazc/0wpA/+XECTwLA6dvp2Swp9vm/psgvwDz4CcxwfvYyQN0/ghw9km0LFpPIDSrxhltJSsfajhb2NZq0A==} engines: {node: '>=20.0.0'} + oxc-resolver@11.6.1: + resolution: {integrity: sha512-WQgmxevT4cM5MZ9ioQnEwJiHpPzbvntV5nInGAKo9NQZzegcOonHvcVcnkYqld7bTG35UFHEKeF7VwwsmA3cZg==} + oxc-walker@0.4.0: resolution: {integrity: sha512-x5TJAZQD3kRnRBGZ+8uryMZUwkTYddwzBftkqyJIcmpBOXmoK/fwriRKATjZroR2d+aS7+2w1B0oz189bBTwfw==} peerDependencies: @@ -1591,6 +1737,65 @@ snapshots: '@oxc-project/types@0.82.1': {} + '@oxc-resolver/binding-android-arm-eabi@11.6.1': + optional: true + + '@oxc-resolver/binding-android-arm64@11.6.1': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.6.1': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.6.1': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.6.1': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.6.1': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.6.1': + dependencies: + '@napi-rs/wasm-runtime': 1.0.3 + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.6.1': + optional: true + + '@oxc-resolver/binding-win32-ia32-msvc@11.6.1': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.6.1': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -1851,6 +2056,8 @@ snapshots: birpc@2.5.0: {} + boolbase@1.0.0: {} + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -1898,6 +2105,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.1.3: {} debug@4.4.1: @@ -1919,12 +2136,32 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + entities@4.5.0: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@1.7.0: {} @@ -1994,6 +2231,8 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + he@1.2.0: {} + is-docker@3.0.0: {} is-fullwidth-code-point@3.0.0: {} @@ -2073,6 +2312,17 @@ snapshots: nanoid@3.3.11: {} + napi-postinstall@0.3.3: {} + + node-html-parser@7.0.1: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} ohash@2.0.11: {} @@ -2104,6 +2354,30 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc': 0.82.1 '@oxc-parser/binding-win32-x64-msvc': 0.82.1 + oxc-resolver@11.6.1: + dependencies: + napi-postinstall: 0.3.3 + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.6.1 + '@oxc-resolver/binding-android-arm64': 11.6.1 + '@oxc-resolver/binding-darwin-arm64': 11.6.1 + '@oxc-resolver/binding-darwin-x64': 11.6.1 + '@oxc-resolver/binding-freebsd-x64': 11.6.1 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.6.1 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.6.1 + '@oxc-resolver/binding-linux-arm64-gnu': 11.6.1 + '@oxc-resolver/binding-linux-arm64-musl': 11.6.1 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.6.1 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.6.1 + '@oxc-resolver/binding-linux-riscv64-musl': 11.6.1 + '@oxc-resolver/binding-linux-s390x-gnu': 11.6.1 + '@oxc-resolver/binding-linux-x64-gnu': 11.6.1 + '@oxc-resolver/binding-linux-x64-musl': 11.6.1 + '@oxc-resolver/binding-wasm32-wasi': 11.6.1 + '@oxc-resolver/binding-win32-arm64-msvc': 11.6.1 + '@oxc-resolver/binding-win32-ia32-msvc': 11.6.1 + '@oxc-resolver/binding-win32-x64-msvc': 11.6.1 + oxc-walker@0.4.0(oxc-parser@0.82.1): dependencies: estree-walker: 3.0.3 diff --git a/src/css.ts b/src/css.ts new file mode 100644 index 0000000..a857567 --- /dev/null +++ b/src/css.ts @@ -0,0 +1,302 @@ +import { caches } from "./cx"; +import { hash } from "./hash"; +import { hyphenateName } from "./hyphenateName"; +import { normalizeValue } from "./normalizeValue"; +import { + preprocessAtomicStyle, + preprocessKeyframes, + preprocessResetStyle, +} from "./preprocess"; +import type { FlatStyle, Input, Keyframes, Style } from "./types"; +import { appendString, forEach } from "./utils"; + +const getSheet = (id: string): CSSStyleSheet | null => { + if (typeof document === "undefined") { + return null; + } + + const current = + document.querySelector(`link[id="${id}"]`) ?? + document.querySelector(`style[id="${id}"]`); + + if (current != null) { + return current.sheet; + } + + const element = document.createElement("style"); + element.setAttribute("id", id); + document.head.appendChild(element); + + return element.sheet; +}; + +const getMediaRule = ( + sheet: CSSStyleSheet | null, + index: number, + media: string, +): { + cssRules: CSSRuleList | []; + toString: () => string; + insertRule: (rule: string) => void; +} => { + const cssRules = new Set(); + + const toString = () => + cssRules.size > 0 + ? `@media ${media}{${[...cssRules].join("")}}` + : `@media ${media}{}`; // Keep an empty media sheet to preserve the index (hydratation) + + if (sheet == null) { + return { + cssRules: [], + toString, + insertRule: (rule) => { + cssRules.add(rule); + }, + }; + } + + if (sheet.cssRules[index] == null) { + try { + sheet.insertRule(`@media ${media}{}`, index); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error(error); + } + + return { + cssRules: [], + toString, + insertRule: (rule) => { + cssRules.add(rule); + }, + }; + } + } + + const mediaRule = sheet.cssRules[index] as CSSMediaRule; + + return { + cssRules: mediaRule.cssRules, + toString, + insertRule: (rule) => { + try { + mediaRule.insertRule(rule, mediaRule.cssRules.length); + cssRules.add(rule); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error(error); + } + } + }, + }; +}; + +const stringifyRule = (key: string, value: string | number): string => { + if (key === "appearance") { + return `-webkit-appearance:${value};appearance:${value}`; + } + if (key === "lineClamp") { + return `-webkit-line-clamp:${value};line-clamp:${value}`; + } + + return `${hyphenateName(key)}:${normalizeValue(key, value)}`; +}; + +const getClassName = (rule: CSSStyleRule): string => { + const selector = rule.selectorText; + const end = selector.indexOf(":"); + return end > -1 ? selector.substring(1, end) : selector.substring(1); +}; + +const sheet = getSheet("swan-stylesheet"); + +const keyframesSheet = getMediaRule(sheet, 0, "all"); +const resetSheet = getMediaRule(sheet, 1, "all"); +const atomicSheet = getMediaRule(sheet, 2, "all"); +const hoverSheet = getMediaRule(sheet, 3, "(hover:hover)"); +const focusSheet = getMediaRule(sheet, 4, "all"); +const activeSheet = getMediaRule(sheet, 5, "all"); + +const keyframesNames = new Set(); + +// Rehydrate keyframes sheet +for (const rule of keyframesSheet.cssRules) { + if (rule instanceof CSSKeyframesRule) { + keyframesNames.add(rule.name); + } +} + +// Rehydrate reset sheet +for (const rule of resetSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + caches.reset.add(getClassName(rule)); + } +} + +// Rehydrate atomic sheet +for (const rule of atomicSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + caches.atomic.set(getClassName(rule), rule.style[0]); + } +} + +// Rehydrate hover sheet +for (const rule of hoverSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + caches.hover.set(getClassName(rule), rule.style[0]); + } +} + +// Rehydrate focus sheet +for (const rule of focusSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + caches.focus.set(getClassName(rule), rule.style[0]); + } +} + +// Rehydrate active sheet +for (const rule of activeSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + caches.active.set(getClassName(rule), rule.style[0]); + } +} + +const insertKeyframes = (keyframes: Keyframes): string | undefined => { + let body = ""; + + forEach(keyframes, (key, value) => { + const rules: string[] = []; + + forEach(value, (key, value) => { + rules.push(stringifyRule(key, value)); + }); + + body += `${key}{${rules.join(";")}}`; + }); + + const name = "k-" + hash(body); + + if (!keyframesNames.has(name)) { + keyframesSheet.insertRule(`@keyframes ${name}{${body}}`); + keyframesNames.add(name); + } + + return name; +}; + +const insertResetRule = (style: FlatStyle): string => { + const rules: string[] = []; + + forEach(style, (key, value) => { + rules.push(stringifyRule(key, value)); + }); + + const body = rules.join(";"); + const className = "r-" + hash(body); + + if (!caches.reset.has(className)) { + resetSheet.insertRule(`.${className}{${body}}`); + caches.reset.add(className); + } + + return className; +}; + +const insertAtomicRules = (style: Style): string => { + let classNames = ""; + + forEach(style, (key, value) => { + if (key === ":hover") { + forEach(value as FlatStyle, (key, value) => { + const rule = stringifyRule(key, value); + const className = "h-" + hash(rule); + + if (!caches.hover.has(className)) { + hoverSheet.insertRule(`.${className}:hover{${rule}}`); + caches.hover.set(className, key); + } + + classNames = appendString(classNames, className); + }); + } else if (key === ":focus") { + forEach(value as FlatStyle, (key, value) => { + const rule = stringifyRule(key, value); + const className = "f-" + hash(rule); + + if (!caches.focus.has(className)) { + focusSheet.insertRule(`.${className}:focus-visible{${rule}}`); + caches.focus.set(className, key); + } + + classNames = appendString(classNames, className); + }); + } else if (key === ":active") { + forEach(value as FlatStyle, (key, value) => { + const rule = stringifyRule(key, value); + const className = "a-" + hash(rule); + + if (!caches.active.has(className)) { + activeSheet.insertRule(`.${className}:active{${rule}}`); + caches.active.set(className, key); + } + + classNames = appendString(classNames, className); + }); + } else { + const rule = stringifyRule(key, value as string | number); + const className = "x-" + hash(rule); + + if (!caches.atomic.has(className)) { + atomicSheet.insertRule(`.${className}{${rule}}`); + caches.atomic.set(className, key); + } + + classNames = appendString(classNames, className); + } + }); + + return classNames; +}; + +export const cssMakeInput: Input = { + keyframes: (keyframes) => insertKeyframes(preprocessKeyframes(keyframes)), +}; + +export const css = { + extend: >(input: T) => { + forEach(input, (key, value) => { + // @ts-expect-error keep initial object instance reference + cssMakeInput[key] = value; + }); + + return input; + }, + make: ( + styles: Record | ((input: Input) => Record), + ): Record => { + const output = {} as Record; + + forEach( + typeof styles === "function" ? styles(cssMakeInput) : styles, + (key, value) => { + output[key] = + key[0] === "$" + ? insertResetRule(preprocessResetStyle(value)) + : insertAtomicRules(preprocessAtomicStyle(value)); + }, + ); + + return output; + }, +}; + +export const getCssFileContent = () => + [ + keyframesSheet.toString(), + resetSheet.toString(), + atomicSheet.toString(), + hoverSheet.toString(), + focusSheet.toString(), + activeSheet.toString(), + ].join("\n"); diff --git a/src/cx.ts b/src/cx.ts new file mode 100644 index 0000000..d068625 --- /dev/null +++ b/src/cx.ts @@ -0,0 +1,106 @@ +import type { ClassNames } from "./types"; +import { appendString, forEach } from "./utils"; + +export const caches = { + reset: new Set(), + atomic: new Map(), + hover: new Map(), + focus: new Map(), + active: new Map(), +}; + +const extractClassNames = (items: ClassNames, acc: string[]): string[] => { + for (const item of items) { + if (typeof item === "string") { + for (const part of item.split(" ")) { + if (part !== "") { + acc.push(part); + } + } + } else if (Array.isArray(item)) { + extractClassNames(item, acc); + } + } + + return acc; +}; + +const appendClassNames = ( + acc: string, + classNames: Record, +): string => { + let output = acc; + + forEach(classNames, (_, value) => { + output = appendString(output, value); + }); + + return output; +}; + +export const cx = (...items: ClassNames): string => { + const classNames = extractClassNames(items, []); + + let output = ""; + + let cacheKey: string | undefined = undefined; + let reset: string | undefined = undefined; + + const atomic: Record = {}; + const hover: Record = {}; + const focus: Record = {}; + const active: Record = {}; + + for (const className of classNames) { + cacheKey = caches.atomic.get(className); + + if (cacheKey != null) { + atomic[cacheKey] = className; + continue; + } + + cacheKey = caches.hover.get(className); + + if (cacheKey != null) { + hover[cacheKey] = className; + continue; + } + + cacheKey = caches.focus.get(className); + + if (cacheKey != null) { + focus[cacheKey] = className; + continue; + } + + cacheKey = caches.active.get(className); + + if (cacheKey != null) { + active[cacheKey] = className; + continue; + } + + if (caches.reset.has(className)) { + if (reset == null) { + reset = className; + } else if (reset !== className) { + if (process.env.NODE_ENV === "development") { + console.warn("`cx` only accepts one reset style."); + } + } + } else { + output = appendString(output, className); + } + } + + if (reset != null) { + output = appendString(output, reset); + } + + output = appendClassNames(output, atomic); + output = appendClassNames(output, hover); + output = appendClassNames(output, focus); + output = appendClassNames(output, active); + + return output; +}; diff --git a/src/index.ts b/src/index.ts index ef1ee7b..4118fd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import { createSheet } from "./sheet"; - -export const { css, cx } = createSheet(); +export { css } from "./css"; +export { cx } from "./cx"; export type { Input } from "./types"; diff --git a/src/sheet.ts b/src/sheet.ts deleted file mode 100644 index c49a7bc..0000000 --- a/src/sheet.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { hash } from "./hash"; -import { hyphenateName } from "./hyphenateName"; -import { normalizeValue } from "./normalizeValue"; -import { - preprocessAtomicStyle, - preprocessKeyframes, - preprocessResetStyle, -} from "./preprocess"; -import type { ClassNames, FlatStyle, Input, Keyframes, Style } from "./types"; -import { forEach } from "./utils"; - -const getSheet = (id: string): CSSStyleSheet | null => { - if (typeof document === "undefined") { - return null; - } - - const current = - document.querySelector(`link[id="${id}"]`) ?? - document.querySelector(`style[id="${id}"]`); - - if (current != null) { - return current.sheet; - } - - const element = document.createElement("style"); - element.setAttribute("id", id); - document.head.appendChild(element); - - return element.sheet; -}; - -const getMediaRule = ( - sheet: CSSStyleSheet | null, - index: number, - media: string, -): { - cssRules: CSSRuleList | []; - toString: () => string; - insertRule: (rule: string) => void; -} => { - const cssRules = new Set(); - - const toString = () => - cssRules.size > 0 - ? `@media ${media}{${[...cssRules].join("")}}` - : `@media ${media}{}`; // Keep an empty media sheet to preserve the index (hydratation) - - if (sheet == null) { - return { - cssRules: [], - toString, - insertRule: (rule) => { - cssRules.add(rule); - }, - }; - } - - if (sheet.cssRules[index] == null) { - try { - sheet.insertRule(`@media ${media}{}`, index); - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error(error); - } - - return { - cssRules: [], - toString, - insertRule: (rule) => { - cssRules.add(rule); - }, - }; - } - } - - const mediaRule = sheet.cssRules[index] as CSSMediaRule; - - return { - cssRules: mediaRule.cssRules, - toString, - insertRule: (rule) => { - try { - mediaRule.insertRule(rule, mediaRule.cssRules.length); - cssRules.add(rule); - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error(error); - } - } - }, - }; -}; - -const stringifyRule = (key: string, value: string | number): string => { - if (key === "appearance") { - return `-webkit-appearance:${value};appearance:${value}`; - } - if (key === "lineClamp") { - return `-webkit-line-clamp:${value};line-clamp:${value}`; - } - - return `${hyphenateName(key)}:${normalizeValue(key, value)}`; -}; - -const extractClassNames = (items: ClassNames, acc: string[]): string[] => { - for (const item of items) { - if (typeof item === "string") { - for (const part of item.split(" ")) { - if (part !== "") { - acc.push(part); - } - } - } else if (Array.isArray(item)) { - extractClassNames(item, acc); - } - } - - return acc; -}; - -const appendString = (acc: string, value: string): string => - acc + (acc ? " " + value : value); - -const appendClassNames = ( - acc: string, - classNames: Record, -): string => { - let output = acc; - - forEach(classNames, (_, value) => { - output = appendString(output, value); - }); - - return output; -}; - -const getClassName = (rule: CSSStyleRule): string => { - const selector = rule.selectorText; - const end = selector.indexOf(":"); - return end > -1 ? selector.substring(1, end) : selector.substring(1); -}; - -export const createSheet = () => { - const sheet = getSheet("swan-stylesheet"); - - const keyframesSheet = getMediaRule(sheet, 0, "all"); - const resetSheet = getMediaRule(sheet, 1, "all"); - const atomicSheet = getMediaRule(sheet, 2, "all"); - const hoverSheet = getMediaRule(sheet, 3, "(hover:hover)"); - const focusSheet = getMediaRule(sheet, 4, "all"); - const activeSheet = getMediaRule(sheet, 5, "all"); - - const keyframesNames = new Set(); - const resetClassNames = new Set(); - const atomicClassNames = new Map(); - const hoverClassNames = new Map(); - const focusClassNames = new Map(); - const activeClassNames = new Map(); - - // Rehydrate keyframes sheet - for (const rule of keyframesSheet.cssRules) { - if (rule instanceof CSSKeyframesRule) { - keyframesNames.add(rule.name); - } - } - - // Rehydrate reset sheet - for (const rule of resetSheet.cssRules) { - if (rule instanceof CSSStyleRule) { - resetClassNames.add(getClassName(rule)); - } - } - - // Rehydrate atomic sheet - for (const rule of atomicSheet.cssRules) { - if (rule instanceof CSSStyleRule) { - atomicClassNames.set(getClassName(rule), rule.style[0]); - } - } - - // Rehydrate hover sheet - for (const rule of hoverSheet.cssRules) { - if (rule instanceof CSSStyleRule) { - hoverClassNames.set(getClassName(rule), rule.style[0]); - } - } - - // Rehydrate focus sheet - for (const rule of focusSheet.cssRules) { - if (rule instanceof CSSStyleRule) { - focusClassNames.set(getClassName(rule), rule.style[0]); - } - } - - // Rehydrate active sheet - for (const rule of activeSheet.cssRules) { - if (rule instanceof CSSStyleRule) { - activeClassNames.set(getClassName(rule), rule.style[0]); - } - } - - const insertKeyframes = (keyframes: Keyframes): string | undefined => { - let body = ""; - - forEach(keyframes, (key, value) => { - const rules: string[] = []; - - forEach(value, (key, value) => { - rules.push(stringifyRule(key, value)); - }); - - body += `${key}{${rules.join(";")}}`; - }); - - const name = "k-" + hash(body); - - if (!keyframesNames.has(name)) { - keyframesSheet.insertRule(`@keyframes ${name}{${body}}`); - keyframesNames.add(name); - } - - return name; - }; - - const insertResetRule = (style: FlatStyle): string => { - const rules: string[] = []; - - forEach(style, (key, value) => { - rules.push(stringifyRule(key, value)); - }); - - const body = rules.join(";"); - const className = "r-" + hash(body); - - if (!resetClassNames.has(className)) { - resetSheet.insertRule(`.${className}{${body}}`); - resetClassNames.add(className); - } - - return className; - }; - - const insertAtomicRules = (style: Style): string => { - let classNames = ""; - - forEach(style, (key, value) => { - if (key === ":hover") { - forEach(value as FlatStyle, (key, value) => { - const rule = stringifyRule(key, value); - const className = "h-" + hash(rule); - - if (!hoverClassNames.has(className)) { - hoverSheet.insertRule(`.${className}:hover{${rule}}`); - hoverClassNames.set(className, key); - } - - classNames = appendString(classNames, className); - }); - } else if (key === ":focus") { - forEach(value as FlatStyle, (key, value) => { - const rule = stringifyRule(key, value); - const className = "f-" + hash(rule); - - if (!focusClassNames.has(className)) { - focusSheet.insertRule(`.${className}:focus-visible{${rule}}`); - focusClassNames.set(className, key); - } - - classNames = appendString(classNames, className); - }); - } else if (key === ":active") { - forEach(value as FlatStyle, (key, value) => { - const rule = stringifyRule(key, value); - const className = "a-" + hash(rule); - - if (!activeClassNames.has(className)) { - activeSheet.insertRule(`.${className}:active{${rule}}`); - activeClassNames.set(className, key); - } - - classNames = appendString(classNames, className); - }); - } else { - const rule = stringifyRule(key, value as string | number); - const className = "x-" + hash(rule); - - if (!atomicClassNames.has(className)) { - atomicSheet.insertRule(`.${className}{${rule}}`); - atomicClassNames.set(className, key); - } - - classNames = appendString(classNames, className); - } - }); - - return classNames; - }; - - const _input: Input = { - keyframes: (keyframes) => insertKeyframes(preprocessKeyframes(keyframes)), - }; - - return { - input: _input, - css: { - extend: >(input: T) => { - forEach(input, (key, value) => { - // @ts-expect-error keep initial object instance reference - _input[key] = value; - }); - - return input; - }, - make: ( - styles: Record | ((input: Input) => Record), - ): Record => { - const output = {} as Record; - - forEach( - typeof styles === "function" ? styles(_input) : styles, - (key, value) => { - output[key] = - key[0] === "$" - ? insertResetRule(preprocessResetStyle(value)) - : insertAtomicRules(preprocessAtomicStyle(value)); - }, - ); - - return output; - }, - }, - cx: (...items: ClassNames): string => { - const classNames = extractClassNames(items, []); - - let output = ""; - - let cacheKey: string | undefined = undefined; - let reset: string | undefined = undefined; - - const atomic: Record = {}; - const hover: Record = {}; - const focus: Record = {}; - const active: Record = {}; - - for (const className of classNames) { - cacheKey = atomicClassNames.get(className); - - if (cacheKey != null) { - atomic[cacheKey] = className; - continue; - } - - cacheKey = hoverClassNames.get(className); - - if (cacheKey != null) { - hover[cacheKey] = className; - continue; - } - - cacheKey = focusClassNames.get(className); - - if (cacheKey != null) { - focus[cacheKey] = className; - continue; - } - - cacheKey = activeClassNames.get(className); - - if (cacheKey != null) { - active[cacheKey] = className; - continue; - } - - if (resetClassNames.has(className)) { - if (reset == null) { - reset = className; - } else if (reset !== className) { - if (process.env.NODE_ENV === "development") { - console.warn("`cx` only accepts one reset style."); - } - } - } else { - output = appendString(output, className); - } - } - - if (reset != null) { - output = appendString(output, reset); - } - - output = appendClassNames(output, atomic); - output = appendClassNames(output, hover); - output = appendClassNames(output, focus); - output = appendClassNames(output, active); - - return output; - }, - toString: () => - [ - keyframesSheet.toString(), - resetSheet.toString(), - atomicSheet.toString(), - hoverSheet.toString(), - focusSheet.toString(), - activeSheet.toString(), - ].join("\n"), - }; -}; diff --git a/src/utils.ts b/src/utils.ts index afa6819..590a943 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,8 @@ import type { ValueOf } from "./types"; +export const appendString = (acc: string, value: string): string => + acc + (acc ? " " + value : value); + export const forEach = >( object: T, callback: (key: keyof T, value: NonNullable>) => void, diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index a9f5cb2..021cde1 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -1,10 +1,37 @@ import MagicString from "magic-string"; +import HTMLParser from "node-html-parser"; import { createHash } from "node:crypto"; -import { join } from "node:path"; -import type { Node } from "oxc-parser"; +import fs from "node:fs"; +import path from "node:path"; +import { type Node } from "oxc-parser"; +import { ResolverFactory } from "oxc-resolver"; import { parseAndWalk } from "oxc-walker"; import type { Plugin } from "vite"; -import { createSheet } from "./sheet"; +import { css, cssMakeInput, getCssFileContent } from "./css"; +import { caches } from "./cx"; + +// Vite's default (https://vite.dev/config/shared-options.html#resolve-extensions) +// TODO: inline? add cjs, cts? +const SUPPORTED_EXTENSIONS = new Set([ + ".mjs", + ".js", + ".mts", + ".ts", + ".jsx", + ".tsx", + // ".json", +]); + +// oxc-parser: "js" | "jsx" | "ts" | "tsx" + +const stringifySet = (value: Set): string => + `new Set([${[...value].map((item) => `"${item}"`).join(",")}])`; + +const stringifyMap = (value: Map): string => + `new Map([${[...value.entries()] + .filter((entry): entry is [string, string] => entry[1] != null) + .map(([key, value]) => `["${key}", "${value}"]`) + .join(",")}])`; type PluginOptions = { fileName?: string; @@ -22,6 +49,34 @@ const isCssMethodNode = ( node.property.type === "Identifier" && node.property.name === methodName; +const normalizeInput = ( + input: string | string[] | Record | undefined, +): string[] => { + if (input == null) { + return ["index.html"]; // Vite's default + } + if (typeof input === "string") { + return [input]; + } + if (Array.isArray(input)) { + return input; + } + + return Object.values(input); +}; + +const normalizeRoot = (root: string, configFile: string | undefined) => { + if (path.isAbsolute(root)) { + return root; + } + if (configFile == null) { + return path.resolve(process.cwd(), root); + } + + return path.resolve(path.dirname(configFile), root); +}; + +// TODO: do nothing if it's SSR build, only client build const plugin = (options: PluginOptions = {}): Plugin => { const packageName = "@swan-io/css"; const packageAliases = new Set([packageName]); @@ -29,23 +84,224 @@ const plugin = (options: PluginOptions = {}): Plugin => { let assetsDir = "assets"; let emittedFileName = ""; - const sheet = createSheet(); + let cx = ""; + + const virtualModuleId = "virtual:@swan-io/cx"; + const resolvedVirtualModuleId = "\0" + virtualModuleId; return { name: packageName, - enforce: "post", - configResolved({ build, resolve }) { - assetsDir = build.assetsDir; - const alias = resolve.alias.find((item) => item.find === packageName); + configResolved: (config) => { + assetsDir = config.build.assetsDir; + + // TODO: normalize aliases to an object + const alias = config.resolve.alias + .filter((alias) => typeof alias.find === "string") + .find((item) => item.find === packageName); if (alias != null) { packageAliases.add(alias.replacement); } + + const extensions = config.resolve.extensions.filter((ext) => + SUPPORTED_EXTENSIONS.has(ext), + ); + + // TODO: add support for aliases + const resolve = new ResolverFactory({ extensions }); + + const getImportMap = (inputs: string[]): Set => { + const seen = new Set(); + + const visit = (file: string) => { + if (seen.has(file)) { + return; + } + + seen.add(file); + + const dir = path.dirname(file); + const ext = path.extname(file); + const code = fs.readFileSync(file, "utf-8"); + + if (ext === ".html" || ext === ".htm") { + const html = HTMLParser.parse(code); + + const imports = [...html.querySelectorAll(`script[type="module"]`)] + .map((item) => item.getAttribute("src")) + .filter((src) => src != null) + .filter((src) => extensions.includes(path.extname(src))) + .map((src) => (path.isAbsolute(src) ? path.join(dir, src) : src)) + .map((src) => path.resolve(root, src)); + + // Depth-first: visit each import before continuing + for (const item of imports) { + visit(item); + } + } else if (extensions.includes(ext)) { + const imports = new Set(); + + // TODO: avoid parsing + walking the file twice (save ASTs?) + parseAndWalk(code, file, (node) => { + if ( + node.type === "ImportDeclaration" || + node.type === "ExportAllDeclaration" + ) { + return imports.add(node.source.value); + } + + if ( + node.type === "ImportExpression" || + node.type === "ExportNamedDeclaration" + ) { + if ( + node.source?.type === "Literal" && + typeof node.source.value === "string" + ) { + return imports.add(node.source.value); + } + } + }); + + for (const specifier of imports) { + try { + const resolved = resolve.sync(dir, specifier).path; + + if (resolved != null) { + visit(resolved); + } + } catch { + // ignore unresolved + } + } + } + }; + + for (const input of inputs) { + visit(input); + } + + return seen; + }; + + const root = normalizeRoot(config.root, config.configFile); + + const input = normalizeInput(config.build.rollupOptions.input) + .filter((item) => item != null) + .map((input) => path.resolve(root, input)); + + const imports = getImportMap(input); + + for (const id of imports) { + const code = fs.readFileSync(id, "utf-8"); + + let cssImportName = ""; + + parseAndWalk(code, id, (node) => { + if ( + node.type === "ImportDeclaration" && + packageAliases.has(node.source.value) + ) { + const specifier = node.specifiers.find( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === "css", + ); + + if (specifier != null) { + cssImportName = specifier.local.name; + } + } else if ( + node.type === "CallExpression" && + isCssMethodNode(cssImportName, "extend", node.callee) + ) { + const fn = node.arguments[0]; + + if (fn != null && fn.type === "ObjectExpression") { + css.extend( + new Function(`return ${code.slice(fn.start, fn.end)};`)(), + ); + } + } else if ( + node.type === "CallExpression" && + isCssMethodNode(cssImportName, "make", node.callee) + ) { + const fn = node.arguments[0]; + + if (fn != null && fn.type === "ObjectExpression") { + css.make( + new Function(`return ${code.slice(fn.start, fn.end)};`)(), + ); + } else if ( + fn != null && + (fn.type === "ArrowFunctionExpression" || + fn.type === "FunctionExpression") + ) { + css.make( + new Function( + "input", + `return (${code.slice(fn.start, fn.end)})(input);`, + )(cssMakeInput), + ); + } + } + }); + } + + // TODO: fix the path + const cxId = path.join(__dirname, "../dist/cx.mjs"); // TODO: only work in production + const cxCode = fs.readFileSync(cxId, "utf-8"); + + const magicString = new MagicString(cxCode); + + // TODO: lint that there's no imports + parseAndWalk(cxCode, cxId, (node) => { + if (node.type === "VariableDeclaration") { + const declaration = node.declarations[0]; + + if ( + declaration != null && + declaration.id.type === "Identifier" && + declaration.id.name === "caches" + ) { + magicString.overwrite( + node.start, + node.end, + ` +var caches = { + reset: ${stringifySet(caches.reset)}, + atomic: ${stringifyMap(caches.atomic)}, + hover: ${stringifyMap(caches.hover)}, + focus: ${stringifyMap(caches.focus)}, + active: ${stringifyMap(caches.active)}, +}; +`.trim(), + ); + } + } + }); + + cx = magicString.toString(); + }, + + resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + }, + + load(id) { + if (id === resolvedVirtualModuleId) { + return cx; + } }, transform(code, id) { let cssImportName = ""; + let isCxImported = false; + const magicString = new MagicString(code); parseAndWalk(code, id, (node) => { @@ -53,15 +309,33 @@ const plugin = (options: PluginOptions = {}): Plugin => { node.type === "ImportDeclaration" && packageAliases.has(node.source.value) ) { - const specifier = node.specifiers.find( + const cssLocalName = node.specifiers.find( (specifier) => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "css", - ); + )?.local.name; + + const cxLocalName = node.specifiers.find( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === "cx", + )?.local.name; + + if (cssLocalName != null) { + cssImportName = cssLocalName; + magicString.overwrite(node.start, node.end, ""); + } - if (specifier != null) { - cssImportName = specifier.local.name; + if (cxLocalName != null && !isCxImported) { + isCxImported = true; + + magicString.overwrite( + node.start, + node.end, + `import { ${cxLocalName === "cx" ? "cx" : `cx as ${cxLocalName}`} } from "${virtualModuleId}";`, + ); } } else if ( node.type === "CallExpression" && @@ -70,7 +344,7 @@ const plugin = (options: PluginOptions = {}): Plugin => { const fn = node.arguments[0]; if (fn != null && fn.type === "ObjectExpression") { - const result = sheet.css.extend( + const result = css.extend( new Function(`return ${magicString.slice(fn.start, fn.end)};`)(), ); @@ -89,7 +363,7 @@ const plugin = (options: PluginOptions = {}): Plugin => { const fn = node.arguments[0]; if (fn != null && fn.type === "ObjectExpression") { - const result = sheet.css.make( + const result = css.make( new Function(`return ${magicString.slice(fn.start, fn.end)};`)(), ); @@ -103,11 +377,11 @@ const plugin = (options: PluginOptions = {}): Plugin => { (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") ) { - const result = sheet.css.make( + const result = css.make( new Function( "input", `return (${magicString.slice(fn.start, fn.end)})(input);`, - )(sheet.input), + )(cssMakeInput), ); magicString.overwrite( @@ -121,7 +395,9 @@ const plugin = (options: PluginOptions = {}): Plugin => { } }); - if (cssImportName !== "") { + // TODO: if there's ANY import, replace them all with empty string + // if there's ANY cx import, replace it on top + if (cssImportName !== "" || isCxImported) { return { code: magicString.toString(), map: magicString.generateMap({ hires: true }), @@ -130,7 +406,7 @@ const plugin = (options: PluginOptions = {}): Plugin => { }, generateBundle(_options, _bundle, _isWrite) { - const source = sheet.toString(); + const source = getCssFileContent(); const hash = createHash("sha256") .update(source) @@ -138,7 +414,7 @@ const plugin = (options: PluginOptions = {}): Plugin => { .slice(0, 8); const fileName = options.fileName ?? "styles"; - emittedFileName = join(assetsDir, `${fileName}-${hash}.css`); + emittedFileName = path.join(assetsDir, `${fileName}-${hash}.css`); this.emitFile({ type: "asset", diff --git a/tsup.config.ts b/tsup.config.ts index 2b811ad..7db866a 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; const config = { - entry: ["src/index.ts", "src/vite-plugin.ts"], + entry: ["src/index.ts", "src/cx.ts", "src/vite-plugin.ts"], target: "es2019", tsconfig: "./tsconfig.build.json", bundle: true, From ab72c4f2afc6c8471363a42bf6c7e8fbc145dd33 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Sun, 17 Aug 2025 22:16:56 +0200 Subject: [PATCH 03/21] Use dynamic imports --- src/css.ts | 8 +++++--- src/vite-plugin.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/css.ts b/src/css.ts index a857567..f029d03 100644 --- a/src/css.ts +++ b/src/css.ts @@ -259,7 +259,7 @@ const insertAtomicRules = (style: Style): string => { return classNames; }; -export const cssMakeInput: Input = { +const _input: Input = { keyframes: (keyframes) => insertKeyframes(preprocessKeyframes(keyframes)), }; @@ -267,7 +267,7 @@ export const css = { extend: >(input: T) => { forEach(input, (key, value) => { // @ts-expect-error keep initial object instance reference - cssMakeInput[key] = value; + _input[key] = value; }); return input; @@ -278,7 +278,7 @@ export const css = { const output = {} as Record; forEach( - typeof styles === "function" ? styles(cssMakeInput) : styles, + typeof styles === "function" ? styles(_input) : styles, (key, value) => { output[key] = key[0] === "$" @@ -291,6 +291,8 @@ export const css = { }, }; +export const getCssMakeInput = () => _input; + export const getCssFileContent = () => [ keyframesSheet.toString(), diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 021cde1..18f89d0 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -7,8 +7,6 @@ import { type Node } from "oxc-parser"; import { ResolverFactory } from "oxc-resolver"; import { parseAndWalk } from "oxc-walker"; import type { Plugin } from "vite"; -import { css, cssMakeInput, getCssFileContent } from "./css"; -import { caches } from "./cx"; // Vite's default (https://vite.dev/config/shared-options.html#resolve-extensions) // TODO: inline? add cjs, cts? @@ -77,7 +75,10 @@ const normalizeRoot = (root: string, configFile: string | undefined) => { }; // TODO: do nothing if it's SSR build, only client build -const plugin = (options: PluginOptions = {}): Plugin => { +const plugin = async (options: PluginOptions = {}): Promise => { + const { css, getCssMakeInput, getCssFileContent } = await import("./css"); + const { caches } = await import("./cx"); + const packageName = "@swan-io/css"; const packageAliases = new Set([packageName]); @@ -143,6 +144,7 @@ const plugin = (options: PluginOptions = {}): Plugin => { const imports = new Set(); // TODO: avoid parsing + walking the file twice (save ASTs?) + // TODO: replace with parseSync + walk parseAndWalk(code, file, (node) => { if ( node.type === "ImportDeclaration" || @@ -243,20 +245,20 @@ const plugin = (options: PluginOptions = {}): Plugin => { new Function( "input", `return (${code.slice(fn.start, fn.end)})(input);`, - )(cssMakeInput), + )(getCssMakeInput()), ); } } }); } - // TODO: fix the path - const cxId = path.join(__dirname, "../dist/cx.mjs"); // TODO: only work in production + const cxId = path.join(__dirname, "./cx.mjs"); const cxCode = fs.readFileSync(cxId, "utf-8"); const magicString = new MagicString(cxCode); // TODO: lint that there's no imports + // TODO: replace with parseSync + walk parseAndWalk(cxCode, cxId, (node) => { if (node.type === "VariableDeclaration") { const declaration = node.declarations[0]; @@ -381,7 +383,7 @@ var caches = { new Function( "input", `return (${magicString.slice(fn.start, fn.end)})(input);`, - )(cssMakeInput), + )(getCssMakeInput()), ); magicString.overwrite( From 2916a63c5e833483d3e1b5a5fcc7ff813694c8bd Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Sun, 17 Aug 2025 22:17:34 +0200 Subject: [PATCH 04/21] Move type --- src/vite-plugin.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 18f89d0..e99cfa2 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -22,6 +22,10 @@ const SUPPORTED_EXTENSIONS = new Set([ // oxc-parser: "js" | "jsx" | "ts" | "tsx" +type PluginOptions = { + fileName?: string; +}; + const stringifySet = (value: Set): string => `new Set([${[...value].map((item) => `"${item}"`).join(",")}])`; @@ -31,10 +35,6 @@ const stringifyMap = (value: Map): string => .map(([key, value]) => `["${key}", "${value}"]`) .join(",")}])`; -type PluginOptions = { - fileName?: string; -}; - const isCssMethodNode = ( importName: string, methodName: "extend" | "make", From 8a3dbcbb1daee42fbd2b79cf4733e7ecbebcb67e Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Sun, 17 Aug 2025 22:19:41 +0200 Subject: [PATCH 05/21] Remove unused args --- src/vite-plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index e99cfa2..7b6642f 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -407,7 +407,7 @@ var caches = { } }, - generateBundle(_options, _bundle, _isWrite) { + generateBundle() { const source = getCssFileContent(); const hash = createHash("sha256") @@ -425,7 +425,7 @@ var caches = { }); }, - transformIndexHtml(html, _context) { + transformIndexHtml(html) { if (emittedFileName !== "") { const attrs = { rel: "stylesheet", From f24d2baf04b73b6feba3e4e0e8596d2c8343caaa Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 01:54:30 +0200 Subject: [PATCH 06/21] Simplify normalize fn --- src/vite-plugin.ts | 44 ++++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 7b6642f..7856f29 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -48,31 +48,21 @@ const isCssMethodNode = ( node.property.name === methodName; const normalizeInput = ( - input: string | string[] | Record | undefined, -): string[] => { - if (input == null) { - return ["index.html"]; // Vite's default - } - if (typeof input === "string") { - return [input]; - } - if (Array.isArray(input)) { - return input; - } - - return Object.values(input); -}; - -const normalizeRoot = (root: string, configFile: string | undefined) => { - if (path.isAbsolute(root)) { - return root; - } - if (configFile == null) { - return path.resolve(process.cwd(), root); - } - - return path.resolve(path.dirname(configFile), root); -}; + input: string | string[] | Record, +): string[] => + typeof input === "string" + ? [input] + : Array.isArray(input) + ? input + : Object.values(input); + +const normalizeRoot = (root: string, configFile: string | undefined) => + path.isAbsolute(root) + ? root + : path.resolve( + configFile != null ? path.dirname(configFile) : process.cwd(), + root, + ); // TODO: do nothing if it's SSR build, only client build const plugin = async (options: PluginOptions = {}): Promise => { @@ -189,7 +179,9 @@ const plugin = async (options: PluginOptions = {}): Promise => { const root = normalizeRoot(config.root, config.configFile); - const input = normalizeInput(config.build.rollupOptions.input) + const input = normalizeInput( + config.build.rollupOptions.input ?? "index.html", // fallback to vite's default + ) .filter((item) => item != null) .map((input) => path.resolve(root, input)); From 7469a8e23f4105f6fc06ff432bd266dbc8ca899d Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 01:55:28 +0200 Subject: [PATCH 07/21] WIP --- src/vite-plugin.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 7856f29..ffee597 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -47,6 +47,14 @@ const isCssMethodNode = ( node.property.type === "Identifier" && node.property.name === methodName; +const normalizeRoot = (root: string, configFile: string | undefined) => + path.isAbsolute(root) + ? root + : path.resolve( + configFile != null ? path.dirname(configFile) : process.cwd(), + root, + ); + const normalizeInput = ( input: string | string[] | Record, ): string[] => @@ -56,14 +64,6 @@ const normalizeInput = ( ? input : Object.values(input); -const normalizeRoot = (root: string, configFile: string | undefined) => - path.isAbsolute(root) - ? root - : path.resolve( - configFile != null ? path.dirname(configFile) : process.cwd(), - root, - ); - // TODO: do nothing if it's SSR build, only client build const plugin = async (options: PluginOptions = {}): Promise => { const { css, getCssMakeInput, getCssFileContent } = await import("./css"); @@ -389,8 +389,6 @@ var caches = { } }); - // TODO: if there's ANY import, replace them all with empty string - // if there's ANY cx import, replace it on top if (cssImportName !== "" || isCxImported) { return { code: magicString.toString(), From 0512907a6a4d7255a745bb7ce47d4a315dc1bb24 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 02:01:50 +0200 Subject: [PATCH 08/21] Only export cx mjs --- tsup.config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tsup.config.ts b/tsup.config.ts index 7db866a..408807d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,16 +1,18 @@ import { defineConfig } from "tsup"; const config = { - entry: ["src/index.ts", "src/cx.ts", "src/vite-plugin.ts"], + entry: ["src/index.ts", "src/vite-plugin.ts"], target: "es2019", tsconfig: "./tsconfig.build.json", bundle: true, clean: false, + dts: false, sourcemap: false, splitting: false, }; export default defineConfig([ { ...config, format: "cjs", dts: true }, - { ...config, format: "esm", dts: false }, + { ...config, format: "esm" }, + { ...config, entry: ["src/cx.ts"], format: "esm" }, ]); From 9d2217f35fc722ac26eb6ec2a426ff03613a7fab Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 02:25:55 +0200 Subject: [PATCH 09/21] Use a simpler but less performant function --- src/normalizeValue.ts | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/normalizeValue.ts b/src/normalizeValue.ts index df252e7..5742f29 100644 --- a/src/normalizeValue.ts +++ b/src/normalizeValue.ts @@ -64,25 +64,15 @@ const isWebColor = (color: string): boolean => color === "inherit" || color.indexOf("var(") === 0; -const hexLookupTable = "0123456789abcdef"; - const rgbToHex = (r: number, g: number, b: number) => { - const r1 = r >> 4; - const r2 = r & 0xf; - const g1 = g >> 4; - const g2 = g & 0xf; - const b1 = b >> 4; - const b2 = b & 0xf; - - return r1 === r2 && g1 === g2 && b1 === b2 - ? "#" + hexLookupTable[r1] + hexLookupTable[g1] + hexLookupTable[b1] - : "#" + - hexLookupTable[r1] + - hexLookupTable[r2] + - hexLookupTable[g1] + - hexLookupTable[g2] + - hexLookupTable[b1] + - hexLookupTable[b2]; + const hex = + r.toString(16).padStart(2, "0") + + g.toString(16).padStart(2, "0") + + b.toString(16).padStart(2, "0"); + + return hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5] + ? "#" + hex[0] + hex[2] + hex[4] + : "#" + hex; }; export const normalizeValue = (key: string, value: string | number): string => { From 6542330361e7da0f063116e86ac082e0f399c198 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 02:32:01 +0200 Subject: [PATCH 10/21] Fix doc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39c1078..cc6a126 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "vite"; export default defineConfig(({ command }) => ({ - plugins: [react(), swanCss()], + plugins: [react(), command === "build" && swanCss()], })); ``` From fb420a246382decf4c7eefab848651bd082df27a Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 10:34:29 +0200 Subject: [PATCH 11/21] Add oxc-resolver into onlyBuiltDependencies --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7b0182b..392e749 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "pnpm": { "onlyBuiltDependencies": [ "@swc/core", - "esbuild" + "esbuild", + "oxc-resolver" ] }, "prettier": { From 9764072c5f93ec3dcfe768f2102a871a5e0a231a Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 11:41:18 +0200 Subject: [PATCH 12/21] Remove comment --- src/vite-plugin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index ffee597..c73928f 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -64,7 +64,6 @@ const normalizeInput = ( ? input : Object.values(input); -// TODO: do nothing if it's SSR build, only client build const plugin = async (options: PluginOptions = {}): Promise => { const { css, getCssMakeInput, getCssFileContent } = await import("./css"); const { caches } = await import("./cx"); From 27cd11d655ac83742dde38fa2110860d8e0e278e Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 12:11:08 +0200 Subject: [PATCH 13/21] Extract normalizeConfig --- src/vite-plugin.ts | 112 +++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 61 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index c73928f..497cc16 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -6,21 +6,7 @@ import path from "node:path"; import { type Node } from "oxc-parser"; import { ResolverFactory } from "oxc-resolver"; import { parseAndWalk } from "oxc-walker"; -import type { Plugin } from "vite"; - -// Vite's default (https://vite.dev/config/shared-options.html#resolve-extensions) -// TODO: inline? add cjs, cts? -const SUPPORTED_EXTENSIONS = new Set([ - ".mjs", - ".js", - ".mts", - ".ts", - ".jsx", - ".tsx", - // ".json", -]); - -// oxc-parser: "js" | "jsx" | "ts" | "tsx" +import type { Plugin, ResolvedConfig } from "vite"; type PluginOptions = { fileName?: string; @@ -47,22 +33,42 @@ const isCssMethodNode = ( node.property.type === "Identifier" && node.property.name === methodName; -const normalizeRoot = (root: string, configFile: string | undefined) => - path.isAbsolute(root) - ? root +const normalizeConfig = (config: ResolvedConfig) => { + const { build, configFile: file, resolve } = config; + const input = build.rollupOptions.input ?? "index.html"; // fallback to vite's default + + // https://vite.dev/config/shared-options.html#resolve-extensions + const supportedExts = new Set([".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx"]); + // oxc-parser: "js" | "jsx" | "ts" | "tsx" + + const root = path.isAbsolute(config.root) + ? config.root : path.resolve( - configFile != null ? path.dirname(configFile) : process.cwd(), - root, + file != null ? path.dirname(file) : process.cwd(), + config.root, ); -const normalizeInput = ( - input: string | string[] | Record, -): string[] => - typeof input === "string" - ? [input] - : Array.isArray(input) - ? input - : Object.values(input); + return { + assetsDir: build.assetsDir, + root, + extensions: resolve.extensions.filter((ext) => supportedExts.has(ext)), + + alias: resolve.alias.reduce>( + (acc, { find, replacement }) => + find === "string" + ? { ...acc, [find]: [...(acc[find] ?? []), replacement] } + : acc, + {}, + ), + + inputs: (typeof input === "string" + ? [input] + : Array.isArray(input) + ? input + : Object.values(input) + ).map((input) => path.resolve(root, input)), + }; +}; const plugin = async (options: PluginOptions = {}): Promise => { const { css, getCssMakeInput, getCssFileContent } = await import("./css"); @@ -72,34 +78,26 @@ const plugin = async (options: PluginOptions = {}): Promise => { const packageAliases = new Set([packageName]); let assetsDir = "assets"; + let cxVirtualModuleCode = ""; let emittedFileName = ""; - let cx = ""; - - const virtualModuleId = "virtual:@swan-io/cx"; - const resolvedVirtualModuleId = "\0" + virtualModuleId; + const cxVirtualModuleId = "virtual:@swan-io/cx"; + const cxResolvedVirtualModuleId = "\0" + cxVirtualModuleId; return { name: packageName, - configResolved: (config) => { - assetsDir = config.build.assetsDir; + configResolved: (rawConfig) => { + const { alias, extensions, inputs, root, ...config } = + normalizeConfig(rawConfig); - // TODO: normalize aliases to an object - const alias = config.resolve.alias - .filter((alias) => typeof alias.find === "string") - .find((item) => item.find === packageName); + assetsDir = config.assetsDir; - if (alias != null) { - packageAliases.add(alias.replacement); + if (alias[packageName] != null) { + alias[packageName].forEach((item) => packageAliases.add(item)); } - const extensions = config.resolve.extensions.filter((ext) => - SUPPORTED_EXTENSIONS.has(ext), - ); - - // TODO: add support for aliases - const resolve = new ResolverFactory({ extensions }); + const resolve = new ResolverFactory({ alias, extensions }); const getImportMap = (inputs: string[]): Set => { const seen = new Set(); @@ -176,15 +174,7 @@ const plugin = async (options: PluginOptions = {}): Promise => { return seen; }; - const root = normalizeRoot(config.root, config.configFile); - - const input = normalizeInput( - config.build.rollupOptions.input ?? "index.html", // fallback to vite's default - ) - .filter((item) => item != null) - .map((input) => path.resolve(root, input)); - - const imports = getImportMap(input); + const imports = getImportMap(inputs); for (const id of imports) { const code = fs.readFileSync(id, "utf-8"); @@ -276,18 +266,18 @@ var caches = { } }); - cx = magicString.toString(); + cxVirtualModuleCode = magicString.toString(); }, resolveId(id) { - if (id === virtualModuleId) { - return resolvedVirtualModuleId; + if (id === cxVirtualModuleId) { + return cxResolvedVirtualModuleId; } }, load(id) { - if (id === resolvedVirtualModuleId) { - return cx; + if (id === cxResolvedVirtualModuleId) { + return cxVirtualModuleCode; } }, @@ -327,7 +317,7 @@ var caches = { magicString.overwrite( node.start, node.end, - `import { ${cxLocalName === "cx" ? "cx" : `cx as ${cxLocalName}`} } from "${virtualModuleId}";`, + `import { ${cxLocalName === "cx" ? "cx" : `cx as ${cxLocalName}`} } from "${cxVirtualModuleId}";`, ); } } else if ( From ebf9498ed6e135ffee0f230579f331a9e7ec8392 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 12:18:27 +0200 Subject: [PATCH 14/21] Extract getImportMap --- src/vite-plugin.ts | 169 +++++++++++++++++++++++---------------------- 1 file changed, 86 insertions(+), 83 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 497cc16..acf5868 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -39,7 +39,6 @@ const normalizeConfig = (config: ResolvedConfig) => { // https://vite.dev/config/shared-options.html#resolve-extensions const supportedExts = new Set([".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx"]); - // oxc-parser: "js" | "jsx" | "ts" | "tsx" const root = path.isAbsolute(config.root) ? config.root @@ -70,6 +69,88 @@ const normalizeConfig = (config: ResolvedConfig) => { }; }; +const getImportMap = ({ + alias, + extensions, + root, + inputs, +}: ReturnType): Set => { + const resolve = new ResolverFactory({ alias, extensions }); + const seen = new Set(); + + const visit = (file: string) => { + if (seen.has(file)) { + return; + } + + seen.add(file); + + const code = fs.readFileSync(file, "utf-8"); + const dir = path.dirname(file); + const ext = path.extname(file); + + if (ext === ".html" || ext === ".htm") { + const html = HTMLParser.parse(code); + + const imports = [...html.querySelectorAll(`script[type="module"]`)] + .map((item) => item.getAttribute("src")) + .filter((src) => src != null) + .filter((src) => extensions.includes(path.extname(src))) + .map((src) => + path.resolve(root, path.isAbsolute(src) ? path.join(dir, src) : src), + ); + + // Depth-first: visit each import before continuing + for (const item of imports) { + visit(item); + } + } else if (extensions.includes(ext)) { + const imports = new Set(); + + // TODO: avoid parsing + walking the file twice (save ASTs?) + // TODO: replace with parseSync + walk + parseAndWalk(code, file, (node) => { + if ( + node.type === "ImportDeclaration" || + node.type === "ExportAllDeclaration" + ) { + return imports.add(node.source.value); + } + + if ( + node.type === "ImportExpression" || + node.type === "ExportNamedDeclaration" + ) { + if ( + node.source?.type === "Literal" && + typeof node.source.value === "string" + ) { + return imports.add(node.source.value); + } + } + }); + + for (const specifier of imports) { + try { + const resolved = resolve.sync(dir, specifier).path; + + if (resolved != null) { + visit(resolved); + } + } catch { + // ignore unresolved + } + } + } + }; + + for (const input of inputs) { + visit(input); + } + + return seen; +}; + const plugin = async (options: PluginOptions = {}): Promise => { const { css, getCssMakeInput, getCssFileContent } = await import("./css"); const { caches } = await import("./cx"); @@ -87,9 +168,9 @@ const plugin = async (options: PluginOptions = {}): Promise => { return { name: packageName, - configResolved: (rawConfig) => { - const { alias, extensions, inputs, root, ...config } = - normalizeConfig(rawConfig); + configResolved: (resolvedConfig) => { + const config = normalizeConfig(resolvedConfig); + const { alias } = config; assetsDir = config.assetsDir; @@ -97,84 +178,7 @@ const plugin = async (options: PluginOptions = {}): Promise => { alias[packageName].forEach((item) => packageAliases.add(item)); } - const resolve = new ResolverFactory({ alias, extensions }); - - const getImportMap = (inputs: string[]): Set => { - const seen = new Set(); - - const visit = (file: string) => { - if (seen.has(file)) { - return; - } - - seen.add(file); - - const dir = path.dirname(file); - const ext = path.extname(file); - const code = fs.readFileSync(file, "utf-8"); - - if (ext === ".html" || ext === ".htm") { - const html = HTMLParser.parse(code); - - const imports = [...html.querySelectorAll(`script[type="module"]`)] - .map((item) => item.getAttribute("src")) - .filter((src) => src != null) - .filter((src) => extensions.includes(path.extname(src))) - .map((src) => (path.isAbsolute(src) ? path.join(dir, src) : src)) - .map((src) => path.resolve(root, src)); - - // Depth-first: visit each import before continuing - for (const item of imports) { - visit(item); - } - } else if (extensions.includes(ext)) { - const imports = new Set(); - - // TODO: avoid parsing + walking the file twice (save ASTs?) - // TODO: replace with parseSync + walk - parseAndWalk(code, file, (node) => { - if ( - node.type === "ImportDeclaration" || - node.type === "ExportAllDeclaration" - ) { - return imports.add(node.source.value); - } - - if ( - node.type === "ImportExpression" || - node.type === "ExportNamedDeclaration" - ) { - if ( - node.source?.type === "Literal" && - typeof node.source.value === "string" - ) { - return imports.add(node.source.value); - } - } - }); - - for (const specifier of imports) { - try { - const resolved = resolve.sync(dir, specifier).path; - - if (resolved != null) { - visit(resolved); - } - } catch { - // ignore unresolved - } - } - } - }; - - for (const input of inputs) { - visit(input); - } - - return seen; - }; - - const imports = getImportMap(inputs); + const imports = getImportMap(config); for (const id of imports) { const code = fs.readFileSync(id, "utf-8"); @@ -235,7 +239,6 @@ const plugin = async (options: PluginOptions = {}): Promise => { const cxId = path.join(__dirname, "./cx.mjs"); const cxCode = fs.readFileSync(cxId, "utf-8"); - const magicString = new MagicString(cxCode); // TODO: lint that there's no imports From 381a8ee6650844d105c2469c543f66103d6bb362 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 12:21:10 +0200 Subject: [PATCH 15/21] Remove comment --- src/vite-plugin.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index acf5868..93d471d 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -241,8 +241,6 @@ const plugin = async (options: PluginOptions = {}): Promise => { const cxCode = fs.readFileSync(cxId, "utf-8"); const magicString = new MagicString(cxCode); - // TODO: lint that there's no imports - // TODO: replace with parseSync + walk parseAndWalk(cxCode, cxId, (node) => { if (node.type === "VariableDeclaration") { const declaration = node.declarations[0]; From 20a3f0483ab14767bb410fd9f3327a2d5cca78e7 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 12:54:55 +0200 Subject: [PATCH 16/21] Cleanup --- src/vite-plugin.ts | 448 +++++++++++++++++++++++---------------------- 1 file changed, 227 insertions(+), 221 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 93d471d..5034432 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -3,7 +3,7 @@ import HTMLParser from "node-html-parser"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { type Node } from "oxc-parser"; +import { type CallExpression, type ImportDeclaration } from "oxc-parser"; import { ResolverFactory } from "oxc-resolver"; import { parseAndWalk } from "oxc-walker"; import type { Plugin, ResolvedConfig } from "vite"; @@ -21,17 +21,28 @@ const stringifyMap = (value: Map): string => .map(([key, value]) => `["${key}", "${value}"]`) .join(",")}])`; -const isCssMethodNode = ( +const isCssMethod = ( + { callee }: CallExpression, importName: string, methodName: "extend" | "make", - node: Node, ) => importName !== "" && - node.type === "MemberExpression" && - node.object.type === "Identifier" && - node.object.name === importName && - node.property.type === "Identifier" && - node.property.name === methodName; + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === importName && + callee.property.type === "Identifier" && + callee.property.name === methodName; + +const findSpecifier = ( + { specifiers }: ImportDeclaration, + specifierName: "css" | "cx", +) => + specifiers.find( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === specifierName, + ); const normalizeConfig = (config: ResolvedConfig) => { const { build, configFile: file, resolve } = config; @@ -69,88 +80,6 @@ const normalizeConfig = (config: ResolvedConfig) => { }; }; -const getImportMap = ({ - alias, - extensions, - root, - inputs, -}: ReturnType): Set => { - const resolve = new ResolverFactory({ alias, extensions }); - const seen = new Set(); - - const visit = (file: string) => { - if (seen.has(file)) { - return; - } - - seen.add(file); - - const code = fs.readFileSync(file, "utf-8"); - const dir = path.dirname(file); - const ext = path.extname(file); - - if (ext === ".html" || ext === ".htm") { - const html = HTMLParser.parse(code); - - const imports = [...html.querySelectorAll(`script[type="module"]`)] - .map((item) => item.getAttribute("src")) - .filter((src) => src != null) - .filter((src) => extensions.includes(path.extname(src))) - .map((src) => - path.resolve(root, path.isAbsolute(src) ? path.join(dir, src) : src), - ); - - // Depth-first: visit each import before continuing - for (const item of imports) { - visit(item); - } - } else if (extensions.includes(ext)) { - const imports = new Set(); - - // TODO: avoid parsing + walking the file twice (save ASTs?) - // TODO: replace with parseSync + walk - parseAndWalk(code, file, (node) => { - if ( - node.type === "ImportDeclaration" || - node.type === "ExportAllDeclaration" - ) { - return imports.add(node.source.value); - } - - if ( - node.type === "ImportExpression" || - node.type === "ExportNamedDeclaration" - ) { - if ( - node.source?.type === "Literal" && - typeof node.source.value === "string" - ) { - return imports.add(node.source.value); - } - } - }); - - for (const specifier of imports) { - try { - const resolved = resolve.sync(dir, specifier).path; - - if (resolved != null) { - visit(resolved); - } - } catch { - // ignore unresolved - } - } - } - }; - - for (const input of inputs) { - visit(input); - } - - return seen; -}; - const plugin = async (options: PluginOptions = {}): Promise => { const { css, getCssMakeInput, getCssFileContent } = await import("./css"); const { caches } = await import("./cx"); @@ -170,7 +99,7 @@ const plugin = async (options: PluginOptions = {}): Promise => { configResolved: (resolvedConfig) => { const config = normalizeConfig(resolvedConfig); - const { alias } = config; + const { alias, extensions, inputs, root } = config; assetsDir = config.assetsDir; @@ -178,67 +107,145 @@ const plugin = async (options: PluginOptions = {}): Promise => { alias[packageName].forEach((item) => packageAliases.add(item)); } - const imports = getImportMap(config); + const resolve = new ResolverFactory({ alias, extensions }); + const resolvedFiles = new Set(); + + const visit = (file: string) => { + if (resolvedFiles.has(file)) { + return; + } - for (const id of imports) { - const code = fs.readFileSync(id, "utf-8"); + resolvedFiles.add(file); - let cssImportName = ""; + const code = fs.readFileSync(file, "utf-8"); + const dir = path.dirname(file); + const ext = path.extname(file); - parseAndWalk(code, id, (node) => { - if ( - node.type === "ImportDeclaration" && - packageAliases.has(node.source.value) - ) { - const specifier = node.specifiers.find( - (specifier) => - specifier.type === "ImportSpecifier" && - specifier.imported.type === "Identifier" && - specifier.imported.name === "css", + if (ext === ".html" || ext === ".htm") { + const html = HTMLParser.parse(code); + + const imports = [...html.querySelectorAll(`script[type="module"]`)] + .map((item) => item.getAttribute("src")) + .filter((src) => src != null) + .filter((src) => extensions.includes(path.extname(src))) + .map((src) => + path.resolve( + root, + path.isAbsolute(src) ? path.join(dir, src) : src, + ), ); - if (specifier != null) { - cssImportName = specifier.local.name; + // Depth-first: visit each import before continuing + for (const item of imports) { + visit(item); + } + } else if (extensions.includes(ext)) { + const imports = new Set(); + + let cssImportName = ""; + + parseAndWalk(code, file, (node) => { + switch (node.type) { + case "ExportNamedDeclaration": { + if ( + typeof node.source != null && + typeof node.source?.value === "string" + ) { + imports.add(node.source.value); + } + + break; + } + + case "ExportAllDeclaration": { + imports.add(node.source.value); + break; + } + + case "ImportExpression": { + if ( + node.source.type === "Literal" && + typeof node.source.value === "string" + ) { + imports.add(node.source.value); + } + + break; + } + + case "ImportDeclaration": { + imports.add(node.source.value); + + if (packageAliases.has(node.source.value)) { + const specifier = findSpecifier(node, "css"); + + if (specifier != null) { + cssImportName = specifier.local.name; + } + } + + break; + } + + case "CallExpression": { + const fn = node.arguments[0]; + + if (fn == null) { + break; + } + + if (isCssMethod(node, cssImportName, "extend")) { + if (fn.type === "ObjectExpression") { + css.extend( + new Function(`return ${code.slice(fn.start, fn.end)};`)(), + ); + } + + break; + } + + if (isCssMethod(node, cssImportName, "make")) { + if (fn.type === "ObjectExpression") { + css.make( + new Function(`return ${code.slice(fn.start, fn.end)};`)(), + ); + } else if ( + fn.type === "ArrowFunctionExpression" || + fn.type === "FunctionExpression" + ) { + css.make( + new Function( + "input", + `return (${code.slice(fn.start, fn.end)})(input);`, + )(getCssMakeInput()), + ); + } + } + } } - } else if ( - node.type === "CallExpression" && - isCssMethodNode(cssImportName, "extend", node.callee) - ) { - const fn = node.arguments[0]; + }); - if (fn != null && fn.type === "ObjectExpression") { - css.extend( - new Function(`return ${code.slice(fn.start, fn.end)};`)(), - ); - } - } else if ( - node.type === "CallExpression" && - isCssMethodNode(cssImportName, "make", node.callee) - ) { - const fn = node.arguments[0]; + for (const specifier of imports) { + try { + const resolved = resolve.sync(dir, specifier).path; - if (fn != null && fn.type === "ObjectExpression") { - css.make( - new Function(`return ${code.slice(fn.start, fn.end)};`)(), - ); - } else if ( - fn != null && - (fn.type === "ArrowFunctionExpression" || - fn.type === "FunctionExpression") - ) { - css.make( - new Function( - "input", - `return (${code.slice(fn.start, fn.end)})(input);`, - )(getCssMakeInput()), - ); + if (resolved != null) { + visit(resolved); + } + } catch { + // ignore unresolved } } - }); + } + }; + + for (const input of inputs) { + visit(input); } const cxId = path.join(__dirname, "./cx.mjs"); const cxCode = fs.readFileSync(cxId, "utf-8"); + const magicString = new MagicString(cxCode); parseAndWalk(cxCode, cxId, (node) => { @@ -284,102 +291,101 @@ var caches = { transform(code, id) { let cssImportName = ""; - let isCxImported = false; + let cxImportName = ""; const magicString = new MagicString(code); parseAndWalk(code, id, (node) => { - if ( - node.type === "ImportDeclaration" && - packageAliases.has(node.source.value) - ) { - const cssLocalName = node.specifiers.find( - (specifier) => - specifier.type === "ImportSpecifier" && - specifier.imported.type === "Identifier" && - specifier.imported.name === "css", - )?.local.name; - - const cxLocalName = node.specifiers.find( - (specifier) => - specifier.type === "ImportSpecifier" && - specifier.imported.type === "Identifier" && - specifier.imported.name === "cx", - )?.local.name; - - if (cssLocalName != null) { - cssImportName = cssLocalName; - magicString.overwrite(node.start, node.end, ""); - } - - if (cxLocalName != null && !isCxImported) { - isCxImported = true; + switch (node.type) { + case "ImportDeclaration": { + if (packageAliases.has(node.source.value)) { + const cssLocalName = findSpecifier(node, "css")?.local.name; + const cxLocalName = findSpecifier(node, "cx")?.local.name; + + if (cssLocalName != null) { + cssImportName = cssLocalName; + magicString.overwrite(node.start, node.end, ""); + } + + if (cxLocalName != null && cxLocalName !== "") { + cxImportName = cxLocalName; + + magicString.overwrite( + node.start, + node.end, + `import { ${cxLocalName === "cx" ? "cx" : `cx as ${cxLocalName}`} } from "${cxVirtualModuleId}";`, + ); + } + } - magicString.overwrite( - node.start, - node.end, - `import { ${cxLocalName === "cx" ? "cx" : `cx as ${cxLocalName}`} } from "${cxVirtualModuleId}";`, - ); + break; } - } else if ( - node.type === "CallExpression" && - isCssMethodNode(cssImportName, "extend", node.callee) - ) { - const fn = node.arguments[0]; - - if (fn != null && fn.type === "ObjectExpression") { - const result = css.extend( - new Function(`return ${magicString.slice(fn.start, fn.end)};`)(), - ); - magicString.overwrite( - node.start, - node.end, - JSON.stringify(result, null, 2), - ); - } else { - magicString.overwrite(node.start, node.end, "{}"); - } - } else if ( - node.type === "CallExpression" && - isCssMethodNode(cssImportName, "make", node.callee) - ) { - const fn = node.arguments[0]; - - if (fn != null && fn.type === "ObjectExpression") { - const result = css.make( - new Function(`return ${magicString.slice(fn.start, fn.end)};`)(), - ); + case "CallExpression": { + const fn = node.arguments[0]; - magicString.overwrite( - node.start, - node.end, - JSON.stringify(result, null, 2), - ); - } else if ( - fn != null && - (fn.type === "ArrowFunctionExpression" || - fn.type === "FunctionExpression") - ) { - const result = css.make( - new Function( - "input", - `return (${magicString.slice(fn.start, fn.end)})(input);`, - )(getCssMakeInput()), - ); + if (fn == null) { + break; + } - magicString.overwrite( - node.start, - node.end, - JSON.stringify(result, null, 2), - ); - } else { - magicString.overwrite(node.start, node.end, "{}"); + if (isCssMethod(node, cssImportName, "extend")) { + if (fn.type === "ObjectExpression") { + const result = css.extend( + new Function( + `return ${magicString.slice(fn.start, fn.end)};`, + )(), + ); + + magicString.overwrite( + node.start, + node.end, + JSON.stringify(result, null, 2), + ); + } else { + magicString.overwrite(node.start, node.end, "{}"); + } + + break; + } + + if (isCssMethod(node, cssImportName, "make")) { + if (fn.type === "ObjectExpression") { + const result = css.make( + new Function( + `return ${magicString.slice(fn.start, fn.end)};`, + )(), + ); + + magicString.overwrite( + node.start, + node.end, + JSON.stringify(result, null, 2), + ); + } else if ( + fn.type === "ArrowFunctionExpression" || + fn.type === "FunctionExpression" + ) { + const result = css.make( + new Function( + "input", + `return (${magicString.slice(fn.start, fn.end)})(input);`, + )(getCssMakeInput()), + ); + + magicString.overwrite( + node.start, + node.end, + JSON.stringify(result, null, 2), + ); + } else { + magicString.overwrite(node.start, node.end, "{}"); + } + } } } }); - if (cssImportName !== "" || isCxImported) { + if (cssImportName !== "" || cxImportName !== "") { return { code: magicString.toString(), map: magicString.generateMap({ hires: true }), From 17a27f776b8cb603c9b74c5ebb6fa2df875634e5 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 13:07:50 +0200 Subject: [PATCH 17/21] Inline toStringMap --- src/vite-plugin.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 5034432..52fb3e5 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -12,15 +12,6 @@ type PluginOptions = { fileName?: string; }; -const stringifySet = (value: Set): string => - `new Set([${[...value].map((item) => `"${item}"`).join(",")}])`; - -const stringifyMap = (value: Map): string => - `new Map([${[...value.entries()] - .filter((entry): entry is [string, string] => entry[1] != null) - .map(([key, value]) => `["${key}", "${value}"]`) - .join(",")}])`; - const isCssMethod = ( { callee }: CallExpression, importName: string, @@ -248,6 +239,13 @@ const plugin = async (options: PluginOptions = {}): Promise => { const magicString = new MagicString(cxCode); + const toStringMap = (map: Map): string => + `new Map([${[...map.entries()] + .reduce((acc, [key, value]) => { + return value != null ? [...acc, `["${key}", "${value}"]`] : acc; + }, []) + .join(",")}])`; + parseAndWalk(cxCode, cxId, (node) => { if (node.type === "VariableDeclaration") { const declaration = node.declarations[0]; @@ -262,11 +260,11 @@ const plugin = async (options: PluginOptions = {}): Promise => { node.end, ` var caches = { - reset: ${stringifySet(caches.reset)}, - atomic: ${stringifyMap(caches.atomic)}, - hover: ${stringifyMap(caches.hover)}, - focus: ${stringifyMap(caches.focus)}, - active: ${stringifyMap(caches.active)}, + reset: new Set([${[...caches.reset].map((item) => `"${item}"`).join(",")}]), + atomic: ${toStringMap(caches.atomic)}, + hover: ${toStringMap(caches.hover)}, + focus: ${toStringMap(caches.focus)}, + active: ${toStringMap(caches.active)}, }; `.trim(), ); From 03836ed2fe4c2a48eef7b8c4b41fe1e83f847cd6 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 13:11:31 +0200 Subject: [PATCH 18/21] Fix type import --- src/vite-plugin.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 52fb3e5..140c339 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -3,7 +3,7 @@ import HTMLParser from "node-html-parser"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { type CallExpression, type ImportDeclaration } from "oxc-parser"; +import type { CallExpression, ImportDeclaration } from "oxc-parser"; import { ResolverFactory } from "oxc-resolver"; import { parseAndWalk } from "oxc-walker"; import type { Plugin, ResolvedConfig } from "vite"; @@ -12,6 +12,17 @@ type PluginOptions = { fileName?: string; }; +const findSpecifier = ( + { specifiers }: ImportDeclaration, + specifierName: "css" | "cx", +) => + specifiers.find( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === specifierName, + ); + const isCssMethod = ( { callee }: CallExpression, importName: string, @@ -24,17 +35,6 @@ const isCssMethod = ( callee.property.type === "Identifier" && callee.property.name === methodName; -const findSpecifier = ( - { specifiers }: ImportDeclaration, - specifierName: "css" | "cx", -) => - specifiers.find( - (specifier) => - specifier.type === "ImportSpecifier" && - specifier.imported.type === "Identifier" && - specifier.imported.name === specifierName, - ); - const normalizeConfig = (config: ResolvedConfig) => { const { build, configFile: file, resolve } = config; const input = build.rollupOptions.input ?? "index.html"; // fallback to vite's default From 6852c3973e18c58e10be9699926175fcb3e4698e Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 13:17:53 +0200 Subject: [PATCH 19/21] Fix method replacement when arg is missing --- src/vite-plugin.ts | 52 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 140c339..85134c9 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -179,16 +179,14 @@ const plugin = async (options: PluginOptions = {}): Promise => { } case "CallExpression": { - const fn = node.arguments[0]; - - if (fn == null) { - break; - } - if (isCssMethod(node, cssImportName, "extend")) { - if (fn.type === "ObjectExpression") { + const arg = node.arguments[0]; + + if (arg?.type === "ObjectExpression") { css.extend( - new Function(`return ${code.slice(fn.start, fn.end)};`)(), + new Function( + `return ${code.slice(arg.start, arg.end)};`, + )(), ); } @@ -196,18 +194,22 @@ const plugin = async (options: PluginOptions = {}): Promise => { } if (isCssMethod(node, cssImportName, "make")) { - if (fn.type === "ObjectExpression") { + const arg = node.arguments[0]; + + if (arg?.type === "ObjectExpression") { css.make( - new Function(`return ${code.slice(fn.start, fn.end)};`)(), + new Function( + `return ${code.slice(arg.start, arg.end)};`, + )(), ); } else if ( - fn.type === "ArrowFunctionExpression" || - fn.type === "FunctionExpression" + arg?.type === "ArrowFunctionExpression" || + arg?.type === "FunctionExpression" ) { css.make( new Function( "input", - `return (${code.slice(fn.start, fn.end)})(input);`, + `return (${code.slice(arg.start, arg.end)})(input);`, )(getCssMakeInput()), ); } @@ -320,17 +322,13 @@ var caches = { } case "CallExpression": { - const fn = node.arguments[0]; - - if (fn == null) { - break; - } - if (isCssMethod(node, cssImportName, "extend")) { - if (fn.type === "ObjectExpression") { + const arg = node.arguments[0]; + + if (arg?.type === "ObjectExpression") { const result = css.extend( new Function( - `return ${magicString.slice(fn.start, fn.end)};`, + `return ${magicString.slice(arg.start, arg.end)};`, )(), ); @@ -347,10 +345,12 @@ var caches = { } if (isCssMethod(node, cssImportName, "make")) { - if (fn.type === "ObjectExpression") { + const arg = node.arguments[0]; + + if (arg?.type === "ObjectExpression") { const result = css.make( new Function( - `return ${magicString.slice(fn.start, fn.end)};`, + `return ${magicString.slice(arg.start, arg.end)};`, )(), ); @@ -360,13 +360,13 @@ var caches = { JSON.stringify(result, null, 2), ); } else if ( - fn.type === "ArrowFunctionExpression" || - fn.type === "FunctionExpression" + arg?.type === "ArrowFunctionExpression" || + arg?.type === "FunctionExpression" ) { const result = css.make( new Function( "input", - `return (${magicString.slice(fn.start, fn.end)})(input);`, + `return (${magicString.slice(arg.start, arg.end)})(input);`, )(getCssMakeInput()), ); From 15b93e57516d24ce71e08ad0c452ec9699cd0e0d Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 13:35:16 +0200 Subject: [PATCH 20/21] Use switch / case --- src/css.ts | 92 ++++++++++++++++++++++++++--------------------- src/preprocess.ts | 25 ++++++++----- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/src/css.ts b/src/css.ts index f029d03..362443a 100644 --- a/src/css.ts +++ b/src/css.ts @@ -207,52 +207,64 @@ const insertAtomicRules = (style: Style): string => { let classNames = ""; forEach(style, (key, value) => { - if (key === ":hover") { - forEach(value as FlatStyle, (key, value) => { - const rule = stringifyRule(key, value); - const className = "h-" + hash(rule); - - if (!caches.hover.has(className)) { - hoverSheet.insertRule(`.${className}:hover{${rule}}`); - caches.hover.set(className, key); - } + switch (key) { + case ":hover": { + forEach(value as FlatStyle, (key, value) => { + const rule = stringifyRule(key, value); + const className = "h-" + hash(rule); - classNames = appendString(classNames, className); - }); - } else if (key === ":focus") { - forEach(value as FlatStyle, (key, value) => { - const rule = stringifyRule(key, value); - const className = "f-" + hash(rule); - - if (!caches.focus.has(className)) { - focusSheet.insertRule(`.${className}:focus-visible{${rule}}`); - caches.focus.set(className, key); - } + if (!caches.hover.has(className)) { + hoverSheet.insertRule(`.${className}:hover{${rule}}`); + caches.hover.set(className, key); + } - classNames = appendString(classNames, className); - }); - } else if (key === ":active") { - forEach(value as FlatStyle, (key, value) => { - const rule = stringifyRule(key, value); - const className = "a-" + hash(rule); - - if (!caches.active.has(className)) { - activeSheet.insertRule(`.${className}:active{${rule}}`); - caches.active.set(className, key); + classNames = appendString(classNames, className); + }); + + break; + } + case ":focus": { + forEach(value as FlatStyle, (key, value) => { + const rule = stringifyRule(key, value); + const className = "f-" + hash(rule); + + if (!caches.focus.has(className)) { + focusSheet.insertRule(`.${className}:focus-visible{${rule}}`); + caches.focus.set(className, key); + } + + classNames = appendString(classNames, className); + }); + + break; + } + case ":active": { + forEach(value as FlatStyle, (key, value) => { + const rule = stringifyRule(key, value); + const className = "a-" + hash(rule); + + if (!caches.active.has(className)) { + activeSheet.insertRule(`.${className}:active{${rule}}`); + caches.active.set(className, key); + } + + classNames = appendString(classNames, className); + }); + + break; + } + default: { + const rule = stringifyRule(key, value as string | number); + const className = "x-" + hash(rule); + + if (!caches.atomic.has(className)) { + atomicSheet.insertRule(`.${className}{${rule}}`); + caches.atomic.set(className, key); } classNames = appendString(classNames, className); - }); - } else { - const rule = stringifyRule(key, value as string | number); - const className = "x-" + hash(rule); - - if (!caches.atomic.has(className)) { - atomicSheet.insertRule(`.${className}{${rule}}`); - caches.atomic.set(className, key); + break; } - - classNames = appendString(classNames, className); } }); diff --git a/src/preprocess.ts b/src/preprocess.ts index ca0c0d6..d1e13e8 100644 --- a/src/preprocess.ts +++ b/src/preprocess.ts @@ -163,14 +163,23 @@ export const preprocessAtomicStyle = (style: Style): Style => { let active: FlatStyle | undefined = undefined; forEach(style, (key, value) => { - if (key === ":hover") { - hover = preprocessStyle(value as FlatStyle); - } else if (key === ":focus") { - focus = preprocessStyle(value as FlatStyle); - } else if (key === ":active") { - active = preprocessStyle(value as FlatStyle); - } else { - preprocessRule(output, key, value as ValueOf); + switch (key) { + case ":hover": { + hover = preprocessStyle(value as FlatStyle); + break; + } + case ":focus": { + focus = preprocessStyle(value as FlatStyle); + break; + } + case ":active": { + active = preprocessStyle(value as FlatStyle); + break; + } + default: { + preprocessRule(output, key, value as ValueOf); + break; + } } }); From 97d592931586626505de01033a7cc44dbc591ed8 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 18 Aug 2025 13:36:24 +0200 Subject: [PATCH 21/21] Newlines + no default break --- src/css.ts | 4 +++- src/preprocess.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/css.ts b/src/css.ts index 362443a..3b51178 100644 --- a/src/css.ts +++ b/src/css.ts @@ -223,6 +223,7 @@ const insertAtomicRules = (style: Style): string => { break; } + case ":focus": { forEach(value as FlatStyle, (key, value) => { const rule = stringifyRule(key, value); @@ -238,6 +239,7 @@ const insertAtomicRules = (style: Style): string => { break; } + case ":active": { forEach(value as FlatStyle, (key, value) => { const rule = stringifyRule(key, value); @@ -253,6 +255,7 @@ const insertAtomicRules = (style: Style): string => { break; } + default: { const rule = stringifyRule(key, value as string | number); const className = "x-" + hash(rule); @@ -263,7 +266,6 @@ const insertAtomicRules = (style: Style): string => { } classNames = appendString(classNames, className); - break; } } }); diff --git a/src/preprocess.ts b/src/preprocess.ts index d1e13e8..4df07ca 100644 --- a/src/preprocess.ts +++ b/src/preprocess.ts @@ -168,17 +168,19 @@ export const preprocessAtomicStyle = (style: Style): Style => { hover = preprocessStyle(value as FlatStyle); break; } + case ":focus": { focus = preprocessStyle(value as FlatStyle); break; } + case ":active": { active = preprocessStyle(value as FlatStyle); break; } + default: { preprocessRule(output, key, value as ValueOf); - break; } } });