diff --git a/bun.lock b/bun.lock index 6e1ebdb..7c12b70 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -9,79 +9,83 @@ }, "devDependencies": { "@biomejs/biome": "^2.5", - "@figma/plugin-typings": "^1.131", - "@rspack/cli": "^2.1.4", - "@rspack/core": "^2.1.4", - "@types/bun": "^1.3", + "@figma/plugin-typings": "^1.137", + "@rspack/cli": "^2.2.2", + "@rspack/core": "^2.2.2", + "@types/bun": "^1.4", "husky": "^9.1", "typescript": "^7.0", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="], - "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@figma/plugin-typings": ["@figma/plugin-typings@1.131.0", "", {}, "sha512-XBTPNGc65myupitMaeemyiF/ir1jn/EXXTdoGUCEVCfVB6KPQUy+gbfHbnC+QQiHxdw/Naikdx+9v/u4d49gfA=="], + "@figma/plugin-typings": ["@figma/plugin-typings@1.137.0", "", {}, "sha512-sab4AZPa5oWXjbO27UHeHWGGbl6oX+25LSXwUQIN+hoKeoeFuYeoZ7h6rCE1LZkHlvbsZjucs8wqcnOqmKiVnQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@rspack/binding": ["@rspack/binding@2.1.4", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.1.4", "@rspack/binding-darwin-x64": "2.1.4", "@rspack/binding-linux-arm64-gnu": "2.1.4", "@rspack/binding-linux-arm64-musl": "2.1.4", "@rspack/binding-linux-riscv64-gnu": "2.1.4", "@rspack/binding-linux-riscv64-musl": "2.1.4", "@rspack/binding-linux-x64-gnu": "2.1.4", "@rspack/binding-linux-x64-musl": "2.1.4", "@rspack/binding-wasm32-wasi": "2.1.4", "@rspack/binding-win32-arm64-msvc": "2.1.4", "@rspack/binding-win32-ia32-msvc": "2.1.4", "@rspack/binding-win32-x64-msvc": "2.1.4" } }, "sha512-iye4BaTYtTt0qa39avWwEsUobVxFNhQHr6B8reFeKU7sdaZsC9LUoDR8JAt5gOnbnzFMPdKgrdU/VzkC6rXbIg=="], + "@rspack/binding": ["@rspack/binding@2.2.2", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.2.2", "@rspack/binding-darwin-x64": "2.2.2", "@rspack/binding-linux-arm64-gnu": "2.2.2", "@rspack/binding-linux-arm64-musl": "2.2.2", "@rspack/binding-linux-ppc64-gnu": "2.2.2", "@rspack/binding-linux-riscv64-gnu": "2.2.2", "@rspack/binding-linux-riscv64-musl": "2.2.2", "@rspack/binding-linux-s390x-gnu": "2.2.2", "@rspack/binding-linux-x64-gnu": "2.2.2", "@rspack/binding-linux-x64-musl": "2.2.2", "@rspack/binding-wasm32-wasi": "2.2.2", "@rspack/binding-win32-arm64-msvc": "2.2.2", "@rspack/binding-win32-ia32-msvc": "2.2.2", "@rspack/binding-win32-x64-msvc": "2.2.2" } }, "sha512-gWjKDQfVQJSBh/I+y9WTlyERsiShSJ7eI6Yl0SJs/6gjx8t4ixuwWCsaEFFjwSL4nSn6ML5hNQ7UFY3gj72BWA=="], - "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3Xcs01iw48F4WeE4SHga6bCNb/UEFvtQX4P4eMIaJfGPjTQuxfabGE8yCPm9e3tpLZ5uo+IBnJ6nh5r6tIDOXQ=="], + "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/le/AvV4HSinXTPs2Lqpujxt189Z5T10Ggt96QzckLRPvIchzqoavVDDjltzC7XcaZ87XCH/X06jNKgzkcBBNA=="], - "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-bz/AsCplLs+3fXULPQU9d4r8H4PdeljgHFyItvVIrA/NKZqzQ8sX0topf/zJVZAOtPH7GNnrKjq0/F0U2DHikQ=="], + "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFIcUPUXiPxM6ljenLafp5TemT8eLZm1riRn8fJYmpqNCK+aCcTaud18XHZpI5SjzzcY+xUHfVShgvNzKXuf2g=="], - "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-x0HQTLU1MusCtNamuXxf3ayEPkvh9uuaq4wVyBqveRkn4FznSOoHUsxTAKMnjGARX+vdLV/y/SwWJRDp2RI4zw=="], + "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Pjby4pDSMNJQK2VBzpgCj6lb+DGuenS1fEDb6xi5/apbJb9v5WE+e43Mz7i+XqgXS8e846pjaONV3KM5WKB1LQ=="], - "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-SEYCQD9UflKJMkYGnG5nt2gcsqdkgJsQmryBC/jxo+bOuY9gUSReU99FqCt7WRQrsHLbGeAFeTWs1QzItWG/Bw=="], + "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0u9O7tTVT2z+F6o/eEY6f6+My8Jn9U56QiBwkfy90ZaoAfZyQSSTxqaK/zA7W43oFxQefeCpiPas27VUzrSakw=="], - "@rspack/binding-linux-riscv64-gnu": ["@rspack/binding-linux-riscv64-gnu@2.1.4", "", { "os": "linux", "cpu": "none" }, "sha512-jYtQKtnDRaVfyasvTGY04Z7m+xDWZYVwAIEOB4hP7czM7FVLOMgHlMlvw/EgF0DNHrBthqbPfBIS2tP50CzpEA=="], + "@rspack/binding-linux-ppc64-gnu": ["@rspack/binding-linux-ppc64-gnu@2.2.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-N3lVnhq5qOvpmP5n386JzR1GcE8HzAqq4/r440Z6yle9m7PhVFJ6oXVWxgaDTYV0mtv9YLJmaG+YrjYfUa1vuA=="], - "@rspack/binding-linux-riscv64-musl": ["@rspack/binding-linux-riscv64-musl@2.1.4", "", { "os": "linux", "cpu": "none" }, "sha512-ZgxKjQAm9pidq2kChQO2PqKI9OQpLuGD7iPBuyT0gQg3m1+7vvbbkArLBPzJ12CQHVZvqua7n/QGx8pQjJI2Zw=="], + "@rspack/binding-linux-riscv64-gnu": ["@rspack/binding-linux-riscv64-gnu@2.2.2", "", { "os": "linux", "cpu": "none" }, "sha512-ReClZyp32/rJkUDV/oGDU0X6BCFyEkTR6r4stFwm/qOOaG6mUMRzZe4gur8Yh979p4Hiz9EdkmfEHY02GBXcaQ=="], - "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-C53B3e6M4yzlYn4hDxR9cHZV+HqkfFGR0zuhH8QCfdBfN5KyGsmXmujiFU85ANEvBgf9CF8VreBQeJ20lyto/g=="], + "@rspack/binding-linux-riscv64-musl": ["@rspack/binding-linux-riscv64-musl@2.2.2", "", { "os": "linux", "cpu": "none" }, "sha512-mqsorvTNerr3r8zI35NFTPYATPDlhepdiUhZjKT6ylqpHlP7vLU9fp4aEMK/CuTv2f+IVsa0+iZqtMxHs2tSjw=="], - "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-UaeG3FRo7e5RameQvWRNQ6KeXF29LEk67U/ohb9tF4U38mKH1OGqhmwCf5yLkqiOSgOi7Ff3ireOZD83Fso1iw=="], + "@rspack/binding-linux-s390x-gnu": ["@rspack/binding-linux-s390x-gnu@2.2.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ql2Jub8QYWSugBppmj2u0SjcIj6fOQuAaLCYQOqU7zbvz/uuWTfoqmdWKExwrxeCU9Tzt7emVM0ETuVEpoca+A=="], - "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.1.4", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "1.1.6" }, "cpu": "none" }, "sha512-0P1WZEfu7JOPzD/jQfk9U/6gnRFc7RpvYCQaYZVVYZNJ2gU6O0/yLegRGKNK/2L0zjYwiD0ynhOIBVUUERf5Pw=="], + "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-MNYKYEHrtIVEno2q5rgpou/JVffRwn109xPK3kxct95EojHsniNa8jwy6eEHeOazP4EN60Si7NElE3aQ6JssHw=="], - "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+aStQipk1EakLRPfD+/aQbtmTXfxqSWetcRRDWV3cAsD4ebv1tF8FLKYY1PCaFpQbX1FxzCBGF0KHxkAsi9cxA=="], + "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-y9/9CmE8lrECaF17GAhacibTL+SvlEPEotQnUuBFC/WFtXh8hU2E7a7bAkpYGSLH6kM0nrJZHO3hTvM1wdWOJw=="], - "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.1.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-7nyrLRQ07j6i6omZuwiwwTI6rpjdy/Su3niLDAG7WsLL21u3/UQQrw6Fvm8SH3XYteghoHLSM3dhgaisuUhdJg=="], + "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.2.2", "", { "dependencies": { "@emnapi/core": "1.11.3", "@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "1.1.6" }, "cpu": "none" }, "sha512-VbDIjjeFwZvMSKAOGY5IbU6lLzt6AHHHncTdMMkZ94Xk7O2BOHe9BXDV32Ln29TIW2C8m1fdxfPZWDiecVghUQ=="], - "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4je7JBaDpO9vvNMgCxlycycRJq+GQwwuXSXagW2xvvGM10Ij63YVtIehSMG9xaW8PED41oc9nWndoEsiD60pA=="], + "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-rfcNg0W3ZPZvXma1gTyEt9/Z8FxASIaQr+sMWTSaTPPaeU3xY1+0hYcrD0kUFNs3/5L4u63myI4R8qRXiuW3pA=="], - "@rspack/cli": ["@rspack/cli@2.1.4", "", { "peerDependencies": { "@rspack/core": "^2.0.0-0", "@rspack/dev-server": "^2.0.0-0" }, "optionalPeers": ["@rspack/dev-server"], "bin": { "rspack": "./bin/rspack.js" } }, "sha512-DYFvcDwL8jzqPV+7zt7DKpq2Fsk7ZtK8YswhTTje6iaIwvtzAPLFI3BsG6YrQ3yfMj7jqjSe4Vb5MDPCxR/g4A=="], + "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.2.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-TFPvr9RZw9oHIhooDhXHzWjKcHpGPTxkznSeM2poIWU0CdEuua2rVUfsrriTF1Dmx+9kMly61DQmyOHCbb+b2g=="], - "@rspack/core": ["@rspack/core@2.1.4", "", { "dependencies": { "@rspack/binding": "2.1.4" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-lpJgtr+JAXuDAMBJfRJ1LHyWVuYJyhZu6L6aj9t4lipUU03qwakHnzn3vwSCr68PsVvVPzR6NbJE1gJienSV0g=="], + "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-GvEGyL594dtWN9SoVnKWh0exrM8WLInaUjwcuA2JKbCi1ak/9iHxip/U1dY3DuVqBYBhmvYq96JPbl91xnzFjg=="], + + "@rspack/cli": ["@rspack/cli@2.2.2", "", { "peerDependencies": { "@rspack/core": "^2.0.0-0", "@rspack/dev-server": "^2.0.0-0" }, "optionalPeers": ["@rspack/dev-server"], "bin": { "rspack": "./bin/rspack.js" } }, "sha512-4fViBJqInw3v43sbgJv1NVVXs0IbjzGFg76jbVECUcbJuO/L104Fwa0wrYQrGI6Fb922sq7zFR0C0XbssUOUtA=="], + + "@rspack/core": ["@rspack/core@2.2.2", "", { "dependencies": { "@rspack/binding": "2.2.2" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-/yztfDZR5syIPBrUpzBpL+6fhhl0IHBPcXlNr4tOMBULbocFIz7Z4/cqvf1ix0DBbKpIGx99v6N1IDbk2gi8hw=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], - "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -123,7 +127,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], diff --git a/package.json b/package.json index 23d022f..920dd5e 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,14 @@ "author": "", "license": "", "devDependencies": { - "@figma/plugin-typings": "^1.131", - "@rspack/cli": "^2.1.4", - "@rspack/core": "^2.1.4", + "@figma/plugin-typings": "^1.137", + "@rspack/cli": "^2.2.2", + "@rspack/core": "^2.2.2", "husky": "^9.1", "typescript": "^7.0", "@biomejs/biome": "^2.5", - "@types/bun": "^1.3" + "@types/bun": "^1.4" }, "dependencies": { "jszip": "^3.10" diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index f08eae7..2f0bd8a 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -2052,3 +2052,57 @@ describe('registerCodegen with usage output', () => { expect(usageCode).toBe('') }) }) + +describe('registerCodegen error reporting', () => { + type CodegenHandler = (event: { + node: SceneNode + language: string + }) => Promise + + it('returns a copyable diagnostic report instead of rejecting', async () => { + let capturedHandler: CodegenHandler | null = null + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: mock((_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }), + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + const consoleError = spyOn(console, 'error').mockImplementation(() => {}) + + codeModule.registerCodegen(figmaMock) + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + // A TEXT node without getStyledTextSegments makes renderText throw the same + // way the minified bundle does in Figma. + const brokenText = { + id: '1:99', + type: 'TEXT', + name: 'Broken', + visible: true, + characters: 'hello', + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ node: brokenText, language: 'devup-ui' }) + + expect(result).toHaveLength(1) + const [entry] = result as [ + { title: string; language: string; code: string }, + ] + expect(entry.title).toBe('Devup UI - Codegen Error') + expect(entry.language).toBe('PLAINTEXT') + expect(entry.code).toContain('DEVUP UI CODEGEN ERROR') + expect(entry.code).toContain('language : devup-ui') + expect(entry.code).toContain('selected : TEXT "Broken" (1:99)') + expect(entry.code).toContain('--- codegen path (innermost first) ---') + expect(entry.code).toContain('buildTree :: TEXT "Broken" (1:99)') + expect(consoleError).toHaveBeenCalled() + }) +}) diff --git a/src/code-impl.ts b/src/code-impl.ts index 4f8e60a..c7b6ccd 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -2,32 +2,20 @@ import { Codegen, DEFAULT_CODEGEN_OPTIONS, resetGlobalBuildTreeCache, - resetMainComponentCache, } from './codegen/Codegen' -import { resetGetPropsCache } from './codegen/props' -import { resetChildAnimationCache } from './codegen/props/reaction' -import { - resetSelectorPropsCache, - sanitizePropertyName, -} from './codegen/props/selector' +import { sanitizePropertyName } from './codegen/props/selector' +import { resetCodegenCaches } from './codegen/reset-caches' import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen' import { coerceBooleanVariantValue, isBooleanVariantOptions, } from './codegen/utils/boolean-variant' -import { resetCheckAssetNodeCache } from './codegen/utils/check-asset-node' -import { resetCheckSameColorCache } from './codegen/utils/check-same-color' import type { ImportMetadata } from './codegen/utils/collect-import-metadata' +import { formatCodegenErrorReport } from './codegen/utils/diagnostics' import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props' -import { - getComponentPropertyDefinitions, - resetComponentPropertyDefinitionsCache, -} from './codegen/utils/get-component-property-definitions' -import { resetGetPageNodeCache } from './codegen/utils/get-page-node' +import { getComponentPropertyDefinitions } from './codegen/utils/get-component-property-definitions' 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' import { exportDevup, importDevup } from './commands/devup' import { exportAssets } from './commands/exportAssets' @@ -40,7 +28,7 @@ import { export { extractCustomComponentImports, extractImports } -import { getComponentName, resetTextStyleCache } from './utils' +import { getComponentName } from './utils' import { toPascal } from './utils/to-pascal' type GeneratedCodeEntry = readonly [string, string, ImportMetadata?] @@ -241,7 +229,10 @@ const debug = true export function registerCodegen(ctx: typeof figma) { if (ctx.editorType === 'dev' && ctx.mode === 'codegen') { - ctx.codegen.on('generate', async ({ node: n, language }) => { + const handler = async ({ + node: n, + language, + }: CodegenEvent): Promise => { // Use the raw node for codegen (no Proxy overhead). // Debug tracking happens AFTER codegen completes via separate walk. const node = n @@ -249,18 +240,7 @@ export function registerCodegen(ctx: typeof figma) { case 'devup-ui': { const time = Date.now() perfReset() - resetGetPropsCache() - resetSelectorPropsCache() - resetChildAnimationCache() - resetVariableCache() - resetCheckAssetNodeCache() - resetCheckSameColorCache() - resetPaintToCssCache() - resetGetPageNodeCache() - resetComponentPropertyDefinitionsCache() - resetTextStyleCache() - resetMainComponentCache() - resetGlobalBuildTreeCache() + resetCodegenCaches() let t = perfStart() const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS) @@ -594,6 +574,28 @@ export function registerCodegen(ctx: typeof figma) { } } return [] + } + + ctx.codegen.on('generate', async (event) => { + try { + return await handler(event) + } catch (error) { + // The bundle is minified, so Figma's own stack trace is unreadable and + // the throw surfaces only as "unhandled promise rejection". Surface a + // structured report in the codegen panel instead, so it can be copied. + const report = formatCodegenErrorReport(error, { + node: event.node, + language: event.language, + }) + console.error(report) + return [ + { + title: 'Devup UI - Codegen Error', + language: 'PLAINTEXT' as const, + code: report, + }, + ] + } }) } } diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 3325c95..995d3df 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -16,6 +16,7 @@ import { type ImportMetadata, mergeImportMetadata, } from './utils/collect-import-metadata' +import { traceAsync, traceSync } from './utils/diagnostics' import { extractInstanceVariantProps } from './utils/extract-instance-variant-props' import { getComponentPropertyDefinitions } from './utils/get-component-property-definitions' import { @@ -500,7 +501,11 @@ export class Codegen { return result } - private async doBuildTree(node: SceneNode): Promise { + private doBuildTree(node: SceneNode): Promise { + return traceAsync('buildTree', node, () => this.doBuildTreeInner(node)) + } + + private async doBuildTreeInner(node: SceneNode): Promise { const tBuild = perfStart() let pendingAddComponentTreeNode: ComponentNode | null = null @@ -912,7 +917,16 @@ export class Codegen { return promise } - private async doAddComponentTree( + private doAddComponentTree( + node: ComponentNode, + nodeId: string, + ): Promise { + return traceAsync('addComponentTree', node, () => + this.doAddComponentTreeInner(node, nodeId), + ) + } + + private async doAddComponentTreeInner( node: ComponentNode, nodeId: string, ): Promise { @@ -1102,6 +1116,12 @@ export class Codegen { * Static method so it can be used independently. */ static renderTree(tree: NodeTree, depth: number = 0): string { + return traceSync('renderTree', tree, () => + Codegen.renderTreeInner(tree, depth), + ) + } + + private static renderTreeInner(tree: NodeTree, depth: number): string { // Handle INSTANCE_SWAP slot placeholders — render as {propName} if (tree.isSlot) { if (tree.condition) { diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts index 915cfef..0648a73 100644 --- a/src/codegen/__tests__/codegen-viewport.test.ts +++ b/src/codegen/__tests__/codegen-viewport.test.ts @@ -1,13 +1,11 @@ import { afterAll, beforeEach, describe, expect, test } from 'bun:test' -import { resetTextStyleCache } from '../../utils' import { Codegen, getGlobalAssetNodes, resetGlobalAssetNodes, resetGlobalBuildTreeCache, } from '../Codegen' -import { resetGetPropsCache } from '../props' -import { resetSelectorPropsCache } from '../props/selector' +import { resetCodegenCaches } from '../reset-caches' import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' // Mock figma global @@ -50,20 +48,17 @@ import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' }, } as unknown as typeof figma +// Every codegen cache is keyed by node.id, and other test files reuse ids such +// as `status-error`. Reset all of them — exactly like a real codegen run does — +// so results never depend on which test file ran first. beforeEach(() => { - resetGlobalBuildTreeCache() + resetCodegenCaches() resetGlobalAssetNodes() - resetGetPropsCache() - resetSelectorPropsCache() - resetTextStyleCache() }) afterAll(() => { - resetGlobalBuildTreeCache() + resetCodegenCaches() resetGlobalAssetNodes() - resetGetPropsCache() - resetSelectorPropsCache() - resetTextStyleCache() ;(globalThis as { figma?: unknown }).figma = undefined }) diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index 942e8b4..8df893c 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, test } from 'bun:test' import { getComponentName } from '../../utils' import { toPascal } from '../../utils/to-pascal' import { Codegen, DEFAULT_CODEGEN_OPTIONS } from '../Codegen' +import { renderText } from '../render/text' import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' import type { NodeTree } from '../types' import { collectComponentProps } from '../utils/collect-component-props' @@ -61237,3 +61238,30 @@ describe('render real world component', () => { } }) }) + +describe('renderText with segments missing listOptions', () => { + it('treats a segment without listOptions as a plain (non-list) segment', async () => { + // Figma returns `listOptions: undefined` — not `{ type: 'NONE' }` — for the + // non-list part of a TEXT node that also contains a bulleted list. + const plain = createTextSegment('Heading\n') + ;(plain as unknown as { listOptions?: unknown }).listOptions = undefined + const bullet = createTextSegment('list item') + ;(bullet as unknown as { listOptions?: unknown }).listOptions = { + type: 'UNORDERED', + } + + const node = { + id: '1:1', + type: 'TEXT', + name: 'Mixed', + getStyledTextSegments: () => [plain, bullet], + } as unknown as TextNode + + const { children } = await renderText(node) + const joined = children.join('') + expect(joined).toContain('Heading') + expect(joined).toContain('') + expect(joined).toContain('list item') + }) +}) diff --git a/src/codegen/props/__tests__/bound-variables.test.ts b/src/codegen/props/__tests__/bound-variables.test.ts index e6ddef1..c9c1af6 100644 --- a/src/codegen/props/__tests__/bound-variables.test.ts +++ b/src/codegen/props/__tests__/bound-variables.test.ts @@ -93,6 +93,36 @@ describe('length bound variables (padding / gap / size / radius)', () => { }) }) + test.each([ + ['SPACE_EVENLY', 'space-evenly'], + ['SPACE_AROUND', 'space-around'], + ] as const)( + 'getAutoLayoutProps maps %s distribution to %s', + async (primaryAxisAlignItems, justifyContent) => { + setupFigmaMocks() + + const node = { + type: 'FRAME', + inferredAutoLayout: { + layoutMode: 'HORIZONTAL', + itemSpacing: 8, + }, + primaryAxisAlignItems, + counterAxisAlignItems: 'CENTER', + children: [{ visible: true }, { visible: true }], + boundVariables: {}, + } as unknown as SceneNode + + expect(await getAutoLayoutProps(node)).toEqual({ + display: 'flex', + flexDir: 'row', + gap: '8px', + justifyContent, + alignItems: 'center', + }) + }, + ) + test('getLayoutProps resolves width/height variables on absolute nodes', async () => { setupFigmaMocks({ variableNamesById: { diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts index f33d95a..96fedc1 100644 --- a/src/codegen/props/auto-layout.ts +++ b/src/codegen/props/auto-layout.ts @@ -59,6 +59,8 @@ function getJustifyContent( MAX: 'flex-end', CENTER: 'center', SPACE_BETWEEN: 'space-between', + SPACE_AROUND: 'space-around', + SPACE_EVENLY: 'space-evenly', }[node.primaryAxisAlignItems] } diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts index d6096e7..12ae2da 100644 --- a/src/codegen/props/index.ts +++ b/src/codegen/props/index.ts @@ -1,5 +1,6 @@ import type { NodeBoundVariables, NodeContext } from '../types' import { checkAssetNode } from '../utils/check-asset-node' +import { traceAsync, traceSync } from '../utils/diagnostics' import { getPageNode } from '../utils/get-page-node' import { isPageRoot } from '../utils/is-page-root' import { perfEnd, perfStart } from '../utils/perf' @@ -76,11 +77,17 @@ export async function getProps( } const t = perfStart() - const promise = (async () => { + const promise = traceAsync('getProps', node, async () => { + // Tag every getter so a failure names the exact getter, not just "getProps". + const traced = (label: string, run: () => T): T => + traceSync(label, node, run) + const tracedAsync = (label: string, run: () => Promise): Promise => + traceAsync(label, node, run) + const isText = node.type === 'TEXT' // Compute cross-cutting node context ONCE for all sync getters that need it. - const ctx = computeNodeContext(node) + const ctx = traced('computeNodeContext', () => 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 @@ -98,52 +105,87 @@ 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 = hasStrokes && !isText ? getBorderProps(node) : undefined + const borderP = + hasStrokes && !isText + ? tracedAsync('getBorderProps', () => getBorderProps(node)) + : undefined const tBg = perfStart() - const bgP = hasFills ? getBackgroundProps(node) : undefined + const bgP = hasFills + ? tracedAsync('getBackgroundProps', () => getBackgroundProps(node)) + : undefined const tTextStroke = perfStart() const textStrokeP = - isText && hasStrokes ? getTextStrokeProps(node) : undefined + isText && hasStrokes + ? tracedAsync('getTextStrokeProps', () => getTextStrokeProps(node)) + : undefined const tReaction = perfStart() const reactionP = hasReactionProps(node) - ? getReactionProps(node) + ? tracedAsync('getReactionProps', () => getReactionProps(node)) : undefined const tAutoLayout = perfStart() const autoLayoutP = hasInferredAutoLayout - ? getAutoLayoutProps(node, ctx) + ? tracedAsync('getAutoLayoutProps', () => getAutoLayoutProps(node, ctx)) : undefined const tMinMax = perfStart() - const minMaxP = getMinMaxProps(node, ctx) + const minMaxP = tracedAsync('getMinMaxProps', () => + getMinMaxProps(node, ctx), + ) const tLayout = perfStart() - const layoutP = getLayoutProps(node, ctx) + const layoutP = tracedAsync('getLayoutProps', () => + getLayoutProps(node, ctx), + ) const tBorderRadius = perfStart() const borderRadiusP = hasRadius - ? getBorderRadiusProps(node, ctx) + ? tracedAsync('getBorderRadiusProps', () => + getBorderRadiusProps(node, ctx), + ) : undefined const tPadding = perfStart() const paddingP = hasInferredAutoLayout || hasPadding - ? getPaddingProps(node, ctx) + ? tracedAsync('getPaddingProps', () => getPaddingProps(node, ctx)) : undefined const tEffect = perfStart() - const effectP = hasEffects ? getEffectProps(node) : undefined + const effectP = hasEffects + ? tracedAsync('getEffectProps', () => getEffectProps(node)) + : undefined const tTextShadow = perfStart() const textShadowP = - isText && hasEffects ? getTextShadowProps(node) : undefined + isText && hasEffects + ? tracedAsync('getTextShadowProps', () => getTextShadowProps(node)) + : undefined // PHASE 2: Run sync prop getters while async IPC is pending in background. const tSync = perfStart() - const blendProps = getBlendProps(node) - const textAlignProps = isText ? getTextAlignProps(node) : undefined - const objectFitProps = getObjectFitProps(node) - const maxLineProps = isText ? getMaxLineProps(node) : undefined - const ellipsisProps = isText ? getEllipsisProps(node) : undefined - const positionProps = getPositionProps(node, ctx) - const gridChildProps = getGridChildProps(node) - const transformProps = getTransformProps(node, ctx) - const overflowProps = getOverflowProps(node) - const cursorProps = getCursorProps(node) - const visibilityProps = getVisibilityProps(node) + const blendProps = traced('getBlendProps', () => getBlendProps(node)) + const textAlignProps = isText + ? traced('getTextAlignProps', () => getTextAlignProps(node)) + : undefined + const objectFitProps = traced('getObjectFitProps', () => + getObjectFitProps(node), + ) + const maxLineProps = isText + ? traced('getMaxLineProps', () => getMaxLineProps(node)) + : undefined + const ellipsisProps = isText + ? traced('getEllipsisProps', () => getEllipsisProps(node)) + : undefined + const positionProps = traced('getPositionProps', () => + getPositionProps(node, ctx), + ) + const gridChildProps = traced('getGridChildProps', () => + getGridChildProps(node), + ) + const transformProps = traced('getTransformProps', () => + getTransformProps(node, ctx), + ) + const overflowProps = traced('getOverflowProps', () => + getOverflowProps(node), + ) + const cursorProps = traced('getCursorProps', () => getCursorProps(node)) + const visibilityProps = traced('getVisibilityProps', () => + getVisibilityProps(node), + ) perfEnd('getProps.sync', tSync) // PHASE 3: Await async results — likely already resolved during sync phase. @@ -202,7 +244,7 @@ export async function getProps( if (textShadowProps) Object.assign(result, textShadowProps) Object.assign(result, reactionProps, cursorProps, visibilityProps) return result - })() + }) if (cacheKey) { getPropsCache.set(cacheKey, promise) diff --git a/src/codegen/render/text.ts b/src/codegen/render/text.ts index 9bc7aac..e81f29b 100644 --- a/src/codegen/render/text.ts +++ b/src/codegen/render/text.ts @@ -1,5 +1,6 @@ import { propsToPropsWithTypography } from '../../utils' import { textSegmentToTypography } from '../../utils/text-segment-to-typography' +import { traceAsync } from '../utils/diagnostics' import { fixTextChild } from '../utils/fix-text-child' import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css' import { perfEnd, perfStart } from '../utils/perf' @@ -31,7 +32,14 @@ const SEGMENT_TYPE = [ 'indentation', 'hyperlink', ] as (keyof Omit)[] -export async function renderText(node: TextNode): Promise<{ +export function renderText(node: TextNode): Promise<{ + children: string[] + props: Record +}> { + return traceAsync('renderText', node, () => renderTextInner(node)) +} + +async function renderTextInner(node: TextNode): Promise<{ children: string[] props: Record }> { @@ -109,10 +117,15 @@ export async function renderText(node: TextNode): Promise<{ let text: string[] = [fixTextChild(seg.characters)] let textComponent: 'ul' | 'ol' | null = null - if (seg.listOptions.type === 'NONE') { + // Figma omits `listOptions` (leaves it undefined) on the non-list + // segments of a TEXT node that also contains a bulleted/numbered list, + // so a missing value means "not a list". + const listType = seg.listOptions?.type ?? 'NONE' + + if (listType === 'NONE') { text = text.map((line) => line.replace(/\r\n|\r|\n/g, '
')) } else { - switch (seg.listOptions.type) { + switch (listType) { case 'UNORDERED': { textComponent = 'ul' break diff --git a/src/codegen/reset-caches.ts b/src/codegen/reset-caches.ts new file mode 100644 index 0000000..a9b1c6f --- /dev/null +++ b/src/codegen/reset-caches.ts @@ -0,0 +1,34 @@ +import { resetTextStyleCache } from '../utils' +import { resetGlobalBuildTreeCache, resetMainComponentCache } from './Codegen' +import { resetGetPropsCache } from './props' +import { resetChildAnimationCache } from './props/reaction' +import { resetSelectorPropsCache } from './props/selector' +import { resetCheckAssetNodeCache } from './utils/check-asset-node' +import { resetCheckSameColorCache } from './utils/check-same-color' +import { resetComponentPropertyDefinitionsCache } from './utils/get-component-property-definitions' +import { resetGetPageNodeCache } from './utils/get-page-node' +import { resetPaintToCssCache } from './utils/paint-to-css' +import { resetVariableCache } from './utils/variable-cache' + +/** + * Clear every memoization cache the codegen pipeline keeps between runs. + * + * All of these caches are keyed by `node.id`, so a stale entry from a previous + * run silently changes the output of the next one. A codegen run must therefore + * start from a clean slate — and so must every test that drives codegen, or the + * result depends on which test happened to run first. + */ +export function resetCodegenCaches(): void { + resetGetPropsCache() + resetSelectorPropsCache() + resetChildAnimationCache() + resetVariableCache() + resetCheckAssetNodeCache() + resetCheckSameColorCache() + resetPaintToCssCache() + resetGetPageNodeCache() + resetComponentPropertyDefinitionsCache() + resetTextStyleCache() + resetMainComponentCache() + resetGlobalBuildTreeCache() +} diff --git a/src/codegen/utils/__tests__/diagnostics.test.ts b/src/codegen/utils/__tests__/diagnostics.test.ts new file mode 100644 index 0000000..15c3709 --- /dev/null +++ b/src/codegen/utils/__tests__/diagnostics.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'bun:test' +import { + describeNode, + errorMessage, + formatCodegenErrorReport, + getErrorTrace, + inspectNode, + tagError, + traceAsync, + traceSync, +} from '../diagnostics' + +function throwingProp(key: string, rest: Record = {}) { + const node: Record = { ...rest } + Object.defineProperty(node, key, { + enumerable: true, + get() { + throw new Error(`cannot read ${key}`) + }, + }) + return node +} + +describe('errorMessage', () => { + it('formats Error instances', () => { + expect(errorMessage(new TypeError('boom'))).toBe('TypeError: boom') + }) + + it('formats non-error throws', () => { + expect(errorMessage('boom')).toBe('Thrown non-error value: boom') + }) +}) + +describe('describeNode', () => { + it('describes a SceneNode', () => { + expect(describeNode({ type: 'TEXT', name: 'Title', id: '1:2' })).toBe( + 'TEXT "Title" (1:2)', + ) + }) + + it('falls back to NodeTree fields', () => { + expect(describeNode({ nodeType: 'FRAME', nodeName: 'Row' })).toBe( + 'FRAME "Row" ()', + ) + }) + + it('falls back to placeholders when fields are missing or unreadable', () => { + expect(describeNode({})).toBe('UNKNOWN "" ()') + expect(describeNode({ type: 1, name: 2, id: 3 })).toBe( + 'UNKNOWN "" ()', + ) + expect(describeNode(throwingProp('type'))).toBe( + 'UNKNOWN "" ()', + ) + }) + + it('handles non-object values', () => { + expect(describeNode(null)).toBe('') + expect(describeNode(undefined)).toBe('') + expect(describeNode('nope')).toBe('') + }) +}) + +describe('inspectNode', () => { + it('reports non-object values', () => { + expect(inspectNode(undefined)).toBe(' ') + expect(inspectNode(null)).toBe(' ') + }) + + it('dumps scalar and list fields', () => { + const report = inspectNode({ + id: '1:2', + name: 'Row', + type: 'FRAME', + visible: true, + width: 100, + fills: [{ type: 'SOLID' }, { color: 1 }, 'x'], + children: [], + reactions: 'MIXED', + }) + expect(report).toContain('id: "1:2"') + expect(report).toContain('type: "FRAME"') + expect(report).toContain('visible: true') + expect(report).toContain('width: 100') + expect(report).toContain('height: ') + expect(report).toContain('fills: len=3 [SOLID, object, string]') + expect(report).toContain('children: len=0 []') + expect(report).toContain('reactions: "MIXED"') + }) + + it('truncates long strings and reports unserializable values', () => { + const circular: Record = {} + circular.self = circular + const report = inspectNode({ + name: 'a'.repeat(200), + component: circular, + }) + expect(report).toContain('...') + expect(report).toContain('component: ') + }) + + it('reports fields whose getter throws', () => { + expect(inspectNode(throwingProp('width'))).toContain( + 'width: ', + ) + }) + + it('dumps styled text segments for TEXT nodes', () => { + const report = inspectNode({ + type: 'TEXT', + characters: 'hi', + getStyledTextSegments: () => [ + { + listOptions: { type: 'NONE' }, + fills: [{ type: 'SOLID' }], + textStyleId: '', + fontName: { family: 'Inter' }, + characters: 'hi', + }, + { characters: 'bye' }, + ], + }) + expect(report).toContain('styledTextSegments: len=2') + expect(report).toContain('[0] listOptions={"type":"NONE"}') + expect(report).toContain('[1] listOptions=undefined') + }) + + it('reports when styled text segments are unavailable or throw', () => { + expect(inspectNode({ type: 'TEXT' })).toContain( + 'styledTextSegments: ', + ) + expect( + inspectNode({ + type: 'TEXT', + getStyledTextSegments: () => { + throw new Error('nope') + }, + }), + ).toContain('styledTextSegments: ') + expect(inspectNode(throwingProp('type'))).toContain( + 'styledTextSegments: ', + ) + }) +}) + +describe('tagError / getErrorTrace', () => { + it('attaches frames to an error and appends on re-tag', () => { + const error = new Error('boom') + tagError(error, 'renderText', { type: 'TEXT', name: 'A', id: '1' }) + tagError(error, 'buildTree', { type: 'FRAME', name: 'B', id: '2' }) + const trace = getErrorTrace(error) + expect(trace).toHaveLength(2) + expect(trace[0].label).toBe('renderText') + expect(trace[1].label).toBe('buildTree') + }) + + it('caps the number of frames', () => { + const error = new Error('boom') + for (let i = 0; i < 60; i++) tagError(error, `frame-${i}`, null) + expect(getErrorTrace(error)).toHaveLength(50) + }) + + it('wraps non-object throws so they can carry a trace', () => { + const tagged = tagError('boom', 'buildTree', null) + expect(tagged).toBeInstanceOf(Error) + expect((tagged as Error).message).toBe('Thrown non-error value: boom') + expect(getErrorTrace(tagged)).toHaveLength(1) + }) + + it('skips non-extensible errors', () => { + const error = Object.preventExtensions(new Error('frozen')) + expect(tagError(error, 'buildTree', null)).toBe(error) + expect(getErrorTrace(error)).toHaveLength(0) + }) + + it('returns an empty trace for untagged values', () => { + expect(getErrorTrace(null)).toHaveLength(0) + expect(getErrorTrace('boom')).toHaveLength(0) + expect(getErrorTrace(new Error('boom'))).toHaveLength(0) + expect(getErrorTrace({ __devupTrace: 'nope' })).toHaveLength(0) + }) +}) + +describe('traceSync / traceAsync', () => { + it('passes values through untouched', async () => { + expect(traceSync('renderTree', null, () => 'ok')).toBe('ok') + await expect(traceAsync('buildTree', null, async () => 'ok')).resolves.toBe( + 'ok', + ) + }) + + it('tags synchronous failures', () => { + const node = { type: 'FRAME', name: 'Row', id: '1:2' } + expect(() => + traceSync('renderTree', node, () => { + throw new Error('boom') + }), + ).toThrow('boom') + try { + traceSync('renderTree', node, () => { + throw new Error('boom') + }) + } catch (error) { + expect(getErrorTrace(error)[0]).toEqual({ label: 'renderTree', node }) + } + }) + + it('tags asynchronous failures', async () => { + const node = { type: 'TEXT', name: 'Title', id: '1:3' } + try { + await traceAsync('renderText', node, async () => { + throw new Error('boom') + }) + throw new Error('should not reach') + } catch (error) { + expect(getErrorTrace(error)[0]).toEqual({ label: 'renderText', node }) + } + }) +}) + +describe('formatCodegenErrorReport', () => { + it('renders the traced path and the failing node', async () => { + const textNode = { + type: 'TEXT', + name: 'Title', + id: '1:3', + getStyledTextSegments: () => [{ characters: 'hi' }], + } + const frameNode = { type: 'FRAME', name: 'Row', id: '1:2' } + let caught: unknown + try { + await traceAsync('buildTree', frameNode, () => + traceAsync('renderText', textNode, async () => { + throw new TypeError("cannot read property 'type' of undefined") + }), + ) + } catch (error) { + caught = error + } + + const report = formatCodegenErrorReport(caught, { + node: frameNode, + language: 'devup-ui', + }) + expect(report).toContain('DEVUP UI CODEGEN ERROR') + expect(report).toContain( + "error : TypeError: cannot read property 'type' of undefined", + ) + expect(report).toContain('language : devup-ui') + expect(report).toContain('selected : FRAME "Row" (1:2)') + expect(report).toContain('failing : TEXT "Title" (1:3)') + expect(report).toContain('1. renderText :: TEXT "Title" (1:3)') + expect(report).toContain('2. buildTree :: FRAME "Row" (1:2)') + expect(report).toContain('styledTextSegments: len=1') + expect(report).toContain('--- raw stack (minified) ---') + }) + + it('falls back when there is no trace and no stack', () => { + const report = formatCodegenErrorReport('boom') + expect(report).toContain('error : Thrown non-error value: boom') + expect(report).toContain('language : ') + expect(report).toContain('') + expect(report).toContain(' ') + expect(report).toContain(' ') + }) +}) diff --git a/src/codegen/utils/diagnostics.ts b/src/codegen/utils/diagnostics.ts new file mode 100644 index 0000000..49ec50d --- /dev/null +++ b/src/codegen/utils/diagnostics.ts @@ -0,0 +1,261 @@ +/** + * Codegen diagnostics. + * + * The plugin ships as one minified bundle, so the stack traces Figma prints + * (`at (PLUGIN_37_SOURCE:1:243768)`) carry no usable information. + * Instead of relying on them, every significant codegen step tags the error it + * observes with the Figma node it was processing. `formatCodegenErrorReport` + * turns that chain into a copy-pasteable report naming the exact node, the + * phase, and the node's raw shape. + * + * Tagging is zero-cost on the success path — nothing is formatted or allocated + * for the report until an error actually escapes. + */ + +export interface TraceFrame { + label: string + node: unknown +} + +interface TracedError { + __devupTrace?: TraceFrame[] +} + +const TRACE_KEY = '__devupTrace' + +/** Caps the trace so a deep tree cannot produce an unbounded report. */ +const MAX_TRACE_FRAMES = 50 + +const SEPARATOR = '='.repeat(72) + +/** Scalar-ish fields dumped verbatim for the failing node. */ +const BASIC_KEYS = [ + 'id', + 'name', + 'type', + 'nodeType', + 'nodeName', + 'component', + 'visible', + 'width', + 'height', + 'opacity', + 'rotation', + 'characters', +] as const + +/** Array fields dumped as `len=N [itemType, ...]`. */ +const LIST_KEYS = [ + 'children', + 'fills', + 'strokes', + 'effects', + 'reactions', +] as const + +const TEXT_SEGMENT_FIELDS = [ + 'listOptions', + 'fills', + 'textStyleId', + 'fontName', + 'fontSize', +] as const + +export function errorMessage(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}` + return `Thrown non-error value: ${String(error)}` +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') + return JSON.stringify( + value.length > 80 ? `${value.slice(0, 80)}...` : value, + ) + if (typeof value === 'object' && value !== null) { + try { + return JSON.stringify(value) + } catch { + return '' + } + } + return String(value) +} + +function summarizeList(value: unknown): string { + if (!Array.isArray(value)) return formatValue(value) + const kinds = value.map((item) => { + if (item !== null && typeof item === 'object') { + const type = (item as { type?: unknown }).type + if (typeof type === 'string') return type + } + return typeof item + }) + return `len=${value.length} [${kinds.join(', ')}]` +} + +function readString( + record: Record, + key: string, +): string | null { + try { + const value = record[key] + return typeof value === 'string' ? value : null + } catch { + return null + } +} + +function readForReport( + record: Record, + key: string, + format: (value: unknown) => string, +): string { + try { + const value = record[key] + return value === undefined ? '' : format(value) + } catch (error) { + return `` + } +} + +/** + * TEXT nodes are the richest failure source (styled segments carry optional + * fields such as `listOptions`), so dump every segment when one is involved. + */ +function inspectTextSegments(record: Record): string[] { + try { + if (record.type !== 'TEXT') return [] + const getSegments = record.getStyledTextSegments + if (typeof getSegments !== 'function') + return [' styledTextSegments: '] + const segments = ( + getSegments as (fields: readonly string[]) => unknown[] + ).call(record, TEXT_SEGMENT_FIELDS) + const lines = [` styledTextSegments: len=${segments.length}`] + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] as Record + lines.push( + ` [${i}] listOptions=${formatValue(seg.listOptions)}` + + ` fills=${summarizeList(seg.fills)}` + + ` textStyleId=${formatValue(seg.textStyleId)}` + + ` fontName=${formatValue(seg.fontName)}` + + ` characters=${formatValue(seg.characters)}`, + ) + } + return lines + } catch (error) { + return [` styledTextSegments: `] + } +} + +/** One-line identity of a Figma SceneNode or an intermediate NodeTree. */ +export function describeNode(node: unknown): string { + if (node === null || typeof node !== 'object') return `<${String(node)}>` + const record = node as Record + const type = + readString(record, 'type') ?? readString(record, 'nodeType') ?? 'UNKNOWN' + const name = + readString(record, 'name') ?? readString(record, 'nodeName') ?? '' + const id = readString(record, 'id') ?? '' + return `${type} "${name}" (${id})` +} + +/** Multi-line dump of everything we can safely read off the failing node. */ +export function inspectNode(node: unknown): string { + if (node === null || typeof node !== 'object') + return ` ` + const record = node as Record + const lines: string[] = [] + for (const key of BASIC_KEYS) + lines.push(` ${key}: ${readForReport(record, key, formatValue)}`) + for (const key of LIST_KEYS) + lines.push(` ${key}: ${readForReport(record, key, summarizeList)}`) + for (const line of inspectTextSegments(record)) lines.push(line) + return lines.join('\n') +} + +/** + * Attach `label` + `node` to an error so the caller chain becomes readable. + * Non-object throws are wrapped so they can carry a trace too. + */ +export function tagError( + error: unknown, + label: string, + node: unknown, +): unknown { + const carrier = + error !== null && typeof error === 'object' + ? error + : new Error(errorMessage(error)) + const traced = carrier as TracedError + const frames = traced[TRACE_KEY] + if (frames) { + if (frames.length < MAX_TRACE_FRAMES) frames.push({ label, node }) + } else if (Object.isExtensible(carrier)) { + traced[TRACE_KEY] = [{ label, node }] + } + return carrier +} + +export function getErrorTrace(error: unknown): readonly TraceFrame[] { + if (error === null || typeof error !== 'object') return [] + const frames = (error as TracedError)[TRACE_KEY] + return Array.isArray(frames) ? frames : [] +} + +export function traceSync(label: string, node: unknown, run: () => T): T { + try { + return run() + } catch (error) { + throw tagError(error, label, node) + } +} + +export async function traceAsync( + label: string, + node: unknown, + run: () => Promise, +): Promise { + try { + return await run() + } catch (error) { + throw tagError(error, label, node) + } +} + +function formatStack(error: unknown): string { + if (error instanceof Error && typeof error.stack === 'string') + return error.stack + return ' ' +} + +export function formatCodegenErrorReport( + error: unknown, + context: { node?: unknown; language?: string } = {}, +): string { + const frames = getErrorTrace(error) + const failingNode = frames.length > 0 ? frames[0].node : context.node + const lines: string[] = [ + SEPARATOR, + 'DEVUP UI CODEGEN ERROR - copy this whole block and send it to the maintainer', + SEPARATOR, + `error : ${errorMessage(error)}`, + `language : ${context.language ?? ''}`, + `selected : ${describeNode(context.node)}`, + `failing : ${describeNode(failingNode)}`, + '', + '--- codegen path (innermost first) ---', + ] + if (frames.length === 0) { + lines.push(' ') + } else { + for (let i = 0; i < frames.length; i++) + lines.push( + ` ${i + 1}. ${frames[i].label} :: ${describeNode(frames[i].node)}`, + ) + } + lines.push('', '--- failing node ---', inspectNode(failingNode)) + lines.push('', '--- raw stack (minified) ---', formatStack(error)) + lines.push(SEPARATOR) + return lines.join('\n') +} diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index 22deefd..9b699d3 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -6,6 +6,7 @@ import { perfStart, } from '../../codegen/utils/perf' import { downloadFile } from '../../utils/download-file' +import { isMotionEasing } from '../../utils/is-motion-easing' import { isVariableAlias } from '../../utils/is-variable-alias' import { optimizeHex } from '../../utils/optimize-hex' import { rgbaToHex } from '../../utils/rgba-to-hex' @@ -119,14 +120,19 @@ export async function buildDevupConfig( variables.map(async (variable, i) => { if (variable === null || variable.resolvedType !== 'COLOR') return const value = variable.valuesByMode[mode.modeId] - if (typeof value === 'boolean' || typeof value === 'number') + if ( + typeof value === 'boolean' || + typeof value === 'number' || + isMotionEasing(value) + ) return if (isVariableAlias(value)) { const nextValue = await variableAliasToValue(value, mode.modeId) if (nextValue === null) return if ( typeof nextValue === 'boolean' || - typeof nextValue === 'number' + typeof nextValue === 'number' || + isMotionEasing(nextValue) ) return colors[camelNames[i]] = optimizeHex( diff --git a/src/utils/__tests__/is-motion-easing.test.ts b/src/utils/__tests__/is-motion-easing.test.ts new file mode 100644 index 0000000..05ec455 --- /dev/null +++ b/src/utils/__tests__/is-motion-easing.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test' +import { isMotionEasing } from '../is-motion-easing' + +describe('isMotionEasing', () => { + test('should check motionEasing', () => { + expect(isMotionEasing({ type: 'EASE_IN_AND_OUT' })).toBe(true) + expect(isMotionEasing({ type: 'CUSTOM_CUBIC_BEZIER' })).toBe(true) + + expect(isMotionEasing({ r: 0, g: 0, b: 0, a: 1 })).toBe(false) + expect(isMotionEasing({ id: 'id', type: 'VARIABLE_ALIAS' })).toBe(false) + expect(isMotionEasing('#fff')).toBe(false) + expect(isMotionEasing(1)).toBe(false) + expect(isMotionEasing(null)).toBe(false) + expect(isMotionEasing(undefined)).toBe(false) + }) +}) diff --git a/src/utils/is-motion-easing.ts b/src/utils/is-motion-easing.ts new file mode 100644 index 0000000..de29068 --- /dev/null +++ b/src/utils/is-motion-easing.ts @@ -0,0 +1,13 @@ +/** + * `VariableValue` gained `MotionEasing` with Figma's Motion API, so colour code + * paths have to narrow it out. It is the only object-valued variant that + * carries a `type` other than `VariableAlias` — `RGB`/`RGBA` have none. + */ +export function isMotionEasing(value: unknown): value is MotionEasing { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + value.type !== 'VARIABLE_ALIAS' + ) +}