diff --git a/AGENTS.md b/AGENTS.md
index 884bc01..970a0a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,6 +61,7 @@ Figma plugin that generates React/TypeScript components using `@devup-ui/react`
- **Never treat responsive arrays as default values** — Arrays bypass `isDefaultProp` filtering (`is-default-prop.ts:27`)
- **Never pass `effect` or `viewport` as component props** — Reserved internal variant keys, handled via pseudo-selectors/responsive arrays
+- **Never infer props, structure, or semantics from generated code strings** — Use `NodeTree`, variant metadata, slot metadata, or other structured intermediate data instead of regex/string scans over JSX/TS output
- **Never append rotation transforms** — Always replace entire value (`reaction.ts`)
- **Animation targets are not assets** — Nodes with `SMART_ANIMATE` reactions must not be exported as images (`check-asset-node.ts:35`)
- **Tile-mode fills are not images** — `PATTERN`/`TILE` fills are backgrounds, not exportable assets
diff --git a/bun.lock b/bun.lock
index dcaff3f..c6acaa5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,8 +10,8 @@
"devDependencies": {
"@biomejs/biome": "^2.4",
"@figma/plugin-typings": "^1.124",
- "@rspack/cli": "^1.7.11",
- "@rspack/core": "^1.7.11",
+ "@rspack/cli": "^2.0.0",
+ "@rspack/core": "^2.0.0",
"@types/bun": "^1.3",
"husky": "^9.1",
"typescript": "^6.0",
@@ -19,570 +19,100 @@
},
},
"packages": {
- "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
+ "@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="],
- "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
+ "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
- "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
-
- "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@figma/plugin-typings": ["@figma/plugin-typings@1.124.0", "", {}, "sha512-dZ7w5TKz8WGAncwev6G5UdX5UrMImnPw7QlSAh3vqOY1trdFL1PUKDtEpWRqB65hfMdfa8X0NuaGM0Z+2ak7QQ=="],
- "@jsonjoy.com/base64": ["@jsonjoy.com/base64@1.1.2", "", { "peerDependencies": { "tslib": "2" } }, "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA=="],
-
- "@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw=="],
-
- "@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@1.0.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g=="],
-
- "@jsonjoy.com/fs-core": ["@jsonjoy.com/fs-core@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA=="],
-
- "@jsonjoy.com/fs-fsa": ["@jsonjoy.com/fs-fsa@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA=="],
-
- "@jsonjoy.com/fs-node": ["@jsonjoy.com/fs-node@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-print": "4.57.1", "@jsonjoy.com/fs-snapshot": "4.57.1", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ=="],
-
- "@jsonjoy.com/fs-node-builtins": ["@jsonjoy.com/fs-node-builtins@4.57.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og=="],
-
- "@jsonjoy.com/fs-node-to-fsa": ["@jsonjoy.com/fs-node-to-fsa@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-fsa": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1" }, "peerDependencies": { "tslib": "2" } }, "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg=="],
-
- "@jsonjoy.com/fs-node-utils": ["@jsonjoy.com/fs-node-utils@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.57.1" }, "peerDependencies": { "tslib": "2" } }, "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw=="],
-
- "@jsonjoy.com/fs-print": ["@jsonjoy.com/fs-print@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-node-utils": "4.57.1", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw=="],
-
- "@jsonjoy.com/fs-snapshot": ["@jsonjoy.com/fs-snapshot@4.57.1", "", { "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", "@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg=="],
-
- "@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@1.21.0", "", { "dependencies": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.2.0", "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/json-pointer": "^1.0.2", "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg=="],
-
- "@jsonjoy.com/json-pointer": ["@jsonjoy.com/json-pointer@1.0.2", "", { "dependencies": { "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/util": "^1.9.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg=="],
-
- "@jsonjoy.com/util": ["@jsonjoy.com/util@1.9.0", "", { "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ=="],
-
- "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
- "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="],
+ "@rspack/binding": ["@rspack/binding@2.0.0", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.0.0", "@rspack/binding-darwin-x64": "2.0.0", "@rspack/binding-linux-arm64-gnu": "2.0.0", "@rspack/binding-linux-arm64-musl": "2.0.0", "@rspack/binding-linux-x64-gnu": "2.0.0", "@rspack/binding-linux-x64-musl": "2.0.0", "@rspack/binding-wasm32-wasi": "2.0.0", "@rspack/binding-win32-arm64-msvc": "2.0.0", "@rspack/binding-win32-ia32-msvc": "2.0.0", "@rspack/binding-win32-x64-msvc": "2.0.0" } }, "sha512-WA2f9eQpejkvf5Vrnf6wNCn1m8RT1p08NjgOZpKhsCzr0uBjWeRvGduawlrFFHZh/jPnWZTVaVdQ08FEAWbwGw=="],
- "@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="],
+ "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ICBHDKYyndFqljLhjxvKfWWZu39RJSH2jkSmbceXl0kmptLSE0cLWpvk+eGSzLqtxKN0jVchwCw+5P5mWCzwAw=="],
- "@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="],
+ "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.0.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YQ96LMmzIzhZt9cZWUDWXSxS9UWWHWoLxJyZ5f42DSaVPVelBg5ThbVORDwOP5QDA2xFXj60rVnmmcZLzg/aDA=="],
- "@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="],
+ "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ufn33gzkIV7JY69k6vJQEdOzRvBqThIgH46pwXksHSMwRZp8IbJhXfyYIAVsRWCk8fXpr9t1nAvCDvJXT2EeyA=="],
- "@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="],
+ "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-CZbvFKlNY9UC0C+Czz6i8JFCzGpuL9oX8gEqcJA1+84Y6eEEBH50UiTzeCewxKW3dOofkZdvT5vgNMXz6aMUmg=="],
- "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="],
+ "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dPjFGpoCvZfFpJBsWAUR+PR7mWYxpou6L026qIOpAVkz7WiTzErwKD3P1jVrpP4dM9yLb3fVE+PHHjTglhTJ4g=="],
- "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
+ "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4fgDTMWt0mJDiugdia2mdOjTbnm7yM1Drzl1JpPqlUlOr113byOhc+qgN57LURSGypz2yz/h/Zad7/UnVAxYJw=="],
- "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+ "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.0.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "1.1.4" }, "cpu": "none" }, "sha512-ANk73ZKtPrZf9gdtyRK2nQUfhi1uXoC5P2KF89pyVAE8+zcoLBnYtZGYpWa/cmNi5BcO5g4Z+v2l1UA3bUPLQQ=="],
- "@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
+ "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.0.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-IHZFRtJ85ONbM+BCtF4TeYXS2Fu9X0IJS2phX1rPibYq9iEtHGfBt4cNlnsJPhbPAXVvi4Oli/yiLRJ1zxtCIg=="],
- "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
+ "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.0.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-n4tbIqacq/FhNJflMlgZV50AeQFTLh5hnDS3v4W+rJWa3IW1VfgB0+XppdeW+Dqhw7QcMIsCmro01kwNdlXZDQ=="],
- "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
+ "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cJOgikIW2t3S+42TQZsv+DJriJt2m6lnUk+pUFu/fO93rrMvNrx8gfMxR8W5zDTreBX0cfMx2pw6EVmyi/YzsQ=="],
- "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
+ "@rspack/cli": ["@rspack/cli@2.0.0", "", { "peerDependencies": { "@rspack/core": "^2.0.0-0", "@rspack/dev-server": "^2.0.0-0" }, "optionalPeers": ["@rspack/dev-server"], "bin": { "rspack": "bin/rspack.js" } }, "sha512-drBB/biz6NHvjEqKLRzJmdPTn/6rg1TLLOY1o7lOySA1nLYoao5h+33TFRCKwpioEigpdAldw35crCLlQ/GHbQ=="],
- "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
-
- "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
-
- "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
-
- "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
-
- "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
-
- "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
-
- "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
-
- "@rspack/cli": ["@rspack/cli@1.7.11", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.7", "@rspack/dev-server": "~1.1.5", "exit-hook": "^4.0.0", "webpack-bundle-analyzer": "4.10.2" }, "peerDependencies": { "@rspack/core": "^1.0.0-alpha || ^1.x" }, "bin": { "rspack": "bin/rspack.js" } }, "sha512-vUnflkq4F654wTEpCd+L4RYVbet8L2lNqLMmAGIZvoZddlXm4Duvg+eqcFE9iF8plAjFflRcU7DhB7WZa76pwg=="],
-
- "@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
-
- "@rspack/dev-server": ["@rspack/dev-server@1.1.5", "", { "dependencies": { "chokidar": "^3.6.0", "http-proxy-middleware": "^2.0.9", "p-retry": "^6.2.0", "webpack-dev-server": "5.2.2", "ws": "^8.18.0" }, "peerDependencies": { "@rspack/core": "*" } }, "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A=="],
-
- "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="],
+ "@rspack/core": ["@rspack/core@2.0.0", "", { "dependencies": { "@rspack/binding": "2.0.0" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-WD1mJM9LbZ7Z399Rbv9dE3BNEV0+3sE5OzDdzV8hOxUb3mX++ynK5n9kil8w60B6nGdcKeV9ly5aN4PgqiwWUg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
- "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
-
- "@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="],
-
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
-
- "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
-
- "@types/connect-history-api-fallback": ["@types/connect-history-api-fallback@1.5.4", "", { "dependencies": { "@types/express-serve-static-core": "*", "@types/node": "*" } }, "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw=="],
-
- "@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
-
- "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="],
-
- "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
-
- "@types/http-proxy": ["@types/http-proxy@1.17.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw=="],
-
- "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
-
- "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
-
- "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
-
- "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
-
- "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
-
- "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
-
- "@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
-
- "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
-
- "@types/serve-index": ["@types/serve-index@1.9.4", "", { "dependencies": { "@types/express": "*" } }, "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug=="],
-
- "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
-
- "@types/sockjs": ["@types/sockjs@0.3.36", "", { "dependencies": { "@types/node": "*" } }, "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q=="],
-
- "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
-
- "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
-
- "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
-
- "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
-
- "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
-
- "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
-
- "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
-
- "ansi-html-community": ["ansi-html-community@0.0.8", "", { "bin": { "ansi-html": "bin/ansi-html" } }, "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw=="],
-
- "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
-
- "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
-
- "batch": ["batch@0.6.1", "", {}, "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw=="],
-
- "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
-
- "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
-
- "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
-
- "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
- "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
-
- "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
-
- "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
-
- "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
-
- "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
-
- "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
-
- "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
-
- "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
-
- "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
-
- "connect-history-api-fallback": ["connect-history-api-fallback@2.0.0", "", {}, "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA=="],
-
- "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
-
- "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
-
- "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
-
- "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
- "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="],
-
- "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
-
- "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="],
-
- "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
-
- "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
-
- "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
-
- "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
-
- "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
-
- "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
-
- "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
-
- "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="],
-
- "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
-
- "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
-
- "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
-
- "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
-
- "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
-
- "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
-
- "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
-
- "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
-
- "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
-
- "exit-hook": ["exit-hook@4.0.0", "", {}, "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ=="],
-
- "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
-
- "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
-
- "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
-
- "faye-websocket": ["faye-websocket@0.11.4", "", { "dependencies": { "websocket-driver": ">=0.5.1" } }, "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g=="],
-
- "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
-
- "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
-
- "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
-
- "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
-
- "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
-
- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
-
- "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
-
- "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
-
- "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
-
- "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
-
- "glob-to-regex.js": ["glob-to-regex.js@1.2.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ=="],
-
- "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
-
- "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
-
- "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
-
- "handle-thing": ["handle-thing@2.0.1", "", {}, "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg=="],
-
- "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
-
- "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
-
- "hpack.js": ["hpack.js@2.1.6", "", { "dependencies": { "inherits": "^2.0.1", "obuf": "^1.0.0", "readable-stream": "^2.0.1", "wbuf": "^1.1.0" } }, "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ=="],
-
- "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
-
- "http-deceiver": ["http-deceiver@1.2.7", "", {}, "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw=="],
-
- "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
-
- "http-parser-js": ["http-parser-js@0.5.10", "", {}, "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="],
-
- "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="],
-
- "http-proxy-middleware": ["http-proxy-middleware@2.0.9", "", { "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", "is-glob": "^4.0.1", "is-plain-obj": "^3.0.0", "micromatch": "^4.0.2" }, "peerDependencies": { "@types/express": "^4.17.13" }, "optionalPeers": ["@types/express"] }, "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q=="],
-
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
- "hyperdyperid": ["hyperdyperid@1.2.0", "", {}, "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A=="],
-
- "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
-
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
- "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
-
- "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
-
- "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
-
- "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
-
- "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
-
- "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
-
- "is-network-error": ["is-network-error@1.3.1", "", {}, "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw=="],
-
- "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
-
- "is-plain-obj": ["is-plain-obj@3.0.0", "", {}, "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="],
-
- "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
-
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
- "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
-
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
- "launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
-
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
- "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
-
- "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
-
- "memfs": ["memfs@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-fsa": "4.57.1", "@jsonjoy.com/fs-node": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-to-fsa": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-print": "4.57.1", "@jsonjoy.com/fs-snapshot": "4.57.1", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ=="],
-
- "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
-
- "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
-
- "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
-
- "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
-
- "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
-
- "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
-
- "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
-
- "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
-
- "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
-
- "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
-
- "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
-
- "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
-
- "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
-
- "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
-
- "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
-
- "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
-
- "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
-
- "open": ["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" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
-
- "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="],
-
- "p-retry": ["p-retry@6.2.1", "", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="],
-
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
- "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
-
- "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
-
- "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
-
- "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
-
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
- "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
-
- "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
-
- "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
-
- "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
-
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
- "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
-
- "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
-
- "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
-
- "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
-
- "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
-
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
- "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
-
- "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
-
- "select-hose": ["select-hose@2.0.0", "", {}, "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg=="],
-
- "selfsigned": ["selfsigned@2.4.1", "", { "dependencies": { "@types/node-forge": "^1.3.0", "node-forge": "^1" } }, "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q=="],
-
- "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
-
- "serve-index": ["serve-index@1.9.2", "", { "dependencies": { "accepts": "~1.3.8", "batch": "0.6.1", "debug": "2.6.9", "escape-html": "~1.0.3", "http-errors": "~1.8.0", "mime-types": "~2.1.35", "parseurl": "~1.3.3" } }, "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ=="],
-
- "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
-
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
- "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
-
- "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
-
- "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
-
- "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
-
- "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
-
- "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
-
- "sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="],
-
- "sockjs": ["sockjs@0.3.24", "", { "dependencies": { "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" } }, "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ=="],
-
- "spdy": ["spdy@4.0.2", "", { "dependencies": { "debug": "^4.1.0", "handle-thing": "^2.0.0", "http-deceiver": "^1.2.7", "select-hose": "^2.0.0", "spdy-transport": "^3.0.0" } }, "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA=="],
-
- "spdy-transport": ["spdy-transport@3.0.0", "", { "dependencies": { "debug": "^4.1.0", "detect-node": "^2.0.4", "hpack.js": "^2.1.6", "obuf": "^1.1.2", "readable-stream": "^3.0.6", "wbuf": "^1.7.3" } }, "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw=="],
-
- "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
-
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
- "thingies": ["thingies@2.6.0", "", { "peerDependencies": { "tslib": "^2" } }, "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg=="],
-
- "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
-
- "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
-
- "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
-
- "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
-
- "tree-dump": ["tree-dump@1.1.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA=="],
-
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
-
- "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
- "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
-
- "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+ "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
-
- "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
-
- "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
-
- "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
-
- "wbuf": ["wbuf@1.7.3", "", { "dependencies": { "minimalistic-assert": "^1.0.0" } }, "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA=="],
-
- "webpack-bundle-analyzer": ["webpack-bundle-analyzer@4.10.2", "", { "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", "commander": "^7.2.0", "debounce": "^1.2.1", "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { "webpack-bundle-analyzer": "lib/bin/analyzer.js" } }, "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw=="],
-
- "webpack-dev-middleware": ["webpack-dev-middleware@7.4.5", "", { "dependencies": { "colorette": "^2.0.10", "memfs": "^4.43.1", "mime-types": "^3.0.1", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "schema-utils": "^4.0.0" }, "peerDependencies": { "webpack": "^5.0.0" }, "optionalPeers": ["webpack"] }, "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA=="],
-
- "webpack-dev-server": ["webpack-dev-server@5.2.2", "", { "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", "@types/express": "^4.17.21", "@types/express-serve-static-core": "^4.17.21", "@types/serve-index": "^1.9.4", "@types/serve-static": "^1.15.5", "@types/sockjs": "^0.3.36", "@types/ws": "^8.5.10", "ansi-html-community": "^0.0.8", "bonjour-service": "^1.2.1", "chokidar": "^3.6.0", "colorette": "^2.0.10", "compression": "^1.7.4", "connect-history-api-fallback": "^2.0.0", "express": "^4.21.2", "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", "launch-editor": "^2.6.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", "selfsigned": "^2.4.1", "serve-index": "^1.9.1", "sockjs": "^0.3.24", "spdy": "^4.0.2", "webpack-dev-middleware": "^7.4.2", "ws": "^8.18.0" }, "peerDependencies": { "webpack": "^5.0.0" }, "optionalPeers": ["webpack"], "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" } }, "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg=="],
-
- "websocket-driver": ["websocket-driver@0.7.4", "", { "dependencies": { "http-parser-js": ">=0.5.1", "safe-buffer": ">=5.1.0", "websocket-extensions": ">=0.1.1" } }, "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg=="],
-
- "websocket-extensions": ["websocket-extensions@0.1.4", "", {}, "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg=="],
-
- "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
-
- "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
-
- "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="],
-
- "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="],
-
- "@jsonjoy.com/json-pack/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="],
-
- "@jsonjoy.com/util/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="],
-
- "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
-
- "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
-
- "compression/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
- "content-disposition/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
- "express/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
- "mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
-
- "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
-
- "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
- "serve-index/http-errors": ["http-errors@1.8.1", "", { "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.1" } }, "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g=="],
-
- "spdy/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
- "spdy-transport/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
- "spdy-transport/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
-
- "webpack-bundle-analyzer/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
-
- "webpack-dev-middleware/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
-
- "websocket-driver/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
- "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/base64": ["@jsonjoy.com/base64@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw=="],
-
- "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="],
-
- "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/json-pointer": ["@jsonjoy.com/json-pointer@17.67.0", "", { "dependencies": { "@jsonjoy.com/util": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA=="],
-
- "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="],
-
- "serve-index/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
-
- "serve-index/http-errors/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="],
-
- "spdy-transport/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
- "spdy/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
diff --git a/package.json b/package.json
index 397cdee..fa2a6c5 100644
--- a/package.json
+++ b/package.json
@@ -16,8 +16,8 @@
"license": "",
"devDependencies": {
"@figma/plugin-typings": "^1.124",
- "@rspack/cli": "^1.7.11",
- "@rspack/core": "^1.7.11",
+ "@rspack/cli": "^2.0.0",
+ "@rspack/core": "^2.0.0",
"husky": "^9.1",
"typescript": "^6.0",
diff --git a/src/__tests__/code-responsive.test.ts b/src/__tests__/code-responsive.test.ts
index 502979e..47310a8 100644
--- a/src/__tests__/code-responsive.test.ts
+++ b/src/__tests__/code-responsive.test.ts
@@ -6,7 +6,7 @@ import { ResponsiveCodegen } from '../codegen/responsive/ResponsiveCodegen'
const runMock = mock(async () => {})
const getComponentsCodesMock = mock(() => [])
const getCodeMock = mock(() => 'base-code')
-const generateResponsiveCodeMock = mock(() => {
+const generateResponsiveResultMock = mock(() => {
throw new Error('boom')
})
@@ -20,16 +20,16 @@ const resetFigma = () => {
const originalRun = Codegen.prototype.run
const originalGetComponentsCodes = Codegen.prototype.getComponentsCodes
const originalGetCode = Codegen.prototype.getCode
-const originalGenerateResponsiveCode =
- ResponsiveCodegen.prototype.generateResponsiveCode
+const originalGenerateResponsiveResult =
+ ResponsiveCodegen.prototype.generateResponsiveResult
describe('registerCodegen responsive error handling', () => {
beforeEach(() => {
Codegen.prototype.run = runMock as unknown as typeof Codegen.prototype.run
Codegen.prototype.getComponentsCodes = getComponentsCodesMock
Codegen.prototype.getCode = getCodeMock
- ResponsiveCodegen.prototype.generateResponsiveCode =
- generateResponsiveCodeMock
+ ResponsiveCodegen.prototype.generateResponsiveResult =
+ generateResponsiveResultMock
console.error = consoleErrorMock as typeof console.error
resetFigma()
@@ -39,8 +39,8 @@ describe('registerCodegen responsive error handling', () => {
Codegen.prototype.run = originalRun
Codegen.prototype.getComponentsCodes = originalGetComponentsCodes
Codegen.prototype.getCode = originalGetCode
- ResponsiveCodegen.prototype.generateResponsiveCode =
- originalGenerateResponsiveCode
+ ResponsiveCodegen.prototype.generateResponsiveResult =
+ originalGenerateResponsiveResult
console.error = originalError
resetFigma()
diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts
index 641e604..bb01905 100644
--- a/src/__tests__/code.test.ts
+++ b/src/__tests__/code.test.ts
@@ -50,6 +50,24 @@ afterEach(() => {
mock.restore()
})
+const componentCode = (
+ name: string,
+ metadata: {
+ devupImports?: string[]
+ customImports?: string[]
+ usesKeyframes?: boolean
+ },
+) =>
+ [
+ name,
+ '',
+ {
+ devupImports: metadata.devupImports ?? [],
+ customImports: metadata.customImports ?? [],
+ usesKeyframes: metadata.usesKeyframes ?? false,
+ },
+ ] as const
+
describe('runCommand', () => {
it.each([
['export-devup', ['json'], 'exportDevup'],
@@ -247,10 +265,10 @@ it('auto-runs on module load when figma is present', async () => {
describe('extractImports', () => {
it('should extract keyframes import when code contains keyframes(', () => {
const result = codeModule.extractImports([
- [
- 'AnimatedBox',
- '',
- ],
+ componentCode('AnimatedBox', {
+ devupImports: ['Box'],
+ usesKeyframes: true,
+ }),
])
expect(result).toContain('keyframes')
expect(result).toContain('Box')
@@ -258,7 +276,10 @@ describe('extractImports', () => {
it('should extract keyframes import when code contains keyframes`', () => {
const result = codeModule.extractImports([
- ['AnimatedBox', ''],
+ componentCode('AnimatedBox', {
+ usesKeyframes: true,
+ devupImports: ['Box'],
+ }),
])
expect(result).toContain('keyframes')
expect(result).toContain('Box')
@@ -266,7 +287,7 @@ describe('extractImports', () => {
it('should not extract keyframes when not present', () => {
const result = codeModule.extractImports([
- ['SimpleBox', ''],
+ componentCode('SimpleBox', { devupImports: ['Box'] }),
])
expect(result).not.toContain('keyframes')
expect(result).toContain('Box')
@@ -276,7 +297,10 @@ describe('extractImports', () => {
describe('extractCustomComponentImports', () => {
it('should extract custom component imports', () => {
const result = codeModule.extractCustomComponentImports([
- ['MyComponent', ''],
+ componentCode('MyComponent', {
+ devupImports: ['Box'],
+ customImports: ['CustomButton', 'CustomInput'],
+ }),
])
expect(result).toContain('CustomButton')
expect(result).toContain('CustomInput')
@@ -286,10 +310,10 @@ describe('extractCustomComponentImports', () => {
it('should not include devup-ui components', () => {
const result = codeModule.extractCustomComponentImports([
- [
- 'MyComponent',
- '',
- ],
+ componentCode('MyComponent', {
+ devupImports: ['Box', 'Flex', 'VStack'],
+ customImports: ['CustomCard'],
+ }),
])
expect(result).toContain('CustomCard')
expect(result).not.toContain('Box')
@@ -299,29 +323,40 @@ describe('extractCustomComponentImports', () => {
it('should return empty array when no custom components', () => {
const result = codeModule.extractCustomComponentImports([
- ['MyComponent', 'Hello'],
+ componentCode('MyComponent', { devupImports: ['Box', 'Flex', 'Text'] }),
])
expect(result).toEqual([])
})
it('should sort custom components alphabetically', () => {
const result = codeModule.extractCustomComponentImports([
- ['MyComponent', ''],
+ componentCode('MyComponent', {
+ customImports: ['Zebra', 'Apple', 'Mango'],
+ }),
])
expect(result).toEqual(['Apple', 'Mango', 'Zebra'])
})
it('should handle multiple components with same custom component', () => {
const result = codeModule.extractCustomComponentImports([
- ['ComponentA', ''],
- ['ComponentB', ''],
+ componentCode('ComponentA', {
+ devupImports: ['Box'],
+ customImports: ['SharedButton'],
+ }),
+ componentCode('ComponentB', {
+ devupImports: ['Flex'],
+ customImports: ['SharedButton'],
+ }),
])
expect(result).toEqual(['SharedButton'])
})
it('should handle nested custom components', () => {
const result = codeModule.extractCustomComponentImports([
- ['Parent', ''],
+ componentCode('Parent', {
+ devupImports: ['Box'],
+ customImports: ['ChildA', 'ChildB', 'ChildC'],
+ }),
])
expect(result).toContain('ChildA')
expect(result).toContain('ChildB')
diff --git a/src/code-impl.ts b/src/code-impl.ts
index 4a7956d..09dcb8f 100644
--- a/src/code-impl.ts
+++ b/src/code-impl.ts
@@ -1,5 +1,6 @@
import {
Codegen,
+ DEFAULT_CODEGEN_OPTIONS,
resetGlobalBuildTreeCache,
resetMainComponentCache,
} from './codegen/Codegen'
@@ -10,9 +11,21 @@ import {
sanitizePropertyName,
} from './codegen/props/selector'
import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
+import {
+ coerceBooleanVariantValue,
+ isBooleanVariantOptions,
+} from './codegen/utils/boolean-variant'
+import { resetCheckAssetNodeCache } from './codegen/utils/check-asset-node'
+import { resetCheckSameColorCache } from './codegen/utils/check-same-color'
+import type { ImportMetadata } from './codegen/utils/collect-import-metadata'
import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props'
-import { getComponentPropertyDefinitions } from './codegen/utils/get-component-property-definitions'
+import {
+ getComponentPropertyDefinitions,
+ resetComponentPropertyDefinitionsCache,
+} from './codegen/utils/get-component-property-definitions'
+import { resetGetPageNodeCache } from './codegen/utils/get-page-node'
import { nodeProxyTracker } from './codegen/utils/node-proxy'
+import { resetPaintToCssCache } from './codegen/utils/paint-to-css'
import { perfEnd, perfReport, perfReset, perfStart } from './codegen/utils/perf'
import { resetVariableCache } from './codegen/utils/variable-cache'
import { wrapComponent } from './codegen/utils/wrap-component'
@@ -30,8 +43,10 @@ export { extractCustomComponentImports, extractImports }
import { getComponentName, resetTextStyleCache } from './utils'
import { toPascal } from './utils/to-pascal'
+type GeneratedCodeEntry = readonly [string, string, ImportMetadata?]
+
function generateImportStatements(
- componentsCodes: ReadonlyArray,
+ componentsCodes: ReadonlyArray,
): string {
const devupImports = extractImports(componentsCodes)
const customImports = extractCustomComponentImports(componentsCodes)
@@ -54,7 +69,7 @@ function generateImportStatements(
}
function generateBashCLI(
- componentsCodes: ReadonlyArray,
+ componentsCodes: ReadonlyArray,
): string {
const importStatement = generateImportStatements(componentsCodes)
@@ -72,7 +87,7 @@ function generateBashCLI(
}
function generatePowerShellCLI(
- componentsCodes: ReadonlyArray,
+ componentsCodes: ReadonlyArray,
): string {
const importStatement = generateImportStatements(componentsCodes)
@@ -172,10 +187,14 @@ export function generateComponentUsage(node: SceneNode): string | null {
if (isReservedVariantKey(key)) continue
const sanitizedKey = sanitizePropertyName(key)
if (def.type === 'VARIANT') {
+ const defaultValue = String(def.defaultValue)
+ const isBooleanVariant = isBooleanVariantOptions(
+ def.variantOptions || [],
+ )
entries.push({
key: sanitizedKey,
- value: String(def.defaultValue),
- type: 'VARIANT',
+ value: String(coerceBooleanVariantValue(defaultValue)),
+ type: isBooleanVariant ? 'BOOLEAN' : 'VARIANT',
})
} else if (def.type === 'BOOLEAN') {
if (def.defaultValue) {
@@ -234,12 +253,17 @@ export function registerCodegen(ctx: typeof figma) {
resetSelectorPropsCache()
resetChildAnimationCache()
resetVariableCache()
+ resetCheckAssetNodeCache()
+ resetCheckSameColorCache()
+ resetPaintToCssCache()
+ resetGetPageNodeCache()
+ resetComponentPropertyDefinitionsCache()
resetTextStyleCache()
resetMainComponentCache()
resetGlobalBuildTreeCache()
let t = perfStart()
- const codegen = new Codegen(node)
+ const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS)
await codegen.run()
perfEnd('Codegen.run()', t)
@@ -248,9 +272,7 @@ export function registerCodegen(ctx: typeof figma) {
perfEnd('getComponentsCodes()', t)
// Generate responsive component codes with variant support
- let responsiveComponentsCodes: ReadonlyArray<
- readonly [string, string]
- > = []
+ let responsiveComponentsCodes: ReadonlyArray = []
if (node.type === 'COMPONENT_SET') {
const componentName = getComponentName(node)
// Reset the global build tree cache so that each variant's Codegen
@@ -264,6 +286,7 @@ export function registerCodegen(ctx: typeof figma) {
await ResponsiveCodegen.generateVariantResponsiveComponents(
node,
componentName,
+ DEFAULT_CODEGEN_OPTIONS,
)
perfEnd('generateVariantResponsiveComponents(COMPONENT_SET)', t)
}
@@ -273,9 +296,7 @@ export function registerCodegen(ctx: typeof figma) {
// because the self-referencing componentTree would trigger the parent
// COMPONENT_SET to be fully expanded — producing ComponentSet-level output
// when the user only wants to see their selected variant.
- let componentsResponsiveCodes: ReadonlyArray<
- readonly [string, string]
- > = []
+ let componentsResponsiveCodes: ReadonlyArray = []
if (
componentsCodes.length > 0 &&
node.type !== 'COMPONENT' &&
@@ -283,7 +304,7 @@ export function registerCodegen(ctx: typeof figma) {
) {
const componentNodes = codegen.getComponentNodes()
const processedComponentSets = new Set()
- const responsiveResults: Array = []
+ const responsiveResults: Array = []
for (const componentNode of componentNodes) {
// Check if the component belongs to a COMPONENT_SET
@@ -303,6 +324,7 @@ export function registerCodegen(ctx: typeof figma) {
await ResponsiveCodegen.generateVariantResponsiveComponents(
parentSet,
componentName,
+ DEFAULT_CODEGEN_OPTIONS,
)
perfEnd(
`generateVariantResponsiveComponents(${componentName})`,
@@ -330,9 +352,12 @@ export function registerCodegen(ctx: typeof figma) {
if (sectionNode) {
try {
- const responsiveCodegen = new ResponsiveCodegen(sectionNode)
- const responsiveCode =
- await responsiveCodegen.generateResponsiveCode()
+ const responsiveCodegen = new ResponsiveCodegen(
+ sectionNode,
+ DEFAULT_CODEGEN_OPTIONS,
+ )
+ const { code: responsiveCode, imports } =
+ await responsiveCodegen.generateResponsiveResult()
const baseName = toPascal(sectionNode.name)
const sectionComponentName = isParentSection
? `${baseName}Page`
@@ -342,9 +367,9 @@ export function registerCodegen(ctx: typeof figma) {
responsiveCode,
{ exportDefault: isParentSection },
)
- const sectionCodes: ReadonlyArray = [
- [sectionComponentName, wrappedCode],
- ]
+ const sectionCodes: ReadonlyArray<
+ readonly [string, string, typeof imports]
+ > = [[sectionComponentName, wrappedCode, imports]]
const importStatement = generateImportStatements(sectionCodes)
const fullCode = importStatement + wrappedCode
@@ -419,22 +444,16 @@ export function registerCodegen(ctx: typeof figma) {
}
// Merge component codes: responsive/variant versions override simple ones.
- const responsiveOverrides = new Map<
- string,
- readonly [string, string]
- >()
+ const responsiveOverrides = new Map()
for (const entry of componentsResponsiveCodes)
responsiveOverrides.set(entry[0], entry)
for (const entry of responsiveComponentsCodes)
responsiveOverrides.set(entry[0], entry)
- const mergedComponentsCodes: ReadonlyArray<
- readonly [string, string]
- > =
+ const mergedComponentsCodes: ReadonlyArray =
componentsCodes.length > 0 && responsiveOverrides.size > 0
? componentsCodes.map(
- ([name, code]) =>
- responsiveOverrides.get(name) ?? ([name, code] as const),
+ (entry) => responsiveOverrides.get(entry[0]) ?? entry,
)
: componentsCodes
diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts
index 73dd944..31f652c 100644
--- a/src/codegen/Codegen.ts
+++ b/src/codegen/Codegen.ts
@@ -8,8 +8,14 @@ import { renderComponent, renderNode } from './render'
import { renderText } from './render/text'
import type { ComponentTree, NodeTree } from './types'
import { addPx } from './utils/add-px'
-import { checkAssetNode } from './utils/check-asset-node'
+import { analyzeAssetNode, checkAssetNode } from './utils/check-asset-node'
import { checkSameColor } from './utils/check-same-color'
+import { collectComponentProps } from './utils/collect-component-props'
+import {
+ collectImportMetadataFromTree,
+ type ImportMetadata,
+ mergeImportMetadata,
+} from './utils/collect-import-metadata'
import { extractInstanceVariantProps } from './utils/extract-instance-variant-props'
import { getComponentPropertyDefinitions } from './utils/get-component-property-definitions'
import {
@@ -19,8 +25,17 @@ import {
import { getPageNode } from './utils/get-page-node'
import { paddingLeftMultiline } from './utils/padding-left-multiline'
import { perfEnd, perfStart } from './utils/perf'
+import { propsToString } from './utils/props-to-str'
import { buildCssUrl } from './utils/wrap-url'
+export interface CodegenOptions {
+ assetComponentInstanceMode?: 'reference' | 'inline'
+}
+
+export const DEFAULT_CODEGEN_OPTIONS: CodegenOptions = {
+ assetComponentInstanceMode: 'inline',
+}
+
// Global cache for node.getMainComponentAsync() results.
// Multiple Codegen instances (from ResponsiveCodegen) process the same INSTANCE nodes,
// each calling getMainComponentAsync which is an expensive Figma IPC call.
@@ -259,9 +274,46 @@ function cloneTree(tree: NodeTree): NodeTree {
isSlot: tree.isSlot,
condition: tree.condition,
textChildren: tree.textChildren,
+ leadingComment: tree.leadingComment,
}
}
+function isAssetLeafTree(tree: NodeTree): boolean {
+ return (
+ tree.children.length === 0 &&
+ ((tree.component === 'Image' && typeof tree.props.src === 'string') ||
+ (tree.component === 'Box' && typeof tree.props.maskImage === 'string'))
+ )
+}
+
+function applyAssetNodeSize(
+ tree: NodeTree,
+ node: SceneNode,
+): Record {
+ const props = { ...tree.props }
+
+ if (node.width === node.height) {
+ props.boxSize = addPx(node.width)
+ delete props.w
+ delete props.h
+ } else {
+ props.w = addPx(node.width)
+ props.h = addPx(node.height)
+ delete props.boxSize
+ }
+
+ return props
+}
+
+function buildComponentReferenceComment(
+ componentName: string,
+ props: Record,
+): string {
+ const propsString = propsToString(props)
+ const normalizedProps = propsString.replace(/\s+/g, ' ').trim()
+ return `<${componentName}${normalizedProps ? ` ${normalizedProps}` : ''} />`
+}
+
export class Codegen {
components: Map<
string,
@@ -270,6 +322,7 @@ export class Codegen {
code: string
variants: Record
variantComments?: Record
+ imports: ImportMetadata
}
> = new Map()
code: string = ''
@@ -283,7 +336,10 @@ export class Codegen {
// Collect fire-and-forget addComponentTree promises so we can await them
// before rendering component codes (decouples INSTANCE buildTree from addComponentTree)
- constructor(private node: SceneNode) {
+ constructor(
+ private node: SceneNode,
+ private options: CodegenOptions = DEFAULT_CODEGEN_OPTIONS,
+ ) {
this.node = node
// if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') {
// this.node = node.parent
@@ -314,17 +370,19 @@ export class Codegen {
}
getComponentsCodes() {
- const result: Array = []
+ const result: Array = []
for (const {
node,
code,
variants,
variantComments,
+ imports,
} of this.components.values()) {
const name = getComponentName(node)
result.push([
name,
renderComponent(name, code, variants, variantComments),
+ imports,
])
}
return result
@@ -366,11 +424,30 @@ export class Codegen {
// Sync componentTrees to components
for (const [compId, compTree] of this.componentTrees) {
if (!this.components.has(compId)) {
+ const variants = { ...compTree.variants }
+ const collectedProps = collectComponentProps(compTree.tree)
+ for (const propName of collectedProps.booleanProps) {
+ if (!variants[propName]) {
+ variants[propName] = 'boolean'
+ }
+ }
+ for (const propName of collectedProps.textProps) {
+ if (!variants[propName]) {
+ variants[propName] = 'string'
+ }
+ }
+ for (const propName of collectedProps.slotProps) {
+ if (!variants[propName]) {
+ variants[propName] = 'React.ReactNode'
+ }
+ }
+
this.components.set(compId, {
node: compTree.node,
code: Codegen.renderTree(compTree.tree, 0),
- variants: compTree.variants,
+ variants,
variantComments: compTree.variantComments,
+ imports: collectImportMetadataFromTree(compTree.tree, compTree.name),
})
}
}
@@ -415,6 +492,7 @@ export class Codegen {
private async doBuildTree(node: SceneNode): Promise {
const tBuild = perfStart()
+ let pendingAddComponentTreeNode: ComponentNode | null = null
// Handle COMPONENT_SET or COMPONENT — fire addComponentTree BEFORE any early returns
// (e.g., asset detection) so that BOOLEAN conditions and INSTANCE_SWAP slots are always
@@ -425,10 +503,8 @@ export class Codegen {
node === this.node.defaultVariant) ||
this.node.type === 'COMPONENT')
) {
- // Fire-and-forget — errors collected via addComponentTreePromises in run().
- this.addComponentTree(
- node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
- )
+ pendingAddComponentTreeNode =
+ node.type === 'COMPONENT_SET' ? node.defaultVariant : node
}
// Handle native Figma SLOT nodes — render as {slotName} in the component.
@@ -452,6 +528,54 @@ export class Codegen {
// instances (containing only vectors) would otherwise be misclassified as SVG assets.
if (node.type === 'INSTANCE') {
const mainComponent = await getMainComponentCached(node)
+ const variantProps = extractInstanceVariantProps(node)
+
+ if (
+ this.options.assetComponentInstanceMode === 'inline' &&
+ mainComponent
+ ) {
+ const inlineTree = cloneTree(await this.buildTree(mainComponent))
+ this.addComponentTree(mainComponent)
+
+ if (isAssetLeafTree(inlineTree)) {
+ inlineTree.props = applyAssetNodeSize(inlineTree, node)
+ inlineTree.nodeType = node.type
+ inlineTree.nodeName = node.name
+ inlineTree.leadingComment = buildComponentReferenceComment(
+ getComponentName(mainComponent),
+ variantProps,
+ )
+
+ const posProps = getPositionProps(node)
+ if (posProps?.pos) {
+ const transformProps = getTransformProps(node)
+ perfEnd('buildTree()', tBuild)
+ return {
+ component: 'Box',
+ props: {
+ pos: posProps.pos,
+ top: posProps.top,
+ left: posProps.left,
+ right: posProps.right,
+ bottom: posProps.bottom,
+ transform: posProps.transform || transformProps?.transform,
+ w:
+ (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode)
+ ?.width === node.width
+ ? '100%'
+ : undefined,
+ },
+ children: [inlineTree],
+ nodeType: 'WRAPPER',
+ nodeName: `${node.name}_wrapper`,
+ }
+ }
+
+ perfEnd('buildTree()', tBuild)
+ return inlineTree
+ }
+ }
+
// Fire addComponentTree without awaiting — it runs in the background.
// All pending promises are collected and awaited in run() before rendering.
if (mainComponent) {
@@ -459,7 +583,6 @@ export class Codegen {
}
const componentName = getComponentName(mainComponent || node)
- const variantProps = extractInstanceVariantProps(node)
// Check for native SLOT children and build their overridden content.
// SLOT children contain the content placed into the component's slot.
@@ -505,7 +628,13 @@ export class Codegen {
}
jsx = `<>\n${childrenStr}\n>`
}
- variantProps[slotName] = { __jsxSlot: true, jsx }
+ variantProps[slotName] = {
+ __jsxSlot: true,
+ __imports: mergeImportMetadata(
+ content.map((tree) => collectImportMetadataFromTree(tree)),
+ ),
+ jsx,
+ }
}
}
@@ -558,6 +687,10 @@ export class Codegen {
// Handle asset nodes (images/SVGs)
const assetNode = checkAssetNode(node)
if (assetNode) {
+ const assetAnalysis = await analyzeAssetNode(node)
+ if (pendingAddComponentTreeNode) {
+ await this.addComponentTree(pendingAddComponentTreeNode)
+ }
// Register in global asset registry for export commands
const assetKey = `${assetNode}/${node.name}`
if (!globalAssetNodes.has(assetKey)) {
@@ -569,7 +702,10 @@ export class Codegen {
const props: Record = { ...baseProps }
props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
if (assetNode === 'svg') {
- const maskColor = await checkSameColor(node)
+ const maskColor =
+ node.type === 'COMPONENT'
+ ? await checkSameColor(node)
+ : assetAnalysis.sameColor
if (maskColor) {
props.maskImage = buildCssUrl(props.src as string)
props.maskRepeat = 'no-repeat'
@@ -618,6 +754,10 @@ export class Codegen {
}
}
+ if (pendingAddComponentTreeNode) {
+ await this.addComponentTree(pendingAddComponentTreeNode)
+ }
+
// Now await props (likely already resolved while children were processing)
const baseProps = await propsPromise
@@ -926,6 +1066,11 @@ export class Codegen {
result = renderNode(tree.component, tree.props, depth, childrenCodes)
}
+ if (tree.leadingComment) {
+ const comment = `${' '.repeat(depth)}{/* ${tree.leadingComment} */}`
+ result = `${comment}\n${result}`
+ }
+
// Wrap with BOOLEAN conditional rendering if needed
if (tree.condition) {
if (result.includes('\n')) {
diff --git a/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap
new file mode 100644
index 0000000..bcca412
--- /dev/null
+++ b/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap
@@ -0,0 +1,40 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`Codegen effect-only COMPONENT_SET generates snapshot for asset-like Status ComponentSet as Image src variants 1`] = `
+"export interface StatusProps {
+ status: 'success' | 'warning' | 'error'
+}
+
+export function Status({ status }: StatusProps) {
+ return (
+
+ )
+}"
+`;
+
+exports[`Codegen effect-only COMPONENT_SET generates boolean prop for true/false variant component sets 1`] = `
+"export interface PrivacyProps {
+ property1?: boolean
+}
+
+export function Privacy({ property1 }: PrivacyProps) {
+ return (
+
+ )
+}"
+`;
diff --git a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
index fef14a6..5e81de9 100644
--- a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
+++ b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
@@ -3982,3 +3982,36 @@ exports[`render real world component real world $ 109`] = `
)
}"
`;
+
+exports[`Codegen Tree Methods buildTree preserves extracted component declaration while inlining commented asset instance usage: inline instance usage keeps commented reference 1`] = `
+"{/* */}
+"
+`;
+
+exports[`Codegen Tree Methods buildTree preserves extracted component declaration while inlining commented asset instance usage: inline instance usage keeps extracted Status declaration 1`] = `
+"export interface StatusProps {
+ status: 'success' | 'warning' | 'error'
+}
+
+export function Status({ status }: StatusProps) {
+ return (
+
+ )
+}"
+`;
diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts
index 7422bec..915cfef 100644
--- a/src/codegen/__tests__/codegen-viewport.test.ts
+++ b/src/codegen/__tests__/codegen-viewport.test.ts
@@ -895,6 +895,241 @@ describe('Codegen effect-only COMPONENT_SET', () => {
expect(generatedCode).toMatch(/leftIcon\s*&&/)
})
+ test('generates snapshot for asset-like Status ComponentSet as Image src variants', async () => {
+ function createVectorChild(
+ id: string,
+ name: string,
+ fills: Paint[],
+ overrides: Record = {},
+ ): SceneNode {
+ return {
+ type: 'VECTOR',
+ id,
+ name,
+ visible: true,
+ isAsset: true,
+ fills,
+ strokes: [],
+ effects: [],
+ reactions: [],
+ width: 20,
+ height: 20,
+ ...overrides,
+ } as unknown as SceneNode
+ }
+
+ function createStatusComponent(status: 'success' | 'warning' | 'error') {
+ const solid = (r: number, g: number, b: number): Paint => ({
+ type: 'SOLID',
+ visible: true,
+ color: { r, g, b },
+ opacity: 1,
+ })
+
+ const children: SceneNode[] = [
+ createVectorChild(`status-${status}-bg`, 'Ellipse 128', [
+ solid(
+ status === 'success'
+ ? 0.0549019608
+ : status === 'warning'
+ ? 1
+ : 0.8784313725,
+ status === 'success'
+ ? 0.6352941176
+ : status === 'warning'
+ ? 0.7137254902
+ : 0.0784313725,
+ status === 'success'
+ ? 0.4705882353
+ : status === 'warning'
+ ? 0.1411764706
+ : 0.2666666667,
+ ),
+ ]),
+ createVectorChild(`status-${status}-fg`, 'Glyph', [solid(1, 1, 1)]),
+ ]
+
+ return createComponentNode(
+ `status=${status}`,
+ { status },
+ {
+ id: `status-${status}`,
+ children: children as unknown as readonly SceneNode[],
+ width: 24,
+ height: 24,
+ layoutMode: 'NONE',
+ },
+ )
+ }
+
+ const success = createStatusComponent('success')
+ const warning = createStatusComponent('warning')
+ const error = createStatusComponent('error')
+
+ const componentSet = createComponentSetNode(
+ 'Status',
+ {
+ status: {
+ type: 'VARIANT',
+ defaultValue: 'success',
+ variantOptions: ['success', 'warning', 'error'],
+ },
+ },
+ [success, warning, error],
+ )
+
+ for (const child of [success, warning, error]) {
+ ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet
+ if ('children' in child && child.children) {
+ for (const grandchild of child.children) {
+ ;(grandchild as unknown as { parent: SceneNode }).parent = child
+ }
+ }
+ }
+
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Status',
+ )
+
+ expect(codes).toHaveLength(1)
+ expect(codes[0]?.[1]).toMatchSnapshot()
+ })
+
+ test('generates boolean prop for true/false variant component sets', async () => {
+ function createPrivacyComponent(
+ value: 'true' | 'false',
+ assetName: string,
+ ) {
+ return createComponentNode(
+ `속성 1=${value}`,
+ { '속성 1': value },
+ {
+ id: `privacy-${value}`,
+ children: [
+ {
+ type: 'VECTOR',
+ id: `${assetName}-vector`,
+ name: 'Vector',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0.42, g: 0.44, b: 0.5 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ] as unknown as readonly SceneNode[],
+ width: 20,
+ height: 20,
+ layoutMode: 'NONE',
+ isAsset: true,
+ },
+ )
+ }
+
+ const falseVariant = createPrivacyComponent('false', '속성 1=false')
+ const trueVariant = createPrivacyComponent('true', '속성 1=true')
+
+ const componentSet = createComponentSetNode(
+ 'Privacy',
+ {
+ '속성 1': {
+ type: 'VARIANT',
+ defaultValue: 'false',
+ variantOptions: ['false', 'true'],
+ },
+ },
+ [falseVariant, trueVariant],
+ )
+
+ ;(falseVariant as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+ ;(trueVariant as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Privacy',
+ )
+
+ expect(codes).toHaveLength(1)
+ expect(codes[0]?.[1]).toContain('property1?: boolean')
+ expect(codes[0]?.[1]).toContain('[property1 ?? false]')
+ expect(codes[0]?.[1]).toMatchSnapshot()
+ })
+
+ test('generates boolean prop for on/off variant component sets', async () => {
+ function createToggleComponent(value: 'on' | 'off', assetName: string) {
+ return createComponentNode(
+ `state=${value}`,
+ { state: value },
+ {
+ id: `toggle-${value}`,
+ children: [
+ {
+ type: 'VECTOR',
+ id: `${assetName}-vector`,
+ name: 'Vector',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0.42, g: 0.44, b: 0.5 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ] as unknown as readonly SceneNode[],
+ width: 20,
+ height: 20,
+ layoutMode: 'NONE',
+ isAsset: true,
+ },
+ )
+ }
+
+ const offVariant = createToggleComponent('off', 'toggle-off')
+ const onVariant = createToggleComponent('on', 'toggle-on')
+
+ const componentSet = createComponentSetNode(
+ 'Toggle',
+ {
+ state: {
+ type: 'VARIANT',
+ defaultValue: 'off',
+ variantOptions: ['off', 'on'],
+ },
+ },
+ [offVariant, onVariant],
+ )
+
+ ;(offVariant as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+ ;(onVariant as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+
+ const codes = await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Toggle',
+ )
+
+ expect(codes).toHaveLength(1)
+ expect(codes[0]?.[1]).toContain('state?: boolean')
+ expect(codes[0]?.[1]).toContain('[state ?? false]')
+ })
+
test('generates BOOLEAN conditions on INSTANCE asset children in multi-variant ComponentSet', async () => {
// Mirrors the real Figma Button structure where:
// - size: lg, md, sm, tag (VARIANT)
diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts
index 8579a4a..942e8b4 100644
--- a/src/codegen/__tests__/codegen.test.ts
+++ b/src/codegen/__tests__/codegen.test.ts
@@ -1,9 +1,10 @@
import { afterAll, describe, expect, it, test } from 'bun:test'
import { getComponentName } from '../../utils'
import { toPascal } from '../../utils/to-pascal'
-import { Codegen } from '../Codegen'
+import { Codegen, DEFAULT_CODEGEN_OPTIONS } from '../Codegen'
import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
import type { NodeTree } from '../types'
+import { collectComponentProps } from '../utils/collect-component-props'
import { assembleNodeTree, type NodeData } from '../utils/node-proxy'
import { wrapComponent } from '../utils/wrap-component'
@@ -3281,6 +3282,396 @@ describe('Codegen Tree Methods', () => {
expect(tree.props).toEqual({})
})
+ test('inlines asset-only INSTANCE when inline mode is enabled', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'status=error',
+ children: [
+ {
+ type: 'VECTOR',
+ name: 'Glyph',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ],
+ visible: true,
+ width: 24,
+ height: 24,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ layoutMode: 'NONE',
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'Status',
+ visible: true,
+ width: 24,
+ height: 24,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+ addParent(instanceNode)
+
+ const codegen = new Codegen(instanceNode, DEFAULT_CODEGEN_OPTIONS)
+ const tree = await codegen.buildTree()
+
+ expect(['Image', 'Box']).toContain(tree.component)
+ expect(tree.isComponent).toBeUndefined()
+ expect(
+ tree.props.src === '/icons/status=error.svg' ||
+ tree.props.maskImage === 'url(/icons/status=error.svg)',
+ ).toBe(true)
+ expect(tree.props.boxSize).toBe('24px')
+ expect(tree.leadingComment).toContain(' name.includes('Status'))).toBe(true)
+ })
+
+ test('keeps component reference for asset-only INSTANCE when reference mode is selected', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'status=error',
+ children: [
+ {
+ type: 'VECTOR',
+ name: 'Glyph',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ],
+ visible: true,
+ width: 24,
+ height: 24,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ layoutMode: 'NONE',
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'Status',
+ visible: true,
+ width: 24,
+ height: 24,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+ addParent(instanceNode)
+
+ const codegen = new Codegen(instanceNode, {
+ assetComponentInstanceMode: 'reference',
+ })
+ const tree = await codegen.buildTree()
+
+ expect(tree.component).toContain('Status')
+ expect(tree.isComponent).toBe(true)
+ expect(tree.props).toEqual({})
+ })
+
+ test('does not inline multi-node asset-like component instances', async () => {
+ const mainComponent = {
+ type: 'COMPONENT',
+ name: 'trend=up',
+ children: [
+ {
+ type: 'VECTOR',
+ name: 'Icon',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ {
+ type: 'TEXT',
+ name: '43.8%',
+ visible: true,
+ characters: '43.8%',
+ getStyledTextSegments: () => [createTextSegment('43.8%')],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ },
+ ],
+ visible: true,
+ width: 73,
+ height: 26,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 4,
+ } as unknown as ComponentNode
+ addParent(mainComponent)
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ name: 'Trend',
+ visible: true,
+ width: 73,
+ height: 26,
+ getMainComponentAsync: async () => mainComponent,
+ } as unknown as InstanceNode
+ addParent(instanceNode)
+
+ const codegen = new Codegen(instanceNode, DEFAULT_CODEGEN_OPTIONS)
+ const tree = await codegen.buildTree()
+
+ expect(tree.isComponent).toBe(true)
+ expect(tree.component).toContain('Trend')
+ expect(tree.leadingComment).toBeUndefined()
+ })
+
+ test('preserves extracted component declaration while inlining commented asset instance usage', async () => {
+ const successComponent = {
+ type: 'COMPONENT',
+ id: 'status-success',
+ name: 'status=success',
+ variantProperties: { status: 'success' },
+ children: [
+ {
+ type: 'VECTOR',
+ id: 'status-success-glyph',
+ name: 'Glyph',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ },
+ ],
+ visible: true,
+ width: 24,
+ height: 24,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ layoutMode: 'NONE',
+ } as unknown as ComponentNode
+
+ const warningComponent = {
+ ...successComponent,
+ id: 'status-warning',
+ name: 'status=warning',
+ variantProperties: { status: 'warning' },
+ } as unknown as ComponentNode
+
+ const errorComponent = {
+ ...successComponent,
+ id: 'status-error',
+ name: 'status=error',
+ variantProperties: { status: 'error' },
+ } as unknown as ComponentNode
+
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ id: 'status-set',
+ name: 'Status',
+ children: [successComponent, warningComponent, errorComponent],
+ defaultVariant: successComponent,
+ componentPropertyDefinitions: {
+ status: {
+ type: 'VARIANT',
+ defaultValue: 'success',
+ variantOptions: ['success', 'warning', 'error'],
+ },
+ },
+ } as unknown as ComponentSetNode
+
+ ;(successComponent as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+ ;(warningComponent as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+ ;(errorComponent as unknown as { parent: ComponentSetNode }).parent =
+ componentSet
+
+ const instanceNode = {
+ type: 'INSTANCE',
+ id: 'toast-status-instance',
+ name: 'Status',
+ visible: true,
+ width: 24,
+ height: 24,
+ componentProperties: {
+ status: {
+ type: 'VARIANT',
+ value: 'error',
+ },
+ },
+ getMainComponentAsync: async () => errorComponent,
+ } as unknown as InstanceNode
+
+ const root = {
+ type: 'FRAME',
+ name: 'Toast',
+ visible: true,
+ children: [instanceNode],
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 12,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ width: 580,
+ height: 48,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as FrameNode
+
+ ;(instanceNode as unknown as { parent: FrameNode }).parent = root
+
+ const codegen = new Codegen(root, DEFAULT_CODEGEN_OPTIONS)
+ await codegen.run()
+
+ expect(codegen.getCode()).toMatchSnapshot(
+ 'inline instance usage keeps commented reference',
+ )
+
+ let foundStatus = false
+ for (const [name, code] of codegen.getComponentsCodes()) {
+ if (name === 'Status') {
+ foundStatus = true
+ expect(code).toMatchSnapshot(
+ 'inline instance usage keeps extracted Status declaration',
+ )
+ }
+ }
+ expect(foundStatus).toBe(true)
+ })
+
+ test('infers boolean props for extracted component declarations from tree conditions', async () => {
+ const conditionalChild = {
+ type: 'TEXT',
+ name: '버튼명',
+ visible: true,
+ characters: '버튼명',
+ getStyledTextSegments: () => [createTextSegment('버튼명')],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ componentPropertyReferences: { visible: 'closeButton#70:1' },
+ } as unknown as TextNode
+
+ const labelChild = {
+ type: 'TEXT',
+ name: '오류 메시지',
+ visible: true,
+ characters: '오류 메시지',
+ getStyledTextSegments: () => [createTextSegment('오류 메시지')],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ } as unknown as TextNode
+
+ const defaultVariant = {
+ type: 'COMPONENT',
+ name: 'state=default',
+ children: [labelChild, conditionalChild],
+ visible: true,
+ reactions: [],
+ variantProperties: { state: 'default' },
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 12,
+ paddingLeft: 16,
+ paddingRight: 16,
+ paddingTop: 12,
+ paddingBottom: 12,
+ fills: [],
+ strokes: [],
+ effects: [],
+ width: 580,
+ height: 48,
+ } as unknown as ComponentNode
+
+ const node = {
+ type: 'COMPONENT_SET',
+ name: 'Toast',
+ children: [defaultVariant],
+ defaultVariant,
+ visible: true,
+ componentPropertyDefinitions: {
+ state: {
+ type: 'VARIANT',
+ variantOptions: ['Default'],
+ },
+ 'closeButton#70:1': {
+ type: 'BOOLEAN',
+ defaultValue: true,
+ },
+ },
+ } as unknown as ComponentSetNode
+ addParent(node)
+
+ const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS)
+ await codegen.run()
+
+ let foundToast = false
+ for (const [name, code] of codegen.getComponentsCodes()) {
+ if (name === 'Toast') {
+ foundToast = true
+ expect(code).toContain('export interface ToastProps')
+ expect(code).toContain('closeButton?: boolean')
+ }
+ }
+ expect(foundToast).toBe(true)
+ })
+
test('builds tree for INSTANCE node with position wrapper (absolute)', async () => {
const mainComponent = {
type: 'COMPONENT',
@@ -4890,6 +5281,49 @@ describe('Codegen Tree Methods', () => {
})
})
+ describe('collectComponentProps', () => {
+ test('collects boolean, text, and slot props from structured tree metadata', () => {
+ const tree: NodeTree = {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'header',
+ props: {},
+ children: [],
+ nodeType: 'SLOT',
+ nodeName: 'Header',
+ isSlot: true,
+ },
+ {
+ component: 'Text',
+ props: {},
+ children: [],
+ nodeType: 'TEXT',
+ nodeName: 'Label',
+ textChildren: ['{title}'],
+ },
+ {
+ component: 'Box',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Action',
+ condition: 'showAction',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ }
+
+ const result = collectComponentProps(tree)
+
+ expect(result.booleanProps).toEqual(['showAction'])
+ expect(result.textProps).toEqual(['title'])
+ expect(result.slotProps).toEqual(['header'])
+ })
+ })
+
describe('getSelectorProps with numeric property names', () => {
test('sanitizes property name that is only digits', async () => {
const defaultVariant = {
diff --git a/src/codegen/__tests__/render.test.ts b/src/codegen/__tests__/render.test.ts
index d2d00f0..6d920ad 100644
--- a/src/codegen/__tests__/render.test.ts
+++ b/src/codegen/__tests__/render.test.ts
@@ -239,6 +239,22 @@ export function Banner({ size }: BannerProps) {
export function Button({ leftIcon, size, rightIcon }: ButtonProps) {
return
+}`,
+ },
+ {
+ title:
+ 'does not infer boolean props from JSX strings when variants are empty',
+ component: 'Toast',
+ code: `
+ {closeButton && 버튼명}
+`,
+ variants: {} as Record,
+ expected: `export function Toast() {
+ return (
+
+ {closeButton && 버튼명}
+
+ )
}`,
},
])('$title', ({ component, code, variants, expected }) => {
diff --git a/src/codegen/props/__tests__/bound-variables.test.ts b/src/codegen/props/__tests__/bound-variables.test.ts
index 60861b8..e6ddef1 100644
--- a/src/codegen/props/__tests__/bound-variables.test.ts
+++ b/src/codegen/props/__tests__/bound-variables.test.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { resetVariableCache } from '../../utils/variable-cache'
import { getAutoLayoutProps } from '../auto-layout'
-import { getBorderRadiusProps } from '../border'
+import { getBorderProps, getBorderRadiusProps } from '../border'
import { getEffectProps } from '../effect'
import { getLayoutProps, getMinMaxProps } from '../layout'
import { getPaddingProps } from '../padding'
@@ -16,6 +16,7 @@ function setupFigmaMocks(options?: {
;(globalThis as { figma?: unknown }).figma = {
mixed: Symbol('mixed'),
+ util: { rgba: (v: unknown) => v },
getStyleByIdAsync: mock(async (id: string) => {
const name = styleNamesById[id]
if (!name) return null
@@ -125,6 +126,359 @@ describe('length bound variables (padding / gap / size / radius)', () => {
})
})
+ test('getLayoutProps uses sync absolute sizing path when no bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'RECTANGLE',
+ width: 120,
+ height: 40,
+ children: [],
+ parent: { width: 500 },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: true,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ }),
+ ).toEqual({
+ w: '120px',
+ h: '40px',
+ })
+ })
+
+ test('getLayoutProps uses sync fixed sizing path when no bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ width: 320,
+ height: 180,
+ maxWidth: null,
+ maxHeight: null,
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ }),
+ ).toEqual({
+ aspectRatio: undefined,
+ flex: undefined,
+ w: '320px',
+ h: '180px',
+ })
+ })
+
+ test('getLayoutProps uses sync text width path when no bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'TEXT',
+ width: 140,
+ textAutoResize: 'HEIGHT',
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as TextNode
+
+ expect(
+ await getLayoutProps(node as unknown as SceneNode, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ }),
+ ).toEqual({
+ w: '140px',
+ })
+ })
+
+ test('getLayoutProps resolves text width variable when textAutoResize is HEIGHT', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-text-width': 'text/width',
+ },
+ })
+
+ const node = {
+ type: 'TEXT',
+ width: 140,
+ textAutoResize: 'HEIGHT',
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ boundVariables: {
+ width: { id: 'var-text-width' },
+ },
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as TextNode
+
+ expect(
+ await getLayoutProps(node as unknown as SceneNode, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ boundVariables: {
+ width: { id: 'var-text-width' },
+ },
+ }),
+ ).toEqual({
+ w: '$textWidth',
+ })
+ })
+
+ test('getLayoutProps returns empty object for variable-backed WIDTH_AND_HEIGHT text', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-text-width-2': 'text/width2',
+ },
+ })
+
+ const node = {
+ type: 'TEXT',
+ width: 140,
+ textAutoResize: 'WIDTH_AND_HEIGHT',
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ boundVariables: {
+ width: { id: 'var-text-width-2' },
+ },
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as TextNode
+
+ expect(
+ await getLayoutProps(node as unknown as SceneNode, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ boundVariables: {
+ width: { id: 'var-text-width-2' },
+ },
+ }),
+ ).toEqual({})
+ })
+
+ test('getLayoutProps falls through NONE text auto resize to fixed variable sizing', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-text-width-3': 'text/width3',
+ 'var-text-height-3': 'text/height3',
+ },
+ })
+
+ const node = {
+ type: 'TEXT',
+ width: 140,
+ height: 32,
+ textAutoResize: 'NONE',
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ maxWidth: null,
+ maxHeight: null,
+ boundVariables: {
+ width: { id: 'var-text-width-3' },
+ height: { id: 'var-text-height-3' },
+ },
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as TextNode
+
+ expect(
+ await getLayoutProps(node as unknown as SceneNode, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ boundVariables: {
+ width: { id: 'var-text-width-3' },
+ height: { id: 'var-text-height-3' },
+ },
+ }),
+ ).toEqual({
+ aspectRatio: undefined,
+ flex: undefined,
+ w: '$textWidth3',
+ h: '$textHeight3',
+ })
+ })
+
+ test('getLayoutProps handles variable-backed fixed sizing in normal flow', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-frame-width': 'frame/width',
+ 'var-frame-height': 'frame/height',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ width: 320,
+ height: 180,
+ maxWidth: null,
+ maxHeight: null,
+ layoutSizingHorizontal: 'FIXED',
+ layoutSizingVertical: 'FIXED',
+ boundVariables: {
+ width: { id: 'var-frame-width' },
+ height: { id: 'var-frame-height' },
+ },
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ boundVariables: {
+ width: { id: 'var-frame-width' },
+ height: { id: 'var-frame-height' },
+ },
+ }),
+ ).toEqual({
+ aspectRatio: undefined,
+ flex: undefined,
+ w: '$frameWidth',
+ h: '$frameHeight',
+ })
+ })
+
+ test('getLayoutProps absolute variable path can leave width undefined for non-asset containers with children', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-abs-width': 'abs/width',
+ 'var-abs-height': 'abs/height',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ width: 120,
+ height: 40,
+ children: [{}],
+ parent: { width: 500 },
+ boundVariables: {
+ width: { id: 'var-abs-width' },
+ height: { id: 'var-abs-height' },
+ },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: true,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ boundVariables: {
+ width: { id: 'var-abs-width' },
+ height: { id: 'var-abs-height' },
+ },
+ }),
+ ).toEqual({
+ w: undefined,
+ h: undefined,
+ })
+ })
+
+ test('getLayoutProps variable-backed fill sizing resolves to 100% when max constraints exist', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-fill-width': 'fill/width',
+ 'var-fill-height': 'fill/height',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ width: 320,
+ height: 180,
+ maxWidth: 500,
+ maxHeight: 400,
+ layoutSizingHorizontal: 'FILL',
+ layoutSizingVertical: 'FILL',
+ boundVariables: {
+ width: { id: 'var-fill-width' },
+ height: { id: 'var-fill-height' },
+ },
+ parent: {
+ layoutMode: 'VERTICAL',
+ },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ boundVariables: {
+ width: { id: 'var-fill-width' },
+ height: { id: 'var-fill-height' },
+ },
+ }),
+ ).toEqual({
+ aspectRatio: undefined,
+ boxSize: '100%',
+ flex: undefined,
+ })
+ })
+
+ test('getLayoutProps returns fill sizing when parent shrinks width and height', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ width: 100,
+ height: 80,
+ maxWidth: null,
+ maxHeight: null,
+ layoutSizingHorizontal: 'FILL',
+ layoutSizingVertical: 'FILL',
+ parent: {
+ layoutMode: 'HORIZONTAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'AUTO',
+ },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: false,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ }),
+ ).toEqual({
+ aspectRatio: undefined,
+ flex: 1,
+ w: undefined,
+ h: undefined,
+ })
+ })
+
test('getMinMaxProps resolves min/max variables with fallback to px', async () => {
setupFigmaMocks({
variableNamesById: {
@@ -153,6 +507,25 @@ describe('length bound variables (padding / gap / size / radius)', () => {
})
})
+ test('getMinMaxProps uses sync path when no bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ minWidth: 100,
+ maxWidth: 500,
+ minHeight: 80,
+ maxHeight: 400,
+ } as unknown as SceneNode
+
+ expect(await getMinMaxProps(node)).toEqual({
+ minW: '100px',
+ maxW: '500px',
+ minH: '80px',
+ maxH: '400px',
+ })
+ })
+
test('getBorderRadiusProps resolves corner radius variables with shorthand optimization', async () => {
setupFigmaMocks({
variableNamesById: {
@@ -180,6 +553,192 @@ describe('length bound variables (padding / gap / size / radius)', () => {
})
})
+ test('getBorderRadiusProps uses sync shorthand path when no bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'RECTANGLE',
+ topLeftRadius: 8,
+ topRightRadius: 4,
+ bottomRightRadius: 8,
+ bottomLeftRadius: 4,
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '8px 4px',
+ })
+ })
+
+ test('getBorderRadiusProps uses sync ellipse path when no bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'ELLIPSE',
+ arcData: { innerRadius: 0 },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '50%',
+ })
+ })
+
+ test('getBorderRadiusProps resolves single cornerRadius variable without corner fields', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-corner': 'radius/corner',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ cornerRadius: 12,
+ boundVariables: {
+ cornerRadius: { id: 'var-corner' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '$radiusCorner',
+ })
+ })
+
+ test('getBorderRadiusProps falls back to raw corner shorthand when corner variables are unresolved', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'RECTANGLE',
+ topLeftRadius: 8,
+ topRightRadius: 4,
+ bottomRightRadius: 8,
+ bottomLeftRadius: 4,
+ boundVariables: {
+ topLeftRadius: { id: 'missing-top-left' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '8px 4px',
+ })
+ })
+
+ test('getBorderRadiusProps returns ellipse radius even when unrelated bound variables exist', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'ELLIPSE',
+ arcData: { innerRadius: 0 },
+ boundVariables: {
+ width: { id: 'missing-width' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '50%',
+ })
+ })
+
+ test('getBorderRadiusProps falls back to raw cornerRadius when variable is unresolved', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'RECTANGLE',
+ cornerRadius: 12,
+ boundVariables: {
+ cornerRadius: { id: 'missing-corner' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '12px',
+ })
+ })
+
+ test('getBorderProps uses simple stroke fast path for border', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'RECTANGLE',
+ strokes: [
+ {
+ type: 'SOLID',
+ visible: true,
+ opacity: 1,
+ color: { r: 1, g: 0, b: 0, a: 1 },
+ },
+ ],
+ strokeWeight: 2,
+ strokeAlign: 'INSIDE',
+ dashPattern: [],
+ } as unknown as SceneNode
+
+ expect(await getBorderProps(node)).toEqual({
+ border: 'solid 2px #F00',
+ })
+ })
+
+ test('getBorderProps uses simple stroke fast path for outline line', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'LINE',
+ strokes: [
+ {
+ type: 'SOLID',
+ visible: true,
+ opacity: 1,
+ color: { r: 0, g: 0, b: 1, a: 1 },
+ },
+ ],
+ strokeWeight: 2,
+ strokeAlign: 'CENTER',
+ dashPattern: [],
+ layoutSizingHorizontal: 'FIXED',
+ width: 100,
+ } as unknown as SceneNode
+
+ expect(await getBorderProps(node)).toEqual({
+ outline: 'solid 2px #00F',
+ outlineOffset: null,
+ maxW: 'calc(100px - 4px)',
+ transform: 'translate(2px, -2px)',
+ })
+ })
+
+ test('getBorderProps handles multi-stroke mixed weights path', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'RECTANGLE',
+ strokes: [
+ {
+ type: 'SOLID',
+ visible: true,
+ opacity: 1,
+ color: { r: 1, g: 0, b: 0, a: 1 },
+ },
+ {
+ type: 'SOLID',
+ visible: true,
+ opacity: 1,
+ color: { r: 0, g: 0, b: 1, a: 1 },
+ },
+ ],
+ strokeWeight: (figma as typeof figma).mixed,
+ strokeTopWeight: 1,
+ strokeRightWeight: 2,
+ strokeBottomWeight: 3,
+ strokeLeftWeight: 4,
+ dashPattern: [2],
+ } as unknown as SceneNode
+
+ expect(await getBorderProps(node)).toEqual({
+ borderBottom: 'dashed 3px #00F, dashed 3px linear-gradient(#F00, #F00)',
+ borderTop: 'dashed 1px #00F, dashed 1px linear-gradient(#F00, #F00)',
+ borderLeft: 'dashed 4px #00F, dashed 4px linear-gradient(#F00, #F00)',
+ borderRight: 'dashed 2px #00F, dashed 2px linear-gradient(#F00, #F00)',
+ })
+ })
+
test('getBorderRadiusProps collapses to single value when all corners resolve equal', async () => {
setupFigmaMocks({
variableNamesById: {
@@ -433,6 +992,118 @@ describe('effect/text-shadow bound variables and style tokens', () => {
})
})
+ test('getEffectProps uses fast path for simple inner shadow without variables', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ effects: [
+ {
+ type: 'INNER_SHADOW',
+ visible: true,
+ offset: { x: 2, y: 4 },
+ radius: 6,
+ spread: 8,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await getEffectProps(node)).toEqual({
+ boxShadow: 'inset 2px 4px 6px 8px #000',
+ })
+ })
+
+ test('getEffectProps resolves bound variables for inner shadow', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-inner-x': 'inner/x',
+ 'var-inner-y': 'inner/y',
+ 'var-inner-r': 'inner/radius',
+ 'var-inner-s': 'inner/spread',
+ 'var-inner-c': 'inner/color',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ effects: [
+ {
+ type: 'INNER_SHADOW',
+ visible: true,
+ offset: { x: 2, y: 4 },
+ radius: 6,
+ spread: 8,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ boundVariables: {
+ offsetX: { id: 'var-inner-x' },
+ offsetY: { id: 'var-inner-y' },
+ radius: { id: 'var-inner-r' },
+ spread: { id: 'var-inner-s' },
+ color: { id: 'var-inner-c' },
+ },
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await getEffectProps(node)).toEqual({
+ boxShadow: 'inset $innerX $innerY $innerRadius $innerSpread $innerColor',
+ })
+ })
+
+ test('getEffectProps handles blur and texture filters', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ effects: [
+ {
+ type: 'LAYER_BLUR',
+ visible: true,
+ radius: 12,
+ },
+ {
+ type: 'BACKGROUND_BLUR',
+ visible: true,
+ radius: 8,
+ },
+ {
+ type: 'TEXTURE',
+ visible: true,
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await getEffectProps(node)).toEqual({
+ filter: 'blur(12px), contrast(100%) brightness(100%)',
+ backdropFilter: 'blur(8px)',
+ })
+ })
+
+ test('getEffectProps handles noise and glass effects', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ effects: [
+ {
+ type: 'NOISE',
+ visible: true,
+ },
+ {
+ type: 'GLASS',
+ visible: true,
+ radius: 10,
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await getEffectProps(node)).toEqual({
+ filter: 'contrast(100%) brightness(100%)',
+ backdropFilter: 'blur(10px)',
+ })
+ })
+
test('getTextShadowProps resolves effect style token and bound variables', async () => {
setupFigmaMocks({
variableNamesById: {
diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts
index ab387f0..f33d95a 100644
--- a/src/codegen/props/auto-layout.ts
+++ b/src/codegen/props/auto-layout.ts
@@ -20,11 +20,12 @@ export async function getAutoLayoutProps(
if (layoutMode === 'GRID') return getGridProps(node)
const bv =
- 'boundVariables' in node
+ ctx?.boundVariables ??
+ ('boundVariables' in node
? (node.boundVariables as
| Record
| undefined)
- : undefined
+ : undefined)
let childrenCount = 0
for (const c of node.children) if (c.visible) childrenCount++
diff --git a/src/codegen/props/background.ts b/src/codegen/props/background.ts
index 874116a..cb9239a 100644
--- a/src/codegen/props/background.ts
+++ b/src/codegen/props/background.ts
@@ -7,23 +7,36 @@ export async function getBackgroundProps(
Record | undefined
> {
if ('fills' in node && node.fills !== figma.mixed) {
+ const fills = node.fills
const gradientText =
node.type === 'TEXT' &&
- !!node.fills.find(
+ !!fills.find(
(fill) =>
fill.visible &&
(fill.type === 'IMAGE' || fill.type.includes('GRADIENT')),
)
+ const visibleFills = [...fills]
+ .reverse()
+ .filter((fill) => fill.opacity !== 0 && fill.visible)
+ .map((fill, i, reversedVisibleFills) => ({
+ fill,
+ isLast: i === reversedVisibleFills.length - 1,
+ }))
+
const cssFills: string[] = []
let backgroundBlend: BlendMode = 'NORMAL'
- for (let i = 0; i < node.fills.length; i++) {
- const fill = node.fills[node.fills.length - 1 - i]
- if (fill.opacity === 0 || !fill.visible) continue
- const cssFill =
- paintToCSSSyncIfPossible(fill, node, i === node.fills.length - 1) ??
- (await paintToCSS(fill, node, i === node.fills.length - 1))
+ const cssFillResults = await Promise.all(
+ visibleFills.map(async ({ fill, isLast }) => {
+ const cssFill =
+ paintToCSSSyncIfPossible(fill, node, isLast) ??
+ (await paintToCSS(fill, node, isLast))
+ return { fill, cssFill }
+ }),
+ )
+
+ for (const { fill, cssFill } of cssFillResults) {
if (
fill.type === 'SOLID' &&
fill.blendMode &&
diff --git a/src/codegen/props/border.ts b/src/codegen/props/border.ts
index 7d95cd2..25fd078 100644
--- a/src/codegen/props/border.ts
+++ b/src/codegen/props/border.ts
@@ -1,3 +1,4 @@
+import type { NodeContext } from '../types'
import { addPx } from '../utils/add-px'
import { fourValueShortcut } from '../utils/four-value-shortcut'
import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css'
@@ -7,11 +8,41 @@ type BoundVars = Record | undefined | null
export async function getBorderRadiusProps(
node: SceneNode,
+ ctx?: NodeContext,
): Promise<
Record | undefined
> {
const bv =
- 'boundVariables' in node ? (node.boundVariables as BoundVars) : undefined
+ (ctx?.boundVariables as BoundVars) ??
+ ('boundVariables' in node ? (node.boundVariables as BoundVars) : undefined)
+
+ if (!bv) {
+ if (
+ 'cornerRadius' in node &&
+ typeof node.cornerRadius === 'number' &&
+ node.cornerRadius !== 0 &&
+ !('topLeftRadius' in node)
+ ) {
+ return { borderRadius: addPx(node.cornerRadius) }
+ }
+ if ('topLeftRadius' in node) {
+ const value = fourValueShortcut(
+ node.topLeftRadius,
+ node.topRightRadius,
+ node.bottomRightRadius,
+ node.bottomLeftRadius,
+ )
+ if (value === '0') return
+ return {
+ borderRadius: value,
+ }
+ }
+ if (node.type === 'ELLIPSE' && !node.arcData.innerRadius) {
+ return {
+ borderRadius: '50%',
+ }
+ }
+ }
if (
'cornerRadius' in node &&
@@ -78,16 +109,60 @@ export async function getBorderProps(
if (!('strokes' in node && node.strokes.length > 0) || node.type === 'TEXT')
return
const strokeStyle = getStrokeStyle(node)
- const paintCssList = []
- for (let i = 0; i < node.strokes.length; i++) {
- const paint = node.strokes[node.strokes.length - 1 - i]
- if (paint.visible !== false && paint.opacity !== 0) {
- paintCssList.push(
- paintToCSSSyncIfPossible(paint, node, i === node.strokes.length - 1) ??
- (await paintToCSS(paint, node, i === node.strokes.length - 1)),
- )
+ const simpleStroke =
+ node.strokes.length === 1 &&
+ node.strokes[0].visible !== false &&
+ node.strokes[0].opacity !== 0 &&
+ paintToCSSSyncIfPossible(node.strokes[0], node, true)
+
+ if (typeof simpleStroke === 'string') {
+ const weight = node.strokeWeight
+ if (
+ weight !== figma.mixed &&
+ (node.strokeAlign !== 'INSIDE' || node.type === 'LINE')
+ ) {
+ const wType =
+ 'layoutSizingHorizontal' in node ? node.layoutSizingHorizontal : 'FILL'
+ return {
+ outline: `${strokeStyle} ${weight}px ${simpleStroke}`,
+ outlineOffset:
+ node.type === 'LINE'
+ ? null
+ : {
+ CENTER: addPx(-weight / 2),
+ OUTSIDE: null,
+ INSIDE: null,
+ }[node.strokeAlign],
+ maxW:
+ node.type === 'LINE'
+ ? `calc(${wType === 'FILL' ? '100%' : `${node.width}px`} - ${weight * 2}px)`
+ : undefined,
+ transform:
+ node.type === 'LINE'
+ ? `translate(${addPx(weight)}, ${addPx(-weight)})`
+ : undefined,
+ }
+ }
+
+ if (weight !== figma.mixed) {
+ return {
+ border: `${strokeStyle} ${weight}px ${simpleStroke}`,
+ }
}
}
+
+ const visibleStrokes = node.strokes
+ .map((paint, i) => ({ paint, isLast: i === node.strokes.length - 1 }))
+ .reverse()
+ .filter(({ paint }) => paint.visible !== false && paint.opacity !== 0)
+ const paintCssList = await Promise.all(
+ visibleStrokes.map(async ({ paint, isLast }) => {
+ return (
+ paintToCSSSyncIfPossible(paint, node, isLast) ??
+ (await paintToCSS(paint, node, isLast))
+ )
+ }),
+ )
const weight = node.strokeWeight
if (
weight !== figma.mixed &&
diff --git a/src/codegen/props/effect.ts b/src/codegen/props/effect.ts
index d356cb5..ec071ac 100644
--- a/src/codegen/props/effect.ts
+++ b/src/codegen/props/effect.ts
@@ -1,3 +1,4 @@
+import { getStyleByIdCached } from '../../utils'
import { optimizeHex } from '../../utils/optimize-hex'
import { rgbaToHex } from '../../utils/rgba-to-hex'
import { styleNameToTypography } from '../../utils/style-name-to-typography'
@@ -21,7 +22,7 @@ async function _resolveEffectStyleToken(
if (!('effectStyleId' in node)) return null
const styleId = (node as SceneNode & { effectStyleId: string }).effectStyleId
if (!styleId || typeof styleId !== 'string') return null
- const style = await figma.getStyleByIdAsync(styleId)
+ const style = await getStyleByIdCached(styleId)
if (style?.name) {
return `$${styleNameToTypography(style.name).name}`
}
@@ -44,8 +45,10 @@ export async function getEffectProps(
// This preserves per-breakpoint differences so the responsive merge
// can detect them and produce responsive arrays.
const result: Record = {}
- for (const effect of effects) {
- const props = await _getEffectPropsFromEffect(effect)
+ const effectPropsList = await Promise.all(
+ effects.map((effect) => _getEffectPropsFromEffect(effect)),
+ )
+ for (const props of effectPropsList) {
for (const [key, value] of Object.entries(props)) {
if (value) {
result[key] = result[key] ? `${result[key]}, ${value}` : value
@@ -66,6 +69,13 @@ export async function getEffectProps(
return result
}
+function hasEffectVariable(
+ bv: BoundVars,
+ field: string,
+): bv is Record {
+ return !!bv?.[field]
+}
+
async function _resolveEffectColor(
bv: BoundVars,
color: RGBA,
@@ -103,6 +113,18 @@ async function _getEffectPropsFromEffect(
const { offset, radius, spread, color } = effect
const { x, y } = offset
+ if (
+ !hasEffectVariable(bv, 'offsetX') &&
+ !hasEffectVariable(bv, 'offsetY') &&
+ !hasEffectVariable(bv, 'radius') &&
+ !hasEffectVariable(bv, 'spread') &&
+ !hasEffectVariable(bv, 'color')
+ ) {
+ return {
+ boxShadow: `${addPx(x, '0')} ${addPx(y, '0')} ${addPx(radius, '0')} ${addPx(spread ?? 0, '0')} ${optimizeHex(rgbaToHex(color))}`,
+ }
+ }
+
const [ex, ey, er, es, ec] = await Promise.all([
_resolveEffectLength(bv, 'offsetX', x, '0'),
_resolveEffectLength(bv, 'offsetY', y, '0'),
@@ -119,6 +141,18 @@ async function _getEffectPropsFromEffect(
const { offset, radius, spread, color } = effect
const { x, y } = offset
+ if (
+ !hasEffectVariable(bv, 'offsetX') &&
+ !hasEffectVariable(bv, 'offsetY') &&
+ !hasEffectVariable(bv, 'radius') &&
+ !hasEffectVariable(bv, 'spread') &&
+ !hasEffectVariable(bv, 'color')
+ ) {
+ return {
+ boxShadow: `inset ${addPx(x, '0')} ${addPx(y, '0')} ${addPx(radius, '0')} ${addPx(spread ?? 0, '0')} ${optimizeHex(rgbaToHex(color))}`,
+ }
+ }
+
const [ex, ey, er, es, ec] = await Promise.all([
_resolveEffectLength(bv, 'offsetX', x, '0'),
_resolveEffectLength(bv, 'offsetY', y, '0'),
diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts
index 3911c85..d6096e7 100644
--- a/src/codegen/props/index.ts
+++ b/src/codegen/props/index.ts
@@ -1,4 +1,4 @@
-import type { NodeContext } from '../types'
+import type { NodeBoundVariables, NodeContext } from '../types'
import { checkAssetNode } from '../utils/check-asset-node'
import { getPageNode } from '../utils/get-page-node'
import { isPageRoot } from '../utils/is-page-root'
@@ -17,7 +17,7 @@ import { getObjectFitProps } from './object-fit'
import { getOverflowProps } from './overflow'
import { getPaddingProps } from './padding'
import { canBeAbsolute, getPositionProps } from './position'
-import { getReactionProps } from './reaction'
+import { getReactionProps, hasReactionProps } from './reaction'
import { getTextAlignProps } from './text-align'
import { getTextShadowProps } from './text-shadow'
import { getTextStrokeProps } from './text-stroke'
@@ -26,6 +26,10 @@ import { getVisibilityProps } from './visibility'
export function computeNodeContext(node: SceneNode): NodeContext {
const asset = checkAssetNode(node)
+ const boundVariables =
+ 'boundVariables' in node
+ ? (node.boundVariables as NodeBoundVariables)
+ : undefined
const pageNode = getPageNode(
node as BaseNode & ChildrenMixin,
) as SceneNode | null
@@ -35,6 +39,7 @@ export function computeNodeContext(node: SceneNode): NodeContext {
canBeAbsolute: canBeAbsolute(node),
isPageRoot: pageRoot,
pageNode,
+ boundVariables,
}
}
@@ -76,6 +81,16 @@ export async function getProps(
// Compute cross-cutting node context ONCE for all sync getters that need it.
const ctx = computeNodeContext(node)
+ const hasFills = 'fills' in node && node.fills !== figma.mixed
+ const hasStrokes = 'strokes' in node && node.strokes.length > 0
+ const hasEffects = 'effects' in node && node.effects.length > 0
+ const hasInferredAutoLayout =
+ 'inferredAutoLayout' in node && !!node.inferredAutoLayout
+ const hasPadding = 'paddingLeft' in node
+ const hasRadius =
+ ('cornerRadius' in node && typeof node.cornerRadius === 'number') ||
+ 'topLeftRadius' in node ||
+ (node.type === 'ELLIPSE' && !node.arcData.innerRadius)
// PHASE 1: Fire ALL async prop getters — initiates Figma IPC calls immediately.
// These return Promises that resolve when IPC completes.
@@ -83,27 +98,38 @@ export async function getProps(
// + padding, auto-layout, layout, min-max, border-radius,
// effect, text-shadow (newly async for variable support)
const tBorder = perfStart()
- const borderP = getBorderProps(node)
+ const borderP = hasStrokes && !isText ? getBorderProps(node) : undefined
const tBg = perfStart()
- const bgP = getBackgroundProps(node)
+ const bgP = hasFills ? getBackgroundProps(node) : undefined
const tTextStroke = perfStart()
- const textStrokeP = isText ? getTextStrokeProps(node) : undefined
+ const textStrokeP =
+ isText && hasStrokes ? getTextStrokeProps(node) : undefined
const tReaction = perfStart()
- const reactionP = getReactionProps(node)
+ const reactionP = hasReactionProps(node)
+ ? getReactionProps(node)
+ : undefined
const tAutoLayout = perfStart()
- const autoLayoutP = getAutoLayoutProps(node, ctx)
+ const autoLayoutP = hasInferredAutoLayout
+ ? getAutoLayoutProps(node, ctx)
+ : undefined
const tMinMax = perfStart()
- const minMaxP = getMinMaxProps(node)
+ const minMaxP = getMinMaxProps(node, ctx)
const tLayout = perfStart()
const layoutP = getLayoutProps(node, ctx)
const tBorderRadius = perfStart()
- const borderRadiusP = getBorderRadiusProps(node)
+ const borderRadiusP = hasRadius
+ ? getBorderRadiusProps(node, ctx)
+ : undefined
const tPadding = perfStart()
- const paddingP = getPaddingProps(node)
+ const paddingP =
+ hasInferredAutoLayout || hasPadding
+ ? getPaddingProps(node, ctx)
+ : undefined
const tEffect = perfStart()
- const effectP = getEffectProps(node)
+ const effectP = hasEffects ? getEffectProps(node) : undefined
const tTextShadow = perfStart()
- const textShadowP = isText ? getTextShadowProps(node) : undefined
+ const textShadowP =
+ isText && hasEffects ? getTextShadowProps(node) : undefined
// PHASE 2: Run sync prop getters while async IPC is pending in background.
const tSync = perfStart()
@@ -121,28 +147,28 @@ export async function getProps(
perfEnd('getProps.sync', tSync)
// PHASE 3: Await async results — likely already resolved during sync phase.
- const autoLayoutProps = await autoLayoutP
- perfEnd('getProps.autoLayout', tAutoLayout)
+ const autoLayoutProps = autoLayoutP ? await autoLayoutP : undefined
+ if (autoLayoutP) perfEnd('getProps.autoLayout', tAutoLayout)
const minMaxProps = await minMaxP
perfEnd('getProps.minMax', tMinMax)
const layoutProps = await layoutP
perfEnd('getProps.layout', tLayout)
- const borderRadiusProps = await borderRadiusP
- perfEnd('getProps.borderRadius', tBorderRadius)
- const borderProps = await borderP
- perfEnd('getProps.border', tBorder)
- const backgroundProps = await bgP
- perfEnd('getProps.background', tBg)
- const paddingProps = await paddingP
- perfEnd('getProps.padding', tPadding)
- const effectProps = await effectP
- perfEnd('getProps.effect', tEffect)
+ const borderRadiusProps = borderRadiusP ? await borderRadiusP : undefined
+ if (borderRadiusP) perfEnd('getProps.borderRadius', tBorderRadius)
+ const borderProps = borderP ? await borderP : undefined
+ if (borderP) perfEnd('getProps.border', tBorder)
+ const backgroundProps = bgP ? await bgP : undefined
+ if (bgP) perfEnd('getProps.background', tBg)
+ const paddingProps = paddingP ? await paddingP : undefined
+ if (paddingP) perfEnd('getProps.padding', tPadding)
+ const effectProps = effectP ? await effectP : undefined
+ if (effectP) perfEnd('getProps.effect', tEffect)
const textStrokeProps = textStrokeP ? await textStrokeP : undefined
if (textStrokeP) perfEnd('getProps.textStroke', tTextStroke)
const textShadowProps = textShadowP ? await textShadowP : undefined
if (textShadowP) perfEnd('getProps.textShadow', tTextShadow)
- const reactionProps = await reactionP
- perfEnd('getProps.reaction', tReaction)
+ const reactionProps = reactionP ? await reactionP : undefined
+ if (reactionP) perfEnd('getProps.reaction', tReaction)
// PHASE 4: Merge in order to preserve last-key-wins semantics.
const result: Record = {}
diff --git a/src/codegen/props/layout.ts b/src/codegen/props/layout.ts
index 7fbabd5..75b8d74 100644
--- a/src/codegen/props/layout.ts
+++ b/src/codegen/props/layout.ts
@@ -1,4 +1,4 @@
-import type { NodeContext } from '../types'
+import type { NodeBoundVariables, NodeContext } from '../types'
import { addPx } from '../utils/add-px'
import { checkAssetNode } from '../utils/check-asset-node'
import { getPageNode } from '../utils/get-page-node'
@@ -8,16 +8,27 @@ import { canBeAbsolute } from './position'
type BoundVars = Record | undefined | null
-function getBoundVars(node: SceneNode): BoundVars {
- return 'boundVariables' in node
- ? (node.boundVariables as BoundVars)
- : undefined
+function getBoundVars(node: SceneNode, ctx?: NodeContext): BoundVars {
+ return (
+ (ctx?.boundVariables as NodeBoundVariables) ??
+ ('boundVariables' in node ? (node.boundVariables as BoundVars) : undefined)
+ )
}
export async function getMinMaxProps(
node: SceneNode,
+ ctx?: NodeContext,
): Promise> {
- const bv = getBoundVars(node)
+ const bv = getBoundVars(node, ctx)
+
+ if (!bv) {
+ return {
+ maxW: addPx(node.maxWidth),
+ maxH: addPx(node.maxHeight),
+ minW: addPx(node.minWidth),
+ minH: addPx(node.minHeight),
+ }
+ }
const [minWVar, maxWVar, minHVar, maxHVar] = await Promise.all([
resolveBoundVariable(bv, 'minWidth'),
@@ -49,11 +60,26 @@ export async function getLayoutProps(
async function _getTextLayoutProps(
node: TextNode,
+ ctx?: NodeContext,
): Promise | null> {
- const bv = getBoundVars(node)
+ const bv = getBoundVars(node, ctx)
+
+ if (!bv) {
+ switch (node.textAutoResize) {
+ case 'WIDTH_AND_HEIGHT':
+ return {}
+ case 'HEIGHT':
+ return {
+ w: addPx(node.width),
+ }
+ case 'NONE':
+ case 'TRUNCATE':
+ return null
+ }
+ }
switch (node.textAutoResize) {
case 'WIDTH_AND_HEIGHT':
@@ -74,9 +100,31 @@ async function _getLayoutProps(
node: SceneNode,
ctx?: NodeContext,
): Promise> {
- const bv = getBoundVars(node)
+ const bv = getBoundVars(node, ctx)
if (ctx ? ctx.canBeAbsolute : canBeAbsolute(node)) {
+ if (!bv) {
+ return {
+ w:
+ node.type === 'TEXT' ||
+ (node.parent &&
+ 'width' in node.parent &&
+ node.parent.width > node.width)
+ ? (ctx ? ctx.isAsset !== null : !!checkAssetNode(node)) ||
+ ('children' in node && node.children.length === 0)
+ ? addPx(node.width)
+ : undefined
+ : '100%',
+ h:
+ ('children' in node && node.children.length > 0) ||
+ node.type === 'TEXT'
+ ? undefined
+ : 'children' in node && node.children.length === 0
+ ? addPx(node.height)
+ : '100%',
+ }
+ }
+
const wVar = await resolveBoundVariable(bv, 'width')
const hVar = await resolveBoundVariable(bv, 'height')
@@ -105,7 +153,7 @@ async function _getLayoutProps(
const wType =
'layoutSizingHorizontal' in node ? node.layoutSizingHorizontal : 'FILL'
if (node.type === 'TEXT' && hType === 'FIXED' && wType === 'FIXED') {
- const ret = await _getTextLayoutProps(node)
+ const ret = await _getTextLayoutProps(node, ctx)
if (ret) return ret
}
const aspectRatio =
@@ -114,6 +162,41 @@ async function _getLayoutProps(
? ctx.pageNode
: getPageNode(node as BaseNode & ChildrenMixin)
+ if (!bv) {
+ return {
+ aspectRatio: aspectRatio
+ ? Math.floor((aspectRatio.x / aspectRatio.y) * 100) / 100
+ : undefined,
+ flex:
+ wType === 'FILL' &&
+ node.parent &&
+ 'layoutMode' in node.parent &&
+ node.parent.layoutMode === 'HORIZONTAL'
+ ? 1
+ : undefined,
+ w:
+ rootNode === node
+ ? undefined
+ : wType === 'FIXED'
+ ? addPx(node.width)
+ : wType === 'FILL' &&
+ ((node.parent && isChildWidthShrinker(node.parent, 'width')) ||
+ node.maxWidth !== null)
+ ? '100%'
+ : undefined,
+ h:
+ rootNode === node
+ ? undefined
+ : hType === 'FIXED'
+ ? addPx(node.height)
+ : hType === 'FILL' &&
+ ((node.parent && isChildWidthShrinker(node.parent, 'height')) ||
+ node.maxHeight !== null)
+ ? '100%'
+ : undefined,
+ }
+ }
+
const wVar =
wType === 'FIXED' ? await resolveBoundVariable(bv, 'width') : null
const hVar =
diff --git a/src/codegen/props/padding.ts b/src/codegen/props/padding.ts
index 6ef1c92..28e4f28 100644
--- a/src/codegen/props/padding.ts
+++ b/src/codegen/props/padding.ts
@@ -1,16 +1,19 @@
+import type { NodeContext } from '../types'
import { optimizeSpaceAsync } from '../utils/optimize-space'
export async function getPaddingProps(
node: SceneNode,
+ ctx?: NodeContext,
): Promise<
Record | undefined
> {
const bv =
- 'boundVariables' in node
+ ctx?.boundVariables ??
+ ('boundVariables' in node
? (node.boundVariables as
| Record
| undefined)
- : undefined
+ : undefined)
if (
'inferredAutoLayout' in node &&
diff --git a/src/codegen/props/reaction.ts b/src/codegen/props/reaction.ts
index 4763fc4..223592a 100644
--- a/src/codegen/props/reaction.ts
+++ b/src/codegen/props/reaction.ts
@@ -28,6 +28,15 @@ export function resetChildAnimationCache(): void {
childAnimationCache.clear()
}
+export function hasReactionProps(node: SceneNode): boolean {
+ if ('reactions' in node && node.reactions && node.reactions.length > 0) {
+ return true
+ }
+
+ const parentAnimations = childAnimationCache.get(node.parent?.id || '')
+ return !!parentAnimations?.has(node.name)
+}
+
// Format duration/delay values (up to 3 decimal places, remove trailing zeros)
function fmtDuration(n: number): string {
return (Math.round(n * 1000) / 1000)
@@ -438,6 +447,16 @@ async function generateChildAnimations(
childrenByName.set(child.name, child)
})
+ const chainChildrenByName = chain.map((step) => {
+ const childMap = new Map()
+ if ('children' in step.node) {
+ for (const child of step.node.children as readonly SceneNode[]) {
+ childMap.set(child.name, child)
+ }
+ }
+ return childMap
+ })
+
// Parallelize per-child animation building — each child's diff is independent.
// Even with single-threaded Figma IPC, Promise.all allows microtask interleaving
// between awaits, overlapping computation with I/O.
@@ -457,11 +476,12 @@ async function generateChildAnimations(
// Find matching child in current step by name
if ('children' in prevNode && 'children' in currentNode) {
- const prevChildren = prevNode.children as readonly SceneNode[]
- const currentChildren = currentNode.children as readonly SceneNode[]
+ const prevChildrenByName =
+ i === 0 ? childrenByName : chainChildrenByName[i - 1]
+ const currentChildrenByName = chainChildrenByName[i]
- const prevChild = prevChildren.find((c) => c.name === childName)
- const currentChild = currentChildren.find((c) => c.name === childName)
+ const prevChild = prevChildrenByName.get(childName)
+ const currentChild = currentChildrenByName.get(childName)
if (prevChild && currentChild) {
const changes = await generateSingleNodeDifferences(
@@ -518,15 +538,13 @@ async function generateChildAnimations(
}
// Get the starting child from startNode
- const startChild = startChildren.find((c) => c.name === childName)
+ const startChild = childrenByName.get(childName)
if (startChild) {
// Get the first step's matching child
const firstStepNode = chain[0]
if ('children' in firstStepNode.node) {
- const firstChildren = firstStepNode.node
- .children as readonly SceneNode[]
- const firstChild = firstChildren.find((c) => c.name === childName)
+ const firstChild = chainChildrenByName[0]?.get(childName)
if (firstChild) {
// Compare first destination back to source to get starting values
diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts
index bec5c6a..b4a37bc 100644
--- a/src/codegen/props/selector.ts
+++ b/src/codegen/props/selector.ts
@@ -1,3 +1,4 @@
+import { getVariantType } from '../utils/boolean-variant'
import { fmtPct } from '../utils/fmtPct'
import { getComponentPropertyDefinitions } from '../utils/get-component-property-definitions'
import { perfEnd, perfStart } from '../utils/perf'
@@ -158,10 +159,11 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{
effectChildren.push({ component: child, effect })
}
}
- const components: (readonly [string, Record])[] = []
- for (const { component, effect } of effectChildren) {
- components.push([effect, await getProps(component)] as const)
- }
+ const components = await Promise.all(
+ effectChildren.map(async ({ component, effect }) => {
+ return [effect, await getProps(component)] as const
+ }),
+ )
perfEnd('getSelectorProps.getPropsAll()', tSelector)
const defaultProps = await getProps(node.defaultVariant)
@@ -177,9 +179,7 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{
const definition = defs[name]
const sanitizedName = sanitizePropertyName(name)
if (definition.type === 'VARIANT' && definition.variantOptions) {
- result.variants[sanitizedName] = definition.variantOptions
- .map((option) => `'${option}'`)
- .join(' | ')
+ result.variants[sanitizedName] = getVariantType(definition.variantOptions)
} else if (definition.type === 'INSTANCE_SWAP') {
result.variants[sanitizedName] = 'React.ReactNode'
} else if (definition.type === 'BOOLEAN') {
@@ -316,12 +316,13 @@ async function computeSelectorPropsForGroup(
const effectPropsResults: {
effect: string
props: Record
- }[] = []
- for (const component of effectComponents) {
- const effect = component.variantProperties?.effect as string
- const props = await getProps(component)
- effectPropsResults.push({ effect, props })
- }
+ }[] = await Promise.all(
+ effectComponents.map(async (component) => {
+ const effect = component.variantProperties?.effect as string
+ const props = await getProps(component)
+ return { effect, props }
+ }),
+ )
for (const { effect, props } of effectPropsResults) {
const def = difference(props, defaultProps)
if (Object.keys(def).length === 0) continue
diff --git a/src/codegen/props/text-shadow.ts b/src/codegen/props/text-shadow.ts
index a12110e..64e72ac 100644
--- a/src/codegen/props/text-shadow.ts
+++ b/src/codegen/props/text-shadow.ts
@@ -1,3 +1,4 @@
+import { getStyleByIdCached } from '../../utils'
import { optimizeHex } from '../../utils/optimize-hex'
import { rgbaToHex } from '../../utils/rgba-to-hex'
import { styleNameToTypography } from '../../utils/style-name-to-typography'
@@ -20,7 +21,7 @@ async function _resolveEffectStyleToken(
if (!('effectStyleId' in node)) return null
const styleId = (node as SceneNode & { effectStyleId: string }).effectStyleId
if (!styleId || typeof styleId !== 'string') return null
- const style = await figma.getStyleByIdAsync(styleId)
+ const style = await getStyleByIdCached(styleId)
if (style?.name) {
return `$${styleNameToTypography(style.name).name}`
}
diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts
index 223c5a1..0a74397 100644
--- a/src/codegen/responsive/ResponsiveCodegen.ts
+++ b/src/codegen/responsive/ResponsiveCodegen.ts
@@ -1,3 +1,4 @@
+import type { CodegenOptions } from '../Codegen'
import { Codegen } from '../Codegen'
import {
applyTextChildrenTransform,
@@ -6,6 +7,12 @@ import {
} from '../props/selector'
import { renderComponent, renderNode } from '../render'
import type { NodeTree, Props } from '../types'
+import { getVariantType } from '../utils/boolean-variant'
+import {
+ collectImportMetadataFromTree,
+ type ImportMetadata,
+ mergeImportMetadata,
+} from '../utils/collect-import-metadata'
import { getComponentPropertyDefinitions } from '../utils/get-component-property-definitions'
import { paddingLeftMultiline } from '../utils/padding-left-multiline'
import { perfEnd, perfStart } from '../utils/perf'
@@ -22,6 +29,8 @@ import {
viewportToBreakpoint,
} from '.'
+type GeneratedResponsiveCode = readonly [string, string, ImportMetadata]
+
const POSITION_PROP_KEYS = new Set([
'pos',
'top',
@@ -42,6 +51,80 @@ function firstMapEntry(map: Map): [K, V] {
throw new Error('empty map')
}
+function renderFragment(childrenCodes: string[], depth: number): string {
+ const indent = ' '.repeat(depth)
+ if (childrenCodes.length === 0) return `${indent}<>>`
+
+ let children = ''
+ for (let i = 0; i < childrenCodes.length; i++) {
+ if (i > 0) children += '\n'
+ children += paddingLeftMultiline(childrenCodes[i], depth + 1)
+ }
+
+ return `${indent}<>\n${children}\n${indent}>`
+}
+
+function wrapConditionalJsx(condition: string, jsx: string): string {
+ if (jsx.includes('\n')) {
+ return `{${condition} && (\n${paddingLeftMultiline(jsx, 1)}\n)}`
+ }
+
+ return `{${condition} && ${jsx}}`
+}
+
+function hasMixedRootComponents(trees: Iterable): boolean {
+ let firstComponent: string | undefined
+
+ for (const tree of trees) {
+ if (!firstComponent) {
+ firstComponent = tree.component
+ continue
+ }
+
+ if (tree.component !== firstComponent) {
+ return true
+ }
+ }
+
+ return false
+}
+
+function cloneNodeTree(tree: NodeTree): NodeTree {
+ return {
+ component: tree.component,
+ props: tree.props,
+ children: tree.children,
+ nodeType: tree.nodeType,
+ nodeName: tree.nodeName,
+ isComponent: tree.isComponent,
+ isSlot: tree.isSlot,
+ condition: tree.condition,
+ textChildren: tree.textChildren,
+ leadingComment: tree.leadingComment,
+ }
+}
+
+function isAssetLeafTree(tree: NodeTree): boolean {
+ return (
+ tree.children.length === 0 &&
+ ((tree.component === 'Image' && typeof tree.props.src === 'string') ||
+ (tree.component === 'Box' && typeof tree.props.maskImage === 'string'))
+ )
+}
+
+function getVariantSourceTree(
+ codegen: Codegen,
+ tree: NodeTree,
+ preferBuiltAssetTree: boolean,
+): NodeTree {
+ if (preferBuiltAssetTree && isAssetLeafTree(tree)) {
+ return cloneNodeTree(tree)
+ }
+
+ const componentTree = codegen.getComponentTree()
+ return componentTree ? cloneNodeTree(componentTree.tree) : tree
+}
+
/**
* Build a stable merged order of child names across multiple variants/breakpoints.
* Uses topological sort on a DAG of ordering constraints from all variants,
@@ -155,8 +238,16 @@ function mergeChildNameOrder(
*/
export class ResponsiveCodegen {
private breakpointNodes: Map = new Map()
+ private lastImportMetadata: ImportMetadata = {
+ devupImports: [],
+ customImports: [],
+ usesKeyframes: false,
+ }
- constructor(private sectionNode: SectionNode | null) {
+ constructor(
+ private sectionNode: SectionNode | null,
+ private options: CodegenOptions = {},
+ ) {
if (this.sectionNode) {
this.categorizeChildren()
}
@@ -182,39 +273,96 @@ export class ResponsiveCodegen {
* Generate responsive code.
*/
async generateResponsiveCode(): Promise {
+ const result = await this.generateResponsiveResult()
+ return result.code
+ }
+
+ async generateResponsiveResult(): Promise<{
+ code: string
+ imports: ImportMetadata
+ }> {
if (this.breakpointNodes.size === 0) {
- return '// No responsive variants found in section'
+ return {
+ code: '// No responsive variants found in section',
+ imports: {
+ devupImports: [],
+ customImports: [],
+ usesKeyframes: false,
+ },
+ }
}
if (this.breakpointNodes.size === 1) {
// If only one breakpoint, generate normal code using Codegen.
const [, node] = firstMapEntry(this.breakpointNodes)
- const codegen = new Codegen(node)
+ const codegen = new Codegen(node, this.options)
const tree = await codegen.getTree()
- return Codegen.renderTree(tree, 0)
+ const imports = collectImportMetadataFromTree(tree)
+ this.lastImportMetadata = imports
+ return { code: Codegen.renderTree(tree, 0), imports }
}
// Extract trees per breakpoint using Codegen — all independent, run in parallel.
const breakpointTrees = new Map()
for (const [bp, node] of this.breakpointNodes) {
- const codegen = new Codegen(node)
+ const codegen = new Codegen(node, this.options)
const tree = await codegen.getTree()
breakpointTrees.set(bp, tree)
}
+ const imports = mergeImportMetadata(
+ [...breakpointTrees.values()].map((tree) =>
+ collectImportMetadataFromTree(tree),
+ ),
+ )
+ this.lastImportMetadata = imports
+
// Merge trees and generate code.
- return this.generateMergedCode(breakpointTrees, 0)
+ return { code: this.generateMergedCode(breakpointTrees, 0), imports }
+ }
+
+ getImportMetadata(): ImportMetadata {
+ return this.lastImportMetadata
}
/**
* Convert NodeTree children array to Map by nodeName.
*/
+ private getChildStructureSignature(tree: NodeTree): string {
+ const propKeys = Object.keys(tree.props).sort().join(',')
+ const childSignatures = tree.children
+ .map((child) => this.getChildStructureSignature(child))
+ .join('|')
+
+ return [
+ tree.component,
+ tree.isComponent ? 'component' : 'node',
+ tree.isSlot ? 'slot' : 'regular',
+ tree.condition ? 'conditional' : 'plain',
+ tree.textChildren?.length ? 'text' : 'notext',
+ propKeys,
+ childSignatures,
+ ].join('::')
+ }
+
private treeChildrenToMap(tree: NodeTree): Map {
const result = new Map()
+ const signatureCounts = new Map()
+
for (const child of tree.children) {
- const existing = result.get(child.nodeName) || []
+ const signature = this.getChildStructureSignature(child)
+ signatureCounts.set(signature, (signatureCounts.get(signature) || 0) + 1)
+ }
+
+ for (const child of tree.children) {
+ const signature = this.getChildStructureSignature(child)
+ const childKey =
+ signatureCounts.get(signature) === 1
+ ? `sig:${signature}`
+ : child.nodeName
+ const existing = result.get(childKey) || []
existing.push(child)
- result.set(child.nodeName, existing)
+ result.set(childKey, existing)
}
return result
}
@@ -398,7 +546,8 @@ export class ResponsiveCodegen {
static async generateViewportResponsiveComponents(
componentSet: ComponentSetNode,
componentName: string,
- ): Promise> {
+ options: CodegenOptions = {},
+ ): Promise> {
// Find viewport and effect variant keys
const viewportDefs = getComponentPropertyDefinitions(componentSet)
let viewportKey: string | undefined
@@ -421,9 +570,9 @@ export class ResponsiveCodegen {
if (lowerName !== 'viewport' && lowerName !== 'effect') {
const sanitizedName = sanitizePropertyName(name)
if (definition.type === 'VARIANT') {
- variants[sanitizedName] =
- definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') ||
- ''
+ variants[sanitizedName] = definition.variantOptions
+ ? getVariantType(definition.variantOptions)
+ : ''
} else if (definition.type === 'INSTANCE_SWAP') {
variants[sanitizedName] = 'React.ReactNode'
} else if (definition.type === 'BOOLEAN') {
@@ -474,8 +623,8 @@ export class ResponsiveCodegen {
}
// Generate responsive code for each group
- const results: Array = []
- const responsiveCodegen = new ResponsiveCodegen(null)
+ const results: Array = []
+ const responsiveCodegen = new ResponsiveCodegen(null, options)
for (const [groupKey, viewportComponents] of groups) {
// Parse group key to get variant filter for getSelectorPropsForGroup
@@ -494,7 +643,7 @@ export class ResponsiveCodegen {
const treesByBreakpoint = new Map()
for (const [bp, component] of viewportComponents) {
let t = perfStart()
- const codegen = new Codegen(component)
+ const codegen = new Codegen(component, options)
const tree = await codegen.getTree()
perfEnd('Codegen.getTree(viewportVariant)', t)
@@ -531,6 +680,11 @@ export class ResponsiveCodegen {
finalVariants,
variantComments,
),
+ mergeImportMetadata(
+ [...treesByBreakpoint.values()].map((tree) =>
+ collectImportMetadataFromTree(tree, componentName),
+ ),
+ ),
] as const)
}
@@ -548,7 +702,8 @@ export class ResponsiveCodegen {
static async generateVariantResponsiveComponents(
componentSet: ComponentSetNode,
componentName: string,
- ): Promise> {
+ options: CodegenOptions = {},
+ ): Promise> {
const tTotal = perfStart()
// Find viewport and effect variant keys
@@ -577,9 +732,9 @@ export class ResponsiveCodegen {
const sanitizedName = sanitizePropertyName(name)
otherVariantKeys.push(name) // Keep original for Figma data access
variantKeyToSanitized[name] = sanitizedName
- variants[sanitizedName] =
- definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') ||
- ''
+ variants[sanitizedName] = definition.variantOptions
+ ? getVariantType(definition.variantOptions)
+ : ''
}
} else if (definition.type === 'INSTANCE_SWAP') {
const sanitizedName = sanitizePropertyName(name)
@@ -601,6 +756,7 @@ export class ResponsiveCodegen {
const r = await ResponsiveCodegen.generateEffectOnlyComponents(
componentSet,
componentName,
+ options,
)
perfEnd('generateVariantResponsiveComponents(total)', tTotal)
return r
@@ -613,6 +769,7 @@ export class ResponsiveCodegen {
componentName,
otherVariantKeys,
finalVariants,
+ options,
)
perfEnd('generateVariantResponsiveComponents(total)', tTotal)
return r
@@ -623,6 +780,7 @@ export class ResponsiveCodegen {
const r = await ResponsiveCodegen.generateViewportResponsiveComponents(
componentSet,
componentName,
+ options,
)
perfEnd('generateVariantResponsiveComponents(total)', tTotal)
return r
@@ -704,7 +862,7 @@ export class ResponsiveCodegen {
return []
}
- const responsiveCodegen = new ResponsiveCodegen(null)
+ const responsiveCodegen = new ResponsiveCodegen(null, options)
// Step 1: For each variant combination, merge by viewport to get responsive props
const responsivePropsByComposite = new Map<
@@ -721,7 +879,7 @@ export class ResponsiveCodegen {
const treesByBreakpoint = new Map()
for (const [bp, component] of viewportComponents) {
let t = perfStart()
- const codegen = new Codegen(component)
+ const codegen = new Codegen(component, options)
const tree = await codegen.getTree()
perfEnd('Codegen.getTree(variant)', t)
@@ -753,7 +911,7 @@ export class ResponsiveCodegen {
0,
)
- const result: Array = [
+ const result: Array = [
[
componentName,
renderComponent(
@@ -762,6 +920,14 @@ export class ResponsiveCodegen {
finalVariants,
variantComments,
),
+ mergeImportMetadata(
+ [...responsivePropsByComposite.values()].flatMap(
+ (treesByBreakpoint) =>
+ [...treesByBreakpoint.values()].map((tree) =>
+ collectImportMetadataFromTree(tree, componentName),
+ ),
+ ),
+ ),
],
]
return result
@@ -774,7 +940,8 @@ export class ResponsiveCodegen {
private static async generateEffectOnlyComponents(
componentSet: ComponentSetNode,
componentName: string,
- ): Promise> {
+ options: CodegenOptions = {},
+ ): Promise> {
// Use defaultVariant as the base component
const defaultComponent = componentSet.defaultVariant
if (!defaultComponent) {
@@ -782,7 +949,7 @@ export class ResponsiveCodegen {
}
// Get base props from defaultVariant
- const codegen = new Codegen(defaultComponent)
+ const codegen = new Codegen(defaultComponent, options)
const tree = await codegen.getTree()
// Get pseudo-selector props (hover, active, disabled, etc.)
@@ -812,10 +979,11 @@ export class ResponsiveCodegen {
const { variants: finalVariants, variantComments } =
applyTextChildrenTransform(variants)
- const result: Array = [
+ const result: Array = [
[
componentName,
renderComponent(componentName, code, finalVariants, variantComments),
+ collectImportMetadataFromTree(tree, componentName),
],
]
return result
@@ -829,7 +997,8 @@ export class ResponsiveCodegen {
componentName: string,
variantKeys: string[],
variants: Record,
- ): Promise> {
+ options: CodegenOptions = {},
+ ): Promise> {
if (variantKeys.length === 0) {
return []
}
@@ -862,6 +1031,7 @@ export class ResponsiveCodegen {
sanitizedVariantKeys[0],
variants,
hasEffect,
+ options,
)
}
@@ -925,6 +1095,14 @@ export class ResponsiveCodegen {
// Build trees for each combination
const treesByComposite = new Map()
+ const builtTreesByComposite = new Map<
+ string,
+ {
+ codegen: Codegen
+ builtTree: NodeTree
+ selectorProps: Record | null
+ }
+ >()
for (const [compositeKey, component] of componentsByComposite) {
const variantFilter = parseCompositeKeyToOriginal(compositeKey)
let t = perfStart()
@@ -934,20 +1112,32 @@ export class ResponsiveCodegen {
perfEnd('getSelectorPropsForGroup(nonViewport)', t)
t = perfStart()
- const codegen = new Codegen(component)
- const tree = await codegen.getTree()
+ const codegen = new Codegen(component, options)
+ const builtTree = await codegen.getTree()
perfEnd('Codegen.getTree(nonViewportVariant)', t)
- // Use the component tree from addComponentTree if available — it includes
- // ALL children (even invisible BOOLEAN-controlled ones) with condition fields
- // and INSTANCE_SWAP slot placeholders, which buildTree() skips.
- const componentTree = codegen.getComponentTree()
- if (componentTree) {
- tree.children = componentTree.tree.children
- }
+ builtTreesByComposite.set(compositeKey, {
+ codegen,
+ builtTree,
+ selectorProps,
+ })
+ }
+
+ const preferBuiltAssetTrees =
+ builtTreesByComposite.size > 0 &&
+ [...builtTreesByComposite.values()].every(({ builtTree }) =>
+ isAssetLeafTree(builtTree),
+ )
+
+ for (const [compositeKey, entry] of builtTreesByComposite) {
+ const tree = getVariantSourceTree(
+ entry.codegen,
+ entry.builtTree,
+ preferBuiltAssetTrees,
+ )
- if (selectorProps && Object.keys(selectorProps).length > 0) {
- tree.props = Object.assign({}, tree.props, selectorProps)
+ if (entry.selectorProps && Object.keys(entry.selectorProps).length > 0) {
+ tree.props = Object.assign({}, tree.props, entry.selectorProps)
}
treesByComposite.set(compositeKey, tree)
}
@@ -964,15 +1154,23 @@ export class ResponsiveCodegen {
treesByCompositeAndBreakpoint.set(compositeKey, singleBreakpointMap)
}
- const responsiveCodegen = new ResponsiveCodegen(null)
+ const responsiveCodegen = new ResponsiveCodegen(null, options)
const mergedCode = responsiveCodegen.generateMultiVariantMergedCode(
sanitizedVariantKeys,
treesByCompositeAndBreakpoint,
0,
)
- const result: Array = [
- [componentName, renderComponent(componentName, mergedCode, variants)],
+ const result: Array = [
+ [
+ componentName,
+ renderComponent(componentName, mergedCode, variants),
+ mergeImportMetadata(
+ [...treesByComposite.values()].map((tree) =>
+ collectImportMetadataFromTree(tree, componentName),
+ ),
+ ),
+ ],
]
return result
}
@@ -987,7 +1185,8 @@ export class ResponsiveCodegen {
sanitizedVariantKey: string,
variants: Record,
hasEffect: boolean,
- ): Promise> {
+ options: CodegenOptions = {},
+ ): Promise> {
// Group components by variant value
const componentsByVariant = new Map()
@@ -1005,6 +1204,14 @@ export class ResponsiveCodegen {
// Build trees for each variant
const treesByVariant = new Map()
+ const builtTreesByVariant = new Map<
+ string,
+ {
+ codegen: Codegen
+ builtTree: NodeTree
+ selectorProps: Record | null
+ }
+ >()
for (const [variantValue, component] of componentsByVariant) {
const variantFilter: Record = {
[variantKey]: variantValue,
@@ -1016,34 +1223,54 @@ export class ResponsiveCodegen {
perfEnd('getSelectorPropsForGroup(nonViewport)', t)
t = perfStart()
- const codegen = new Codegen(component)
- const tree = await codegen.getTree()
+ const codegen = new Codegen(component, options)
+ const builtTree = await codegen.getTree()
perfEnd('Codegen.getTree(nonViewportVariant)', t)
- // Use the component tree from addComponentTree if available — it includes
- // ALL children (even invisible BOOLEAN-controlled ones) with condition fields
- // and INSTANCE_SWAP slot placeholders, which buildTree() skips.
- const componentTree = codegen.getComponentTree()
- if (componentTree) {
- tree.children = componentTree.tree.children
- }
+ builtTreesByVariant.set(variantValue, {
+ codegen,
+ builtTree,
+ selectorProps,
+ })
+ }
- if (selectorProps && Object.keys(selectorProps).length > 0) {
- tree.props = Object.assign({}, tree.props, selectorProps)
+ const preferBuiltAssetTrees =
+ builtTreesByVariant.size > 0 &&
+ [...builtTreesByVariant.values()].every(({ builtTree }) =>
+ isAssetLeafTree(builtTree),
+ )
+
+ for (const [variantValue, entry] of builtTreesByVariant) {
+ const tree = getVariantSourceTree(
+ entry.codegen,
+ entry.builtTree,
+ preferBuiltAssetTrees,
+ )
+
+ if (entry.selectorProps && Object.keys(entry.selectorProps).length > 0) {
+ tree.props = Object.assign({}, tree.props, entry.selectorProps)
}
treesByVariant.set(variantValue, tree)
}
// Generate merged code with variant conditionals
- const responsiveCodegen = new ResponsiveCodegen(null)
+ const responsiveCodegen = new ResponsiveCodegen(null, options)
const mergedCode = responsiveCodegen.generateVariantOnlyMergedCode(
sanitizedVariantKey,
treesByVariant,
0,
)
- const result: Array = [
- [componentName, renderComponent(componentName, mergedCode, variants)],
+ const result: Array = [
+ [
+ componentName,
+ renderComponent(componentName, mergedCode, variants),
+ mergeImportMetadata(
+ [...treesByVariant.values()].map((tree) =>
+ collectImportMetadataFromTree(tree, componentName),
+ ),
+ ),
+ ],
]
return result
}
@@ -1173,6 +1400,21 @@ export class ResponsiveCodegen {
treesByVariant: Map,
depth: number,
): string {
+ if (hasMixedRootComponents(treesByVariant.values())) {
+ const conditionalTrees: string[] = []
+
+ for (const [variant, tree] of treesByVariant) {
+ conditionalTrees.push(
+ wrapConditionalJsx(
+ `${variantKey} === "${variant}"`,
+ Codegen.renderTree(tree, 0),
+ ),
+ )
+ }
+
+ return renderFragment(conditionalTrees, depth)
+ }
+
const firstTree = firstMapValue(treesByVariant)
// Merge props across variants
@@ -1378,6 +1620,23 @@ export class ResponsiveCodegen {
treesByComposite: Map,
depth: number,
): string {
+ if (hasMixedRootComponents(treesByComposite.values())) {
+ const conditionalTrees: string[] = []
+
+ for (const [compositeKey, tree] of treesByComposite) {
+ const parsed = this.parseCompositeKey(compositeKey)
+ const condition = variantKeys
+ .map((variantKey) => `${variantKey} === "${parsed[variantKey]}"`)
+ .join(' && ')
+
+ conditionalTrees.push(
+ wrapConditionalJsx(condition, Codegen.renderTree(tree, 0)),
+ )
+ }
+
+ return renderFragment(conditionalTrees, depth)
+ }
+
const firstTree = firstMapValue(treesByComposite)
// Build props map indexed by composite key
diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
index 5d73e03..8bbb482 100644
--- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
+++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts
@@ -40,13 +40,23 @@ const mockGetTree = mock(
}),
)
+const mockGetComponentTree = mock(() => undefined)
+
+const defaultGetTreeImplementation = async (): Promise => ({
+ component: 'Box',
+ props: { id: 'test' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'test',
+})
+
const mockRenderTree = mock((tree: NodeTree, depth: number) =>
renderNodeMock(tree.component, tree.props, depth, []),
)
const MockCodegen = class {
getTree = mockGetTree
- getComponentTree = mock(() => undefined)
+ getComponentTree = mockGetComponentTree
static renderTree = mockRenderTree
}
@@ -63,6 +73,9 @@ describe('ResponsiveCodegen', () => {
;({ ResponsiveCodegen } = await import('../ResponsiveCodegen'))
renderNodeMock.mockClear()
mockGetTree.mockClear()
+ mockGetTree.mockImplementation(defaultGetTreeImplementation)
+ mockGetComponentTree.mockClear()
+ mockGetComponentTree.mockImplementation(() => undefined)
mockRenderTree.mockClear()
})
@@ -110,6 +123,157 @@ describe('ResponsiveCodegen', () => {
expect(result.startsWith('render:Box')).toBeTrue()
})
+ it('returns import metadata from generateResponsiveResult', async () => {
+ const child = makeNode('desktop', 1200, [], 'FRAME')
+ const section = {
+ type: 'SECTION',
+ children: [child],
+ } as unknown as SectionNode
+
+ mockGetTree.mockImplementation(async () => ({
+ component: 'Box',
+ props: {
+ slot: {
+ __imports: {
+ devupImports: ['Text'],
+ customImports: ['Status'],
+ usesKeyframes: true,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Desktop',
+ }))
+
+ const generator = new ResponsiveCodegen(section)
+ const result = await generator.generateResponsiveResult()
+
+ expect(result.code.startsWith('render:Box')).toBeTrue()
+ expect(result.imports.devupImports).toEqual(['Box', 'Text'])
+ expect(result.imports.customImports).toEqual(['Status'])
+ expect(result.imports.usesKeyframes).toBe(true)
+ expect(generator.getImportMetadata()).toEqual(result.imports)
+ })
+
+ it('merges import metadata across multiple breakpoints in generateResponsiveResult', async () => {
+ const mobile = makeNode('mobile', 320, [], 'FRAME')
+ const desktop = makeNode('desktop', 1200, [], 'FRAME')
+ const section = {
+ type: 'SECTION',
+ children: [mobile, desktop],
+ } as unknown as SectionNode
+
+ let callCount = 0
+ mockGetTree.mockImplementation(async () => {
+ callCount++
+ return callCount === 1
+ ? {
+ component: 'Box',
+ props: {
+ mobileOnly: {
+ __imports: {
+ devupImports: ['Text'],
+ customImports: ['MobileBadge'],
+ usesKeyframes: false,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Mobile',
+ }
+ : {
+ component: 'Box',
+ props: {
+ desktopOnly: {
+ __imports: {
+ devupImports: ['Image'],
+ customImports: ['DesktopBadge'],
+ usesKeyframes: true,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Desktop',
+ }
+ })
+
+ const generator = new ResponsiveCodegen(section)
+ const result = await generator.generateResponsiveResult()
+
+ expect(result.code).toContain('render:Box')
+ expect(result.imports.devupImports).toEqual(['Box', 'Image', 'Text'])
+ expect(result.imports.customImports).toEqual([
+ 'DesktopBadge',
+ 'MobileBadge',
+ ])
+ expect(result.imports.usesKeyframes).toBe(true)
+ })
+
+ it('exposes stable structural child keys for unique leaf children', () => {
+ const generator = new ResponsiveCodegen(null)
+ const leaf: NodeTree = {
+ component: 'Box',
+ props: { bg: '$success', boxSize: '16px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'trend-up 1',
+ }
+
+ const key = (
+ generator as unknown as {
+ getChildStructureSignature: (tree: NodeTree) => string
+ treeChildrenToMap: (tree: NodeTree) => Map
+ }
+ ).getChildStructureSignature(leaf)
+
+ expect(key).toContain('Box')
+
+ const map = (
+ generator as unknown as {
+ treeChildrenToMap: (tree: NodeTree) => Map
+ }
+ ).treeChildrenToMap({
+ component: 'Center',
+ props: {},
+ children: [leaf],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ })
+
+ expect([...map.keys()][0]).toContain('sig:')
+ })
+
+ it('computes structural signatures for non-leaf children recursively', () => {
+ const generator = new ResponsiveCodegen(null)
+ const nonLeaf: NodeTree = {
+ component: 'Center',
+ props: { gap: '4px' },
+ children: [
+ {
+ component: 'Box',
+ props: { bg: '$success', boxSize: '16px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Icon',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Trend',
+ }
+
+ const signature = (
+ generator as unknown as {
+ getChildStructureSignature: (tree: NodeTree) => string
+ }
+ ).getChildStructureSignature(nonLeaf)
+
+ expect(signature).toContain('Center')
+ expect(signature).toContain('Box')
+ })
+
it('merges breakpoints and adds display for missing child variants', async () => {
const onlyMobileChild: NodeTree = {
component: 'Box',
@@ -890,6 +1054,55 @@ describe('ResponsiveCodegen', () => {
expect(result).toContain('size === "lg"')
expect(result).not.toContain('leftIcon')
})
+
+ it('falls back to conditional full trees when root components differ across variants', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const treesByVariant = new Map([
+ [
+ 'success',
+ {
+ component: 'Image',
+ props: { src: '/icons/status=success.svg' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Status',
+ },
+ ],
+ [
+ 'error',
+ {
+ component: 'Box',
+ props: { boxSize: '24px' },
+ children: [
+ {
+ component: 'Box',
+ props: { bg: '#E01444', borderRadius: '50%' },
+ children: [],
+ nodeType: 'ELLIPSE',
+ nodeName: 'Ellipse',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Status',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'status',
+ treesByVariant,
+ 0,
+ )
+
+ expect(result).toContain('status === "success"')
+ expect(result).toContain('status === "error"')
+ expect(result).toContain('render:Image:depth=0')
+ expect(result).toContain('render:Box:depth=0')
+ expect(result).not.toContain(
+ 'render:Image:depth=0:{"src":"/icons/status=success.svg"}|render:Box',
+ )
+ })
})
describe('createNestedVariantProp optimization', () => {
@@ -1501,7 +1714,269 @@ describe('ResponsiveCodegen', () => {
expect(result[0][1]).toContain('status')
})
+ it('covers viewport-only import metadata aggregation callbacks', async () => {
+ let callCount = 0
+ mockGetTree.mockImplementation(async () => {
+ callCount++
+ return callCount === 1
+ ? {
+ component: 'Box',
+ props: {
+ mobileOnly: {
+ __imports: {
+ devupImports: ['Text'],
+ customImports: ['MobileOnly'],
+ usesKeyframes: false,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Mobile',
+ }
+ : {
+ component: 'Box',
+ props: {
+ desktopOnly: {
+ __imports: {
+ devupImports: ['Image'],
+ customImports: ['DesktopOnly'],
+ usesKeyframes: true,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Desktop',
+ }
+ })
+
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'ViewportOnlyImports',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile',
+ variantProperties: { viewport: 'mobile' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop',
+ variantProperties: { viewport: 'desktop' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateViewportResponsiveComponents(
+ componentSet,
+ 'ViewportOnlyImports',
+ )
+
+ expect(result[0]?.[2]).toEqual({
+ devupImports: ['Box', 'Image', 'Text'],
+ customImports: ['DesktopOnly', 'MobileOnly'],
+ usesKeyframes: true,
+ })
+ })
+
+ it('covers viewport+variant import metadata aggregation callbacks', async () => {
+ let callCount = 0
+ mockGetTree.mockImplementation(async () => {
+ callCount++
+ return callCount % 2 === 1
+ ? {
+ component: 'Box',
+ props: {
+ mobileOnly: {
+ __imports: {
+ devupImports: ['Text'],
+ customImports: ['VariantMobile'],
+ usesKeyframes: false,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Mobile',
+ }
+ : {
+ component: 'Box',
+ props: {
+ desktopOnly: {
+ __imports: {
+ devupImports: ['Image'],
+ customImports: ['VariantDesktop'],
+ usesKeyframes: true,
+ },
+ },
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Desktop',
+ }
+ })
+
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'ViewportAndVariantImports',
+ componentPropertyDefinitions: {
+ viewport: {
+ type: 'VARIANT',
+ variantOptions: ['mobile', 'desktop'],
+ },
+ status: {
+ type: 'VARIANT',
+ variantOptions: ['default', 'alt'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'viewport=mobile, status=default',
+ variantProperties: { viewport: 'mobile', status: 'default' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 320,
+ height: 100,
+ },
+ {
+ type: 'COMPONENT',
+ name: 'viewport=desktop, status=default',
+ variantProperties: { viewport: 'desktop', status: 'default' },
+ children: [],
+ layoutMode: 'VERTICAL',
+ width: 1200,
+ height: 100,
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'ViewportAndVariantImports',
+ )
+
+ expect(result[0]?.[2]).toEqual({
+ devupImports: ['Box', 'Image', 'Text'],
+ customImports: ['VariantDesktop', 'VariantMobile'],
+ usesKeyframes: true,
+ })
+ })
+
+ it('prefers collapsed asset trees for non-viewport variants when the built tree is already an asset leaf', async () => {
+ let treeCallCount = 0
+ mockGetTree.mockImplementation(async () => {
+ treeCallCount++
+
+ return {
+ component: 'Image',
+ props: {
+ src:
+ treeCallCount === 1
+ ? '/icons/status=success.svg'
+ : '/icons/status=error.svg',
+ },
+ children: [],
+ nodeType: 'COMPONENT',
+ nodeName: 'Status',
+ }
+ })
+
+ mockGetComponentTree.mockImplementation((() => ({
+ name: 'Status',
+ node: {} as SceneNode,
+ tree: {
+ component: 'Box',
+ props: { boxSize: '24px' },
+ children: [
+ {
+ component: 'Box',
+ props: { bg: '#E01444' },
+ children: [],
+ nodeType: 'ELLIPSE',
+ nodeName: 'Ellipse',
+ },
+ ],
+ nodeType: 'COMPONENT',
+ nodeName: 'Status',
+ },
+ variants: {},
+ })) as never)
+
+ const componentSet = {
+ type: 'COMPONENT_SET',
+ name: 'Status',
+ componentPropertyDefinitions: {
+ status: {
+ type: 'VARIANT',
+ variantOptions: ['success', 'error'],
+ },
+ },
+ children: [
+ {
+ type: 'COMPONENT',
+ name: 'status=success',
+ variantProperties: { status: 'success' },
+ children: [],
+ },
+ {
+ type: 'COMPONENT',
+ name: 'status=error',
+ variantProperties: { status: 'error' },
+ children: [],
+ },
+ ],
+ } as unknown as ComponentSetNode
+
+ const result =
+ await ResponsiveCodegen.generateVariantResponsiveComponents(
+ componentSet,
+ 'Status',
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0][1]).toContain('render:Image:depth=0')
+ expect(result[0][1]).toContain('/icons/status=success.svg')
+ expect(result[0][1]).toContain('/icons/status=error.svg')
+ expect(result[0][1]).not.toContain(
+ 'render:Box:depth=0:{"boxSize":"24px"}',
+ )
+ })
+
it('handles effect + viewport + size + variant (4 dimensions)', async () => {
+ mockGetTree.mockImplementation(async () => ({
+ component: 'Box',
+ props: { id: 'RootTablet' },
+ children: [
+ {
+ component: 'Box',
+ props: { id: 'Shared' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Shared',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'RootTablet',
+ }))
+
// Button component with:
// - effect: default, hover, active
// - viewport: Desktop, Mobile
@@ -2784,6 +3259,191 @@ describe('ResponsiveCodegen', () => {
})
})
+ describe('generateVariantOnlyMergedCode with structurally equivalent asset children', () => {
+ it('merges asset-like children even when node names differ', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const treesByVariant = new Map([
+ [
+ 'up',
+ {
+ component: 'Center',
+ props: { gap: '4px' },
+ children: [
+ {
+ component: 'Box',
+ props: {
+ aspectRatio: '1',
+ bg: '$success',
+ boxSize: '16px',
+ maskImage: "url('/icons/trend-up 1.svg')",
+ maskPos: 'center',
+ maskRepeat: 'no-repeat',
+ maskSize: 'contain',
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'trend-up 1',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'down',
+ {
+ component: 'Center',
+ props: { gap: '4px' },
+ children: [
+ {
+ component: 'Box',
+ props: {
+ aspectRatio: '1',
+ bg: '$error',
+ boxSize: '16px',
+ maskImage: "url('/icons/trend-down 1.svg')",
+ maskPos: 'center',
+ maskRepeat: 'no-repeat',
+ maskSize: 'contain',
+ },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'trend-down 1',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ])
+
+ const result = generator.generateVariantOnlyMergedCode(
+ 'grpah',
+ treesByVariant,
+ 0,
+ )
+
+ expect(result).not.toContain('grpah === "up" && render:Box')
+ expect(result).not.toContain('grpah === "down" && render:Box')
+ expect(result).toContain('"variantKey":"grpah"')
+ expect(result).toContain('trend-up 1.svg')
+ expect(result).toContain('trend-down 1.svg')
+ expect(result).toMatchSnapshot()
+ })
+ })
+
+ describe('private helper coverage', () => {
+ it('covers parseCompositeKey, getSharedCondition, mergeChildrenAcrossBreakpoints, and mergePropsAcrossComposites', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const parsed = (
+ generator as unknown as {
+ parseCompositeKey: (compositeKey: string) => Record
+ }
+ ).parseCompositeKey('size=lg|variant=primary')
+ expect(parsed).toEqual({ size: 'lg', variant: 'primary' })
+
+ const sharedCondition = (
+ generator as unknown as {
+ getSharedCondition: (
+ childMap: Map,
+ ) => string | undefined
+ }
+ ).getSharedCondition(
+ new Map([
+ [
+ 'a',
+ {
+ component: 'Box',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'A',
+ condition: 'showIcon',
+ },
+ ],
+ [
+ 'b',
+ {
+ component: 'Box',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'B',
+ condition: 'showIcon',
+ },
+ ],
+ ]),
+ )
+ expect(sharedCondition).toBe('showIcon')
+
+ const mergedChildren = (
+ generator as unknown as {
+ mergeChildrenAcrossBreakpoints: (
+ treesByBreakpoint: Map,
+ ) => NodeTree[]
+ }
+ ).mergeChildrenAcrossBreakpoints(
+ new Map([
+ [
+ 'mobile' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Box',
+ props: { w: '10px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Shared',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ [
+ 'pc' as import('../index').BreakpointKey,
+ {
+ component: 'Flex',
+ props: {},
+ children: [
+ {
+ component: 'Box',
+ props: { w: '20px' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Shared',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ },
+ ],
+ ]),
+ )
+ expect(mergedChildren).toHaveLength(1)
+
+ const mergedCompositeProps = (
+ generator as unknown as {
+ mergePropsAcrossComposites: (
+ variantKeys: string[],
+ propsMap: Map>,
+ ) => Record
+ }
+ ).mergePropsAcrossComposites(
+ ['size', 'variant'],
+ new Map([
+ ['size=lg|variant=primary', { bg: '$successBg' }],
+ ['size=lg|variant=down', { bg: '$errorBg' }],
+ ]),
+ )
+ expect(mergedCompositeProps.bg).toBeTruthy()
+ })
+ })
+
describe('generateNestedVariantMergedCode with differing textChildren controlled by one key', () => {
it('creates variant-mapped text when texts differ and one key controls the text', () => {
const generator = new ResponsiveCodegen(null)
@@ -2858,6 +3518,65 @@ describe('ResponsiveCodegen', () => {
})
})
+ describe('generateNestedVariantMergedCode with mixed root components', () => {
+ it('falls back to composite-conditional full trees when root components differ', () => {
+ const generator = new ResponsiveCodegen(null)
+
+ const treesByComposite = new Map([
+ [
+ 'size=Md|variant=success',
+ {
+ component: 'Image',
+ props: { src: '/icons/status=success.svg' },
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Status',
+ },
+ ],
+ [
+ 'size=Md|variant=error',
+ {
+ component: 'Box',
+ props: { boxSize: '24px' },
+ children: [
+ {
+ component: 'Box',
+ props: { bg: '#E01444' },
+ children: [],
+ nodeType: 'ELLIPSE',
+ nodeName: 'Ellipse',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Status',
+ },
+ ],
+ ])
+
+ const result = (
+ generator as unknown as {
+ generateNestedVariantMergedCode: (
+ variantKeys: string[],
+ trees: Map,
+ depth: number,
+ ) => string
+ }
+ ).generateNestedVariantMergedCode(
+ ['size', 'variant'],
+ treesByComposite,
+ 0,
+ )
+
+ expect(result).toContain('size === "Md" && variant === "success"')
+ expect(result).toContain('size === "Md" && variant === "error"')
+ expect(result).toContain('render:Image:depth=0')
+ expect(result).toContain('render:Box:depth=0')
+ expect(result).not.toContain(
+ 'render:Image:depth=0:{"src":"/icons/status=success.svg"}|render:Box',
+ )
+ })
+ })
+
describe('child ordering across variants', () => {
it('preserves relative child order when merging variants with different children', () => {
const generator = new ResponsiveCodegen(null)
diff --git a/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap b/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap
index 7184667..0fbed94 100644
--- a/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap
+++ b/src/codegen/responsive/__tests__/__snapshots__/ResponsiveCodegen.test.ts.snap
@@ -3,3 +3,5 @@
exports[`ResponsiveCodegen generateVariantOnlyMergedCode renders OR conditional for child existing in multiple but not all variants 1`] = `"render:Flex:depth=0:{}|{(status === "scroll" || status === "hover") && render:Box:depth=0:{"id":{"__variantProp":true,"variantKey":"status","values":{"scroll":"PartialChild","hover":"PartialChildHover"}}}|}"`;
exports[`ResponsiveCodegen generateVariantResponsiveComponents handles effect + viewport + size + variant (4 dimensions) 1`] = `"component:Button:{"size":"'Md' | 'Sm'","variant":"'primary' | 'white'"}|render:Box:depth=0:{"id":"RootTablet","_hover":{"bg":{"__variantProp":true,"variantKey":"variant","values":{"primary":"#3D2B1F","white":"#F2F2F2"}}},"_active":{"bg":{"__variantProp":true,"variantKey":"variant","values":{"primary":"#30241A","white":"#E6E6E6"}}}}|render:Box:depth=0:{"id":"Shared"}|"`;
+
+exports[`ResponsiveCodegen generateVariantOnlyMergedCode with structurally equivalent asset children merges asset-like children even when node names differ 1`] = `"render:Center:depth=0:{"gap":"4px"}|render:Box:depth=0:{"aspectRatio":"1","bg":{"__variantProp":true,"variantKey":"grpah","values":{"up":"$success","down":"$error"}},"boxSize":"16px","maskImage":{"__variantProp":true,"variantKey":"grpah","values":{"up":"url('/icons/trend-up 1.svg')","down":"url('/icons/trend-down 1.svg')"}},"maskPos":"center","maskRepeat":"no-repeat","maskSize":"contain"}|"`;
diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts
index a27e0c1..16b8497 100644
--- a/src/codegen/responsive/index.ts
+++ b/src/codegen/responsive/index.ts
@@ -1,3 +1,8 @@
+import {
+ getBooleanVariantAccessor,
+ isBooleanVariantOptions,
+ normalizeBooleanVariantKey,
+} from '../utils/boolean-variant'
import { isDefaultProp } from '../utils/is-default-prop'
// Breakpoint thresholds (by width)
@@ -457,6 +462,7 @@ export interface VariantPropValue {
__variantProp: true
variantKey: string // e.g., 'status'
values: Record // e.g., { scroll: [1, 2], default: [3, 4] }
+ accessorExpression?: string
}
/**
@@ -478,10 +484,22 @@ export function createVariantPropValue(
variantKey: string,
values: Record,
): VariantPropValue {
+ const valueKeys = Object.keys(values)
+ const normalizedValues = isBooleanVariantOptions(valueKeys)
+ ? Object.fromEntries(
+ Object.entries(values).map(([key, value]) => [
+ normalizeBooleanVariantKey(key),
+ value,
+ ]),
+ )
+ : values
return {
__variantProp: true,
variantKey,
- values,
+ values: normalizedValues,
+ accessorExpression: isBooleanVariantOptions(valueKeys)
+ ? getBooleanVariantAccessor(variantKey)
+ : undefined,
}
}
diff --git a/src/codegen/types.ts b/src/codegen/types.ts
index bb9088f..73a2c61 100644
--- a/src/codegen/types.ts
+++ b/src/codegen/types.ts
@@ -1,8 +1,14 @@
+export type NodeBoundVariables =
+ | Record
+ | undefined
+ | null
+
export interface NodeContext {
isAsset: 'svg' | 'png' | null
canBeAbsolute: boolean
isPageRoot: boolean
pageNode: SceneNode | null
+ boundVariables?: NodeBoundVariables
}
export type Props = Record
@@ -17,6 +23,7 @@ export interface NodeTree {
isSlot?: boolean // true if this is an INSTANCE_SWAP slot — renders as {component}
condition?: string // BOOLEAN prop name — renders as {condition && <.../>}
textChildren?: string[] // raw text content for TEXT nodes
+ leadingComment?: string // optional JSX comment emitted immediately before this node
}
export interface ComponentTree {
diff --git a/src/codegen/utils/__tests__/check-asset-node.test.ts b/src/codegen/utils/__tests__/check-asset-node.test.ts
new file mode 100644
index 0000000..af6e018
--- /dev/null
+++ b/src/codegen/utils/__tests__/check-asset-node.test.ts
@@ -0,0 +1,650 @@
+import { beforeEach, describe, expect, test } from 'bun:test'
+import { analyzeAssetNode, resetCheckAssetNodeCache } from '../check-asset-node'
+import { resetVariableCache } from '../variable-cache'
+
+describe('analyzeAssetNode', () => {
+ beforeEach(() => {
+ resetCheckAssetNodeCache()
+ resetVariableCache()
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ } as unknown as typeof figma
+ })
+
+ test('returns svg + sameColor for a component with one solid vector child', async () => {
+ const child = {
+ type: 'VECTOR',
+ id: 'glyph',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'COMPONENT',
+ id: 'status-error',
+ visible: true,
+ children: [child],
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'svg',
+ sameColor: '#FFF',
+ })
+ })
+
+ test('returns null for excluded root types', async () => {
+ const textNode = {
+ type: 'TEXT',
+ id: 'text-node',
+ } as unknown as SceneNode
+
+ const componentSetNode = {
+ type: 'COMPONENT_SET',
+ id: 'component-set-node',
+ } as unknown as SceneNode
+
+ const gridNode = {
+ type: 'FRAME',
+ id: 'grid-node',
+ inferredAutoLayout: { layoutMode: 'GRID' },
+ } as unknown as SceneNode
+
+ await expect(analyzeAssetNode(textNode)).resolves.toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ await expect(analyzeAssetNode(componentSetNode)).resolves.toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ await expect(analyzeAssetNode(gridNode)).resolves.toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null for animation targets', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'animated-node',
+ reactions: [
+ {
+ actions: [
+ {
+ type: 'NODE',
+ transition: { type: 'SMART_ANIMATE' },
+ },
+ ],
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns svg for ellipse with inner radius', async () => {
+ const node = {
+ type: 'ELLIPSE',
+ id: 'ellipse-svg',
+ arcData: { innerRadius: 0.5 },
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'svg',
+ sameColor: '#FFF',
+ })
+ })
+
+ test('returns svg for star nodes', async () => {
+ const node = {
+ type: 'STAR',
+ id: 'star-node',
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'svg',
+ sameColor: '#FFF',
+ })
+ })
+
+ test('keeps sameColor null when wrapper has non-solid own fill', async () => {
+ const child = {
+ type: 'VECTOR',
+ id: 'glyph-2',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'instagram-like',
+ visible: true,
+ children: [child, child],
+ fills: [
+ {
+ type: 'GRADIENT_LINEAR',
+ visible: true,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'svg',
+ sameColor: null,
+ })
+ })
+
+ test('returns svg for nested asset leaf with all solid fills', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'nested-solid-leaf',
+ isAsset: true,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node, true)).toEqual({
+ assetType: 'svg',
+ sameColor: '#FFF',
+ })
+ })
+
+ test('returns null for asset leaf with no fills metadata', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'asset-no-fills',
+ isAsset: true,
+ visible: true,
+ children: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null for non-nested asset leaf with only solid fills', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'asset-solid-root',
+ isAsset: true,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null for tile/pattern asset leaf', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'asset-pattern-leaf',
+ isAsset: true,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'PATTERN',
+ visible: true,
+ },
+ ],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null for asset leaf with multiple image fills', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'asset-multi-image',
+ isAsset: true,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'IMAGE',
+ visible: true,
+ scaleMode: 'FILL',
+ },
+ {
+ type: 'IMAGE',
+ visible: true,
+ scaleMode: 'FIT',
+ },
+ ],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns png for asset leaf with a single image fill', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'asset-single-image',
+ isAsset: true,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'IMAGE',
+ visible: true,
+ scaleMode: 'FILL',
+ },
+ ],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'png',
+ sameColor: null,
+ })
+ })
+
+ test('returns svg for nested non-asset leaf with solid fills only', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'nested-non-asset-leaf',
+ isAsset: false,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node, true)).toEqual({
+ assetType: 'svg',
+ sameColor: '#F00',
+ })
+ })
+
+ test('returns null for non-nested non-asset leaf', async () => {
+ const node = {
+ type: 'FRAME',
+ id: 'root-non-asset-leaf',
+ isAsset: false,
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns false sameColor for multi-child svg asset with conflicting own solid colors', async () => {
+ const child = {
+ type: 'VECTOR',
+ id: 'child-svg',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'multi-child-own-false',
+ visible: true,
+ children: [child, child],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ strokes: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0, b: 1 },
+ opacity: 1,
+ },
+ ],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'svg',
+ sameColor: false,
+ })
+ })
+
+ test('returns null when single-child wrapper has padding', async () => {
+ const child = {
+ type: 'VECTOR',
+ id: 'single-child',
+ visible: true,
+ isAsset: true,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'padded-wrapper',
+ visible: true,
+ children: [child],
+ paddingLeft: 4,
+ paddingRight: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ fills: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null when a multi-child wrapper has a non-svg child', async () => {
+ const svgChild = {
+ type: 'VECTOR',
+ id: 'svg-child-ok',
+ visible: true,
+ isAsset: true,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const nonSvgChild = {
+ type: 'FRAME',
+ id: 'non-svg-child',
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'IMAGE',
+ visible: true,
+ scaleMode: 'FILL',
+ },
+ ],
+ isAsset: true,
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'mixed-children-wrapper',
+ visible: true,
+ children: [svgChild, nonSvgChild],
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null when non-solid own color wrapper also has a non-svg child', async () => {
+ const svgChild = {
+ type: 'VECTOR',
+ id: 'svg-child-own-null',
+ visible: true,
+ isAsset: true,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const nonSvgChild = {
+ type: 'FRAME',
+ id: 'non-svg-child-own-null',
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'IMAGE',
+ visible: true,
+ scaleMode: 'FILL',
+ },
+ ],
+ isAsset: true,
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'own-null-non-svg-wrapper',
+ visible: true,
+ children: [svgChild, nonSvgChild],
+ fills: [
+ {
+ type: 'GRADIENT_LINEAR',
+ visible: true,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('returns null when conflicting own solid colors wrapper also has a non-svg child', async () => {
+ const svgChild = {
+ type: 'VECTOR',
+ id: 'svg-child-own-false',
+ visible: true,
+ isAsset: true,
+ fills: [],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const nonSvgChild = {
+ type: 'FRAME',
+ id: 'non-svg-child-own-false',
+ visible: true,
+ children: [],
+ fills: [
+ {
+ type: 'IMAGE',
+ visible: true,
+ scaleMode: 'FILL',
+ },
+ ],
+ isAsset: true,
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'own-false-non-svg-wrapper',
+ visible: true,
+ children: [svgChild, nonSvgChild],
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ strokes: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0, b: 1 },
+ opacity: 1,
+ },
+ ],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: null,
+ sameColor: null,
+ })
+ })
+
+ test('single-child wrapper delegates to child analysis when clean', async () => {
+ const child = {
+ type: 'VECTOR',
+ id: 'delegated-child',
+ visible: true,
+ isAsset: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 1, b: 1 },
+ opacity: 1,
+ },
+ ],
+ strokes: [],
+ effects: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ const node = {
+ type: 'FRAME',
+ id: 'delegating-wrapper',
+ visible: true,
+ children: [child],
+ fills: [],
+ reactions: [],
+ } as unknown as SceneNode
+
+ expect(await analyzeAssetNode(node)).toEqual({
+ assetType: 'svg',
+ sameColor: '#FFF',
+ })
+ })
+})
diff --git a/src/codegen/utils/__tests__/check-same-color.test.ts b/src/codegen/utils/__tests__/check-same-color.test.ts
new file mode 100644
index 0000000..f9ddf49
--- /dev/null
+++ b/src/codegen/utils/__tests__/check-same-color.test.ts
@@ -0,0 +1,107 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test'
+import { checkSameColor, resetCheckSameColorCache } from '../check-same-color'
+import { resetVariableCache } from '../variable-cache'
+
+describe('checkSameColor cache', () => {
+ beforeEach(() => {
+ resetCheckSameColorCache()
+ resetVariableCache()
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: mock(async (id: string) => {
+ if (id === 'color-var') {
+ return { id, name: 'brand/primary' }
+ }
+ return null
+ }),
+ },
+ } as unknown as typeof figma
+ })
+
+ test('returns cached null when called again with a target color', async () => {
+ const node = {
+ id: 'node-null',
+ type: 'RECTANGLE',
+ fills: [{ visible: true, type: 'GRADIENT_LINEAR' }],
+ } as unknown as SceneNode
+
+ expect(await checkSameColor(node)).toBeNull()
+ expect(await checkSameColor(node, '#ff0000')).toBeNull()
+ })
+
+ test('returns cached false when called again with a target color', async () => {
+ const node = {
+ id: 'node-false',
+ type: 'FRAME',
+ children: [
+ {
+ type: 'RECTANGLE',
+ visible: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ },
+ {
+ type: 'RECTANGLE',
+ visible: true,
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 0, g: 0, b: 1 },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await checkSameColor(node)).toBeFalse()
+ expect(await checkSameColor(node, '#ff0000')).toBeFalse()
+ })
+
+ test('compares target color against cached uniform color', async () => {
+ const node = {
+ id: 'node-color',
+ type: 'RECTANGLE',
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await checkSameColor(node)).toBe('#F00')
+ expect(await checkSameColor(node, '#F00')).toBe('#F00')
+ expect(await checkSameColor(node, '#00FF00')).toBeFalse()
+ })
+
+ test('resolves variable-bound solid colors through async path', async () => {
+ const node = {
+ id: 'node-variable',
+ type: 'RECTANGLE',
+ fills: [
+ {
+ type: 'SOLID',
+ visible: true,
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ boundVariables: {
+ color: { id: 'color-var' },
+ },
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await checkSameColor(node)).toBe('$brandPrimary')
+ })
+})
diff --git a/src/codegen/utils/__tests__/collect-import-metadata.test.ts b/src/codegen/utils/__tests__/collect-import-metadata.test.ts
new file mode 100644
index 0000000..e83798b
--- /dev/null
+++ b/src/codegen/utils/__tests__/collect-import-metadata.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, test } from 'bun:test'
+
+import type { NodeTree } from '../../types'
+import {
+ collectImportMetadataFromTree,
+ mergeImportMetadata,
+} from '../collect-import-metadata'
+
+describe('collectImportMetadataFromTree', () => {
+ test('collects devup imports, custom imports, keyframes, arrays, and slot metadata', () => {
+ const tree: NodeTree = {
+ component: 'Box',
+ props: {
+ animationName: 'keyframes({"0%":{"opacity":0}})',
+ values: [
+ 'plain',
+ {
+ __imports: {
+ devupImports: ['Text'],
+ customImports: ['Status'],
+ usesKeyframes: false,
+ },
+ },
+ ],
+ },
+ children: [
+ {
+ component: 'CustomButton',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'CustomButton',
+ },
+ {
+ component: 'ignoredSlot',
+ props: {},
+ children: [],
+ nodeType: 'SLOT',
+ nodeName: 'Slot',
+ isSlot: true,
+ },
+ {
+ component: 'Fragment',
+ props: {},
+ children: [],
+ nodeType: 'FRAME',
+ nodeName: 'Fragment',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Root',
+ }
+
+ const result = collectImportMetadataFromTree(tree, 'CurrentComponent')
+
+ expect(result.devupImports).toEqual(['Box', 'Text'])
+ expect(result.customImports).toEqual(['CustomButton', 'Status'])
+ expect(result.usesKeyframes).toBe(true)
+ })
+
+ test('skips current component name from custom imports', () => {
+ const tree: NodeTree = {
+ component: 'Toast',
+ props: {},
+ children: [
+ {
+ component: 'Toast',
+ props: {},
+ children: [],
+ nodeType: 'INSTANCE',
+ nodeName: 'Toast',
+ },
+ ],
+ nodeType: 'FRAME',
+ nodeName: 'Toast',
+ }
+
+ const result = collectImportMetadataFromTree(tree, 'Toast')
+
+ expect(result.customImports).toEqual([])
+ })
+})
+
+describe('mergeImportMetadata', () => {
+ test('merges and deduplicates import metadata', () => {
+ const result = mergeImportMetadata([
+ {
+ devupImports: ['Box', 'Text'],
+ customImports: ['Status'],
+ usesKeyframes: false,
+ },
+ {
+ devupImports: ['Box', 'Image'],
+ customImports: ['Status', 'Button'],
+ usesKeyframes: true,
+ },
+ ])
+
+ expect(result.devupImports).toEqual(['Box', 'Image', 'Text'])
+ expect(result.customImports).toEqual(['Button', 'Status'])
+ expect(result.usesKeyframes).toBe(true)
+ })
+})
diff --git a/src/codegen/utils/__tests__/paint-to-css.test.ts b/src/codegen/utils/__tests__/paint-to-css.test.ts
index fec3c89..b997a78 100644
--- a/src/codegen/utils/__tests__/paint-to-css.test.ts
+++ b/src/codegen/utils/__tests__/paint-to-css.test.ts
@@ -8,7 +8,7 @@ import {
test,
} from 'bun:test'
import * as checkAssetNodeModule from '../check-asset-node'
-import { paintToCSS } from '../paint-to-css'
+import { paintToCSS, resetPaintToCssCache } from '../paint-to-css'
import { resetVariableCache } from '../variable-cache'
// mock asset checker to avoid real node handling
@@ -24,6 +24,7 @@ afterAll(() => {
describe('paintToCSS', () => {
beforeEach(() => {
resetVariableCache()
+ resetPaintToCssCache()
})
test('converts image paint with TILE scaleMode to repeat url', async () => {
;(globalThis as { figma?: unknown }).figma = {
diff --git a/src/codegen/utils/__tests__/solid-to-string.test.ts b/src/codegen/utils/__tests__/solid-to-string.test.ts
new file mode 100644
index 0000000..b6b0cc7
--- /dev/null
+++ b/src/codegen/utils/__tests__/solid-to-string.test.ts
@@ -0,0 +1,103 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test'
+import { solidToString, solidToStringSync } from '../solid-to-string'
+import { resetVariableCache } from '../variable-cache'
+
+describe('solidToString caching', () => {
+ beforeEach(() => {
+ resetVariableCache()
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: mock(async (id: string) => {
+ if (id === 'color-var') {
+ return { id, name: 'brand/primary' }
+ }
+ if (id === 'missing-var') {
+ return null
+ }
+ return null
+ }),
+ },
+ } as unknown as typeof figma
+ })
+
+ test('reuses cached sync solid color result', () => {
+ const paintData = {
+ type: 'SOLID',
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ }
+
+ expect(solidToStringSync(paintData as unknown as SolidPaint)).toBe('#F00')
+
+ paintData.color = { r: 0, g: 1, b: 0 }
+
+ expect(solidToStringSync(paintData as unknown as SolidPaint)).toBe('#F00')
+ })
+
+ test('reuses cached async variable-bound solid color result', async () => {
+ const paintData: {
+ type: 'SOLID'
+ color: { r: number; g: number; b: number }
+ opacity: number
+ boundVariables?: { color?: { id: string } }
+ } = {
+ type: 'SOLID',
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ boundVariables: {
+ color: { id: 'color-var' },
+ },
+ }
+
+ expect(await solidToString(paintData as unknown as SolidPaint)).toBe(
+ '$brandPrimary',
+ )
+
+ paintData.boundVariables = undefined
+ paintData.color = { r: 0, g: 1, b: 0 }
+
+ expect(await solidToString(paintData as unknown as SolidPaint)).toBe(
+ '$brandPrimary',
+ )
+ })
+
+ test('async path reuses color cached by sync path', async () => {
+ const paintData = {
+ type: 'SOLID',
+ color: { r: 0, g: 0, b: 1 },
+ opacity: 1,
+ }
+
+ expect(solidToStringSync(paintData as unknown as SolidPaint)).toBe('#00F')
+ expect(await solidToString(paintData as unknown as SolidPaint)).toBe('#00F')
+ })
+
+ test('returns transparent for zero-opacity solid paint', async () => {
+ const paintData = {
+ type: 'SOLID',
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 0,
+ }
+
+ expect(solidToStringSync(paintData as unknown as SolidPaint)).toBe(
+ 'transparent',
+ )
+ expect(await solidToString(paintData as unknown as SolidPaint)).toBe(
+ 'transparent',
+ )
+ })
+
+ test('falls back to raw color when bound variable lookup misses', async () => {
+ const paintData = {
+ type: 'SOLID',
+ color: { r: 1, g: 0, b: 0 },
+ opacity: 1,
+ boundVariables: {
+ color: { id: 'missing-var' },
+ },
+ }
+
+ expect(await solidToString(paintData as unknown as SolidPaint)).toBe('#F00')
+ })
+})
diff --git a/src/codegen/utils/boolean-variant.ts b/src/codegen/utils/boolean-variant.ts
new file mode 100644
index 0000000..bb5f993
--- /dev/null
+++ b/src/codegen/utils/boolean-variant.ts
@@ -0,0 +1,43 @@
+function normalizeOption(value: string): string {
+ return value.trim().toLowerCase()
+}
+
+const BOOLEAN_VALUE_ALIASES = {
+ true: new Set(['true', 'on', 'yes', 'enabled', 'enable', 'checked']),
+ false: new Set(['false', 'off', 'no', 'disabled', 'disable', 'unchecked']),
+} as const
+
+function getBooleanAlias(value: string): 'true' | 'false' | null {
+ const normalized = normalizeOption(value)
+ if (BOOLEAN_VALUE_ALIASES.true.has(normalized)) return 'true'
+ if (BOOLEAN_VALUE_ALIASES.false.has(normalized)) return 'false'
+ return null
+}
+
+export function isBooleanVariantOptions(options: readonly string[]): boolean {
+ if (options.length !== 2) return false
+ const normalized = new Set(options.map((option) => getBooleanAlias(option)))
+ return (
+ normalized.size === 2 && normalized.has('true') && normalized.has('false')
+ )
+}
+
+export function getVariantType(options: readonly string[]): string {
+ if (isBooleanVariantOptions(options)) return 'boolean'
+ return options.map((option) => `'${option}'`).join(' | ')
+}
+
+export function coerceBooleanVariantValue(value: string): boolean | string {
+ const normalized = getBooleanAlias(value)
+ if (normalized === 'true') return true
+ if (normalized === 'false') return false
+ return value
+}
+
+export function normalizeBooleanVariantKey(value: string): string {
+ return getBooleanAlias(value) ?? value
+}
+
+export function getBooleanVariantAccessor(variantKey: string): string {
+ return `${variantKey} ?? false`
+}
diff --git a/src/codegen/utils/check-asset-node.ts b/src/codegen/utils/check-asset-node.ts
index 8f0f3e9..875fb30 100644
--- a/src/codegen/utils/check-asset-node.ts
+++ b/src/codegen/utils/check-asset-node.ts
@@ -1,3 +1,24 @@
+import { solidToString, solidToStringSync } from './solid-to-string'
+
+const checkAssetNodeCache = new Map()
+const assetAnalysisCache = new Map>()
+
+interface AssetAnalysis {
+ assetType: 'svg' | 'png' | null
+ sameColor: null | string | false
+}
+
+type OwnColorAnalysis =
+ | { kind: 'none' }
+ | { kind: 'color'; color: string }
+ | { kind: 'false' }
+ | { kind: 'null' }
+
+export function resetCheckAssetNodeCache(): void {
+ checkAssetNodeCache.clear()
+ assetAnalysisCache.clear()
+}
+
function hasSmartAnimateReaction(node: BaseNode | null): boolean {
if (!node || node.type === 'DOCUMENT' || node.type === 'PAGE') return false
if (
@@ -24,6 +45,246 @@ function isAnimationTarget(node: SceneNode): boolean {
export function checkAssetNode(
node: SceneNode,
nested = false,
+): 'svg' | 'png' | null {
+ const cacheKey = node.id ? `${node.id}:${nested ? '1' : '0'}` : null
+ if (cacheKey && checkAssetNodeCache.has(cacheKey)) {
+ return checkAssetNodeCache.get(cacheKey) ?? null
+ }
+
+ const result = computeAssetNode(node, nested)
+ if (cacheKey) {
+ checkAssetNodeCache.set(cacheKey, result)
+ }
+ return result
+}
+
+export async function analyzeAssetNode(
+ node: SceneNode,
+ nested = false,
+): Promise {
+ const cacheKey = node.id ? `${node.id}:${nested ? '1' : '0'}` : null
+ if (cacheKey) {
+ const cached = assetAnalysisCache.get(cacheKey)
+ if (cached) return cached
+ }
+
+ const promise = computeAssetAnalysis(node, nested)
+ if (cacheKey) {
+ assetAnalysisCache.set(cacheKey, promise)
+ }
+ return await promise
+}
+
+function mergeSameColor(
+ current: null | string | false,
+ next: null | string | false,
+): null | string | false {
+ if (next === false) return false
+ if (current === null) return next
+ if (current !== next) return false
+ return current
+}
+
+async function analyzeOwnSameColor(node: SceneNode): Promise {
+ let targetColor: string | null = null
+ let hasPaints = false
+
+ const paintArrays: Paint[][] = []
+ if ('fills' in node && Array.isArray(node.fills)) paintArrays.push(node.fills)
+ if ('strokes' in node && Array.isArray(node.strokes))
+ paintArrays.push(node.strokes)
+
+ for (const paints of paintArrays) {
+ for (const paint of paints) {
+ if (!paint.visible) continue
+ hasPaints = true
+ if (paint.type !== 'SOLID') return { kind: 'null' }
+
+ const syncColor = solidToStringSync(paint)
+ const resolvedColor = syncColor ?? (await solidToString(paint))
+ if (targetColor === null) targetColor = resolvedColor
+ else if (targetColor !== resolvedColor) return { kind: 'false' }
+ }
+ }
+
+ if (!hasPaints) return { kind: 'none' }
+ if (targetColor === null) return { kind: 'null' }
+ return { kind: 'color', color: targetColor }
+}
+
+async function computeAssetAnalysis(
+ node: SceneNode,
+ nested = false,
+): Promise {
+ if (
+ node.type === 'TEXT' ||
+ node.type === 'COMPONENT_SET' ||
+ ('inferredAutoLayout' in node &&
+ node.inferredAutoLayout?.layoutMode === 'GRID')
+ ) {
+ return { assetType: null, sameColor: null }
+ }
+
+ if (isAnimationTarget(node)) {
+ return { assetType: null, sameColor: null }
+ }
+
+ if (['VECTOR', 'STAR', 'POLYGON'].includes(node.type)) {
+ const ownColor = await analyzeOwnSameColor(node)
+ return {
+ assetType: 'svg',
+ sameColor: ownColor.kind === 'color' ? ownColor.color : null,
+ }
+ }
+
+ if (node.type === 'ELLIPSE' && node.arcData.innerRadius) {
+ const ownColor = await analyzeOwnSameColor(node)
+ return {
+ assetType: 'svg',
+ sameColor: ownColor.kind === 'color' ? ownColor.color : null,
+ }
+ }
+
+ if (!('children' in node) || node.children.length === 0) {
+ if (
+ 'fills' in node &&
+ Array.isArray(node.fills) &&
+ node.fills.find(
+ (fill: Paint) =>
+ fill.visible !== false &&
+ (fill.type === 'PATTERN' ||
+ (fill.type === 'IMAGE' && fill.scaleMode === 'TILE')),
+ )
+ ) {
+ return { assetType: null, sameColor: null }
+ }
+
+ if (node.isAsset) {
+ if ('fills' in node && Array.isArray(node.fills)) {
+ const hasImageFill = node.fills.some(
+ (fill: Paint) =>
+ fill.visible !== false &&
+ fill.type === 'IMAGE' &&
+ fill.scaleMode !== 'TILE',
+ )
+ if (hasImageFill) {
+ return {
+ assetType: node.fills.length === 1 ? 'png' : null,
+ sameColor: null,
+ }
+ }
+
+ const allVisibleSolid = node.fills.every(
+ (fill: Paint) => fill.visible && fill.type === 'SOLID',
+ )
+ if (allVisibleSolid) {
+ const ownColor = await analyzeOwnSameColor(node)
+ return nested
+ ? {
+ assetType: 'svg',
+ sameColor: ownColor.kind === 'color' ? ownColor.color : null,
+ }
+ : { assetType: null, sameColor: null }
+ }
+
+ const ownColor = await analyzeOwnSameColor(node)
+ return {
+ assetType: 'svg',
+ sameColor:
+ ownColor.kind === 'color'
+ ? ownColor.color
+ : ownColor.kind === 'false'
+ ? false
+ : null,
+ }
+ }
+
+ return { assetType: null, sameColor: null }
+ }
+
+ if (
+ nested &&
+ 'fills' in node &&
+ Array.isArray(node.fills) &&
+ !node.fills.some(
+ (fill: Paint) =>
+ fill.visible !== false &&
+ (fill.type === 'IMAGE' ||
+ fill.type === 'VIDEO' ||
+ fill.type === 'PATTERN'),
+ )
+ ) {
+ const ownColor = await analyzeOwnSameColor(node)
+ return {
+ assetType: 'svg',
+ sameColor: ownColor.kind === 'color' ? ownColor.color : null,
+ }
+ }
+
+ return { assetType: null, sameColor: null }
+ }
+
+ const { children } = node
+ if (children.length === 1) {
+ if (
+ ('paddingLeft' in node &&
+ (node.paddingLeft > 0 ||
+ node.paddingRight > 0 ||
+ node.paddingTop > 0 ||
+ node.paddingBottom > 0)) ||
+ ('fills' in node &&
+ (Array.isArray(node.fills)
+ ? node.fills.find((fill) => fill.visible !== false)
+ : true))
+ ) {
+ return { assetType: null, sameColor: null }
+ }
+
+ return await analyzeAssetNode(children[0], true)
+ }
+
+ const filteredChildren = children.filter((child) => child.visible)
+ const ownColor = await analyzeOwnSameColor(node)
+ if (ownColor.kind === 'null') {
+ for (const child of filteredChildren) {
+ const childAnalysis = await analyzeAssetNode(child, true)
+ if (childAnalysis.assetType !== 'svg') {
+ return { assetType: null, sameColor: null }
+ }
+ }
+ return { assetType: 'svg', sameColor: null }
+ }
+
+ if (ownColor.kind === 'false') {
+ for (const child of filteredChildren) {
+ const childAnalysis = await analyzeAssetNode(child, true)
+ if (childAnalysis.assetType !== 'svg') {
+ return { assetType: null, sameColor: null }
+ }
+ }
+ return { assetType: 'svg', sameColor: false }
+ }
+
+ let sameColor: null | string | false =
+ ownColor.kind === 'color' ? ownColor.color : null
+
+ for (const child of filteredChildren) {
+ const childAnalysis = await analyzeAssetNode(child, true)
+ if (childAnalysis.assetType !== 'svg') {
+ return { assetType: null, sameColor: null }
+ }
+ sameColor = mergeSameColor(sameColor, childAnalysis.sameColor)
+ }
+
+ return {
+ assetType: 'svg',
+ sameColor,
+ }
+}
+
+function computeAssetNode(
+ node: SceneNode,
+ nested = false,
): 'svg' | 'png' | null {
if (
node.type === 'TEXT' ||
diff --git a/src/codegen/utils/check-same-color.ts b/src/codegen/utils/check-same-color.ts
index bca234d..d83c7ff 100644
--- a/src/codegen/utils/check-same-color.ts
+++ b/src/codegen/utils/check-same-color.ts
@@ -1,9 +1,23 @@
import { solidToString, solidToStringSync } from './solid-to-string'
+const sameColorCache = new Map()
+
+export function resetCheckSameColorCache(): void {
+ sameColorCache.clear()
+}
+
export async function checkSameColor(
node: SceneNode,
color: string | null = null,
): Promise {
+ const cacheKey = node.id
+ if (cacheKey && sameColorCache.has(cacheKey)) {
+ const cached = sameColorCache.get(cacheKey) ?? null
+ if (color === null) return cached
+ if (cached === false || cached === null) return cached
+ return cached === color ? cached : false
+ }
+
let targetColor: string | null = color
// Check both fills and strokes for solid colors
@@ -21,8 +35,9 @@ export async function checkSameColor(
if (targetColor === null) targetColor = syncColor
else if (targetColor !== syncColor) return false
} else {
- if (targetColor === null) targetColor = await solidToString(paint)
- else if (targetColor !== (await solidToString(paint))) return false
+ const resolvedColor = await solidToString(paint)
+ if (targetColor === null) targetColor = resolvedColor
+ else if (targetColor !== resolvedColor) return false
}
} else return null
}
@@ -38,5 +53,9 @@ export async function checkSameColor(
}
}
+ if (cacheKey && color === null) {
+ sameColorCache.set(cacheKey, targetColor)
+ }
+
return targetColor
}
diff --git a/src/codegen/utils/collect-boolean-condition-props.ts b/src/codegen/utils/collect-boolean-condition-props.ts
new file mode 100644
index 0000000..3722f8c
--- /dev/null
+++ b/src/codegen/utils/collect-boolean-condition-props.ts
@@ -0,0 +1,20 @@
+import type { NodeTree } from '../types'
+
+const IDENTIFIER_REGEX = /^[A-Za-z_$][\w$]*$/
+
+export function collectBooleanConditionProps(tree: NodeTree): string[] {
+ const props = new Set()
+
+ const visit = (node: NodeTree) => {
+ if (node.condition && IDENTIFIER_REGEX.test(node.condition)) {
+ props.add(node.condition)
+ }
+
+ for (const child of node.children) {
+ visit(child)
+ }
+ }
+
+ visit(tree)
+ return [...props]
+}
diff --git a/src/codegen/utils/collect-component-props.ts b/src/codegen/utils/collect-component-props.ts
new file mode 100644
index 0000000..0dcf450
--- /dev/null
+++ b/src/codegen/utils/collect-component-props.ts
@@ -0,0 +1,48 @@
+import type { NodeTree } from '../types'
+
+const IDENTIFIER_REGEX = /^[A-Za-z_$][\w$]*$/
+
+export interface CollectedComponentProps {
+ booleanProps: string[]
+ textProps: string[]
+ slotProps: string[]
+}
+
+export function collectComponentProps(tree: NodeTree): CollectedComponentProps {
+ const booleanProps = new Set()
+ const textProps = new Set()
+ const slotProps = new Set()
+
+ const visit = (node: NodeTree) => {
+ if (node.condition && IDENTIFIER_REGEX.test(node.condition)) {
+ booleanProps.add(node.condition)
+ }
+
+ if (node.isSlot && IDENTIFIER_REGEX.test(node.component)) {
+ slotProps.add(node.component)
+ }
+
+ if (
+ node.textChildren?.length === 1 &&
+ typeof node.textChildren[0] === 'string' &&
+ /^\{[A-Za-z_$][\w$]*\}$/.test(node.textChildren[0])
+ ) {
+ const propName = node.textChildren[0].slice(1, -1)
+ if (IDENTIFIER_REGEX.test(propName) && propName !== 'children') {
+ textProps.add(propName)
+ }
+ }
+
+ for (const child of node.children) {
+ visit(child)
+ }
+ }
+
+ visit(tree)
+
+ return {
+ booleanProps: [...booleanProps],
+ textProps: [...textProps],
+ slotProps: [...slotProps],
+ }
+}
diff --git a/src/codegen/utils/collect-import-metadata.ts b/src/codegen/utils/collect-import-metadata.ts
new file mode 100644
index 0000000..7100155
--- /dev/null
+++ b/src/codegen/utils/collect-import-metadata.ts
@@ -0,0 +1,119 @@
+import type { NodeTree } from '../types'
+
+export interface ImportMetadata {
+ devupImports: string[]
+ customImports: string[]
+ usesKeyframes: boolean
+}
+
+const DEVUP_COMPONENT_SET = new Set([
+ 'Center',
+ 'VStack',
+ 'Flex',
+ 'Grid',
+ 'Box',
+ 'Text',
+ 'Image',
+])
+
+function walkValue(
+ value: unknown,
+ metadata: {
+ devupImports: Set
+ customImports: Set
+ usesKeyframes: boolean
+ },
+) {
+ if (typeof value === 'string') {
+ if (value.startsWith('keyframes(')) {
+ metadata.usesKeyframes = true
+ }
+ return
+ }
+
+ if (Array.isArray(value)) {
+ for (const item of value) walkValue(item, metadata)
+ return
+ }
+
+ if (typeof value !== 'object' || value === null) return
+
+ if ('__imports' in value) {
+ const imports = value as {
+ __imports?: ImportMetadata
+ }
+ if (imports.__imports) {
+ for (const name of imports.__imports.devupImports) {
+ metadata.devupImports.add(name)
+ }
+ for (const name of imports.__imports.customImports) {
+ metadata.customImports.add(name)
+ }
+ metadata.usesKeyframes ||= imports.__imports.usesKeyframes
+ }
+ }
+
+ for (const nestedValue of Object.values(value)) {
+ walkValue(nestedValue, metadata)
+ }
+}
+
+export function collectImportMetadataFromTree(
+ tree: NodeTree,
+ currentComponentName?: string,
+): ImportMetadata {
+ const metadata = {
+ devupImports: new Set(),
+ customImports: new Set(),
+ usesKeyframes: false,
+ }
+
+ const visit = (node: NodeTree) => {
+ if (!node.isSlot) {
+ if (DEVUP_COMPONENT_SET.has(node.component)) {
+ metadata.devupImports.add(node.component)
+ } else if (
+ node.component !== currentComponentName &&
+ node.component !== 'Fragment'
+ ) {
+ metadata.customImports.add(node.component)
+ }
+ }
+
+ walkValue(node.props, metadata)
+
+ for (const child of node.children) {
+ visit(child)
+ }
+ }
+
+ visit(tree)
+
+ return {
+ devupImports: [...metadata.devupImports].sort(),
+ customImports: [...metadata.customImports].sort(),
+ usesKeyframes: metadata.usesKeyframes,
+ }
+}
+
+export function mergeImportMetadata(
+ metadatas: Iterable,
+): ImportMetadata {
+ const merged = {
+ devupImports: new Set(),
+ customImports: new Set(),
+ usesKeyframes: false,
+ }
+
+ for (const metadata of metadatas) {
+ for (const name of metadata.devupImports) merged.devupImports.add(name)
+ for (const name of metadata.customImports) merged.customImports.add(name)
+ merged.usesKeyframes ||= metadata.usesKeyframes
+ }
+
+ return {
+ devupImports: [...merged.devupImports].sort(),
+ customImports: [...merged.customImports].sort(),
+ usesKeyframes: merged.usesKeyframes,
+ }
+}
diff --git a/src/codegen/utils/extract-instance-variant-props.ts b/src/codegen/utils/extract-instance-variant-props.ts
index 8397f07..60948a2 100644
--- a/src/codegen/utils/extract-instance-variant-props.ts
+++ b/src/codegen/utils/extract-instance-variant-props.ts
@@ -1,4 +1,5 @@
import { sanitizePropertyName } from '../props/selector'
+import { coerceBooleanVariantValue } from './boolean-variant'
/**
* Reserved variant keys that should not be passed as props.
@@ -47,7 +48,7 @@ export function extractInstanceVariantProps(
if (isReservedVariantKey(key)) continue
const sanitizedKey = sanitizePropertyName(key)
if (prop.type === 'VARIANT') {
- variantProps[sanitizedKey] = String(prop.value)
+ variantProps[sanitizedKey] = coerceBooleanVariantValue(String(prop.value))
} else if (prop.type === 'BOOLEAN' && prop.value === true) {
variantProps[sanitizedKey] = true
}
diff --git a/src/codegen/utils/get-component-property-definitions.ts b/src/codegen/utils/get-component-property-definitions.ts
index f97678c..a17bfee 100644
--- a/src/codegen/utils/get-component-property-definitions.ts
+++ b/src/codegen/utils/get-component-property-definitions.ts
@@ -1,3 +1,12 @@
+const componentPropertyDefinitionsCache = new Map<
+ string,
+ ComponentSetNode['componentPropertyDefinitions']
+>()
+
+export function resetComponentPropertyDefinitionsCache(): void {
+ componentPropertyDefinitionsCache.clear()
+}
+
/**
* Safely access componentPropertyDefinitions on a node.
* Figma's getter throws when the component set has validation errors
@@ -10,12 +19,23 @@ export function getComponentPropertyDefinitions(
if (!node) {
return {} as ComponentSetNode['componentPropertyDefinitions']
}
+
+ const cacheKey = 'id' in node ? node.id : undefined
+ if (cacheKey && componentPropertyDefinitionsCache.has(cacheKey)) {
+ return componentPropertyDefinitionsCache.get(cacheKey) ?? {}
+ }
+
+ let result: ComponentSetNode['componentPropertyDefinitions']
try {
- return (
+ result =
node.componentPropertyDefinitions ||
({} as ComponentSetNode['componentPropertyDefinitions'])
- )
} catch {
- return {} as ComponentSetNode['componentPropertyDefinitions']
+ result = {} as ComponentSetNode['componentPropertyDefinitions']
+ }
+
+ if (cacheKey) {
+ componentPropertyDefinitionsCache.set(cacheKey, result)
}
+ return result
}
diff --git a/src/codegen/utils/get-page-node.ts b/src/codegen/utils/get-page-node.ts
index ab6c5fe..ef3d9ea 100644
--- a/src/codegen/utils/get-page-node.ts
+++ b/src/codegen/utils/get-page-node.ts
@@ -1,4 +1,25 @@
-export function getPageNode(node: BaseNode & ChildrenMixin) {
+const pageNodeCache = new Map()
+
+export function resetGetPageNodeCache(): void {
+ pageNodeCache.clear()
+}
+
+export function getPageNode(
+ node: BaseNode & ChildrenMixin,
+): (BaseNode & ChildrenMixin) | null {
+ const cacheKey = 'id' in node ? node.id : undefined
+ if (cacheKey && pageNodeCache.has(cacheKey)) {
+ return pageNodeCache.get(cacheKey) ?? null
+ }
+
+ const result: (BaseNode & ChildrenMixin) | null = computePageNode(node)
+ if (cacheKey) pageNodeCache.set(cacheKey, result)
+ return result
+}
+
+function computePageNode(
+ node: BaseNode & ChildrenMixin,
+): (BaseNode & ChildrenMixin) | null {
if (!node.parent) return null
switch (node.parent.type) {
case 'COMPONENT_SET':
diff --git a/src/codegen/utils/paint-to-css.ts b/src/codegen/utils/paint-to-css.ts
index 4874170..2adfb70 100644
--- a/src/codegen/utils/paint-to-css.ts
+++ b/src/codegen/utils/paint-to-css.ts
@@ -7,6 +7,15 @@ import { solidToString, solidToStringSync } from './solid-to-string'
import { getVariableByIdCached } from './variable-cache'
import { buildCssUrl } from './wrap-url'
+const patternSourceCache = new Map<
+ string,
+ { imageExtension: 'svg' | 'png' | null; imageName: string }
+>()
+
+export function resetPaintToCssCache(): void {
+ patternSourceCache.clear()
+}
+
interface Point {
x: number
y: number
@@ -245,9 +254,18 @@ async function convertRadial(
}
async function convertPattern(fill: PatternPaint): Promise {
- const node = await figma.getNodeByIdAsync(fill.sourceNodeId)
- const imageExtension = node ? checkAssetNode(node as SceneNode) : null
- const imageName = node?.name ?? 'pattern'
+ const cachedSource = patternSourceCache.get(fill.sourceNodeId)
+ const { imageExtension, imageName } = cachedSource
+ ? cachedSource
+ : await (async () => {
+ const node = await figma.getNodeByIdAsync(fill.sourceNodeId)
+ const source = {
+ imageExtension: node ? checkAssetNode(node as SceneNode) : null,
+ imageName: node?.name ?? 'pattern',
+ }
+ patternSourceCache.set(fill.sourceNodeId, source)
+ return source
+ })()
const horizontalPosition = convertPosition(
fill.horizontalAlignment,
fill.spacing.x,
diff --git a/src/codegen/utils/props-to-str.ts b/src/codegen/utils/props-to-str.ts
index 0d98fbf..12a5a09 100644
--- a/src/codegen/utils/props-to-str.ts
+++ b/src/codegen/utils/props-to-str.ts
@@ -89,12 +89,13 @@ function formatVariantPropValue(
variantProp: {
variantKey: string
values: Record
+ accessorExpression?: string
},
indent: number = 0,
asConst: boolean = false,
): string {
const entries = Object.entries(variantProp.values)
- const accessor = `[${variantProp.variantKey}]`
+ const accessor = `[${variantProp.accessorExpression || variantProp.variantKey}]`
// Helper to wrap with as const if needed
const wrapAsConst = (objStr: string) => {
diff --git a/src/codegen/utils/solid-to-string.ts b/src/codegen/utils/solid-to-string.ts
index fd071b0..1e303e8 100644
--- a/src/codegen/utils/solid-to-string.ts
+++ b/src/codegen/utils/solid-to-string.ts
@@ -3,6 +3,8 @@ import { rgbaToHex } from '../../utils/rgba-to-hex'
import { toCamel } from '../../utils/to-camel'
import { getVariableByIdCached } from './variable-cache'
+const solidPaintStringCache = new WeakMap()
+
/**
* Synchronous fast path for solidToString.
* Returns the color string immediately for non-variable paints.
@@ -10,31 +12,51 @@ import { getVariableByIdCached } from './variable-cache'
*/
export function solidToStringSync(solid: SolidPaint): string | null {
if (solid.boundVariables?.color) return null
- if (solid.opacity === 0) return 'transparent'
- return optimizeHex(
- rgbaToHex(
- figma.util.rgba({
- ...solid.color,
- a: solid.opacity ?? 1,
- }),
- ),
- )
+ const cached = solidPaintStringCache.get(solid)
+ if (cached) return cached
+
+ const result =
+ solid.opacity === 0
+ ? 'transparent'
+ : optimizeHex(
+ rgbaToHex(
+ figma.util.rgba({
+ ...solid.color,
+ a: solid.opacity ?? 1,
+ }),
+ ),
+ )
+
+ solidPaintStringCache.set(solid, result)
+ return result
}
export async function solidToString(solid: SolidPaint) {
+ const cached = solidPaintStringCache.get(solid)
+ if (cached) return cached
+
if (solid.boundVariables?.color) {
const variable = await getVariableByIdCached(
solid.boundVariables.color.id as string,
)
- if (variable?.name) return `$${toCamel(variable.name)}`
+ if (variable?.name) {
+ const result = `$${toCamel(variable.name)}`
+ solidPaintStringCache.set(solid, result)
+ return result
+ }
}
- if (solid.opacity === 0) return 'transparent'
- return optimizeHex(
- rgbaToHex(
- figma.util.rgba({
- ...solid.color,
- a: solid.opacity ?? 1,
- }),
- ),
- )
+ const result =
+ solid.opacity === 0
+ ? 'transparent'
+ : optimizeHex(
+ rgbaToHex(
+ figma.util.rgba({
+ ...solid.color,
+ a: solid.opacity ?? 1,
+ }),
+ ),
+ )
+
+ solidPaintStringCache.set(solid, result)
+ return result
}
diff --git a/src/commands/__tests__/exportPagesAndComponents.test.ts b/src/commands/__tests__/exportPagesAndComponents.test.ts
index 78d59b1..a3a390f 100644
--- a/src/commands/__tests__/exportPagesAndComponents.test.ts
+++ b/src/commands/__tests__/exportPagesAndComponents.test.ts
@@ -1,5 +1,7 @@
import { afterAll, describe, expect, spyOn, test } from 'bun:test'
+
import * as checkAssetNodeModule from '../../codegen/utils/check-asset-node'
+import type { ImportMetadata } from '../../codegen/utils/collect-import-metadata'
import {
collectAssetNodes,
DEVUP_COMPONENTS,
@@ -8,6 +10,21 @@ import {
generateImportStatements,
} from '../exportPagesAndComponents'
+function componentCode(
+ name: string,
+ metadata: Partial,
+): readonly [string, string, ImportMetadata] {
+ return [
+ name,
+ '',
+ {
+ devupImports: metadata.devupImports ?? [],
+ customImports: metadata.customImports ?? [],
+ usesKeyframes: metadata.usesKeyframes ?? false,
+ },
+ ]
+}
+
const checkAssetNodeSpy = spyOn(
checkAssetNodeModule,
'checkAssetNode',
@@ -39,76 +56,68 @@ describe('DEVUP_COMPONENTS', () => {
describe('extractImports', () => {
test('should extract Box import', () => {
- const result = extractImports([['Test', 'Hello']])
+ const result = extractImports([
+ componentCode('Test', { devupImports: ['Box'] }),
+ ])
expect(result).toContain('Box')
})
test('should extract multiple devup-ui components', () => {
const result = extractImports([
- ['Test', 'Hello'],
+ componentCode('Test', { devupImports: ['Box', 'Flex', 'Text'] }),
])
expect(result).toContain('Box')
expect(result).toContain('Flex')
expect(result).toContain('Text')
})
- test('should extract keyframes with parenthesis', () => {
+ test('should extract keyframes import', () => {
const result = extractImports([
- ['Test', ''],
+ componentCode('Test', {
+ devupImports: ['Box'],
+ usesKeyframes: true,
+ }),
])
expect(result).toContain('keyframes')
expect(result).toContain('Box')
})
- test('should extract keyframes with template literal', () => {
+ test('should not extract keyframes when not present', () => {
const result = extractImports([
- ['Test', ''],
+ componentCode('Test', { devupImports: ['Box'] }),
])
- expect(result).toContain('keyframes')
- })
-
- test('should not extract keyframes when not present', () => {
- const result = extractImports([['Test', '']])
expect(result).not.toContain('keyframes')
})
test('should return sorted imports', () => {
const result = extractImports([
- ['Test', ''],
+ componentCode('Test', { devupImports: ['VStack', 'Box', 'Center'] }),
])
expect(result).toEqual(['Box', 'Center', 'VStack'])
})
test('should not include duplicates', () => {
const result = extractImports([
- ['Test1', 'A'],
- ['Test2', 'B'],
+ componentCode('Test1', { devupImports: ['Box'] }),
+ componentCode('Test2', { devupImports: ['Box'] }),
])
expect(result.filter((x) => x === 'Box').length).toBe(1)
})
-
- test('should handle self-closing tags', () => {
- const result = extractImports([['Test', '']])
- expect(result).toContain('Image')
- })
-
- test('should handle tags with spaces', () => {
- const result = extractImports([['Test', '']])
- expect(result).toContain('Grid')
- })
})
describe('extractCustomComponentImports', () => {
test('should extract custom component', () => {
const result = extractCustomComponentImports([
- ['Test', ''],
+ componentCode('Test', { customImports: ['CustomButton'] }),
])
expect(result).toContain('CustomButton')
})
test('should extract multiple custom components', () => {
const result = extractCustomComponentImports([
- ['Test', ''],
+ componentCode('Test', {
+ customImports: ['CustomA', 'CustomB', 'CustomC'],
+ }),
])
expect(result).toContain('CustomA')
expect(result).toContain('CustomB')
@@ -117,7 +126,10 @@ describe('extractCustomComponentImports', () => {
test('should not include devup-ui components', () => {
const result = extractCustomComponentImports([
- ['Test', ''],
+ componentCode('Test', {
+ devupImports: ['Box', 'Flex'],
+ customImports: ['CustomCard'],
+ }),
])
expect(result).toContain('CustomCard')
expect(result).not.toContain('Box')
@@ -126,22 +138,22 @@ describe('extractCustomComponentImports', () => {
test('should return sorted imports', () => {
const result = extractCustomComponentImports([
- ['Test', ''],
+ componentCode('Test', { customImports: ['Zebra', 'Apple', 'Mango'] }),
])
expect(result).toEqual(['Apple', 'Mango', 'Zebra'])
})
test('should not include duplicates', () => {
const result = extractCustomComponentImports([
- ['Test1', ''],
- ['Test2', ''],
+ componentCode('Test1', { customImports: ['SharedButton'] }),
+ componentCode('Test2', { customImports: ['SharedButton'] }),
])
expect(result.filter((x) => x === 'SharedButton').length).toBe(1)
})
test('should return empty array when no custom components', () => {
const result = extractCustomComponentImports([
- ['Test', 'Hello'],
+ componentCode('Test', { devupImports: ['Box', 'Flex'] }),
])
expect(result).toEqual([])
})
@@ -210,7 +222,6 @@ describe('collectAssetNodes', () => {
test('should not descend into asset node children', () => {
const assets = new Map()
- // VECTOR is an asset — even if it somehow had children, we don't walk them
const node = createNode('VECTOR', 'icon-parent')
collectAssetNodes(node, assets)
expect(assets.size).toBe(1)
@@ -268,19 +279,16 @@ describe('collectAssetNodes', () => {
const visited = new Set()
const sharedChild = createNode('VECTOR', 'shared-icon', { id: 'shared-1' })
- // First call walks the child
const parent1 = createNode('FRAME', 'parent-a', {
children: [sharedChild],
})
collectAssetNodes(parent1, assets, visited)
expect(assets.size).toBe(1)
- // Second call with overlapping subtree — shared-1 already visited
const parent2 = createNode('FRAME', 'parent-b', {
children: [sharedChild],
})
collectAssetNodes(parent2, assets, visited)
- // Still 1 — child was skipped via visited set
expect(assets.size).toBe(1)
expect(visited.has('shared-1')).toBe(true)
})
@@ -296,7 +304,6 @@ describe('collectAssetNodes', () => {
collectAssetNodes(node, assets, visited)
expect(assets.size).toBe(1)
- // Clear assets but keep visited — re-collecting same root yields nothing new
assets.clear()
collectAssetNodes(node, assets, visited)
expect(assets.size).toBe(0)
@@ -305,7 +312,6 @@ describe('collectAssetNodes', () => {
test('should work without visited set (backward compatible)', () => {
const assets = new Map()
const node = createNode('VECTOR', 'compat-icon')
- // No visited parameter — should still work
collectAssetNodes(node, assets)
expect(assets.size).toBe(1)
})
@@ -313,13 +319,18 @@ describe('collectAssetNodes', () => {
describe('generateImportStatements', () => {
test('should generate devup-ui import statement', () => {
- const result = generateImportStatements([['Test', '']])
+ const result = generateImportStatements([
+ componentCode('Test', { devupImports: ['Box', 'Flex'] }),
+ ])
expect(result).toContain("import { Box, Flex } from '@devup-ui/react'")
})
test('should generate custom component import statements', () => {
const result = generateImportStatements([
- ['Test', ''],
+ componentCode('Test', {
+ devupImports: ['Box'],
+ customImports: ['CustomButton'],
+ }),
])
expect(result).toContain("import { Box } from '@devup-ui/react'")
expect(result).toContain(
@@ -329,33 +340,41 @@ describe('generateImportStatements', () => {
test('should generate multiple custom component imports on separate lines', () => {
const result = generateImportStatements([
- ['Test', ''],
+ componentCode('Test', { customImports: ['ButtonA', 'ButtonB'] }),
])
expect(result).toContain("import { ButtonA } from '@/components/ButtonA'")
expect(result).toContain("import { ButtonB } from '@/components/ButtonB'")
})
test('should return empty string when no imports', () => {
- const result = generateImportStatements([['Test', 'just text']])
+ const result = generateImportStatements([componentCode('Test', {})])
expect(result).toBe('')
})
test('should include keyframes in devup-ui import', () => {
const result = generateImportStatements([
- ['Test', ''],
+ componentCode('Test', {
+ devupImports: ['Box'],
+ usesKeyframes: true,
+ }),
])
expect(result).toContain('keyframes')
expect(result).toContain("from '@devup-ui/react'")
})
test('should end with double newline when has imports', () => {
- const result = generateImportStatements([['Test', '']])
+ const result = generateImportStatements([
+ componentCode('Test', { devupImports: ['Box'] }),
+ ])
expect(result.endsWith('\n\n')).toBe(true)
})
test('should return the same imports across repeated calls', () => {
const components = [
- ['Test', ''],
+ componentCode('Test', {
+ devupImports: ['Box', 'Flex'],
+ customImports: ['CustomButton'],
+ }),
] as const
const first = generateImportStatements(components)
const second = generateImportStatements(components)
diff --git a/src/commands/exportAssets.ts b/src/commands/exportAssets.ts
index 8816106..ab0bb63 100644
--- a/src/commands/exportAssets.ts
+++ b/src/commands/exportAssets.ts
@@ -1,10 +1,12 @@
-import { Codegen } from '../codegen/Codegen'
+import { Codegen, DEFAULT_CODEGEN_OPTIONS } from '../codegen/Codegen'
export async function exportAssets() {
// try {
figma.notify('Exporting assets...')
const elements = await Promise.all(
- figma.currentPage.selection.map(async (node) => new Codegen(node)),
+ figma.currentPage.selection.map(
+ async (node) => new Codegen(node, DEFAULT_CODEGEN_OPTIONS),
+ ),
)
await Promise.all(elements.map((element) => element.run()))
diff --git a/src/commands/exportComponents.ts b/src/commands/exportComponents.ts
index f849ead..1f28092 100644
--- a/src/commands/exportComponents.ts
+++ b/src/commands/exportComponents.ts
@@ -1,6 +1,6 @@
import JSZip from 'jszip'
-import { Codegen } from '../codegen/Codegen'
+import { Codegen, DEFAULT_CODEGEN_OPTIONS } from '../codegen/Codegen'
import { downloadFile } from '../utils/download-file'
const NOTIFY_TIMEOUT = 3000
@@ -16,7 +16,7 @@ export async function exportComponents() {
try {
figma.notify('Exporting components...')
const elements = figma.currentPage.selection.map(
- (node) => new Codegen(node),
+ (node) => new Codegen(node, DEFAULT_CODEGEN_OPTIONS),
)
await Promise.all(elements.map((element) => element.run()))
diff --git a/src/commands/exportPagesAndComponents.ts b/src/commands/exportPagesAndComponents.ts
index 08b599e..2dcf124 100644
--- a/src/commands/exportPagesAndComponents.ts
+++ b/src/commands/exportPagesAndComponents.ts
@@ -2,11 +2,13 @@ import JSZip from 'jszip'
import {
Codegen,
+ DEFAULT_CODEGEN_OPTIONS,
getGlobalAssetNodes,
resetGlobalAssetNodes,
} from '../codegen/Codegen'
import { ResponsiveCodegen } from '../codegen/responsive/ResponsiveCodegen'
import { checkAssetNode } from '../codegen/utils/check-asset-node'
+import type { ImportMetadata } from '../codegen/utils/collect-import-metadata'
import {
perfEnd,
perfReport,
@@ -43,60 +45,52 @@ export const DEVUP_COMPONENTS = [
'Image',
]
const DEVUP_COMPONENT_SET = new Set(DEVUP_COMPONENTS)
-const DEVUP_COMPONENT_PATTERNS = DEVUP_COMPONENTS.map(
- (component) => [component, new RegExp(`<${component}[\\s/>]`)] as const,
-)
-const CUSTOM_COMPONENT_USAGE_REGEX = /<([A-Z][a-zA-Z0-9]*)/g
+type GeneratedComponentCode = readonly [string, string, ImportMetadata?]
-function getCombinedCode(
- componentsCodes: ReadonlyArray,
-): string {
- let allCode = ''
- for (let i = 0; i < componentsCodes.length; i++) {
- if (i > 0) allCode += '\n'
- allCode += componentsCodes[i][1]
- }
- return allCode
-}
-
-export function extractImports(
- componentsCodes: ReadonlyArray,
-): string[] {
- const allCode = getCombinedCode(componentsCodes)
- const imports = new Set()
+function getMergedImportMetadata(
+ componentsCodes: ReadonlyArray,
+): ImportMetadata {
+ const devupImports = new Set()
+ const customImports = new Set()
+ let usesKeyframes = false
- for (const [component, regex] of DEVUP_COMPONENT_PATTERNS) {
- if (regex.test(allCode)) {
- imports.add(component)
+ for (const [, , metadata] of componentsCodes) {
+ if (!metadata) continue
+ for (const component of metadata.devupImports) {
+ devupImports.add(component)
+ }
+ for (const component of metadata.customImports) {
+ if (!DEVUP_COMPONENT_SET.has(component)) {
+ customImports.add(component)
+ }
}
+ usesKeyframes ||= metadata.usesKeyframes
}
- if (/\bkeyframes\s*(\(|`)/.test(allCode)) {
- imports.add('keyframes')
+ return {
+ devupImports: [...devupImports].sort(),
+ customImports: [...customImports].sort(),
+ usesKeyframes,
}
+}
+export function extractImports(
+ componentsCodes: ReadonlyArray,
+): string[] {
+ const metadata = getMergedImportMetadata(componentsCodes)
+ const imports = new Set(metadata.devupImports)
+ if (metadata.usesKeyframes) imports.add('keyframes')
return Array.from(imports).sort()
}
export function extractCustomComponentImports(
- componentsCodes: ReadonlyArray,
+ componentsCodes: ReadonlyArray,
): string[] {
- const allCode = getCombinedCode(componentsCodes)
- const customImports = new Set()
-
- CUSTOM_COMPONENT_USAGE_REGEX.lastIndex = 0
- for (const match of allCode.matchAll(CUSTOM_COMPONENT_USAGE_REGEX)) {
- const componentName = match[1]
- if (!DEVUP_COMPONENT_SET.has(componentName)) {
- customImports.add(componentName)
- }
- }
-
- return Array.from(customImports).sort()
+ return getMergedImportMetadata(componentsCodes).customImports
}
export function generateImportStatements(
- componentsCodes: ReadonlyArray,
+ componentsCodes: ReadonlyArray,
): string {
const devupImports = extractImports(componentsCodes)
const customImports = extractCustomComponentImports(componentsCodes)
@@ -261,6 +255,7 @@ export async function exportPagesAndComponents() {
await ResponsiveCodegen.generateVariantResponsiveComponents(
componentSet,
componentName,
+ DEFAULT_CODEGEN_OPTIONS,
)
perfEnd(`responsiveCodegen(${componentName})`, t)
@@ -304,7 +299,7 @@ export async function exportPagesAndComponents() {
// 3. Extract components using Codegen for other node types
let t = perfStart()
- const codegen = new Codegen(node)
+ const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS)
await codegen.run()
perfEnd(`codegen(${node.name})`, t)
@@ -342,17 +337,21 @@ export async function exportPagesAndComponents() {
updateProgress(`Generating page: ${sectionNode.name}`)
t = perfStart()
- const responsiveCodegen = new ResponsiveCodegen(sectionNode)
- const responsiveCode = await responsiveCodegen.generateResponsiveCode()
+ const responsiveCodegen = new ResponsiveCodegen(
+ sectionNode,
+ DEFAULT_CODEGEN_OPTIONS,
+ )
+ const { code: responsiveCode, imports } =
+ await responsiveCodegen.generateResponsiveResult()
const baseName = toPascal(sectionNode.name)
const pageName = isParentSection ? `${baseName}Page` : baseName
const wrappedCode = wrapComponent('Page', responsiveCode, {
exportDefault: true,
})
- const pageCodeEntry: ReadonlyArray = [
- ['Page', wrappedCode],
- ]
+ const pageCodeEntry: ReadonlyArray<
+ readonly [string, string, typeof imports]
+ > = [['Page', wrappedCode, imports]]
const importStatement = generateImportStatements(pageCodeEntry)
const fullCode = importStatement + wrappedCode
diff --git a/src/utils.ts b/src/utils.ts
index 69b31d8..de12ae3 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -5,7 +5,7 @@ import { toPascal } from './utils/to-pascal'
const styleByIdCache = new Map>()
const styleByIdResolved = new Map()
-function getStyleByIdCached(styleId: string): Promise {
+export function getStyleByIdCached(styleId: string): Promise {
const cached = styleByIdCache.get(styleId)
if (cached) return cached
const promise = Promise.resolve(figma.getStyleByIdAsync(styleId)).then(