From 189862d558999a6b8fb1ae1cc9e30f90ef0f91f3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 22 Apr 2026 19:37:14 +0900 Subject: [PATCH 1/6] Fix image component --- bun.lock | 536 ++---------------- package.json | 4 +- .../codegen-viewport.test.ts.snap | 19 + .../__tests__/codegen-viewport.test.ts | 101 ++++ src/codegen/responsive/ResponsiveCodegen.ts | 185 +++++- .../__tests__/ResponsiveCodegen.test.ts | 219 ++++++- 6 files changed, 538 insertions(+), 526 deletions(-) create mode 100644 src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap 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/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap new file mode 100644 index 0000000..3b79620 --- /dev/null +++ b/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap @@ -0,0 +1,19 @@ +// 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 ( + + ) +}" +`; diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts index 7422bec..d647f56 100644 --- a/src/codegen/__tests__/codegen-viewport.test.ts +++ b/src/codegen/__tests__/codegen-viewport.test.ts @@ -895,6 +895,107 @@ 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 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/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index 223c5a1..e5dfe22 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -42,6 +42,79 @@ 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, + } +} + +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, @@ -925,6 +998,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() @@ -935,19 +1016,31 @@ export class ResponsiveCodegen { t = perfStart() const codegen = new Codegen(component) - const tree = await codegen.getTree() + 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), + ) - if (selectorProps && Object.keys(selectorProps).length > 0) { - tree.props = Object.assign({}, tree.props, selectorProps) + for (const [compositeKey, entry] of builtTreesByComposite) { + 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) } treesByComposite.set(compositeKey, tree) } @@ -1005,6 +1098,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, @@ -1017,19 +1118,31 @@ export class ResponsiveCodegen { t = perfStart() const codegen = new Codegen(component) - const tree = await codegen.getTree() + 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, + }) + } + + const preferBuiltAssetTrees = + builtTreesByVariant.size > 0 && + [...builtTreesByVariant.values()].every(({ builtTree }) => + isAssetLeafTree(builtTree), + ) - if (selectorProps && Object.keys(selectorProps).length > 0) { - tree.props = Object.assign({}, tree.props, selectorProps) + 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) } @@ -1173,6 +1286,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 +1506,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..fba51f6 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() }) @@ -890,6 +903,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 +1563,103 @@ describe('ResponsiveCodegen', () => { expect(result[0][1]).toContain('status') }) + 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 @@ -2858,6 +3016,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) From 8044d734e17d9d5c341ffbacf04558d878da6736 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 22 Apr 2026 19:58:49 +0900 Subject: [PATCH 2/6] Add inline --- src/code-impl.ts | 10 +- src/codegen/Codegen.ts | 105 ++++++++++++++++- src/codegen/__tests__/codegen.test.ts | 121 +++++++++++++++++++- src/codegen/responsive/ResponsiveCodegen.ts | 38 ++++-- src/codegen/types.ts | 1 + src/commands/exportAssets.ts | 6 +- src/commands/exportComponents.ts | 4 +- src/commands/exportPagesAndComponents.ts | 9 +- 8 files changed, 271 insertions(+), 23 deletions(-) diff --git a/src/code-impl.ts b/src/code-impl.ts index 4a7956d..42f81bc 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' @@ -239,7 +240,7 @@ export function registerCodegen(ctx: typeof figma) { resetGlobalBuildTreeCache() let t = perfStart() - const codegen = new Codegen(node) + const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS) await codegen.run() perfEnd('Codegen.run()', t) @@ -264,6 +265,7 @@ export function registerCodegen(ctx: typeof figma) { await ResponsiveCodegen.generateVariantResponsiveComponents( node, componentName, + DEFAULT_CODEGEN_OPTIONS, ) perfEnd('generateVariantResponsiveComponents(COMPONENT_SET)', t) } @@ -303,6 +305,7 @@ export function registerCodegen(ctx: typeof figma) { await ResponsiveCodegen.generateVariantResponsiveComponents( parentSet, componentName, + DEFAULT_CODEGEN_OPTIONS, ) perfEnd( `generateVariantResponsiveComponents(${componentName})`, @@ -330,7 +333,10 @@ export function registerCodegen(ctx: typeof figma) { if (sectionNode) { try { - const responsiveCodegen = new ResponsiveCodegen(sectionNode) + const responsiveCodegen = new ResponsiveCodegen( + sectionNode, + DEFAULT_CODEGEN_OPTIONS, + ) const responsiveCode = await responsiveCodegen.generateResponsiveCode() const baseName = toPascal(sectionNode.name) diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 73dd944..b365d1f 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -19,8 +19,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,7 +268,43 @@ 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) + return `<${componentName}${propsString ? ` ${propsString}` : ''} />` } export class Codegen { @@ -283,7 +328,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 @@ -452,6 +500,55 @@ 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 + ) { + this.addComponentTree(mainComponent) + + const inlineTree = cloneTree(await this.buildTree(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 +556,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. @@ -926,6 +1022,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__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index 8579a4a..8a86d84 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -1,7 +1,7 @@ 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 { assembleNodeTree, type NodeData } from '../utils/node-proxy' @@ -3281,6 +3281,125 @@ 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('builds tree for INSTANCE node with position wrapper (absolute)', async () => { const mainComponent = { type: 'COMPONENT', diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index e5dfe22..d27e575 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, @@ -91,6 +92,7 @@ function cloneNodeTree(tree: NodeTree): NodeTree { isSlot: tree.isSlot, condition: tree.condition, textChildren: tree.textChildren, + leadingComment: tree.leadingComment, } } @@ -229,7 +231,10 @@ function mergeChildNameOrder( export class ResponsiveCodegen { private breakpointNodes: Map = new Map() - constructor(private sectionNode: SectionNode | null) { + constructor( + private sectionNode: SectionNode | null, + private options: CodegenOptions = {}, + ) { if (this.sectionNode) { this.categorizeChildren() } @@ -262,7 +267,7 @@ export class ResponsiveCodegen { 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) } @@ -270,7 +275,7 @@ export class ResponsiveCodegen { // 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) } @@ -471,6 +476,7 @@ export class ResponsiveCodegen { static async generateViewportResponsiveComponents( componentSet: ComponentSetNode, componentName: string, + options: CodegenOptions = {}, ): Promise> { // Find viewport and effect variant keys const viewportDefs = getComponentPropertyDefinitions(componentSet) @@ -548,7 +554,7 @@ export class ResponsiveCodegen { // Generate responsive code for each group const results: Array = [] - const responsiveCodegen = new ResponsiveCodegen(null) + const responsiveCodegen = new ResponsiveCodegen(null, options) for (const [groupKey, viewportComponents] of groups) { // Parse group key to get variant filter for getSelectorPropsForGroup @@ -567,7 +573,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) @@ -621,6 +627,7 @@ export class ResponsiveCodegen { static async generateVariantResponsiveComponents( componentSet: ComponentSetNode, componentName: string, + options: CodegenOptions = {}, ): Promise> { const tTotal = perfStart() @@ -674,6 +681,7 @@ export class ResponsiveCodegen { const r = await ResponsiveCodegen.generateEffectOnlyComponents( componentSet, componentName, + options, ) perfEnd('generateVariantResponsiveComponents(total)', tTotal) return r @@ -686,6 +694,7 @@ export class ResponsiveCodegen { componentName, otherVariantKeys, finalVariants, + options, ) perfEnd('generateVariantResponsiveComponents(total)', tTotal) return r @@ -696,6 +705,7 @@ export class ResponsiveCodegen { const r = await ResponsiveCodegen.generateViewportResponsiveComponents( componentSet, componentName, + options, ) perfEnd('generateVariantResponsiveComponents(total)', tTotal) return r @@ -777,7 +787,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< @@ -794,7 +804,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) @@ -847,6 +857,7 @@ export class ResponsiveCodegen { private static async generateEffectOnlyComponents( componentSet: ComponentSetNode, componentName: string, + options: CodegenOptions = {}, ): Promise> { // Use defaultVariant as the base component const defaultComponent = componentSet.defaultVariant @@ -855,7 +866,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.) @@ -902,6 +913,7 @@ export class ResponsiveCodegen { componentName: string, variantKeys: string[], variants: Record, + options: CodegenOptions = {}, ): Promise> { if (variantKeys.length === 0) { return [] @@ -935,6 +947,7 @@ export class ResponsiveCodegen { sanitizedVariantKeys[0], variants, hasEffect, + options, ) } @@ -1015,7 +1028,7 @@ export class ResponsiveCodegen { perfEnd('getSelectorPropsForGroup(nonViewport)', t) t = perfStart() - const codegen = new Codegen(component) + const codegen = new Codegen(component, options) const builtTree = await codegen.getTree() perfEnd('Codegen.getTree(nonViewportVariant)', t) @@ -1057,7 +1070,7 @@ export class ResponsiveCodegen { treesByCompositeAndBreakpoint.set(compositeKey, singleBreakpointMap) } - const responsiveCodegen = new ResponsiveCodegen(null) + const responsiveCodegen = new ResponsiveCodegen(null, options) const mergedCode = responsiveCodegen.generateMultiVariantMergedCode( sanitizedVariantKeys, treesByCompositeAndBreakpoint, @@ -1080,6 +1093,7 @@ export class ResponsiveCodegen { sanitizedVariantKey: string, variants: Record, hasEffect: boolean, + options: CodegenOptions = {}, ): Promise> { // Group components by variant value const componentsByVariant = new Map() @@ -1117,7 +1131,7 @@ export class ResponsiveCodegen { perfEnd('getSelectorPropsForGroup(nonViewport)', t) t = perfStart() - const codegen = new Codegen(component) + const codegen = new Codegen(component, options) const builtTree = await codegen.getTree() perfEnd('Codegen.getTree(nonViewportVariant)', t) @@ -1148,7 +1162,7 @@ export class ResponsiveCodegen { } // Generate merged code with variant conditionals - const responsiveCodegen = new ResponsiveCodegen(null) + const responsiveCodegen = new ResponsiveCodegen(null, options) const mergedCode = responsiveCodegen.generateVariantOnlyMergedCode( sanitizedVariantKey, treesByVariant, diff --git a/src/codegen/types.ts b/src/codegen/types.ts index bb9088f..bfde2f8 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -17,6 +17,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/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..952426b 100644 --- a/src/commands/exportPagesAndComponents.ts +++ b/src/commands/exportPagesAndComponents.ts @@ -2,6 +2,7 @@ import JSZip from 'jszip' import { Codegen, + DEFAULT_CODEGEN_OPTIONS, getGlobalAssetNodes, resetGlobalAssetNodes, } from '../codegen/Codegen' @@ -261,6 +262,7 @@ export async function exportPagesAndComponents() { await ResponsiveCodegen.generateVariantResponsiveComponents( componentSet, componentName, + DEFAULT_CODEGEN_OPTIONS, ) perfEnd(`responsiveCodegen(${componentName})`, t) @@ -304,7 +306,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,7 +344,10 @@ export async function exportPagesAndComponents() { updateProgress(`Generating page: ${sectionNode.name}`) t = perfStart() - const responsiveCodegen = new ResponsiveCodegen(sectionNode) + const responsiveCodegen = new ResponsiveCodegen( + sectionNode, + DEFAULT_CODEGEN_OPTIONS, + ) const responsiveCode = await responsiveCodegen.generateResponsiveCode() const baseName = toPascal(sectionNode.name) const pageName = isParentSection ? `${baseName}Page` : baseName From 4a723afde1c212b583c7c6c34749b1c93a471a5b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 22 Apr 2026 21:09:38 +0900 Subject: [PATCH 3/6] Add inline --- AGENTS.md | 1 + src/__tests__/code-responsive.test.ts | 14 +- src/__tests__/code.test.ts | 67 ++++-- src/code-impl.ts | 53 ++--- src/codegen/Codegen.ts | 33 ++- .../codegen-viewport.test.ts.snap | 21 ++ .../__snapshots__/codegen.test.ts.snap | 33 +++ .../__tests__/codegen-viewport.test.ts | 69 ++++++ src/codegen/__tests__/codegen.test.ts | 207 ++++++++++++++++++ src/codegen/__tests__/render.test.ts | 16 ++ src/codegen/props/selector.ts | 5 +- src/codegen/responsive/ResponsiveCodegen.ts | 113 ++++++++-- .../__tests__/ResponsiveCodegen.test.ts | 33 +++ src/codegen/responsive/index.ts | 9 + .../__tests__/collect-import-metadata.test.ts | 103 +++++++++ src/codegen/utils/boolean-variant.ts | 27 +++ .../utils/collect-boolean-condition-props.ts | 20 ++ src/codegen/utils/collect-import-metadata.ts | 119 ++++++++++ .../utils/extract-instance-variant-props.ts | 3 +- src/codegen/utils/props-to-str.ts | 3 +- .../exportPagesAndComponents.test.ts | 107 +++++---- src/commands/exportPagesAndComponents.ts | 80 ++++--- 22 files changed, 970 insertions(+), 166 deletions(-) create mode 100644 src/codegen/utils/__tests__/collect-import-metadata.test.ts create mode 100644 src/codegen/utils/boolean-variant.ts create mode 100644 src/codegen/utils/collect-boolean-condition-props.ts create mode 100644 src/codegen/utils/collect-import-metadata.ts 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/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 42f81bc..df14102 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -11,6 +11,11 @@ import { sanitizePropertyName, } from './codegen/props/selector' import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen' +import { + coerceBooleanVariantValue, + isBooleanVariantOptions, +} from './codegen/utils/boolean-variant' +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 { nodeProxyTracker } from './codegen/utils/node-proxy' @@ -31,8 +36,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) @@ -55,7 +62,7 @@ function generateImportStatements( } function generateBashCLI( - componentsCodes: ReadonlyArray, + componentsCodes: ReadonlyArray, ): string { const importStatement = generateImportStatements(componentsCodes) @@ -73,7 +80,7 @@ function generateBashCLI( } function generatePowerShellCLI( - componentsCodes: ReadonlyArray, + componentsCodes: ReadonlyArray, ): string { const importStatement = generateImportStatements(componentsCodes) @@ -173,10 +180,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) { @@ -249,9 +260,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 @@ -275,9 +284,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' && @@ -285,7 +292,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 @@ -337,8 +344,8 @@ export function registerCodegen(ctx: typeof figma) { sectionNode, DEFAULT_CODEGEN_OPTIONS, ) - const responsiveCode = - await responsiveCodegen.generateResponsiveCode() + const { code: responsiveCode, imports } = + await responsiveCodegen.generateResponsiveResult() const baseName = toPascal(sectionNode.name) const sectionComponentName = isParentSection ? `${baseName}Page` @@ -348,9 +355,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 @@ -425,22 +432,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 b365d1f..17ad78a 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -10,6 +10,12 @@ import type { ComponentTree, NodeTree } from './types' import { addPx } from './utils/add-px' import { checkAssetNode } from './utils/check-asset-node' import { checkSameColor } from './utils/check-same-color' +import { collectBooleanConditionProps } from './utils/collect-boolean-condition-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 { @@ -304,7 +310,8 @@ function buildComponentReferenceComment( props: Record, ): string { const propsString = propsToString(props) - return `<${componentName}${propsString ? ` ${propsString}` : ''} />` + const normalizedProps = propsString.replace(/\s+/g, ' ').trim() + return `<${componentName}${normalizedProps ? ` ${normalizedProps}` : ''} />` } export class Codegen { @@ -315,6 +322,7 @@ export class Codegen { code: string variants: Record variantComments?: Record + imports: ImportMetadata } > = new Map() code: string = '' @@ -362,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 @@ -414,11 +424,20 @@ export class Codegen { // Sync componentTrees to components for (const [compId, compTree] of this.componentTrees) { if (!this.components.has(compId)) { + const inferredBooleanProps = collectBooleanConditionProps(compTree.tree) + const variants = { ...compTree.variants } + for (const propName of inferredBooleanProps) { + if (!variants[propName]) { + variants[propName] = 'boolean' + } + } + 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), }) } } @@ -601,7 +620,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, + } } } diff --git a/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap index 3b79620..bcca412 100644 --- a/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap +++ b/src/codegen/__tests__/__snapshots__/codegen-viewport.test.ts.snap @@ -17,3 +17,24 @@ export function Status({ status }: StatusProps) { ) }" `; + +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 d647f56..93cd212 100644 --- a/src/codegen/__tests__/codegen-viewport.test.ts +++ b/src/codegen/__tests__/codegen-viewport.test.ts @@ -996,6 +996,75 @@ describe('Codegen effect-only COMPONENT_SET', () => { 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 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 8a86d84..a4bb9b9 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -3400,6 +3400,213 @@ describe('Codegen Tree Methods', () => { expect(tree.props).toEqual({}) }) + 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', 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/selector.ts b/src/codegen/props/selector.ts index bec5c6a..3989a35 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' @@ -177,9 +178,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') { diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index d27e575..02c3f0a 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -7,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' @@ -23,6 +29,8 @@ import { viewportToBreakpoint, } from '.' +type GeneratedResponsiveCode = readonly [string, string, ImportMetadata] + const POSITION_PROP_KEYS = new Set([ 'pos', 'top', @@ -230,6 +238,11 @@ function mergeChildNameOrder( */ export class ResponsiveCodegen { private breakpointNodes: Map = new Map() + private lastImportMetadata: ImportMetadata = { + devupImports: [], + customImports: [], + usesKeyframes: false, + } constructor( private sectionNode: SectionNode | null, @@ -260,8 +273,23 @@ 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) { @@ -269,7 +297,9 @@ export class ResponsiveCodegen { const [, node] = firstMapEntry(this.breakpointNodes) 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. @@ -280,8 +310,19 @@ export class ResponsiveCodegen { 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 } /** @@ -477,7 +518,7 @@ export class ResponsiveCodegen { componentSet: ComponentSetNode, componentName: string, options: CodegenOptions = {}, - ): Promise> { + ): Promise> { // Find viewport and effect variant keys const viewportDefs = getComponentPropertyDefinitions(componentSet) let viewportKey: string | undefined @@ -500,9 +541,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') { @@ -553,7 +594,7 @@ export class ResponsiveCodegen { } // Generate responsive code for each group - const results: Array = [] + const results: Array = [] const responsiveCodegen = new ResponsiveCodegen(null, options) for (const [groupKey, viewportComponents] of groups) { @@ -610,6 +651,11 @@ export class ResponsiveCodegen { finalVariants, variantComments, ), + mergeImportMetadata( + [...treesByBreakpoint.values()].map((tree) => + collectImportMetadataFromTree(tree, componentName), + ), + ), ] as const) } @@ -628,7 +674,7 @@ export class ResponsiveCodegen { componentSet: ComponentSetNode, componentName: string, options: CodegenOptions = {}, - ): Promise> { + ): Promise> { const tTotal = perfStart() // Find viewport and effect variant keys @@ -657,9 +703,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) @@ -836,7 +882,7 @@ export class ResponsiveCodegen { 0, ) - const result: Array = [ + const result: Array = [ [ componentName, renderComponent( @@ -845,6 +891,14 @@ export class ResponsiveCodegen { finalVariants, variantComments, ), + mergeImportMetadata( + [...responsivePropsByComposite.values()].flatMap( + (treesByBreakpoint) => + [...treesByBreakpoint.values()].map((tree) => + collectImportMetadataFromTree(tree, componentName), + ), + ), + ), ], ] return result @@ -858,7 +912,7 @@ export class ResponsiveCodegen { componentSet: ComponentSetNode, componentName: string, options: CodegenOptions = {}, - ): Promise> { + ): Promise> { // Use defaultVariant as the base component const defaultComponent = componentSet.defaultVariant if (!defaultComponent) { @@ -896,10 +950,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 @@ -914,7 +969,7 @@ export class ResponsiveCodegen { variantKeys: string[], variants: Record, options: CodegenOptions = {}, - ): Promise> { + ): Promise> { if (variantKeys.length === 0) { return [] } @@ -1077,8 +1132,16 @@ export class ResponsiveCodegen { 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 } @@ -1094,7 +1157,7 @@ export class ResponsiveCodegen { variants: Record, hasEffect: boolean, options: CodegenOptions = {}, - ): Promise> { + ): Promise> { // Group components by variant value const componentsByVariant = new Map() @@ -1169,8 +1232,16 @@ export class ResponsiveCodegen { 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 } diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts index fba51f6..c5fa884 100644 --- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts +++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts @@ -123,6 +123,39 @@ 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 breakpoints and adds display for missing child variants', async () => { const onlyMobileChild: NodeTree = { component: 'Box', diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts index a27e0c1..6852eb4 100644 --- a/src/codegen/responsive/index.ts +++ b/src/codegen/responsive/index.ts @@ -1,3 +1,7 @@ +import { + getBooleanVariantAccessor, + isBooleanVariantOptions, +} from '../utils/boolean-variant' import { isDefaultProp } from '../utils/is-default-prop' // Breakpoint thresholds (by width) @@ -457,6 +461,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 +483,14 @@ export function createVariantPropValue( variantKey: string, values: Record, ): VariantPropValue { + const valueKeys = Object.keys(values) return { __variantProp: true, variantKey, values, + accessorExpression: isBooleanVariantOptions(valueKeys) + ? getBooleanVariantAccessor(variantKey) + : undefined, } } 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/boolean-variant.ts b/src/codegen/utils/boolean-variant.ts new file mode 100644 index 0000000..302512f --- /dev/null +++ b/src/codegen/utils/boolean-variant.ts @@ -0,0 +1,27 @@ +function normalizeOption(value: string): string { + return value.trim().toLowerCase() +} + +export function isBooleanVariantOptions(options: readonly string[]): boolean { + if (options.length !== 2) return false + const normalized = new Set(options.map(normalizeOption)) + 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 = normalizeOption(value) + if (normalized === 'true') return true + if (normalized === 'false') return false + return value +} + +export function getBooleanVariantAccessor(variantKey: string): string { + return `${variantKey} ?? false` +} 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-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/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/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/exportPagesAndComponents.ts b/src/commands/exportPagesAndComponents.ts index 952426b..2dcf124 100644 --- a/src/commands/exportPagesAndComponents.ts +++ b/src/commands/exportPagesAndComponents.ts @@ -8,6 +8,7 @@ import { } 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, @@ -44,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) @@ -348,16 +341,17 @@ export async function exportPagesAndComponents() { sectionNode, DEFAULT_CODEGEN_OPTIONS, ) - const responsiveCode = await responsiveCodegen.generateResponsiveCode() + 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 From 3bb2777e69206c723f24f22fb78dd0da8ef5e2f6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 22 Apr 2026 21:20:53 +0900 Subject: [PATCH 4/6] Support more conditional --- src/codegen/Codegen.ts | 16 +++++-- src/codegen/__tests__/codegen.test.ts | 44 ++++++++++++++++++ src/codegen/responsive/index.ts | 11 ++++- src/codegen/utils/boolean-variant.ts | 20 +++++++- src/codegen/utils/collect-component-props.ts | 48 ++++++++++++++++++++ 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 src/codegen/utils/collect-component-props.ts diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 17ad78a..f437add 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -10,7 +10,7 @@ import type { ComponentTree, NodeTree } from './types' import { addPx } from './utils/add-px' import { checkAssetNode } from './utils/check-asset-node' import { checkSameColor } from './utils/check-same-color' -import { collectBooleanConditionProps } from './utils/collect-boolean-condition-props' +import { collectComponentProps } from './utils/collect-component-props' import { collectImportMetadataFromTree, type ImportMetadata, @@ -424,13 +424,23 @@ export class Codegen { // Sync componentTrees to components for (const [compId, compTree] of this.componentTrees) { if (!this.components.has(compId)) { - const inferredBooleanProps = collectBooleanConditionProps(compTree.tree) const variants = { ...compTree.variants } - for (const propName of inferredBooleanProps) { + 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, diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index a4bb9b9..3ddbfd8 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -4,6 +4,7 @@ import { toPascal } from '../../utils/to-pascal' 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' @@ -5216,6 +5217,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/responsive/index.ts b/src/codegen/responsive/index.ts index 6852eb4..16b8497 100644 --- a/src/codegen/responsive/index.ts +++ b/src/codegen/responsive/index.ts @@ -1,6 +1,7 @@ import { getBooleanVariantAccessor, isBooleanVariantOptions, + normalizeBooleanVariantKey, } from '../utils/boolean-variant' import { isDefaultProp } from '../utils/is-default-prop' @@ -484,10 +485,18 @@ export function createVariantPropValue( 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/utils/boolean-variant.ts b/src/codegen/utils/boolean-variant.ts index 302512f..bb5f993 100644 --- a/src/codegen/utils/boolean-variant.ts +++ b/src/codegen/utils/boolean-variant.ts @@ -2,9 +2,21 @@ 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(normalizeOption)) + const normalized = new Set(options.map((option) => getBooleanAlias(option))) return ( normalized.size === 2 && normalized.has('true') && normalized.has('false') ) @@ -16,12 +28,16 @@ export function getVariantType(options: readonly string[]): string { } export function coerceBooleanVariantValue(value: string): boolean | string { - const normalized = normalizeOption(value) + 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/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], + } +} From e2738b1b95ef51fb9d60ef4643e0fc6592fe939b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 22 Apr 2026 22:04:32 +0900 Subject: [PATCH 5/6] Merge component --- .../__tests__/codegen-viewport.test.ts | 65 +++ src/codegen/__tests__/codegen.test.ts | 64 +++ src/codegen/responsive/ResponsiveCodegen.ts | 33 +- .../__tests__/ResponsiveCodegen.test.ts | 469 ++++++++++++++++++ .../ResponsiveCodegen.test.ts.snap | 2 + 5 files changed, 631 insertions(+), 2 deletions(-) diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts index 93cd212..915cfef 100644 --- a/src/codegen/__tests__/codegen-viewport.test.ts +++ b/src/codegen/__tests__/codegen-viewport.test.ts @@ -1065,6 +1065,71 @@ describe('Codegen effect-only COMPONENT_SET', () => { 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 3ddbfd8..942e8b4 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -3401,6 +3401,70 @@ describe('Codegen Tree Methods', () => { 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', diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index 02c3f0a..0a74397 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -328,12 +328,41 @@ export class ResponsiveCodegen { /** * 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 signature = this.getChildStructureSignature(child) + signatureCounts.set(signature, (signatureCounts.get(signature) || 0) + 1) + } + for (const child of tree.children) { - const existing = result.get(child.nodeName) || [] + 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 } diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts index c5fa884..8bbb482 100644 --- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts +++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts @@ -156,6 +156,124 @@ describe('ResponsiveCodegen', () => { 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', @@ -1596,6 +1714,172 @@ 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 () => { @@ -2975,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) 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"}|"`; From 4ee85a00e4539d89440078f4cec784a43b435396 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 23 Apr 2026 00:12:56 +0900 Subject: [PATCH 6/6] Optimize --- src/code-impl.ts | 14 +- src/codegen/Codegen.ts | 25 +- .../props/__tests__/bound-variables.test.ts | 673 +++++++++++++++++- src/codegen/props/auto-layout.ts | 5 +- src/codegen/props/background.ts | 27 +- src/codegen/props/border.ts | 93 ++- src/codegen/props/effect.ts | 40 +- src/codegen/props/index.ts | 78 +- src/codegen/props/layout.ts | 101 ++- src/codegen/props/padding.ts | 7 +- src/codegen/props/reaction.ts | 34 +- src/codegen/props/selector.ts | 22 +- src/codegen/props/text-shadow.ts | 3 +- src/codegen/types.ts | 6 + .../utils/__tests__/check-asset-node.test.ts | 650 +++++++++++++++++ .../utils/__tests__/check-same-color.test.ts | 107 +++ .../utils/__tests__/paint-to-css.test.ts | 3 +- .../utils/__tests__/solid-to-string.test.ts | 103 +++ src/codegen/utils/check-asset-node.ts | 261 +++++++ src/codegen/utils/check-same-color.ts | 23 +- .../get-component-property-definitions.ts | 26 +- src/codegen/utils/get-page-node.ts | 23 +- src/codegen/utils/paint-to-css.ts | 24 +- src/codegen/utils/solid-to-string.ts | 60 +- src/utils.ts | 2 +- 25 files changed, 2293 insertions(+), 117 deletions(-) create mode 100644 src/codegen/utils/__tests__/check-asset-node.test.ts create mode 100644 src/codegen/utils/__tests__/check-same-color.test.ts create mode 100644 src/codegen/utils/__tests__/solid-to-string.test.ts diff --git a/src/code-impl.ts b/src/code-impl.ts index df14102..09dcb8f 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -15,10 +15,17 @@ 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' @@ -246,6 +253,11 @@ export function registerCodegen(ctx: typeof figma) { resetSelectorPropsCache() resetChildAnimationCache() resetVariableCache() + resetCheckAssetNodeCache() + resetCheckSameColorCache() + resetPaintToCssCache() + resetGetPageNodeCache() + resetComponentPropertyDefinitionsCache() resetTextStyleCache() resetMainComponentCache() resetGlobalBuildTreeCache() diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index f437add..31f652c 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -8,7 +8,7 @@ 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 { @@ -492,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 @@ -502,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. @@ -535,9 +534,8 @@ export class Codegen { this.options.assetComponentInstanceMode === 'inline' && mainComponent ) { - this.addComponentTree(mainComponent) - const inlineTree = cloneTree(await this.buildTree(mainComponent)) + this.addComponentTree(mainComponent) if (isAssetLeafTree(inlineTree)) { inlineTree.props = applyAssetNodeSize(inlineTree, node) @@ -689,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)) { @@ -700,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' @@ -749,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 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 3989a35..b4a37bc 100644 --- a/src/codegen/props/selector.ts +++ b/src/codegen/props/selector.ts @@ -159,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) @@ -315,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/types.ts b/src/codegen/types.ts index bfde2f8..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 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__/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/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/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/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/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(