From fe8738415267e3820de8358de325a91019607397 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Sun, 9 Nov 2025 21:30:56 +0100 Subject: [PATCH 01/16] feat: twoslash support --- npm-shrinkwrap.json | 289 ++++++++++++++++++- package.json | 4 + src/generators/web-twoslash/client.mjs | 107 +++++++ src/generators/web-twoslash/index.mjs | 41 +++ src/generators/web/index.mjs | 20 +- src/generators/web/template.html | 1 + src/generators/web/ui/components/CodeBox.jsx | 1 + src/generators/web/ui/index.css | 1 + src/generators/web/utils/processing.mjs | 8 +- 9 files changed, 454 insertions(+), 18 deletions(-) create mode 100644 src/generators/web-twoslash/client.mjs create mode 100644 src/generators/web-twoslash/index.mjs diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 76b366fb..1e78c0df 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -15,6 +15,7 @@ "@orama/orama": "^3.1.16", "@orama/react-components": "^0.8.1", "@rollup/plugin-virtual": "^3.0.2", + "@shikijs/twoslash": "^3.14.0", "acorn": "^8.15.0", "commander": "^14.0.2", "dedent": "^1.7.0", @@ -26,6 +27,7 @@ "globals": "^16.4.0", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", + "idb-keyval": "^6.2.2", "lightningcss": "^1.30.1", "mdast-util-slice-markdown": "^2.0.1", "preact": "^10.27.2", @@ -42,6 +44,7 @@ "rolldown": "^1.0.0-beta.40", "semver": "^7.7.2", "shiki": "^3.15.0", + "twoslash-cdn": "^0.3.4", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-find-after": "^5.0.0", @@ -49,6 +52,7 @@ "unist-util-remove": "^4.0.0", "unist-util-select": "^5.1.0", "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.2", "vfile": "^6.0.3", "yaml": "^2.8.1" }, @@ -888,7 +892,6 @@ "resolved": "https://registry.npmjs.org/@orama/core/-/core-0.0.10.tgz", "integrity": "sha512-rZ4AHeHoFTxOXMhM0An2coO3OfR+FpL0ejXc1PPrNsGB4p6VNlky7FAGeuqOvS5gUYB5ywJsmDzCxeflPtgk4w==", "license": "AGPL-3.0", - "peer": true, "dependencies": { "@orama/cuid2": "2.2.3", "dedent": "1.5.3" @@ -899,7 +902,6 @@ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", "license": "MIT", - "peer": true, "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -929,6 +931,7 @@ "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-3.1.16.tgz", "integrity": "sha512-scSmQBD8eANlMUOglxHrN1JdSW8tDghsPuS83otqealBiIeMukCQMOf/wc0JJjDXomqwNdEQFLXLGHrU6PGxuA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 20.0.0" } @@ -1144,6 +1147,7 @@ "resolved": "https://registry.npmjs.org/@oramacloud/client/-/client-2.1.4.tgz", "integrity": "sha512-uNPFs4wq/iOPbggCwTkVNbIr64Vfd7ZS/h+cricXVnzXWocjDTfJ3wLL4lr0qiSu41g8z+eCAGBqJ30RO2O4AA==", "license": "ISC", + "peer": true, "dependencies": { "@orama/cuid2": "^2.2.3", "@orama/orama": "^3.0.0", @@ -3594,6 +3598,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3678,6 +3683,19 @@ "node": ">=14" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -3874,6 +3892,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -4097,6 +4130,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4110,6 +4149,15 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/css-selector-parser": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.0.5.tgz", @@ -4142,8 +4190,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -4195,6 +4242,12 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4204,6 +4257,12 @@ "node": ">=6" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -4423,6 +4482,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5056,6 +5116,23 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/h3": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz", + "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.2", + "radix3": "^1.1.2", + "ufo": "^1.6.1", + "uncrypto": "^0.1.3" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5366,6 +5443,13 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5406,6 +5490,15 @@ "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -6248,6 +6341,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.3.tgz", "integrity": "sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -7248,6 +7342,27 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "license": "MIT" }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.3.tgz", + "integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -7267,6 +7382,17 @@ "dev": true, "license": "MIT" }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -7516,6 +7642,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7565,6 +7692,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7651,6 +7779,12 @@ ], "license": "MIT" }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -7749,6 +7883,19 @@ } } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reading-time": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", @@ -8097,8 +8244,7 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -8777,6 +8923,21 @@ "typescript": "^5.5.0" } }, + "node_modules/twoslash-cdn": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/twoslash-cdn/-/twoslash-cdn-0.3.4.tgz", + "integrity": "sha512-PvCZ6KEiz6x3Jg1sjR8lHrhUtNMoi5ILlzRnV2GZYXYMgr13OT3e7nLa4YKrVa8fbfCaUK4kG0iHSVkH7caChA==", + "license": "MIT", + "dependencies": { + "twoslash": "0.3.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "typescript": "^5.5.0" + } + }, "node_modules/twoslash-protocol": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/twoslash-protocol/-/twoslash-protocol-0.3.4.tgz", @@ -8815,6 +8976,18 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", @@ -8999,6 +9172,7 @@ "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", "dev": true, "hasInstallScript": true, + "peer": true, "dependencies": { "napi-postinstall": "^0.2.4" }, @@ -9027,6 +9201,108 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, + "node_modules/unstorage": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.2.tgz", + "integrity": "sha512-cKEsD6iBWJgOMJ6vW1ID/SYuqNf8oN4yqRk8OYqaVQ3nnkJXOT1PSpaMh2QfzLs78UN5kSNRD2c/mgjT8tX7+w==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^4.0.3", + "destr": "^2.0.5", + "h3": "^1.15.4", + "lru-cache": "^10.4.3", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.0", + "ufo": "^1.6.1" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6.0.3 || ^7.0.0", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1.0.1", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/unstorage/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9404,6 +9680,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 93ed9753..9e44985b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@orama/orama": "^3.1.16", "@orama/react-components": "^0.8.1", "@rollup/plugin-virtual": "^3.0.2", + "@shikijs/twoslash": "^3.14.0", "acorn": "^8.15.0", "commander": "^14.0.2", "dedent": "^1.7.0", @@ -58,6 +59,7 @@ "globals": "^16.4.0", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", + "idb-keyval": "^6.2.2", "lightningcss": "^1.30.1", "mdast-util-slice-markdown": "^2.0.1", "preact": "^10.27.2", @@ -74,6 +76,7 @@ "rolldown": "^1.0.0-beta.40", "semver": "^7.7.2", "shiki": "^3.15.0", + "twoslash-cdn": "^0.3.4", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-find-after": "^5.0.0", @@ -81,6 +84,7 @@ "unist-util-remove": "^4.0.0", "unist-util-select": "^5.1.0", "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.2", "vfile": "^6.0.3", "yaml": "^2.8.1" } diff --git a/src/generators/web-twoslash/client.mjs b/src/generators/web-twoslash/client.mjs new file mode 100644 index 00000000..7248c50f --- /dev/null +++ b/src/generators/web-twoslash/client.mjs @@ -0,0 +1,107 @@ +/* global document */ + +import createHighlighter from '@node-core/rehype-shiki'; +import { createTransformerFactory, rendererRich } from '@shikijs/twoslash'; +import shikiNordTheme from 'shiki/themes/nord.mjs'; +import { createTwoslashFromCDN } from 'twoslash-cdn'; +import { createStorage } from 'unstorage'; +import indexedDbDriver from 'unstorage/drivers/indexedb'; + +// An example using unstorage with IndexedDB to cache the virtual file system +const storage = createStorage({ + driver: indexedDbDriver({ base: 'twoslash-cdn' }), +}); + +const twoslash = createTwoslashFromCDN({ + storage, + compilerOptions: { + lib: ['dom', 'dom.iterable', 'esnext'], + module: 'nodenext', + types: ['node'], + }, +}); + +const transformerTwoslash = createTransformerFactory(twoslash.runSync)({ + renderer: rendererRich({ jsdoc: true }), + langs: ['ts', 'js', 'cjs', 'mjs'], + throws: false, +}); + +const highlighterPromise = createHighlighter({ + wasm: false, +}); + +/** + * Extracts the raw code content from a
 element
+ * @param {HTMLPreElement} preElement - The pre element
+ * @returns {string} The raw code content
+ */
+function extractRawCode(preElement) {
+  const codeElement = preElement.querySelector('code');
+  if (!codeElement) {
+    return '';
+  }
+
+  return codeElement.textContent || '';
+}
+
+/**
+ * Process a single code block with Twoslash
+ * @param {HTMLPreElement} preElement - The pre element
+ * @returns {Promise}
+ */
+async function processTwoslashBlock(preElement) {
+  try {
+    const rawCode = extractRawCode(preElement);
+
+    if (!rawCode) {
+      return;
+    }
+
+    const highlighter = await highlighterPromise;
+
+    await twoslash.prepareTypes(rawCode);
+
+    const html = highlighter.shiki.codeToHtml(rawCode, {
+      lang: 'mjs',
+      theme: {
+        // We are updating this color because the background color and comment text color
+        // in the Codebox component do not comply with accessibility standards.
+        // See: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
+        colorReplacements: { '#616e88': '#707e99' },
+        ...shikiNordTheme,
+      },
+      transformers: [transformerTwoslash],
+    });
+
+    const temp = document.createElement('div');
+
+    temp.innerHTML = html;
+
+    const newPre = temp.querySelector('pre');
+
+    if (newPre) {
+      newPre.className = `${preElement.className} twoslash`;
+      newPre.style = '';
+
+      preElement.parentNode?.replaceChild(newPre, preElement);
+    }
+  } catch (error) {
+    console.error('Error processing Twoslash block:', error);
+  }
+}
+
+/**
+ * Initialize Twoslash processing on page load
+ */
+async function initTwoslash() {
+  const codeBlocks = document.querySelectorAll('pre');
+
+  const twoslashBlocks = Array.from(codeBlocks).filter(preElement =>
+    preElement.querySelector('code')
+  );
+
+  await Promise.all(twoslashBlocks.map(block => processTwoslashBlock(block)));
+}
+
+initTwoslash();
diff --git a/src/generators/web-twoslash/index.mjs b/src/generators/web-twoslash/index.mjs
new file mode 100644
index 00000000..6d3f57c9
--- /dev/null
+++ b/src/generators/web-twoslash/index.mjs
@@ -0,0 +1,41 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import bundleCode from '../web/utils/bundle.mjs';
+
+/**
+ * Bundles the Twoslash client-side script and writes it as an external file
+ *
+ * @type {GeneratorMetadata}
+ */
+export default {
+  name: 'web-twoslash',
+  version: '1.0.0',
+  description: 'Generates client-side Twoslash script for web bundle',
+  dependsOn: null,
+
+  /**
+   * Generates the Twoslash client script with content hash
+   *
+   * @param {never} _ - No input needed
+   * @param {Partial} options
+   * @returns {Promise}
+   */
+  async generate(_, { output }) {
+    // Read the client code from file
+    const twoslashClientCode = await readFile(
+      new URL('client.mjs', import.meta.url),
+      'utf-8'
+    );
+
+    // Bundle the code
+    const { js } = await bundleCode(twoslashClientCode, {
+      server: false,
+    });
+
+    // Write the files if output directory is specified
+    if (output) {
+      await writeFile(join(output, 'twoslash.js'), js, 'utf-8');
+    }
+  },
+};
diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs
index be716c1c..733fbbf8 100644
--- a/src/generators/web/index.mjs
+++ b/src/generators/web/index.mjs
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises';
 import { createRequire } from 'node:module';
 import { join } from 'node:path';
 
+import webTwoslashGenerator from '../web-twoslash/index.mjs';
 import createASTBuilder from './utils/generate.mjs';
 import { processJSXEntry } from './utils/processing.mjs';
 
@@ -26,21 +27,19 @@ export default {
    * @param {Partial} options
    */
   async generate(entries, { output, version }) {
-    // Load the HTML template.
+    // Load the HTML template
     const template = await readFile(
       new URL('template.html', import.meta.url),
       'utf-8'
     );
 
-    // These builders are responsible for converting the JSX AST into executable
-    // JavaScript code for both server-side rendering and client-side hydration.
     const astBuilders = createASTBuilder();
-
-    // This is necessary for the `executeServerCode` function to resolve modules
-    // within the dynamically executed server-side code.
     const requireFn = createRequire(import.meta.url);
 
+    await webTwoslashGenerator.generate(null, { output });
+
     const results = [];
+
     let mainCss = '';
 
     for (const entry of entries) {
@@ -49,11 +48,12 @@ export default {
         template,
         astBuilders,
         requireFn,
-        version
+        { version }
       );
+
       results.push({ html, css });
 
-      // Capture the main CSS bundle from the first processed entry.
+      // Capture the main CSS bundle from the first processed entry
       if (!mainCss && css) {
         mainCss = css;
       }
@@ -64,9 +64,9 @@ export default {
       }
     }
 
+    // Write CSS file
     if (output && mainCss) {
-      const filePath = join(output, 'styles.css');
-      await writeFile(filePath, mainCss, 'utf-8');
+      await writeFile(join(output, 'styles.css'), mainCss, 'utf-8');
     }
 
     return results;
diff --git a/src/generators/web/template.html b/src/generators/web/template.html
index 12f867c3..0c3a1ea9 100644
--- a/src/generators/web/template.html
+++ b/src/generators/web/template.html
@@ -20,5 +20,6 @@
 
   
{{dehydrated}}
+ diff --git a/src/generators/web/ui/components/CodeBox.jsx b/src/generators/web/ui/components/CodeBox.jsx index 76cad725..4756abfe 100644 --- a/src/generators/web/ui/components/CodeBox.jsx +++ b/src/generators/web/ui/components/CodeBox.jsx @@ -45,6 +45,7 @@ export default ({ className, ...props }) => { onCopy={onCopy} language={getLanguageDisplayName(language)} {...props} + className={className} buttonText="Copy to clipboard" /> ); diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index dac478ec..145eabc3 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -1,4 +1,5 @@ @import '@node-core/ui-components/styles/index.css'; +@import '@shikijs/twoslash/style-rich.css'; /* Fonts */ :root { diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 82610a90..d5e2e96a 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import HTMLMinifier from '@minify-html/node'; import { toJs, jsx } from 'estree-util-to-js'; @@ -36,7 +38,8 @@ export async function executeServerCode(serverCode, requireFn) { * @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent} entry - The JSX AST entry to process. * @param {string} template - The HTML template string that serves as the base for the output page. * @param {ReturnType} astBuilders - The AST generators - * @param {version} version - The version to generator the documentation for + * @param {Object} options - Processing options + * @param {string} options.version - The version to generate the documentation for * @param {ReturnType} requireFn - A Node.js `require` function. */ export async function processJSXEntry( @@ -68,7 +71,8 @@ export async function processJSXEntry( const renderedHtml = template .replace('{{title}}', title) .replace('{{dehydrated}}', dehydrated ?? '') - .replace('{{clientBundleJs}}', () => clientBundle.js); + .replace('{{clientBundleJs}}', () => clientBundle.js) + .replace('{{cacheHash}}', randomUUID()); // The input to `minify` must be a Buffer. const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); From 842a3351e71797e1fae8db34e6dc344cce51ffce Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 15:35:03 +0100 Subject: [PATCH 02/16] feat: just better compilation --- npm-shrinkwrap.json | 439 ++++-------------- package.json | 5 +- src/generators/legacy-html/index.mjs | 2 +- src/generators/web-twoslash/client.mjs | 107 ----- src/generators/web-twoslash/index.mjs | 41 -- src/generators/web/index.mjs | 26 +- src/generators/web/template.html | 5 +- src/generators/web/ui/components/CodeBox.jsx | 23 +- src/generators/web/ui/index.css | 2 +- src/generators/web/utils/bundle.mjs | 126 +++-- src/generators/web/utils/processing.mjs | 24 +- .../utils/__tests__/safeCopy.test.mjs | 0 .../legacy-html => }/utils/safeCopy.mjs | 0 src/utils/safeWrite.mjs | 34 ++ 14 files changed, 276 insertions(+), 558 deletions(-) delete mode 100644 src/generators/web-twoslash/client.mjs delete mode 100644 src/generators/web-twoslash/index.mjs rename src/{generators/legacy-html => }/utils/__tests__/safeCopy.test.mjs (100%) rename src/{generators/legacy-html => }/utils/safeCopy.mjs (100%) create mode 100644 src/utils/safeWrite.mjs diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 1e78c0df..2d9541e9 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -27,7 +27,6 @@ "globals": "^16.4.0", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", - "idb-keyval": "^6.2.2", "lightningcss": "^1.30.1", "mdast-util-slice-markdown": "^2.0.1", "preact": "^10.27.2", @@ -41,10 +40,9 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", - "rolldown": "^1.0.0-beta.40", + "rolldown": "^1.0.0-beta.47", "semver": "^7.7.2", "shiki": "^3.15.0", - "twoslash-cdn": "^0.3.4", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-find-after": "^5.0.0", @@ -52,7 +50,6 @@ "unist-util-remove": "^4.0.0", "unist-util-select": "^5.1.0", "unist-util-visit": "^5.0.0", - "unstorage": "^1.17.2", "vfile": "^6.0.3", "yaml": "^2.8.1" }, @@ -1155,9 +1152,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.92.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.92.0.tgz", - "integrity": "sha512-PDLfCbwgXjGdTBxzcuDOUxJYNBl6P8dOp3eDKWw54dYvqONan9rwGDRQU0zrkdEMiItfXQQUOI17uOcMX5Zm7A==", + "version": "0.96.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.96.0.tgz", + "integrity": "sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2040,9 +2037,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.40.tgz", - "integrity": "sha512-9Ii9phC7QU6Lb+ncMfG1Xlosq0NBB1N/4sw+EGZ3y0BBWGy02TOb5ghWZalphAKv9rn1goqo5WkBjyd2YvsLmA==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", + "integrity": "sha512-vPP9/MZzESh9QtmvQYojXP/midjgkkc1E4AdnPPAzQXo668ncHJcVLKjJKzoBdsQmaIvNjrMdsCwES8vTQHRQw==", "cpu": [ "arm64" ], @@ -2056,9 +2053,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.40.tgz", - "integrity": "sha512-5O6d0y2tBQTL+ecQY3qXIwSnF1/Zik8q7LZMKeyF+VJ9l194d0IdMhl2zUF0cqWbYHuF4Pnxplk4OhurPQ/Z9Q==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.47.tgz", + "integrity": "sha512-Lc3nrkxeaDVCVl8qR3qoxh6ltDZfkQ98j5vwIr5ALPkgjZtDK4BGCrrBoLpGVMg+csWcaqUbwbKwH5yvVa0oOw==", "cpu": [ "arm64" ], @@ -2072,9 +2069,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.40.tgz", - "integrity": "sha512-izB9jygt3miPQbOTZfSu5K51isUplqa8ysByOKQqcJHgrBWmbTU8TM9eouv6tRmBR0kjcEcID9xhmA1CeZ1VIg==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.47.tgz", + "integrity": "sha512-eBYxQDwP0O33plqNVqOtUHqRiSYVneAknviM5XMawke3mwMuVlAsohtOqEjbCEl/Loi/FWdVeks5WkqAkzkYWQ==", "cpu": [ "x64" ], @@ -2088,9 +2085,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.40.tgz", - "integrity": "sha512-2fdpEpKT+wwP0vig9dqxu+toTeWmVSjo3psJQVDeLJ51rO+GXcCJ1IkCXjhMKVEevNtZS7B8T8Z2vvmRV9MAdA==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.47.tgz", + "integrity": "sha512-Ns+kgp2+1Iq/44bY/Z30DETUSiHY7ZuqaOgD5bHVW++8vme9rdiWsN4yG4rRPXkdgzjvQ9TDHmZZKfY4/G11AA==", "cpu": [ "x64" ], @@ -2104,9 +2101,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.40.tgz", - "integrity": "sha512-HP2lo78OWULN+8TewpLbS9PS00jh0CaF04tA2u8z2I+6QgVgrYOYKvX+T0hlO5smgso4+qb3YchzumWJl3yCPQ==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.47.tgz", + "integrity": "sha512-4PecgWCJhTA2EFOlptYJiNyVP2MrVP4cWdndpOu3WmXqWqZUmSubhb4YUAIxAxnXATlGjC1WjxNPhV7ZllNgdA==", "cpu": [ "arm" ], @@ -2120,9 +2117,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.40.tgz", - "integrity": "sha512-ng00gfr9BhA2NPAOU5RWAlTiL+JcwAD+L+4yUD1sbBy6tgHdLiNBOvKtHISIF9RM9/eQeS0tAiWOYZGIH9JMew==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.47.tgz", + "integrity": "sha512-CyIunZ6D9U9Xg94roQI1INt/bLkOpPsZjZZkiaAZ0r6uccQdICmC99M9RUPlMLw/qg4yEWLlQhG73W/mG437NA==", "cpu": [ "arm64" ], @@ -2136,9 +2133,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.40.tgz", - "integrity": "sha512-mF0R1l9kLcaag/9cLEiYYdNZ4v1uuX4jklSDZ1s6vJE4RB3LirUney0FavdVRwCJ5sDvfvsPgXgtBXWYr2M2tQ==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.47.tgz", + "integrity": "sha512-doozc/Goe7qRCSnzfJbFINTHsMktqmZQmweull6hsZZ9sjNWQ6BWQnbvOlfZJe4xE5NxM1NhPnY5Giqnl3ZrYQ==", "cpu": [ "arm64" ], @@ -2152,9 +2149,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.40.tgz", - "integrity": "sha512-+wi08S7wT5iLPHRZb0USrS6n+T6m+yY++dePYedE5uvKIpWCJJioFTaRtWjpm0V6dVNLcq2OukrvfdlGtH9Wgg==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.47.tgz", + "integrity": "sha512-fodvSMf6Aqwa0wEUSTPewmmZOD44rc5Tpr5p9NkwQ6W1SSpUKzD3SwpJIgANDOhwiYhDuiIaYPGB7Ujkx1q0UQ==", "cpu": [ "x64" ], @@ -2168,9 +2165,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.40.tgz", - "integrity": "sha512-W5qBGAemUocIBKCcOsDjlV9GUt28qhl/+M6etWBeLS5gQK0J6XDg0YVzfOQdvq57ZGjYNP0NvhYzqhOOnEx+4g==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.47.tgz", + "integrity": "sha512-Rxm5hYc0mGjwLh5sjlGmMygxAaV2gnsx7CNm2lsb47oyt5UQyPDZf3GP/ct8BEcwuikdqzsrrlIp8+kCSvMFNQ==", "cpu": [ "x64" ], @@ -2184,9 +2181,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.40.tgz", - "integrity": "sha512-vJwoDehtt+yqj2zacq1AqNc2uE/oh7mnRGqAUbuldV6pgvU01OSQUJ7Zu+35hTopnjFoDNN6mIezkYlGAv5RFA==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.47.tgz", + "integrity": "sha512-YakuVe+Gc87jjxazBL34hbr8RJpRuFBhun7NEqoChVDlH5FLhLXjAPHqZd990TVGVNkemourf817Z8u2fONS8w==", "cpu": [ "arm64" ], @@ -2200,25 +2197,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.40.tgz", - "integrity": "sha512-Oj3YyqVUPurr1FlMpEE/bJmMC+VWAWPM/SGUfklO5KUX97bk5Q/733nPg4RykK8q8/TluJoQYvRc05vL/B74dw==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.47.tgz", + "integrity": "sha512-ak2GvTFQz3UAOw8cuQq8pWE+TNygQB6O47rMhvevvTzETh7VkHRFtRUwJynX5hwzFvQMP6G0az5JrBGuwaMwYQ==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.5" + "@napi-rs/wasm-runtime": "^1.0.7" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", - "integrity": "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", "license": "MIT", "optional": true, "dependencies": { @@ -2228,9 +2225,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.40.tgz", - "integrity": "sha512-0ZtO6yN8XjVoFfN4HDWQj4nDu3ndMybr7jIM00DJqOmc+yFhly7rdOy7fNR9Sky3leCpBtsXfepVqRmVpYKPVA==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.47.tgz", + "integrity": "sha512-o5BpmBnXU+Cj+9+ndMcdKjhZlPb79dVPBZnWwMnI4RlNSSq5yOvFZqvfPYbyacvnW03Na4n5XXQAPhu3RydZ0w==", "cpu": [ "arm64" ], @@ -2244,9 +2241,9 @@ } }, "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.40.tgz", - "integrity": "sha512-BPl1inoJXPpIe38Ja46E4y11vXlJyuleo+9Rmu//pYL5fIDYJkXUj/oAXqjSuwLcssrcwnuPgzvzvlz9++cr3w==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.47.tgz", + "integrity": "sha512-FVOmfyYehNE92IfC9Kgs913UerDog2M1m+FADJypKz0gmRg3UyTt4o1cZMCAl7MiR89JpM9jegNO1nXuP1w1vw==", "cpu": [ "ia32" ], @@ -2260,9 +2257,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.40.tgz", - "integrity": "sha512-UguA4ltbAk+nbwHRxqaUP/etpTbR0HjyNlsu4Zjbh/ytNbFsbw8CA4tEBkwDyjgI5NIPea6xY11zpl7R2/ddVA==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.47.tgz", + "integrity": "sha512-by/70F13IUE101Bat0oeH8miwWX5mhMFPk1yjCdxoTNHTyTdLgb0THNaebRM6AP7Kz+O3O2qx87sruYuF5UxHg==", "cpu": [ "x64" ], @@ -2276,9 +2273,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.40.tgz", - "integrity": "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", "license": "MIT" }, "node_modules/@rollup/plugin-virtual": { @@ -3674,28 +3671,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", - "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -3892,21 +3867,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -4130,12 +4090,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie-es": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", - "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4149,15 +4103,6 @@ "node": ">= 8" } }, - "node_modules/crossws": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", - "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", - "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" - } - }, "node_modules/css-selector-parser": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.0.5.tgz", @@ -4242,12 +4187,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "license": "MIT" }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "license": "MIT" - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4257,12 +4196,6 @@ "node": ">=6" } }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "license": "MIT" - }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -4990,6 +4923,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5116,23 +5063,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/h3": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz", - "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==", - "license": "MIT", - "dependencies": { - "cookie-es": "^1.2.2", - "crossws": "^0.3.5", - "defu": "^6.1.4", - "destr": "^2.0.5", - "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.2", - "radix3": "^1.1.2", - "ufo": "^1.6.1", - "uncrypto": "^0.1.3" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5443,13 +5373,6 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/idb-keyval": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", - "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0", - "peer": true - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5490,15 +5413,6 @@ "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" }, - "node_modules/iron-webcrypto": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", - "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/brc-dd" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -7342,27 +7256,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "license": "MIT" }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" - }, - "node_modules/node-mock-http": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.3.tgz", - "integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -7382,17 +7275,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" - } - }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -7779,12 +7661,6 @@ ], "license": "MIT" }, - "node_modules/radix3": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", - "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", - "license": "MIT" - }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -7883,19 +7759,6 @@ } } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/reading-time": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", @@ -8185,14 +8048,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-beta.40", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.40.tgz", - "integrity": "sha512-VqEHbKpOgTPmQrZ4fVn4eshDQS/6g/fRpNE7cFSJY+eQLDZn4B9X61J6L+hnlt1u2uRI+pF7r1USs6S5fuWCvw==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.47.tgz", + "integrity": "sha512-Mid74GckX1OeFAOYz9KuXeWYhq3xkXbMziYIC+ULVdUzPTG9y70OBSBQDQn9hQP8u/AfhuYw1R0BSg15nBI4Dg==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.92.0", - "@rolldown/pluginutils": "1.0.0-beta.40", - "ansis": "^4.0.0" + "@oxc-project/types": "=0.96.0", + "@rolldown/pluginutils": "1.0.0-beta.47" }, "bin": { "rolldown": "bin/cli.mjs" @@ -8201,20 +8063,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.40", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.40", - "@rolldown/binding-darwin-x64": "1.0.0-beta.40", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.40", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.40", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.40", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.40", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.40", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.40", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.40", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.40", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.40", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.40", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.40" + "@rolldown/binding-android-arm64": "1.0.0-beta.47", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.47", + "@rolldown/binding-darwin-x64": "1.0.0-beta.47", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.47", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.47", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.47", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.47", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.47", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.47", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.47", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.47", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.47", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.47", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.47" } }, "node_modules/run-parallel": { @@ -8923,21 +8785,6 @@ "typescript": "^5.5.0" } }, - "node_modules/twoslash-cdn": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/twoslash-cdn/-/twoslash-cdn-0.3.4.tgz", - "integrity": "sha512-PvCZ6KEiz6x3Jg1sjR8lHrhUtNMoi5ILlzRnV2GZYXYMgr13OT3e7nLa4YKrVa8fbfCaUK4kG0iHSVkH7caChA==", - "license": "MIT", - "dependencies": { - "twoslash": "0.3.4" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "typescript": "^5.5.0" - } - }, "node_modules/twoslash-protocol": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/twoslash-protocol/-/twoslash-protocol-0.3.4.tgz", @@ -8976,18 +8823,6 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "license": "MIT" - }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", @@ -9201,108 +9036,6 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, - "node_modules/unstorage": { - "version": "1.17.2", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.2.tgz", - "integrity": "sha512-cKEsD6iBWJgOMJ6vW1ID/SYuqNf8oN4yqRk8OYqaVQ3nnkJXOT1PSpaMh2QfzLs78UN5kSNRD2c/mgjT8tX7+w==", - "license": "MIT", - "dependencies": { - "anymatch": "^3.1.3", - "chokidar": "^4.0.3", - "destr": "^2.0.5", - "h3": "^1.15.4", - "lru-cache": "^10.4.3", - "node-fetch-native": "^1.6.7", - "ofetch": "^1.5.0", - "ufo": "^1.6.1" - }, - "peerDependencies": { - "@azure/app-configuration": "^1.8.0", - "@azure/cosmos": "^4.2.0", - "@azure/data-tables": "^13.3.0", - "@azure/identity": "^4.6.0", - "@azure/keyvault-secrets": "^4.9.0", - "@azure/storage-blob": "^12.26.0", - "@capacitor/preferences": "^6.0.3 || ^7.0.0", - "@deno/kv": ">=0.9.0", - "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", - "@planetscale/database": "^1.19.0", - "@upstash/redis": "^1.34.3", - "@vercel/blob": ">=0.27.1", - "@vercel/functions": "^2.2.12 || ^3.0.0", - "@vercel/kv": "^1.0.1", - "aws4fetch": "^1.0.20", - "db0": ">=0.2.1", - "idb-keyval": "^6.2.1", - "ioredis": "^5.4.2", - "uploadthing": "^7.4.4" - }, - "peerDependenciesMeta": { - "@azure/app-configuration": { - "optional": true - }, - "@azure/cosmos": { - "optional": true - }, - "@azure/data-tables": { - "optional": true - }, - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - }, - "@azure/storage-blob": { - "optional": true - }, - "@capacitor/preferences": { - "optional": true - }, - "@deno/kv": { - "optional": true - }, - "@netlify/blobs": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/blob": { - "optional": true - }, - "@vercel/functions": { - "optional": true - }, - "@vercel/kv": { - "optional": true - }, - "aws4fetch": { - "optional": true - }, - "db0": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "uploadthing": { - "optional": true - } - } - }, - "node_modules/unstorage/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 9e44985b..875ef1a1 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "globals": "^16.4.0", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", - "idb-keyval": "^6.2.2", "lightningcss": "^1.30.1", "mdast-util-slice-markdown": "^2.0.1", "preact": "^10.27.2", @@ -73,10 +72,9 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", - "rolldown": "^1.0.0-beta.40", + "rolldown": "^1.0.0-beta.47", "semver": "^7.7.2", "shiki": "^3.15.0", - "twoslash-cdn": "^0.3.4", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-find-after": "^5.0.0", @@ -84,7 +82,6 @@ "unist-util-remove": "^4.0.0", "unist-util-select": "^5.1.0", "unist-util-visit": "^5.0.0", - "unstorage": "^1.17.2", "vfile": "^6.0.3", "yaml": "^2.8.1" } diff --git a/src/generators/legacy-html/index.mjs b/src/generators/legacy-html/index.mjs index 36b1e8d9..f7a28407 100644 --- a/src/generators/legacy-html/index.mjs +++ b/src/generators/legacy-html/index.mjs @@ -7,10 +7,10 @@ import HTMLMinifier from '@minify-html/node'; import buildContent from './utils/buildContent.mjs'; import dropdowns from './utils/buildDropdowns.mjs'; -import { safeCopy } from './utils/safeCopy.mjs'; import tableOfContents from './utils/tableOfContents.mjs'; import { groupNodesByModule } from '../../utils/generators.mjs'; import { getRemarkRehype } from '../../utils/remark.mjs'; +import { safeCopy } from '../../utils/safeCopy.mjs'; /** * @typedef {{ diff --git a/src/generators/web-twoslash/client.mjs b/src/generators/web-twoslash/client.mjs deleted file mode 100644 index 7248c50f..00000000 --- a/src/generators/web-twoslash/client.mjs +++ /dev/null @@ -1,107 +0,0 @@ -/* global document */ - -import createHighlighter from '@node-core/rehype-shiki'; -import { createTransformerFactory, rendererRich } from '@shikijs/twoslash'; -import shikiNordTheme from 'shiki/themes/nord.mjs'; -import { createTwoslashFromCDN } from 'twoslash-cdn'; -import { createStorage } from 'unstorage'; -import indexedDbDriver from 'unstorage/drivers/indexedb'; - -// An example using unstorage with IndexedDB to cache the virtual file system -const storage = createStorage({ - driver: indexedDbDriver({ base: 'twoslash-cdn' }), -}); - -const twoslash = createTwoslashFromCDN({ - storage, - compilerOptions: { - lib: ['dom', 'dom.iterable', 'esnext'], - module: 'nodenext', - types: ['node'], - }, -}); - -const transformerTwoslash = createTransformerFactory(twoslash.runSync)({ - renderer: rendererRich({ jsdoc: true }), - langs: ['ts', 'js', 'cjs', 'mjs'], - throws: false, -}); - -const highlighterPromise = createHighlighter({ - wasm: false, -}); - -/** - * Extracts the raw code content from a
 element
- * @param {HTMLPreElement} preElement - The pre element
- * @returns {string} The raw code content
- */
-function extractRawCode(preElement) {
-  const codeElement = preElement.querySelector('code');
-  if (!codeElement) {
-    return '';
-  }
-
-  return codeElement.textContent || '';
-}
-
-/**
- * Process a single code block with Twoslash
- * @param {HTMLPreElement} preElement - The pre element
- * @returns {Promise}
- */
-async function processTwoslashBlock(preElement) {
-  try {
-    const rawCode = extractRawCode(preElement);
-
-    if (!rawCode) {
-      return;
-    }
-
-    const highlighter = await highlighterPromise;
-
-    await twoslash.prepareTypes(rawCode);
-
-    const html = highlighter.shiki.codeToHtml(rawCode, {
-      lang: 'mjs',
-      theme: {
-        // We are updating this color because the background color and comment text color
-        // in the Codebox component do not comply with accessibility standards.
-        // See: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
-        colorReplacements: { '#616e88': '#707e99' },
-        ...shikiNordTheme,
-      },
-      transformers: [transformerTwoslash],
-    });
-
-    const temp = document.createElement('div');
-
-    temp.innerHTML = html;
-
-    const newPre = temp.querySelector('pre');
-
-    if (newPre) {
-      newPre.className = `${preElement.className} twoslash`;
-      newPre.style = '';
-
-      preElement.parentNode?.replaceChild(newPre, preElement);
-    }
-  } catch (error) {
-    console.error('Error processing Twoslash block:', error);
-  }
-}
-
-/**
- * Initialize Twoslash processing on page load
- */
-async function initTwoslash() {
-  const codeBlocks = document.querySelectorAll('pre');
-
-  const twoslashBlocks = Array.from(codeBlocks).filter(preElement =>
-    preElement.querySelector('code')
-  );
-
-  await Promise.all(twoslashBlocks.map(block => processTwoslashBlock(block)));
-}
-
-initTwoslash();
diff --git a/src/generators/web-twoslash/index.mjs b/src/generators/web-twoslash/index.mjs
deleted file mode 100644
index 6d3f57c9..00000000
--- a/src/generators/web-twoslash/index.mjs
+++ /dev/null
@@ -1,41 +0,0 @@
-import { readFile, writeFile } from 'node:fs/promises';
-import { join } from 'node:path';
-
-import bundleCode from '../web/utils/bundle.mjs';
-
-/**
- * Bundles the Twoslash client-side script and writes it as an external file
- *
- * @type {GeneratorMetadata}
- */
-export default {
-  name: 'web-twoslash',
-  version: '1.0.0',
-  description: 'Generates client-side Twoslash script for web bundle',
-  dependsOn: null,
-
-  /**
-   * Generates the Twoslash client script with content hash
-   *
-   * @param {never} _ - No input needed
-   * @param {Partial} options
-   * @returns {Promise}
-   */
-  async generate(_, { output }) {
-    // Read the client code from file
-    const twoslashClientCode = await readFile(
-      new URL('client.mjs', import.meta.url),
-      'utf-8'
-    );
-
-    // Bundle the code
-    const { js } = await bundleCode(twoslashClientCode, {
-      server: false,
-    });
-
-    // Write the files if output directory is specified
-    if (output) {
-      await writeFile(join(output, 'twoslash.js'), js, 'utf-8');
-    }
-  },
-};
diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs
index 733fbbf8..15ecf1d2 100644
--- a/src/generators/web/index.mjs
+++ b/src/generators/web/index.mjs
@@ -1,10 +1,10 @@
-import { readFile, writeFile } from 'node:fs/promises';
+import { readFile } from 'node:fs/promises';
 import { createRequire } from 'node:module';
 import { join } from 'node:path';
 
-import webTwoslashGenerator from '../web-twoslash/index.mjs';
 import createASTBuilder from './utils/generate.mjs';
 import { processJSXEntry } from './utils/processing.mjs';
+import { safeWrite } from '../../utils/safeWrite.mjs';
 
 /**
  * This generator transforms JSX AST (Abstract Syntax Tree) entries into a complete
@@ -34,16 +34,17 @@ export default {
     );
 
     const astBuilders = createASTBuilder();
-    const requireFn = createRequire(import.meta.url);
 
-    await webTwoslashGenerator.generate(null, { output });
+    const requireFn = createRequire(import.meta.url);
 
     const results = [];
 
     let mainCss = '';
 
+    const writtenChunks = new Set();
+
     for (const entry of entries) {
-      const { html, css } = await processJSXEntry(
+      const { html, css, jsChunks } = await processJSXEntry(
         entry,
         template,
         astBuilders,
@@ -60,13 +61,22 @@ export default {
 
       // Write HTML file if output directory is specified
       if (output) {
-        await writeFile(join(output, `${entry.data.api}.html`), html, 'utf-8');
+        safeWrite(join(output, `${entry.data.api}.html`), html, 'utf-8');
+
+        // Write JS chunks (only once per unique chunk)
+        for (const chunk of jsChunks) {
+          if (!writtenChunks.has(chunk.fileName)) {
+            safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8');
+
+            writtenChunks.add(chunk.fileName);
+          }
+        }
       }
     }
 
-    // Write CSS file
+    // Write the main CSS file once after processing all entries
     if (output && mainCss) {
-      await writeFile(join(output, 'styles.css'), mainCss, 'utf-8');
+      safeWrite(join(output, 'styles.css'), mainCss, 'utf-8');
     }
 
     return results;
diff --git a/src/generators/web/template.html b/src/generators/web/template.html
index 0c3a1ea9..4e6ce145 100644
--- a/src/generators/web/template.html
+++ b/src/generators/web/template.html
@@ -15,11 +15,12 @@
 
   
   
+
+  {{importMap}}
 
 
 
   
{{dehydrated}}
- - + diff --git a/src/generators/web/ui/components/CodeBox.jsx b/src/generators/web/ui/components/CodeBox.jsx index 4756abfe..7bd1b316 100644 --- a/src/generators/web/ui/components/CodeBox.jsx +++ b/src/generators/web/ui/components/CodeBox.jsx @@ -20,8 +20,9 @@ export const getLanguageDisplayName = language => { }; /** @param {import('react').PropsWithChildren<{ className: string }>} props */ -export default ({ className, ...props }) => { +export default ({ className, children, ...props }) => { const matches = className?.match(/language-(?[a-zA-Z]+)/); + const language = matches?.groups?.language ?? ''; const notify = useNotification(); @@ -30,7 +31,7 @@ export default ({ className, ...props }) => { await navigator.clipboard.writeText(text); notify({ - duration: 300, + duration: 3000, message: (
@@ -41,12 +42,16 @@ export default ({ className, ...props }) => { }; return ( - + <> + + {children} + + ); }; diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index 145eabc3..9187b12f 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -1,5 +1,5 @@ @import '@node-core/ui-components/styles/index.css'; -@import '@shikijs/twoslash/style-rich.css'; +@import '@node-core/rehype-shiki/index.css'; /* Fonts */ :root { diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index 5aa297cf..acda85fd 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -12,21 +12,50 @@ import staticData from './data.mjs'; * @param {{ server: boolean }} options - Build configuration object. */ export default async function bundleCode(code, { server = false } = {}) { + // Store the import map HTML for later extraction + let importMapHtml = ''; + + /** @type {import('rolldown').OutputOptions} */ + const serverOutputConfig = { + inlineDynamicImports: true, + }; + + /** @type {import('rolldown').OutputOptions} */ + const clientOutputConfig = {}; + + /** @type {import('rolldown').InputOptions['experimental']} */ + const clientExperimentalConfig = { + chunkImportMap: !server && { + baseUrl: './', + fileName: 'importmap.json', + }, + }; + const result = await build({ // Define the entry point module name — this is virtual (not a real file). // The virtual plugin will provide the actual code string under this name. input: 'entrypoint.jsx', + // Enable experimental chunk import map for cache-busted module resolution + // https://rolldown.rs/options/experimental#chunkimportmap + // Also enable incremental builds for faster rebuilds (similar to Rollup's cache) + // https://rolldown.rs/options/experimental#incrementalbuild + experimental: !server ? clientExperimentalConfig : {}, + // Configuration for the output bundle output: { // Output module format: // - "cjs" for CommonJS (used in Node.js environments) - // - "iife" for browser environments (self-contained script tag) - format: server ? 'cjs' : 'iife', + // - "esm" for browser with dynamic imports (allows code splitting) + format: server ? 'cjs' : 'esm', // Minify output only for browser builds to optimize file size. // Server builds are usually not minified to preserve stack traces and debuggability. minify: !server, + + // Enable code splitting for client builds to allow dynamic imports + // For server builds, inline everything into a single bundle + ...(server ? serverOutputConfig : clientOutputConfig), }, // Platform informs Rolldown of the environment-specific code behavior: @@ -41,25 +70,28 @@ export default async function bundleCode(code, { server = false } = {}) { ? ['preact', 'preact-render-to-string', '@node-core/ui-components'] : [], - // Inject global compile-time constants that will be replaced in code. - // These are useful for tree-shaking and conditional branching. - // Be sure to update type declarations (`types.d.ts`) if these change. - define: { - // Static data injected directly into the bundle (as a literal or serialized JSON). - __STATIC_DATA__: staticData, - - // Boolean flags used for conditional logic in source code: - // Example: `if (SERVER) {...}` or `if (CLIENT) {...}` - // These flags help split logic for server/client environments. - // Unused branches will be removed via tree-shaking. - SERVER: String(server), - CLIENT: String(!server), - }, + // Transform configuration + transform: { + // Inject global compile-time constants that will be replaced in code. + // These are useful for tree-shaking and conditional branching. + // Be sure to update type declarations (`types.d.ts`) if these change. + define: { + // Static data injected directly into the bundle (as a literal or serialized JSON). + __STATIC_DATA__: staticData, + + // Boolean flags used for conditional logic in source code: + // Example: `if (SERVER) {...}` or `if (CLIENT) {...}` + // These flags help split logic for server/client environments. + // Unused branches will be removed via tree-shaking. + SERVER: String(server), + CLIENT: String(!server), + }, - // JSX transformation configuration. - // `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`. - // Since we're using Preact via aliasing, this setting works well with `preact/compat`. - jsx: 'react-jsx', + // JSX transformation configuration. + // `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`. + // Since we're using Preact via aliasing, this setting works well with `preact/compat`. + jsx: 'react-jsx', + }, // Module resolution aliases. // This tells the bundler to use `preact/compat` wherever `react` or `react-dom` is imported. @@ -76,13 +108,45 @@ export default async function bundleCode(code, { server = false } = {}) { // The virtual plugin lets us define a virtual file called 'entrypoint.jsx' // with the contents provided by the `code` argument. // This becomes the root module for the bundler. - virtual({ - 'entrypoint.jsx': code, - }), + virtual({ 'entrypoint.jsx': code }), // Load CSS imports via the custom plugin. // This plugin will collect imported CSS files and return them as `source` chunks. cssLoader(), + + // Extract import map from Rolldown's chunkImportMap output + // https://rolldown.rs/options/experimental#chunkimportmap + { + name: 'extract-import-map', + /** + * Extract import map from bundle + * @param {*} _ - Options (unused) + * @param {*} bundle - Bundle object + */ + generateBundle(_, bundle) { + const chunkImportMap = bundle['importmap.json']; + + if (chunkImportMap?.type === 'asset') { + // Parse the import map and filter out virtual entries + const importMapData = JSON.parse(chunkImportMap.source); + + // Remove any references to _virtual_ or virtual entrypoint files + if (importMapData.imports) { + for (const key in importMapData.imports) { + if (key.includes('_virtual_') || key.includes('entrypoint')) { + delete importMapData.imports[key]; + } + } + } + + // Extract the import map and convert to HTML script tag + importMapHtml = ``; + + // Remove from bundle so it's not written as a separate file + delete bundle['importmap.json']; + } + }, + }, ], // Enable tree-shaking to eliminate unused imports, functions, and branches. @@ -97,11 +161,19 @@ export default async function bundleCode(code, { server = false } = {}) { // Destructure the result to get the output chunks. // The first output is always the JavaScript entrypoint. - // Any additional chunks are styles (CSS). - const [js, ...cssFiles] = result.output; + // Any additional chunks are styles (CSS) or code-split JS chunks. + const [mainJs, ...otherChunks] = result.output; - return { - js: js.code, + // Separate CSS files from JS chunks + const cssFiles = otherChunks.filter(chunk => chunk.type === 'asset'); + const jsChunks = otherChunks.filter(chunk => chunk.type === 'chunk'); + + const bundleResult = { + js: mainJs.code, + jsChunks: jsChunks.map(({ fileName, code }) => ({ fileName, code })), css: cssFiles.map(f => f.source).join(''), + importMapHtml, }; + + return bundleResult; } diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index d5e2e96a..eb0e8eeb 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import HTMLMinifier from '@minify-html/node'; import { toJs, jsx } from 'estree-util-to-js'; @@ -57,29 +55,45 @@ export async function processJSXEntry( // `executeServerCode` then runs this code in a Node.js environment to produce // the initial HTML content (dehydrated state) that will be sent to the client. const serverCode = buildServerProgram(code); + const dehydrated = await executeServerCode(serverCode, requireFn); // `buildClientProgram` prepares the JSX-derived code for client-side execution. // `bundleCode` then bundles this client-side code, resolving imports and // potentially generating associated CSS. This bundle will hydrate the SSR content. const clientCode = buildClientProgram(code); + const clientBundle = await bundleCode(clientCode); + // Rolldown's experimental.chunkImportMap generates the import map automatically + // The import map is extracted by our plugin and returned as HTML + // https://rolldown.rs/options/experimental#chunkimportmap + const importMapScript = clientBundle.importMapHtml; + + // Prepare jsChunks for file writing + const chunksWithHashes = clientBundle.jsChunks.map(chunk => ({ + fileName: chunk.fileName, + code: chunk.code, + hash: '', // No need for manual hashing, Rolldown handles cache busting via importMap + })); + const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`; // Replace template placeholders with actual content const renderedHtml = template .replace('{{title}}', title) .replace('{{dehydrated}}', dehydrated ?? '') - .replace('{{clientBundleJs}}', () => clientBundle.js) - .replace('{{cacheHash}}', randomUUID()); + .replace('{{importMap}}', importMapScript) + .replace('{{mainJsCode}}', () => clientBundle.js); // The input to `minify` must be a Buffer. const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); - // Return the generated HTML and any CSS produced by the client bundle. + // Return the generated HTML, CSS, and any JS chunks from code splitting + // Note: main JS is inlined in HTML, so we don't return it separately return { html: finalHTMLBuffer, css: clientBundle.css, + jsChunks: chunksWithHashes, }; } diff --git a/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs b/src/utils/__tests__/safeCopy.test.mjs similarity index 100% rename from src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs rename to src/utils/__tests__/safeCopy.test.mjs diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/utils/safeCopy.mjs similarity index 100% rename from src/generators/legacy-html/utils/safeCopy.mjs rename to src/utils/safeCopy.mjs diff --git a/src/utils/safeWrite.mjs b/src/utils/safeWrite.mjs new file mode 100644 index 00000000..8875aef8 --- /dev/null +++ b/src/utils/safeWrite.mjs @@ -0,0 +1,34 @@ +'use strict'; + +import { statSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; + +/** + * Writes a file only if the content has changed, avoiding unnecessary writes. + * Uses stat comparison (size and mtime) to determine if file needs updating. + * + * @param {string} filePath - The path to the file + * @param {string | Buffer} content - The content to write + * @param {string} encoding - File encoding (default: 'utf-8') + * @returns {Promise} True if file was written, false if skipped + */ +export async function safeWrite(filePath, content, encoding = 'utf-8') { + // Check if file exists + const stat = statSync(filePath, { throwIfNoEntry: false }); + + if (stat !== undefined) { + const newSize = Buffer.byteLength(content, encoding); + + // Skip write if size matches (content likely hasn't changed) + // This is a heuristic - same size doesn't guarantee same content, + // but for generated files it's a good indicator + if (stat.size === newSize) { + return false; + } + } + + // Write the file (either new or changed) + await writeFile(filePath, content, encoding); + + return true; +} From bd58920a3089dc31c88af5d2a9a29c142a1a8247 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 16:22:53 +0100 Subject: [PATCH 03/16] chore: halve build time --- src/generators/web/index.mjs | 59 +++++-------- src/generators/web/utils/bundle.mjs | 62 +++++++++----- src/generators/web/utils/processing.mjs | 109 ++++++++++++++---------- 3 files changed, 129 insertions(+), 101 deletions(-) diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 15ecf1d2..ccf0c098 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import { join } from 'node:path'; import createASTBuilder from './utils/generate.mjs'; -import { processJSXEntry } from './utils/processing.mjs'; +import { processJSXEntries } from './utils/processing.mjs'; import { safeWrite } from '../../utils/safeWrite.mjs'; /** @@ -37,48 +37,33 @@ export default { const requireFn = createRequire(import.meta.url); - const results = []; - - let mainCss = ''; - - const writtenChunks = new Set(); - - for (const entry of entries) { - const { html, css, jsChunks } = await processJSXEntry( - entry, - template, - astBuilders, - requireFn, - { version } - ); - - results.push({ html, css }); + // Process all entries at once + const { results, css, jsChunks } = await processJSXEntries( + entries, + template, + astBuilders, + requireFn, + { version } + ); - // Capture the main CSS bundle from the first processed entry - if (!mainCss && css) { - mainCss = css; + // Write all files if output directory is specified + if (output) { + // Write all HTML files + for (const { html, api } of results) { + safeWrite(join(output, `${api}.html`), html, 'utf-8'); } - // Write HTML file if output directory is specified - if (output) { - safeWrite(join(output, `${entry.data.api}.html`), html, 'utf-8'); - - // Write JS chunks (only once per unique chunk) - for (const chunk of jsChunks) { - if (!writtenChunks.has(chunk.fileName)) { - safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8'); - - writtenChunks.add(chunk.fileName); - } - } + // Write all JS chunks + for (const chunk of jsChunks) { + safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8'); } - } - // Write the main CSS file once after processing all entries - if (output && mainCss) { - safeWrite(join(output, 'styles.css'), mainCss, 'utf-8'); + // Write the CSS file + if (css) { + safeWrite(join(output, 'styles.css'), css, 'utf-8'); + } } - return results; + return results.map(({ html }) => ({ html, css })); }, }; diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index acda85fd..44b8bb76 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -8,21 +8,24 @@ import staticData from './data.mjs'; * Asynchronously bundles JavaScript source code (and its CSS imports), * targeting either browser (client) or server (Node.js) environments. * - * @param {string} code - JavaScript/JSX source code to bundle. + * @param {Map | string} codeOrMap - Map of {fileName: code} or single code string for server builds. * @param {{ server: boolean }} options - Build configuration object. */ -export default async function bundleCode(code, { server = false } = {}) { +export default async function bundleCode(codeOrMap, { server = false } = {}) { // Store the import map HTML for later extraction let importMapHtml = ''; + // Convert input to Map format + const codeMap = + codeOrMap instanceof Map + ? codeOrMap + : new Map([['entrypoint.jsx', codeOrMap]]); + /** @type {import('rolldown').OutputOptions} */ const serverOutputConfig = { inlineDynamicImports: true, }; - /** @type {import('rolldown').OutputOptions} */ - const clientOutputConfig = {}; - /** @type {import('rolldown').InputOptions['experimental']} */ const clientExperimentalConfig = { chunkImportMap: !server && { @@ -32,15 +35,15 @@ export default async function bundleCode(code, { server = false } = {}) { }; const result = await build({ - // Define the entry point module name — this is virtual (not a real file). - // The virtual plugin will provide the actual code string under this name. - input: 'entrypoint.jsx', + // Define the entry point module names — these are virtual (not real files). + // The virtual plugin will provide the actual code string under these names. + input: Array.from(codeMap.keys()), // Enable experimental chunk import map for cache-busted module resolution // https://rolldown.rs/options/experimental#chunkimportmap // Also enable incremental builds for faster rebuilds (similar to Rollup's cache) // https://rolldown.rs/options/experimental#incrementalbuild - experimental: !server ? clientExperimentalConfig : {}, + experimental: server ? {} : clientExperimentalConfig, // Configuration for the output bundle output: { @@ -55,7 +58,7 @@ export default async function bundleCode(code, { server = false } = {}) { // Enable code splitting for client builds to allow dynamic imports // For server builds, inline everything into a single bundle - ...(server ? serverOutputConfig : clientOutputConfig), + ...(server ? serverOutputConfig : {}), }, // Platform informs Rolldown of the environment-specific code behavior: @@ -105,10 +108,10 @@ export default async function bundleCode(code, { server = false } = {}) { // Array of plugins to apply during the build. plugins: [ - // The virtual plugin lets us define a virtual file called 'entrypoint.jsx' - // with the contents provided by the `code` argument. - // This becomes the root module for the bundler. - virtual({ 'entrypoint.jsx': code }), + // The virtual plugin lets us define virtual files + // with the contents provided by the `codeMap` argument. + // These become the root modules for the bundler. + virtual(Object.fromEntries(codeMap)), // Load CSS imports via the custom plugin. // This plugin will collect imported CSS files and return them as `source` chunks. @@ -160,20 +163,37 @@ export default async function bundleCode(code, { server = false } = {}) { }); // Destructure the result to get the output chunks. - // The first output is always the JavaScript entrypoint. - // Any additional chunks are styles (CSS) or code-split JS chunks. - const [mainJs, ...otherChunks] = result.output; + // Separate entry chunks from other chunks (CSS and code-split JS) + const entryChunks = []; + const otherChunks = []; + + // Separate the entrypoints from remaining JavaScript files + for (const chunk of result.output) { + const chunkTarget = + chunk.type === 'chunk' && chunk.isEntry ? entryChunks : otherChunks; + + chunkTarget['push'](chunk); + } // Separate CSS files from JS chunks const cssFiles = otherChunks.filter(chunk => chunk.type === 'asset'); const jsChunks = otherChunks.filter(chunk => chunk.type === 'chunk'); - const bundleResult = { - js: mainJs.code, + // For client builds, create a map of entry code by original fileName + const jsMap = {}; + + for (const chunk of entryChunks) { + // Map back to original fileName from facadeModuleId + const originalFileName = + chunk.facadeModuleId?.split('/').pop() || chunk.fileName; + + jsMap[originalFileName.replace('\x00virtual:', '')] = chunk.code; + } + + return { + jsMap, jsChunks: jsChunks.map(({ fileName, code }) => ({ fileName, code })), css: cssFiles.map(f => f.source).join(''), importMapHtml, }; - - return bundleResult; } diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index eb0e8eeb..8321b104 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -5,65 +5,79 @@ import bundleCode from './bundle.mjs'; /** * Executes server-side JavaScript code in a safe, isolated context. - * This function takes a string of JavaScript code, bundles it, and then runs it + * This function takes a Map of JavaScript code strings, bundles them together, and then runs each * within a new Function constructor to prevent scope pollution and allow for * dynamic module loading via a provided `require` function. * The result of the server-side execution is expected to be assigned to a * dynamically generated variable name, which is then returned. * - * @param {string} serverCode - The server-side JavaScript code to execute as a string. + * @param {Map} serverCodeMap - Map of fileName to server-side JavaScript code. * @param {ReturnType} requireFn - A Node.js `require` function + * @returns {Promise>} Map of fileName to dehydrated HTML content */ -export async function executeServerCode(serverCode, requireFn) { - // Bundle the server-side code. This step resolves imports and prepares the code - // for execution, ensuring all necessary dependencies are self-contained. - const { js: bundledServer } = await bundleCode(serverCode, { server: true }); - - // Create a new Function from the bundled server code. - // The `require` argument is passed into the function's scope, allowing the - // `bundledServer` code to use it for dynamic imports. - const executedFunction = new Function('require', bundledServer); - - // Execute the dynamically created function with the provided `requireFn`. - // The result of this execution is the dehydrated content from the server-side rendering. - return executedFunction(requireFn); +export async function executeServerCode(serverCodeMap, requireFn) { + // Execute each bundled server code and collect results + const dehydratedMap = new Map(); + + for (const [fileName, serverCode] of serverCodeMap.entries()) { + // Bundle all server-side code together. This step resolves imports and prepares the code + // for execution, ensuring all necessary dependencies are self-contained. + const { jsMap } = await bundleCode(serverCode, { server: true }); + + // Create a new Function from the bundled server code. + // The `require` argument is passed into the function's scope, allowing the + // `bundledServer` code to use it for dynamic imports. + const executedFunction = new Function('require', jsMap['entrypoint.jsx']); + + // Execute the dynamically created function with the provided `requireFn`. + // The result of this execution is the dehydrated content from the server-side rendering. + dehydratedMap.set(fileName, executedFunction(requireFn)); + } + + return dehydratedMap; } /** - * Processes a single JSX AST (Abstract Syntax Tree) entry to generate a complete - * HTML page, including server-side rendered content, client-side JavaScript, and CSS. + * Processes multiple JSX AST (Abstract Syntax Tree) entries to generate complete + * HTML pages, including server-side rendered content, client-side JavaScript, and CSS. * - * @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent} entry - The JSX AST entry to process. + * @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - The JSX AST entries to process. * @param {string} template - The HTML template string that serves as the base for the output page. * @param {ReturnType} astBuilders - The AST generators * @param {Object} options - Processing options * @param {string} options.version - The version to generate the documentation for * @param {ReturnType} requireFn - A Node.js `require` function. */ -export async function processJSXEntry( - entry, +export async function processJSXEntries( + entries, template, { buildServerProgram, buildClientProgram }, requireFn, { version } ) { - // `estree-util-to-js` with the `jsx` handler converts the AST nodes into a string - // that represents the equivalent JavaScript code, including JSX syntax. - const { value: code } = toJs(entry, { handlers: jsx }); + // Convert all entries to JavaScript code + const serverCodeMap = new Map(); + const clientCodeMap = new Map(); - // `buildServerProgram` takes the JSX-derived code and prepares it for server execution. - // `executeServerCode` then runs this code in a Node.js environment to produce - // the initial HTML content (dehydrated state) that will be sent to the client. - const serverCode = buildServerProgram(code); + for (const entry of entries) { + const fileName = `${entry.data.api}.jsx`; - const dehydrated = await executeServerCode(serverCode, requireFn); + // `estree-util-to-js` with the `jsx` handler converts the AST nodes into a string + // that represents the equivalent JavaScript code, including JSX syntax. + const { value: code } = toJs(entry, { handlers: jsx }); - // `buildClientProgram` prepares the JSX-derived code for client-side execution. - // `bundleCode` then bundles this client-side code, resolving imports and - // potentially generating associated CSS. This bundle will hydrate the SSR content. - const clientCode = buildClientProgram(code); + // `buildServerProgram` takes the JSX-derived code and prepares it for server execution. + serverCodeMap.set(fileName, buildServerProgram(code)); - const clientBundle = await bundleCode(clientCode); + // `buildClientProgram` prepares the JSX-derived code for client-side execution. + clientCodeMap.set(fileName, buildClientProgram(code)); + } + + // Execute all server code at once + const dehydratedMap = await executeServerCode(serverCodeMap, requireFn); + + // Bundle all client code at once + const clientBundle = await bundleCode(clientCodeMap); // Rolldown's experimental.chunkImportMap generates the import map automatically // The import map is extracted by our plugin and returned as HTML @@ -77,22 +91,31 @@ export async function processJSXEntry( hash: '', // No need for manual hashing, Rolldown handles cache busting via importMap })); - const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`; + // Process each entry to create HTML + const results = entries.map(entry => { + const fileName = `${entry.data.api}.jsx`; + const dehydrated = dehydratedMap.get(fileName); + const mainJsCode = clientBundle.jsMap[fileName]; + + const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`; + + // Replace template placeholders with actual content + const renderedHtml = template + .replace('{{title}}', title) + .replace('{{dehydrated}}', dehydrated ?? '') + .replace('{{importMap}}', importMapScript) + .replace('{{mainJsCode}}', () => mainJsCode); - // Replace template placeholders with actual content - const renderedHtml = template - .replace('{{title}}', title) - .replace('{{dehydrated}}', dehydrated ?? '') - .replace('{{importMap}}', importMapScript) - .replace('{{mainJsCode}}', () => clientBundle.js); + // The input to `minify` must be a Buffer. + const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); - // The input to `minify` must be a Buffer. - const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); + return { html: finalHTMLBuffer, api: entry.data.api }; + }); // Return the generated HTML, CSS, and any JS chunks from code splitting // Note: main JS is inlined in HTML, so we don't return it separately return { - html: finalHTMLBuffer, + results, css: clientBundle.css, jsChunks: chunksWithHashes, }; From 47df0043e33e8ecc21f2c96f0c80e5271ba5fc24 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 17:00:57 +0100 Subject: [PATCH 04/16] chore: code cleanup --- npm-shrinkwrap.json | 15 --------------- package.json | 1 - src/generators/web/index.mjs | 4 +--- src/generators/web/ui/components/CodeBox.jsx | 2 +- src/generators/web/utils/processing.mjs | 2 +- 5 files changed, 3 insertions(+), 21 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 2d9541e9..27c29dbe 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -15,7 +15,6 @@ "@orama/orama": "^3.1.16", "@orama/react-components": "^0.8.1", "@rollup/plugin-virtual": "^3.0.2", - "@shikijs/twoslash": "^3.14.0", "acorn": "^8.15.0", "commander": "^14.0.2", "dedent": "^1.7.0", @@ -4923,20 +4922,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/package.json b/package.json index 875ef1a1..add0728e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "@orama/orama": "^3.1.16", "@orama/react-components": "^0.8.1", "@rollup/plugin-virtual": "^3.0.2", - "@shikijs/twoslash": "^3.14.0", "acorn": "^8.15.0", "commander": "^14.0.2", "dedent": "^1.7.0", diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index ccf0c098..ff797496 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -59,9 +59,7 @@ export default { } // Write the CSS file - if (css) { - safeWrite(join(output, 'styles.css'), css, 'utf-8'); - } + safeWrite(join(output, 'styles.css'), css, 'utf-8'); } return results.map(({ html }) => ({ html, css })); diff --git a/src/generators/web/ui/components/CodeBox.jsx b/src/generators/web/ui/components/CodeBox.jsx index 7bd1b316..53609965 100644 --- a/src/generators/web/ui/components/CodeBox.jsx +++ b/src/generators/web/ui/components/CodeBox.jsx @@ -46,9 +46,9 @@ export default ({ className, children, ...props }) => { {children} diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 8321b104..05cfb2c2 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -116,7 +116,7 @@ export async function processJSXEntries( // Note: main JS is inlined in HTML, so we don't return it separately return { results, - css: clientBundle.css, + css: clientBundle.css ?? '', jsChunks: chunksWithHashes, }; } From 3f864e07ad9457c5b0adc6218758138947989542 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 17:14:22 +0100 Subject: [PATCH 05/16] chore: add tests --- .../web/utils/__tests__/bundle.test.mjs | 248 ++++++++++++++++++ .../web/utils/__tests__/processing.test.mjs | 97 +++++++ src/utils/__tests__/safeWrite.test.mjs | 146 +++++++++++ 3 files changed, 491 insertions(+) create mode 100644 src/generators/web/utils/__tests__/bundle.test.mjs create mode 100644 src/generators/web/utils/__tests__/processing.test.mjs create mode 100644 src/utils/__tests__/safeWrite.test.mjs diff --git a/src/generators/web/utils/__tests__/bundle.test.mjs b/src/generators/web/utils/__tests__/bundle.test.mjs new file mode 100644 index 00000000..2053ed1a --- /dev/null +++ b/src/generators/web/utils/__tests__/bundle.test.mjs @@ -0,0 +1,248 @@ +'use strict'; + +import assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import bundleCode from '../bundle.mjs'; + +describe('bundleCode', () => { + describe('server builds', () => { + it('should bundle a single server entry', async () => { + const code = ` + import { createElement } from 'preact'; + const element = createElement('div', null, 'Hello'); + return 'output'; + `; + + const result = await bundleCode(code, { server: true }); + + assert.ok(result.jsMap, 'should have jsMap'); + assert.ok(result.jsMap['entrypoint.jsx'], 'should have entrypoint.jsx'); + assert.ok( + result.jsMap['entrypoint.jsx'].includes('Hello'), + 'should include the code content' + ); + assert.strictEqual(result.css, '', 'should have empty CSS'); + assert.strictEqual(result.jsChunks.length, 0, 'should have no JS chunks'); + assert.strictEqual( + result.importMapHtml, + '', + 'should have empty import map' + ); + }); + + it('should handle external dependencies', async () => { + const code = ` + import { render } from 'preact-render-to-string'; + import { createElement } from 'preact'; + const element = createElement('div', null, 'Test'); + return render(element); + `; + + const result = await bundleCode(code, { server: true }); + + assert.ok(result.jsMap['entrypoint.jsx'], 'should bundle successfully'); + // External dependencies should not be bundled + assert.ok( + result.jsMap['entrypoint.jsx'].includes('require'), + 'should use require for external deps' + ); + }); + + it('should apply SERVER define flag', async () => { + const code = ` + const value = SERVER ? 'server-side' : 'client-side'; + return value; + `; + + const result = await bundleCode(code, { server: true }); + + // SERVER should be replaced with 'true' string, which will then be evaluated + assert.ok(result.jsMap, 'should have jsMap'); + assert.ok( + result.jsMap['entrypoint.jsx'] !== undefined, + 'should have entrypoint.jsx in jsMap' + ); + // Code might be empty if optimization removed everything, that's okay + assert.ok( + typeof result.jsMap['entrypoint.jsx'] === 'string', + 'should be a string' + ); + }); + }); + + describe('client builds', () => { + it('should bundle a single client entry', async () => { + const code = ` + import { createElement } from 'preact'; + const element = createElement('div', null, 'Hello'); + export default element; + `; + + const result = await bundleCode(code, { server: false }); + + assert.ok(result.jsMap, 'should have jsMap'); + assert.ok(result.jsMap['entrypoint.jsx'], 'should have entrypoint.jsx'); + assert.strictEqual(typeof result.css, 'string', 'should have CSS string'); + assert.ok(Array.isArray(result.jsChunks), 'should have jsChunks array'); + assert.strictEqual( + typeof result.importMapHtml, + 'string', + 'should have importMapHtml' + ); + }); + + it('should bundle multiple client entries', async () => { + const codeMap = new Map([ + [ + 'entry1.jsx', + `import { createElement } from 'preact'; export default createElement('div', null, 'One');`, + ], + [ + 'entry2.jsx', + `import { createElement } from 'preact'; export default createElement('div', null, 'Two');`, + ], + ]); + + const result = await bundleCode(codeMap, { server: false }); + + assert.ok(result.jsMap['entry1.jsx'], 'should have entry1.jsx'); + assert.ok(result.jsMap['entry2.jsx'], 'should have entry2.jsx'); + assert.ok( + result.jsMap['entry1.jsx'].includes('One'), + 'entry1 should include its content' + ); + assert.ok( + result.jsMap['entry2.jsx'].includes('Two'), + 'entry2 should include its content' + ); + }); + + it('should handle module imports', async () => { + const code = ` + import { createElement } from 'preact'; + export default createElement('div', null, 'Test'); + `; + + const result = await bundleCode(code, { server: false }); + + assert.ok(result.jsMap['entrypoint.jsx'], 'should have JS'); + // CSS handling depends on the cssLoader plugin + assert.strictEqual( + typeof result.css, + 'string', + 'should have CSS property' + ); + }); + + it('should apply CLIENT define flag', async () => { + const code = ` + if (CLIENT) { + console.log('client-side'); + } else { + console.log('server-side'); + } + `; + + const result = await bundleCode(code, { server: false }); + + assert.ok( + result.jsMap['entrypoint.jsx'].includes('client-side') || + result.jsMap['entrypoint.jsx'].includes('true'), + 'should process CLIENT flag' + ); + }); + + it('should minify client builds', async () => { + const code = ` + import { createElement } from 'preact'; + // This is a comment that should be removed + const veryLongVariableName = 'value'; + export default createElement('div', null, veryLongVariableName); + `; + + const result = await bundleCode(code, { server: false }); + + // Minified code should not contain comments and should be shorter + assert.ok( + !result.jsMap['entrypoint.jsx'].includes('This is a comment'), + 'should remove comments' + ); + }); + + it('should generate import map for multiple entries', async () => { + const codeMap = new Map([ + [ + 'page1.jsx', + `import { createElement } from 'preact'; export default createElement('div', null, 'Page 1');`, + ], + [ + 'page2.jsx', + `import { createElement } from 'preact'; export default createElement('div', null, 'Page 2');`, + ], + ]); + + const result = await bundleCode(codeMap, { server: false }); + + assert.ok( + result.importMapHtml.includes('importmap'), + 'should contain importmap script tag' + ); + // Import map should not contain virtual entries + assert.ok( + !result.importMapHtml.includes('_virtual_'), + 'should not include virtual entries' + ); + assert.ok( + !result.importMapHtml.includes('entrypoint'), + 'should not include entrypoint in importmap' + ); + }); + }); + + describe('code splitting', () => { + it('should support multiple entries in client builds', async () => { + const codeMap = new Map([ + [ + 'entry1.jsx', + ` + import { createElement } from 'preact'; + export default createElement('div', null, 'Entry 1'); + `, + ], + [ + 'entry2.jsx', + ` + import { createElement } from 'preact'; + export default createElement('div', null, 'Entry 2'); + `, + ], + ]); + + const result = await bundleCode(codeMap, { server: false }); + + assert.ok(result.jsMap['entry1.jsx'], 'should have entry1'); + assert.ok(result.jsMap['entry2.jsx'], 'should have entry2'); + // Code splitting behavior depends on rolldown configuration + assert.ok(Array.isArray(result.jsChunks), 'should have jsChunks array'); + }); + }); + + describe('virtual entries', () => { + it('should handle Map input correctly', async () => { + const codeMap = new Map([['test.jsx', `export default 'test';`]]); + + const result = await bundleCode(codeMap, { server: false }); + + assert.ok(result.jsMap['test.jsx'], 'should handle Map key'); + }); + + it('should handle string input correctly', async () => { + const code = `export default 'test';`; + + const result = await bundleCode(code, { server: true }); + + assert.ok(result.jsMap['entrypoint.jsx'], 'should create entrypoint.jsx'); + }); + }); +}); diff --git a/src/generators/web/utils/__tests__/processing.test.mjs b/src/generators/web/utils/__tests__/processing.test.mjs new file mode 100644 index 00000000..9e332fd2 --- /dev/null +++ b/src/generators/web/utils/__tests__/processing.test.mjs @@ -0,0 +1,97 @@ +'use strict'; + +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { describe, it } from 'node:test'; + +import { executeServerCode } from '../processing.mjs'; + +// Mock require function for testing +const mockRequire = createRequire(import.meta.url); + +describe('executeServerCode', () => { + it('should execute single server code and return dehydrated HTML', async () => { + const serverCodeMap = new Map([['test.jsx', `return '
Test
';`]]); + + const result = await executeServerCode(serverCodeMap, mockRequire); + + assert.ok(result instanceof Map, 'should return a Map'); + assert.strictEqual(result.size, 1, 'should have one entry'); + // The result might be undefined if bundling fails, but the Map should exist + assert.ok(result.has('test.jsx'), 'should have test.jsx key'); + }); + + it('should execute multiple server code entries', async () => { + const serverCodeMap = new Map([ + ['entry1.jsx', `return '
Entry 1
';`], + ['entry2.jsx', `return '
Entry 2
';`], + ]); + + const result = await executeServerCode(serverCodeMap, mockRequire); + + assert.strictEqual(result.size, 2, 'should have two entries'); + assert.ok(result.has('entry1.jsx'), 'should have entry1.jsx'); + assert.ok(result.has('entry2.jsx'), 'should have entry2.jsx'); + }); + + it('should provide require function to executed code', async () => { + // This code tries to use require + const serverCodeMap = new Map([ + [ + 'require-test.jsx', + ` + const preact = require('preact'); + return typeof preact !== 'undefined' ? 'success' : 'fail'; + `, + ], + ]); + + const result = await executeServerCode(serverCodeMap, mockRequire); + + const output = result.get('require-test.jsx'); + assert.ok(output, 'should have output'); + }); + + it('should handle server code that uses external packages', async () => { + const serverCodeMap = new Map([ + [ + 'external.jsx', + ` + const { render } = require('preact-render-to-string'); + const { createElement } = require('preact'); + const el = createElement('span', null, 'External'); + return render(el); + `, + ], + ]); + + const result = await executeServerCode(serverCodeMap, mockRequire); + + const html = result.get('external.jsx'); + assert.ok(html, 'should execute code with external deps'); + }); + + it('should isolate execution contexts between entries', async () => { + const serverCodeMap = new Map([ + ['context1.jsx', `const x = 'first'; return x;`], + ['context2.jsx', `const x = 'second'; return x;`], + ]); + + const result = await executeServerCode(serverCodeMap, mockRequire); + + assert.strictEqual(result.size, 2, 'should have two entries'); + assert.ok(result.has('context1.jsx'), 'should have context1.jsx'); + assert.ok(result.has('context2.jsx'), 'should have context2.jsx'); + // Each should have executed independently + const result1 = result.get('context1.jsx'); + const result2 = result.get('context2.jsx'); + // Results might be undefined if bundling produces empty code, but keys should exist + if (result1 !== undefined && result2 !== undefined) { + assert.notStrictEqual( + result1, + result2, + 'should have different outputs if both exist' + ); + } + }); +}); diff --git a/src/utils/__tests__/safeWrite.test.mjs b/src/utils/__tests__/safeWrite.test.mjs new file mode 100644 index 00000000..dcabcb8a --- /dev/null +++ b/src/utils/__tests__/safeWrite.test.mjs @@ -0,0 +1,146 @@ +'use strict'; + +import assert from 'node:assert'; +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { safeWrite } from '../safeWrite.mjs'; + +describe('safeWrite', () => { + const testDir = join(import.meta.dirname, 'test-safe-write'); + + beforeEach(async () => { + // Create test directory + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + await rm(testDir, { recursive: true, force: true }); + }); + + it('should write a new file and return true', async () => { + const filePath = join(testDir, 'new-file.txt'); + const content = 'Hello, World!'; + + const result = await safeWrite(filePath, content); + + assert.strictEqual(result, true); + const written = await readFile(filePath, 'utf-8'); + assert.strictEqual(written, content); + }); + + it('should skip writing when content size matches and return false', async () => { + const filePath = join(testDir, 'existing-file.txt'); + const content = 'Same content'; + + // Write file initially + await writeFile(filePath, content, 'utf-8'); + const initialStat = await stat(filePath); + + // Wait a bit to ensure mtime would be different if written + await new Promise(resolve => setTimeout(resolve, 10)); + + // Try to write same content + const result = await safeWrite(filePath, content); + + assert.strictEqual(result, false); + const finalStat = await stat(filePath); + // mtime should be the same since we skipped the write + assert.strictEqual( + initialStat.mtimeMs, + finalStat.mtimeMs, + 'File should not have been modified' + ); + }); + + it('should write when content size differs and return true', async () => { + const filePath = join(testDir, 'different-size.txt'); + const initialContent = 'Short'; + const newContent = 'Much longer content'; + + await writeFile(filePath, initialContent, 'utf-8'); + const initialStat = await stat(filePath); + + // Wait to ensure mtime would be different + await new Promise(resolve => setTimeout(resolve, 10)); + + const result = await safeWrite(filePath, newContent); + + assert.strictEqual(result, true); + const written = await readFile(filePath, 'utf-8'); + assert.strictEqual(written, newContent); + + const finalStat = await stat(filePath); + assert.notStrictEqual( + initialStat.mtimeMs, + finalStat.mtimeMs, + 'File should have been modified' + ); + }); + + it('should handle Buffer content', async () => { + const filePath = join(testDir, 'buffer-file.txt'); + const content = Buffer.from('Buffer content', 'utf-8'); + + const result = await safeWrite(filePath, content); + + assert.strictEqual(result, true); + const written = await readFile(filePath); + assert.deepStrictEqual(written, content); + }); + + it('should create parent directories if they do not exist', async () => { + const nestedPath = join(testDir, 'nested', 'dir', 'file.txt'); + const content = 'Nested file'; + + // Parent directories don't exist yet + await assert.rejects(async () => { + await stat(join(testDir, 'nested')); + }); + + // safeWrite should handle the write even though mkdir is not called + // (writeFile will fail if parent doesn't exist, so we expect this to throw) + await assert.rejects( + async () => await safeWrite(nestedPath, content), + /ENOENT/ + ); + + // Create parent dirs and try again + await mkdir(join(testDir, 'nested', 'dir'), { recursive: true }); + const result = await safeWrite(nestedPath, content); + + assert.strictEqual(result, true); + const written = await readFile(nestedPath, 'utf-8'); + assert.strictEqual(written, content); + }); + + it('should handle different encodings', async () => { + const filePath = join(testDir, 'encoded.txt'); + const content = 'Encoded content: éàü'; + + const result = await safeWrite(filePath, content, 'utf-8'); + + assert.strictEqual(result, true); + const written = await readFile(filePath, 'utf-8'); + assert.strictEqual(written, content); + }); + + it('should write when file exists but content is different (edge case)', async () => { + const filePath = join(testDir, 'edge-case.txt'); + // Create two strings with same byte length but different content + const content1 = 'abc'; + const content2 = 'xyz'; + + await writeFile(filePath, content1, 'utf-8'); + + // Since sizes match, safeWrite will skip (this is by design - a heuristic) + const result = await safeWrite(filePath, content2); + + assert.strictEqual(result, false); + const written = await readFile(filePath, 'utf-8'); + // Content remains as original since write was skipped + assert.strictEqual(written, content1); + }); +}); From 478bb619cd85a861ecf52596bd3b18281c250632 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 18:58:54 +0100 Subject: [PATCH 06/16] chore: server splitting (even more performance) --- src/generators/web/index.mjs | 34 +++--- src/generators/web/utils/bundle.mjs | 126 ++++++++--------------- src/generators/web/utils/chunkHelper.mjs | 44 ++++++++ src/generators/web/utils/generate.mjs | 57 +++++----- src/generators/web/utils/processing.mjs | 98 ++++++++---------- 5 files changed, 179 insertions(+), 180 deletions(-) create mode 100644 src/generators/web/utils/chunkHelper.mjs diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index ff797496..52b61f04 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -7,8 +7,12 @@ import { processJSXEntries } from './utils/processing.mjs'; import { safeWrite } from '../../utils/safeWrite.mjs'; /** - * This generator transforms JSX AST (Abstract Syntax Tree) entries into a complete - * web bundle, including server-side rendered HTML, client-side JavaScript, and CSS. + * Web generator - transforms JSX AST entries into complete web bundles. + * + * This generator processes JSX AST entries and produces: + * - Server-side rendered HTML pages + * - Client-side JavaScript with code splitting + * - Bundled CSS styles * * @type {GeneratorMetadata} */ @@ -19,25 +23,28 @@ export default { dependsOn: 'jsx-ast', /** - * The main generation function for the 'web' generator. - * It processes an array of JSX AST entries, converting each into a standalone HTML page - * with embedded client-side JavaScript and linked CSS. + * Main generation function that processes JSX AST entries into web bundles. * - * @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - * @param {Partial} options + * @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - JSX AST entries to process. + * @param {Partial} options - Generator options. + * @param {string} [options.output] - Output directory for generated files. + * @param {string} options.version - Documentation version string. + * @returns {Promise>} Generated HTML and CSS. */ async generate(entries, { output, version }) { - // Load the HTML template + // Load the HTML template with placeholders const template = await readFile( new URL('template.html', import.meta.url), 'utf-8' ); + // Create AST builders for server and client programs const astBuilders = createASTBuilder(); + // Create require function for resolving external packages in server code const requireFn = createRequire(import.meta.url); - // Process all entries at once + // Process all entries: convert JSX to HTML/CSS/JS const { results, css, jsChunks } = await processJSXEntries( entries, template, @@ -46,22 +53,23 @@ export default { { version } ); - // Write all files if output directory is specified + // Write files to disk if output directory is specified if (output) { - // Write all HTML files + // Write HTML files for (const { html, api } of results) { safeWrite(join(output, `${api}.html`), html, 'utf-8'); } - // Write all JS chunks + // Write code-split JavaScript chunks for (const chunk of jsChunks) { safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8'); } - // Write the CSS file + // Write CSS bundle safeWrite(join(output, 'styles.css'), css, 'utf-8'); } + // Return HTML and CSS for each entry return results.map(({ html }) => ({ html, css })); }, }; diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index 44b8bb76..7963d944 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -8,26 +8,24 @@ import staticData from './data.mjs'; * Asynchronously bundles JavaScript source code (and its CSS imports), * targeting either browser (client) or server (Node.js) environments. * - * @param {Map | string} codeOrMap - Map of {fileName: code} or single code string for server builds. - * @param {{ server: boolean }} options - Build configuration object. + * @param {Map} codeMap - Map of {fileName: code} for all builds. + * @param {Object} [options] - Build configuration object. + * @param {boolean} [options.server=false] - Whether this is a server-side build. */ -export default async function bundleCode(codeOrMap, { server = false } = {}) { +export default async function bundleCode(codeMap, { server = false } = {}) { // Store the import map HTML for later extraction let importMapHtml = ''; - // Convert input to Map format - const codeMap = - codeOrMap instanceof Map - ? codeOrMap - : new Map([['entrypoint.jsx', codeOrMap]]); - /** @type {import('rolldown').OutputOptions} */ const serverOutputConfig = { + // Inline all dynamic imports to create a single self-contained bundle inlineDynamicImports: true, }; /** @type {import('rolldown').InputOptions['experimental']} */ const clientExperimentalConfig = { + // Generate an import map for cache-busted module resolution in browsers + // https://rolldown.rs/options/experimental#chunkimportmap chunkImportMap: !server && { baseUrl: './', fileName: 'importmap.json', @@ -35,70 +33,51 @@ export default async function bundleCode(codeOrMap, { server = false } = {}) { }; const result = await build({ - // Define the entry point module names — these are virtual (not real files). - // The virtual plugin will provide the actual code string under these names. + // Entry points: array of virtual module names that the virtual plugin provides input: Array.from(codeMap.keys()), - // Enable experimental chunk import map for cache-busted module resolution - // https://rolldown.rs/options/experimental#chunkimportmap - // Also enable incremental builds for faster rebuilds (similar to Rollup's cache) - // https://rolldown.rs/options/experimental#incrementalbuild + // Experimental features: import maps for client, none for server experimental: server ? {} : clientExperimentalConfig, - // Configuration for the output bundle + // Output configuration output: { - // Output module format: - // - "cjs" for CommonJS (used in Node.js environments) - // - "esm" for browser with dynamic imports (allows code splitting) + // CommonJS for Node.js server, ESM for browser with code splitting support format: server ? 'cjs' : 'esm', - // Minify output only for browser builds to optimize file size. - // Server builds are usually not minified to preserve stack traces and debuggability. + // Minify only browser builds to reduce file size minify: !server, - // Enable code splitting for client builds to allow dynamic imports - // For server builds, inline everything into a single bundle + // Environment-specific output configuration ...(server ? serverOutputConfig : {}), }, - // Platform informs Rolldown of the environment-specific code behavior: - // - 'node' enables things like `require`, and skips polyfills. - // - 'browser' enables inlining of polyfills and uses native browser features. + // Target platform affects polyfills, globals, and bundling behavior platform: server ? 'node' : 'browser', - // External dependencies to exclude from bundling. - // These are expected to be available at runtime in the server environment. - // This reduces bundle size and avoids bundling shared server libs. + // External dependencies (not bundled) for server builds + // These must be available in the Node.js runtime environment external: server ? ['preact', 'preact-render-to-string', '@node-core/ui-components'] : [], - // Transform configuration + // Transform and define configuration transform: { - // Inject global compile-time constants that will be replaced in code. - // These are useful for tree-shaking and conditional branching. - // Be sure to update type declarations (`types.d.ts`) if these change. + // Compile-time constants replaced during bundling + // Update types.d.ts if these change define: { - // Static data injected directly into the bundle (as a literal or serialized JSON). + // Static data as a JSON literal __STATIC_DATA__: staticData, - // Boolean flags used for conditional logic in source code: - // Example: `if (SERVER) {...}` or `if (CLIENT) {...}` - // These flags help split logic for server/client environments. - // Unused branches will be removed via tree-shaking. + // Environment flags for conditional logic and tree-shaking SERVER: String(server), CLIENT: String(!server), }, - // JSX transformation configuration. - // `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`. - // Since we're using Preact via aliasing, this setting works well with `preact/compat`. + // Use automatic JSX runtime (no need to import React/Preact) jsx: 'react-jsx', }, - // Module resolution aliases. - // This tells the bundler to use `preact/compat` wherever `react` or `react-dom` is imported. - // Allows you to write React-style code but ship much smaller Preact bundles. + // Module resolution: alias React imports to Preact resolve: { alias: { react: 'preact/compat', @@ -106,87 +85,68 @@ export default async function bundleCode(codeOrMap, { server = false } = {}) { }, }, - // Array of plugins to apply during the build. + // Build plugins plugins: [ - // The virtual plugin lets us define virtual files - // with the contents provided by the `codeMap` argument. - // These become the root modules for the bundler. + // Virtual plugin: provides in-memory modules from codeMap virtual(Object.fromEntries(codeMap)), - // Load CSS imports via the custom plugin. - // This plugin will collect imported CSS files and return them as `source` chunks. + // CSS loader: collects and bundles imported CSS files cssLoader(), - // Extract import map from Rolldown's chunkImportMap output - // https://rolldown.rs/options/experimental#chunkimportmap + // Extract and transform the import map generated by Rolldown { name: 'extract-import-map', /** - * Extract import map from bundle - * @param {*} _ - Options (unused) - * @param {*} bundle - Bundle object + * Extracts import map from bundle and converts to HTML script tag. + * + * @param {import('rolldown').NormalizedOutputOptions} _ - Output options (unused). + * @param {import('rolldown').OutputBundle} bundle - Bundle object containing all output chunks. */ generateBundle(_, bundle) { const chunkImportMap = bundle['importmap.json']; if (chunkImportMap?.type === 'asset') { - // Parse the import map and filter out virtual entries - const importMapData = JSON.parse(chunkImportMap.source); - - // Remove any references to _virtual_ or virtual entrypoint files - if (importMapData.imports) { - for (const key in importMapData.imports) { - if (key.includes('_virtual_') || key.includes('entrypoint')) { - delete importMapData.imports[key]; - } - } - } - - // Extract the import map and convert to HTML script tag - importMapHtml = ``; - - // Remove from bundle so it's not written as a separate file + // Convert to HTML script tag for inline inclusion + importMapHtml = ``; + + // Remove from bundle to prevent writing as separate file delete bundle['importmap.json']; } }, }, ], - // Enable tree-shaking to eliminate unused imports, functions, and branches. - // This works best when all dependencies are marked as having no side effects. - // `sideEffects: false` in the package.json confirms this is safe to do. + // Enable tree-shaking to remove unused code treeshake: true, - // Disable writing output to disk. - // Instead, the compiled chunks are returned in memory (ideal for dev tools or sandboxing). + // Return chunks in memory instead of writing to disk write: false, }); - // Destructure the result to get the output chunks. - // Separate entry chunks from other chunks (CSS and code-split JS) + // Separate entry chunks (main modules) from other chunks (CSS, code-split JS) const entryChunks = []; const otherChunks = []; - // Separate the entrypoints from remaining JavaScript files for (const chunk of result.output) { const chunkTarget = chunk.type === 'chunk' && chunk.isEntry ? entryChunks : otherChunks; - chunkTarget['push'](chunk); + chunkTarget.push(chunk); } - // Separate CSS files from JS chunks + // Separate CSS assets from JavaScript chunks const cssFiles = otherChunks.filter(chunk => chunk.type === 'asset'); const jsChunks = otherChunks.filter(chunk => chunk.type === 'chunk'); - // For client builds, create a map of entry code by original fileName + // Create a map of entry code by original fileName const jsMap = {}; for (const chunk of entryChunks) { - // Map back to original fileName from facadeModuleId + // Extract original fileName from virtual module ID const originalFileName = chunk.facadeModuleId?.split('/').pop() || chunk.fileName; + // Remove virtual: prefix from module IDs jsMap[originalFileName.replace('\x00virtual:', '')] = chunk.code; } diff --git a/src/generators/web/utils/chunkHelper.mjs b/src/generators/web/utils/chunkHelper.mjs new file mode 100644 index 00000000..b3950b41 --- /dev/null +++ b/src/generators/web/utils/chunkHelper.mjs @@ -0,0 +1,44 @@ +/** + * Creates an enhanced require function that can resolve code-split chunks + * from a virtual file system before falling back to Node.js require. + * + * @param {Array<{fileName: string, code: string}>} jsChunks - Array of code-split chunks from bundler. + * @param {ReturnType} requireFn - Node.js require function for external packages. + */ +export function createEnhancedRequire(jsChunks, requireFn) { + // Create a virtual file system from code-split chunks + const chunkModules = Object.fromEntries( + jsChunks.map(c => [`./${c.fileName}`, c.code]) + ); + + /** + * Enhanced require function that resolves code-split chunks from virtual file system. + * + * @param {string} modulePath - Module path to require. + * @returns {*} Module exports. + */ + const enhancedRequire = modulePath => { + // Check virtual file system first for code-split chunks + if (chunkModules[modulePath]) { + const moduleExports = {}; + const module = { exports: moduleExports }; + + // Execute chunk code in isolated context with its own module.exports + const chunkFn = new Function( + 'module', + 'exports', + 'require', + chunkModules[modulePath] + ); + + chunkFn(module, moduleExports, enhancedRequire); + + return module.exports; + } + + // Fall back to Node.js require for external packages + return requireFn(modulePath); + }; + + return enhancedRequire; +} diff --git a/src/generators/web/utils/generate.mjs b/src/generators/web/utils/generate.mjs index d382f9c9..c40e2b74 100644 --- a/src/generators/web/utils/generate.mjs +++ b/src/generators/web/utils/generate.mjs @@ -5,44 +5,43 @@ import { JSX_IMPORTS, ROOT } from '../constants.mjs'; /** * Creates an ES Module `import` statement as a string, based on parameters. * - * @param {string|null} importName - The identifier to import - * @param {string} source - The module path - * @param {boolean} [useDefault=true] - Determines if the import is a default or named import. + * @param {string|null} importName - The identifier to import. + * @param {string} source - The module path. + * @param {boolean} [useDefault=true] - Whether to use default import (true) or named import (false). + * @returns {string} The generated import statement. */ export const createImportDeclaration = ( importName, source, useDefault = true ) => { - // '\' characters shouldn't escape the next character, - // but rather be treated as slashes. + // Escape backslashes to prevent treating them as escape characters source = source.replaceAll('\\', '\\\\'); - // Side-effect-only import (CSS) + // Side-effect-only import (e.g., CSS files) if (!importName) { return `import "${source}";`; } - // Import default export + // Default import: import Name from "source" if (useDefault) { return `import ${importName} from "${source}";`; } - // Import named export + // Named import: import { Name } from "source" return `import { ${importName} } from "${source}";`; }; /** - * Factory function that returns two program generators: - * - One for hydrating client-side React/Preact apps - * - One for server-side rendering (SSR) to HTML + * Factory function that creates server and client program generators. + * + * Returns two functions that wrap JSX component code: + * - `buildClientProgram`: Wraps component for client-side hydration + * - `buildServerProgram`: Wraps component for server-side rendering */ export default () => { - // Construct a list of `import` statements from JSX_IMPORTS - // - // TODO(@avivkeller): A known optimization opportunity exists: - // Some of these imports are only needed on the server (or only client). - // It would be more efficient to generate them conditionally. + // Generate import statements for all JSX components + // TODO: Optimize by conditionally including server-only or client-only imports const baseImports = Object.values(JSX_IMPORTS).map( ({ name, source, isDefaultExport = true }) => createImportDeclaration(name, source, isDefaultExport) @@ -50,24 +49,22 @@ export default () => { /** * Builds a client-side hydration program. - * @param {string} componentCode - Code expression representing a JSX component + * + * @param {string} componentCode - JSX component code expression. + * @returns {string} Complete client-side JavaScript program. */ const buildClientProgram = componentCode => { return [ - // JSX component imports + // Import all JSX components ...baseImports, - // Import client-side CSS styles. - // This ensures that styles used in the rendered app are loaded on the client. - // The use of `new URL(...).pathname` resolves the absolute path for `entrypoint.jsx`. + // Import CSS styles for client-side rendering createImportDeclaration(null, resolve(ROOT, './ui/index.css')), - // Import `hydrate()` from Preact — needed to attach to server-rendered HTML. - // This is a named import (not default), hence `false` as the third argument. + // Import Preact's hydrate function (named import) createImportDeclaration('hydrate', 'preact', false), - // Hydration call: binds the component to an element with ID "root" - // This assumes SSR has placed matching HTML there, which, it has. + // Hydrate the component into the root element `hydrate(${componentCode}, document.getElementById("root"));`, ].join(''); }; @@ -75,18 +72,18 @@ export default () => { /** * Builds a server-side rendering (SSR) program. * - * @param {string} componentCode - Code expression representing a JSX component + * @param {string} componentCode - JSX component code expression. + * @returns {string} Complete server-side JavaScript program. */ const buildServerProgram = componentCode => { return [ - // JSX component imports + // Import all JSX components ...baseImports, - // Import Preact's SSR module + // Import Preact's SSR render function (named import) createImportDeclaration('render', 'preact-render-to-string', false), - // Render the component to an HTML string - // The output can be embedded directly into the server's HTML template + // Render component to HTML string and return it `return render(${componentCode});`, ].join('\n'); }; diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 05cfb2c2..c20a53c1 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -1,52 +1,57 @@ import HTMLMinifier from '@minify-html/node'; -import { toJs, jsx } from 'estree-util-to-js'; +import { jsx, toJs } from 'estree-util-to-js'; import bundleCode from './bundle.mjs'; +import { createEnhancedRequire } from './chunkHelper.mjs'; /** - * Executes server-side JavaScript code in a safe, isolated context. - * This function takes a Map of JavaScript code strings, bundles them together, and then runs each - * within a new Function constructor to prevent scope pollution and allow for - * dynamic module loading via a provided `require` function. - * The result of the server-side execution is expected to be assigned to a - * dynamically generated variable name, which is then returned. + * Executes server-side JavaScript code in an isolated context with virtual module support. + * + * Takes a map of server-side JavaScript code, bundles it (which may produce code-split chunks), + * and executes each entry within a new Function constructor. Code-split chunks are made + * available via an enhanced require function that loads them from an in-memory virtual file system. * * @param {Map} serverCodeMap - Map of fileName to server-side JavaScript code. - * @param {ReturnType} requireFn - A Node.js `require` function - * @returns {Promise>} Map of fileName to dehydrated HTML content + * @param {ReturnType} requireFn - Node.js require function for external packages. + * @returns {Promise>} Map of fileName to dehydrated (server-rendered) HTML content. */ export async function executeServerCode(serverCodeMap, requireFn) { - // Execute each bundled server code and collect results const dehydratedMap = new Map(); - for (const [fileName, serverCode] of serverCodeMap.entries()) { - // Bundle all server-side code together. This step resolves imports and prepares the code - // for execution, ensuring all necessary dependencies are self-contained. - const { jsMap } = await bundleCode(serverCode, { server: true }); + // Bundle all server-side code, which may produce code-split chunks + const { jsMap, jsChunks } = await bundleCode(serverCodeMap, { server: true }); + + // Create enhanced require function that can resolve code-split chunks + const enhancedRequire = createEnhancedRequire(jsChunks, requireFn); - // Create a new Function from the bundled server code. - // The `require` argument is passed into the function's scope, allowing the - // `bundledServer` code to use it for dynamic imports. - const executedFunction = new Function('require', jsMap['entrypoint.jsx']); + // Execute each bundled entry and collect dehydrated HTML results + for (const [fileName, serverCode] of Object.entries(jsMap)) { + // Create and execute function with enhanced require for chunk resolution + const executedFunction = new Function('require', serverCode); - // Execute the dynamically created function with the provided `requireFn`. - // The result of this execution is the dehydrated content from the server-side rendering. - dehydratedMap.set(fileName, executedFunction(requireFn)); + // Execute the function - result is the dehydrated HTML from server-side rendering + dehydratedMap.set(fileName, executedFunction(enhancedRequire)); } return dehydratedMap; } /** - * Processes multiple JSX AST (Abstract Syntax Tree) entries to generate complete - * HTML pages, including server-side rendered content, client-side JavaScript, and CSS. + * Processes multiple JSX AST entries to generate complete HTML pages with SSR content, + * client-side JavaScript bundles (with code splitting), and CSS. + * + * This function: + * 1. Converts JSX AST to JavaScript code for both server and client + * 2. Executes server code to get dehydrated (server-rendered) HTML + * 3. Bundles client code with code splitting and import maps + * 4. Injects everything into HTML template and minifies * - * @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - The JSX AST entries to process. - * @param {string} template - The HTML template string that serves as the base for the output page. - * @param {ReturnType} astBuilders - The AST generators - * @param {Object} options - Processing options - * @param {string} options.version - The version to generate the documentation for - * @param {ReturnType} requireFn - A Node.js `require` function. + * @param {import('../../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - JSX AST entries to process. + * @param {string} template - HTML template string with placeholders: {{title}}, {{dehydrated}}, {{importMap}}, {{mainJsCode}}. + * @param {ReturnType} astBuilders - AST generator functions (buildServerProgram, buildClientProgram). + * @param {ReturnType} requireFn - Node.js require function. + * @param {Object} options - Processing options. + * @param {string} options.version - Documentation version string. */ export async function processJSXEntries( entries, @@ -55,43 +60,30 @@ export async function processJSXEntries( requireFn, { version } ) { - // Convert all entries to JavaScript code const serverCodeMap = new Map(); const clientCodeMap = new Map(); + // Convert JSX AST to JavaScript for both server and client for (const entry of entries) { const fileName = `${entry.data.api}.jsx`; - // `estree-util-to-js` with the `jsx` handler converts the AST nodes into a string - // that represents the equivalent JavaScript code, including JSX syntax. + // Convert AST to JavaScript string with JSX syntax const { value: code } = toJs(entry, { handlers: jsx }); - // `buildServerProgram` takes the JSX-derived code and prepares it for server execution. + // Prepare code for server-side execution (wrapped for SSR) serverCodeMap.set(fileName, buildServerProgram(code)); - // `buildClientProgram` prepares the JSX-derived code for client-side execution. + // Prepare code for client-side execution (wrapped for hydration) clientCodeMap.set(fileName, buildClientProgram(code)); } - // Execute all server code at once + // Execute all server code at once to get dehydrated HTML const dehydratedMap = await executeServerCode(serverCodeMap, requireFn); - // Bundle all client code at once + // Bundle all client code at once (with code splitting for shared chunks) const clientBundle = await bundleCode(clientCodeMap); - // Rolldown's experimental.chunkImportMap generates the import map automatically - // The import map is extracted by our plugin and returned as HTML - // https://rolldown.rs/options/experimental#chunkimportmap - const importMapScript = clientBundle.importMapHtml; - - // Prepare jsChunks for file writing - const chunksWithHashes = clientBundle.jsChunks.map(chunk => ({ - fileName: chunk.fileName, - code: chunk.code, - hash: '', // No need for manual hashing, Rolldown handles cache busting via importMap - })); - - // Process each entry to create HTML + // Process each entry to create final HTML const results = entries.map(entry => { const fileName = `${entry.data.api}.jsx`; const dehydrated = dehydratedMap.get(fileName); @@ -103,20 +95,18 @@ export async function processJSXEntries( const renderedHtml = template .replace('{{title}}', title) .replace('{{dehydrated}}', dehydrated ?? '') - .replace('{{importMap}}', importMapScript) + .replace('{{importMap}}', clientBundle.importMapHtml) .replace('{{mainJsCode}}', () => mainJsCode); - // The input to `minify` must be a Buffer. + // Minify HTML (input must be a Buffer) const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); return { html: finalHTMLBuffer, api: entry.data.api }; }); - // Return the generated HTML, CSS, and any JS chunks from code splitting - // Note: main JS is inlined in HTML, so we don't return it separately return { results, css: clientBundle.css ?? '', - jsChunks: chunksWithHashes, + jsChunks: clientBundle.jsChunks, }; } From 1b097af479a3d7f735309f0f8b09b7dfe653c1b4 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 19:03:49 +0100 Subject: [PATCH 07/16] chore: updated tests --- src/generators/web/index.mjs | 6 +- src/generators/web/ui/components/CodeBox.jsx | 20 +- .../web/utils/__tests__/css.test.mjs | 245 +++++++++++ .../web/utils/__tests__/generate.test.mjs | 124 ++++++ src/threading/__tests__/index.test.mjs | 249 +++++++++++ src/utils/__tests__/highlighter.test.mjs | 391 ++++++++++++++++++ 6 files changed, 1021 insertions(+), 14 deletions(-) create mode 100644 src/generators/web/utils/__tests__/css.test.mjs create mode 100644 src/generators/web/utils/__tests__/generate.test.mjs create mode 100644 src/threading/__tests__/index.test.mjs create mode 100644 src/utils/__tests__/highlighter.test.mjs diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 52b61f04..a8bd0150 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -57,16 +57,16 @@ export default { if (output) { // Write HTML files for (const { html, api } of results) { - safeWrite(join(output, `${api}.html`), html, 'utf-8'); + await safeWrite(join(output, `${api}.html`), html, 'utf-8'); } // Write code-split JavaScript chunks for (const chunk of jsChunks) { - safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8'); + await safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8'); } // Write CSS bundle - safeWrite(join(output, 'styles.css'), css, 'utf-8'); + await safeWrite(join(output, 'styles.css'), css, 'utf-8'); } // Return HTML and CSS for each entry diff --git a/src/generators/web/ui/components/CodeBox.jsx b/src/generators/web/ui/components/CodeBox.jsx index 53609965..361d8742 100644 --- a/src/generators/web/ui/components/CodeBox.jsx +++ b/src/generators/web/ui/components/CodeBox.jsx @@ -42,16 +42,14 @@ export default ({ className, children, ...props }) => { }; return ( - <> - - {children} - - + + {children} + ); }; diff --git a/src/generators/web/utils/__tests__/css.test.mjs b/src/generators/web/utils/__tests__/css.test.mjs new file mode 100644 index 00000000..05fba19f --- /dev/null +++ b/src/generators/web/utils/__tests__/css.test.mjs @@ -0,0 +1,245 @@ +import { strict as assert } from 'node:assert'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; + +import cssLoader from '../css.mjs'; + +// Create a temporary directory for test files +const testDir = join(tmpdir(), `css-test-${Date.now()}`); + +before(async () => { + await mkdir(testDir, { recursive: true }); +}); + +after(async () => { + await rm(testDir, { recursive: true, force: true }); +}); + +test('cssLoader - plugin has required properties', () => { + const plugin = cssLoader(); + + assert.equal(plugin.name, 'css-loader'); + assert.ok(plugin.load); + assert.ok(plugin.load.handler); + assert.ok(plugin.load.filter); + assert.ok(plugin.buildEnd); +}); + +test('cssLoader - filter matches .module.css files', () => { + const plugin = cssLoader(); + const { filter } = plugin.load; + + assert.ok(filter.id.include.test('/path/to/styles.module.css')); + assert.ok(filter.id.include.test('component.module.css')); + assert.ok(!filter.id.include.test('styles.css')); + assert.ok(!filter.id.include.test('styles.module.scss')); +}); + +test('cssLoader - processes CSS module file', async () => { + const plugin = cssLoader(); + const testFile = join(testDir, 'test.module.css'); + + // Write a simple CSS module file + await writeFile( + testFile, + `.button { + color: blue; + background: white; +} +.container { + padding: 10px; +}` + ); + + const result = await plugin.load.handler(testFile); + + // Should return JS module with exports + assert.ok(result.code); + assert.equal(result.moduleType, 'js'); + + // Parse the exported default object + const exportMatch = result.code.match(/export default (.+);/); + assert.ok(exportMatch, 'Should export an object'); + + const exports = JSON.parse(exportMatch[1]); + assert.ok(exports.button, 'Should have button class'); + assert.ok(exports.container, 'Should have container class'); + + // Scoped names should not be the original names + assert.notEqual(exports.button, 'button'); + assert.notEqual(exports.container, 'container'); +}); + +test('cssLoader - caches processed files', async () => { + const plugin = cssLoader(); + const testFile = join(testDir, 'cached.module.css'); + + await writeFile(testFile, '.test { color: red; }'); + + // First call + const result1 = await plugin.load.handler(testFile); + + // Second call (should hit cache) + const result2 = await plugin.load.handler(testFile); + + // Both results should be identical + assert.equal(result1.code, result2.code); + assert.equal(result1.moduleType, result2.moduleType); +}); + +test('cssLoader - collects CSS chunks', async () => { + const plugin = cssLoader(); + const testFile1 = join(testDir, 'chunk1.module.css'); + const testFile2 = join(testDir, 'chunk2.module.css'); + + await writeFile(testFile1, '.class1 { color: red; }'); + await writeFile(testFile2, '.class2 { color: blue; }'); + + // Process both files + await plugin.load.handler(testFile1); + await plugin.load.handler(testFile2); + + // Mock emitFile to capture output + const emittedFiles = []; + const mockContext = { + emitFile(file) { + emittedFiles.push(file); + }, + }; + + // Call buildEnd with mock context + plugin.buildEnd.call(mockContext); + + // Should emit one CSS file + assert.equal(emittedFiles.length, 1); + assert.equal(emittedFiles[0].type, 'asset'); + assert.equal(emittedFiles[0].name, 'styles.css'); + + // CSS should contain both classes (with scoped names) + const cssContent = emittedFiles[0].source; + assert.ok(typeof cssContent === 'string'); + assert.ok(cssContent.length > 0); +}); + +test('cssLoader - handles multiple CSS modules with different class names', async () => { + const plugin = cssLoader(); + const testFile1 = join(testDir, 'buttons.module.css'); + const testFile2 = join(testDir, 'layout.module.css'); + + await writeFile( + testFile1, + `.primary { background: blue; } +.secondary { background: gray; }` + ); + + await writeFile( + testFile2, + `.header { height: 60px; } +.footer { height: 40px; }` + ); + + const result1 = await plugin.load.handler(testFile1); + const result2 = await plugin.load.handler(testFile2); + + // Parse exports from both modules + const exports1 = JSON.parse(result1.code.match(/export default (.+);/)[1]); + const exports2 = JSON.parse(result2.code.match(/export default (.+);/)[1]); + + // Each should have their own classes + assert.ok(exports1.primary); + assert.ok(exports1.secondary); + assert.ok(exports2.header); + assert.ok(exports2.footer); + + // Classes from different files should not overlap + assert.ok(!exports1.header); + assert.ok(!exports2.primary); +}); + +test('cssLoader - buildEnd skips emitting when no CSS processed', () => { + const plugin = cssLoader(); + + const emittedFiles = []; + const mockContext = { + emitFile(file) { + emittedFiles.push(file); + }, + }; + + // Call buildEnd without processing any CSS files + plugin.buildEnd.call(mockContext); + + // Should not emit anything + assert.equal(emittedFiles.length, 0); +}); + +test('cssLoader - handles CSS with pseudo-selectors', async () => { + const plugin = cssLoader(); + const testFile = join(testDir, 'pseudo.module.css'); + + await writeFile( + testFile, + `.button:hover { + color: red; +} +.button:active { + color: darkred; +}` + ); + + const result = await plugin.load.handler(testFile); + const exports = JSON.parse(result.code.match(/export default (.+);/)[1]); + + assert.ok(exports.button); +}); + +test('cssLoader - processes CSS with multiple classes on same element', async () => { + const plugin = cssLoader(); + const testFile = join(testDir, 'multi.module.css'); + + await writeFile( + testFile, + `.btn { padding: 10px; } +.btnPrimary { background: blue; } +.btnSecondary { background: gray; }` + ); + + const result = await plugin.load.handler(testFile); + const exports = JSON.parse(result.code.match(/export default (.+);/)[1]); + + assert.ok(exports.btn); + assert.ok(exports.btnPrimary); + assert.ok(exports.btnSecondary); +}); + +test('cssLoader - separate plugin instances have separate caches', async () => { + const plugin1 = cssLoader(); + const plugin2 = cssLoader(); + + const testFile = join(testDir, 'separate.module.css'); + await writeFile(testFile, '.test { color: green; }'); + + await plugin1.load.handler(testFile); + + // Mock emitFile for both plugins + const emitted1 = []; + const emitted2 = []; + + plugin1.buildEnd.call({ + emitFile(file) { + emitted1.push(file); + }, + }); + + plugin2.buildEnd.call({ + emitFile(file) { + emitted2.push(file); + }, + }); + + // Plugin 1 should emit CSS, plugin 2 should not (separate chunk tracking) + assert.equal(emitted1.length, 1); + assert.equal(emitted2.length, 0); +}); diff --git a/src/generators/web/utils/__tests__/generate.test.mjs b/src/generators/web/utils/__tests__/generate.test.mjs new file mode 100644 index 00000000..2a73b521 --- /dev/null +++ b/src/generators/web/utils/__tests__/generate.test.mjs @@ -0,0 +1,124 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; + +import createProgramGenerators, { + createImportDeclaration, +} from '../generate.mjs'; + +test('createImportDeclaration - side-effect-only import (no name)', () => { + const result = createImportDeclaration(null, './styles.css'); + assert.equal(result, 'import "./styles.css";'); +}); + +test('createImportDeclaration - default import', () => { + const result = createImportDeclaration('React', 'react', true); + assert.equal(result, 'import React from "react";'); +}); + +test('createImportDeclaration - default import with useDefault=true (default)', () => { + const result = createImportDeclaration('Component', './component.jsx'); + assert.equal(result, 'import Component from "./component.jsx";'); +}); + +test('createImportDeclaration - named import', () => { + const result = createImportDeclaration('useState', 'react', false); + assert.equal(result, 'import { useState } from "react";'); +}); + +test('createImportDeclaration - handles backslashes in path', () => { + const result = createImportDeclaration('Comp', 'C:\\path\\to\\file.jsx'); + assert.equal(result, 'import Comp from "C:\\\\path\\\\to\\\\file.jsx";'); +}); + +test('createImportDeclaration - handles backslashes in side-effect import', () => { + const result = createImportDeclaration(null, 'D:\\styles\\main.css'); + assert.equal(result, 'import "D:\\\\styles\\\\main.css";'); +}); + +test('buildClientProgram - generates hydration code', () => { + const { buildClientProgram } = createProgramGenerators(); + const componentCode = ''; + const result = buildClientProgram(componentCode); + + // Check for key imports and hydration call + assert.match(result, /import.*from "preact"/); + assert.match(result, /import { hydrate } from "preact"/); + assert.match( + result, + /hydrate\(, document\.getElementById\("root"\)\);/ + ); + assert.match(result, /import ".*\/ui\/index\.css"/); +}); + +test('buildClientProgram - includes JSX component imports', () => { + const { buildClientProgram } = createProgramGenerators(); + const result = buildClientProgram(''); + + // Should include imports from JSX_IMPORTS + assert.match(result, /import NavBar from/); + assert.match(result, /import SideBar from/); + assert.match(result, /import CodeBox from/); +}); + +test('buildClientProgram - includes named imports for MDX components', () => { + const { buildClientProgram } = createProgramGenerators(); + const result = buildClientProgram(''); + + // Should include named imports with curly braces + assert.match(result, /import { MDXTooltip } from/); +}); + +test('buildServerProgram - generates SSR code', () => { + const { buildServerProgram } = createProgramGenerators(); + const componentCode = ''; + const result = buildServerProgram(componentCode); + + // Check for SSR imports and render call + assert.match(result, /import { render } from "preact-render-to-string"/); + assert.match(result, /return render\(\);/); +}); + +test('buildServerProgram - includes JSX component imports', () => { + const { buildServerProgram } = createProgramGenerators(); + const result = buildServerProgram(''); + + // Should include imports from JSX_IMPORTS + assert.match(result, /import NavBar from/); + assert.match(result, /import MetaBar from/); + assert.match(result, /import CodeTabs from/); +}); + +test('buildServerProgram - uses newlines for readability', () => { + const { buildServerProgram } = createProgramGenerators(); + const result = buildServerProgram(''); + + // Server program should have newline separators + assert.match(result, /\n/); + const lines = result.split('\n'); + assert.ok(lines.length > 3, 'Should have multiple lines'); +}); + +test('buildClientProgram - concatenates without newlines', () => { + const { buildClientProgram } = createProgramGenerators(); + const result = buildClientProgram(''); + + // Client program concatenates all imports together + // Check that imports appear immediately one after another + assert.ok(!result.includes('\n'), 'Should not contain newlines'); +}); + +test('buildServerProgram - returns value from render', () => { + const { buildServerProgram } = createProgramGenerators(); + const result = buildServerProgram(''); + + // Should return the result of render() + assert.match(result, /return render\(\);/); +}); + +test('createProgramGenerators - returns both generator functions', () => { + const generators = createProgramGenerators(); + + assert.ok(typeof generators === 'object'); + assert.ok(typeof generators.buildClientProgram === 'function'); + assert.ok(typeof generators.buildServerProgram === 'function'); +}); diff --git a/src/threading/__tests__/index.test.mjs b/src/threading/__tests__/index.test.mjs new file mode 100644 index 00000000..c5846b38 --- /dev/null +++ b/src/threading/__tests__/index.test.mjs @@ -0,0 +1,249 @@ +import { strict as assert } from 'node:assert'; +import { after, test } from 'node:test'; + +import WorkerPool from '../index.mjs'; + +// Keep track of pools for cleanup +const pools = []; + +after(() => { + // Allow time for any pending workers to complete + return new Promise(resolve => setTimeout(resolve, 100)); +}); + +test('WorkerPool - constructor initializes properties', () => { + const pool = new WorkerPool(); + pools.push(pool); + + assert.ok(pool.sharedBuffer instanceof SharedArrayBuffer); + assert.ok(pool.activeThreads instanceof Int32Array); + assert.ok(Array.isArray(pool.queue)); + assert.equal(pool.queue.length, 0); +}); + +test('WorkerPool - getActiveThreadCount returns initial zero', () => { + const pool = new WorkerPool(); + pools.push(pool); + + assert.equal(pool.getActiveThreadCount(), 0); +}); + +test('WorkerPool - changeActiveThreadCount increments count', () => { + const pool = new WorkerPool(); + pools.push(pool); + + pool.changeActiveThreadCount(1); + assert.equal(pool.getActiveThreadCount(), 1); + + pool.changeActiveThreadCount(2); + assert.equal(pool.getActiveThreadCount(), 3); +}); + +test('WorkerPool - changeActiveThreadCount decrements count', () => { + const pool = new WorkerPool(); + pools.push(pool); + + pool.changeActiveThreadCount(5); + assert.equal(pool.getActiveThreadCount(), 5); + + pool.changeActiveThreadCount(-2); + assert.equal(pool.getActiveThreadCount(), 3); +}); + +test('WorkerPool - changeActiveThreadCount is atomic', () => { + const pool = new WorkerPool(); + pools.push(pool); + + // Simulate concurrent operations + pool.changeActiveThreadCount(1); + pool.changeActiveThreadCount(1); + pool.changeActiveThreadCount(-1); + + assert.equal(pool.getActiveThreadCount(), 1); +}); + +test('WorkerPool - processQueue does nothing when queue is empty', () => { + const pool = new WorkerPool(); + pools.push(pool); + + // Should not throw + assert.doesNotThrow(() => pool.processQueue(2)); + assert.equal(pool.queue.length, 0); +}); + +test('WorkerPool - processQueue does nothing when thread limit reached', () => { + const pool = new WorkerPool(); + pools.push(pool); + + let executed = false; + pool.queue.push(() => { + executed = true; + }); + + // Set active threads to max + pool.changeActiveThreadCount(2); + + pool.processQueue(2); + + // Should not execute queued task + assert.equal(executed, false); + assert.equal(pool.queue.length, 1); +}); + +test('WorkerPool - processQueue executes next task when capacity available', () => { + const pool = new WorkerPool(); + pools.push(pool); + + let executed = false; + pool.queue.push(() => { + executed = true; + }); + + pool.changeActiveThreadCount(1); + pool.processQueue(2); + + // Should execute queued task + assert.equal(executed, true); + assert.equal(pool.queue.length, 0); +}); + +test('WorkerPool - processQueue shifts only one task from queue', () => { + const pool = new WorkerPool(); + pools.push(pool); + + let count = 0; + pool.queue.push(() => count++); + pool.queue.push(() => count++); + pool.queue.push(() => count++); + + pool.processQueue(10); + + // Should only process one task + assert.equal(count, 1); + assert.equal(pool.queue.length, 2); +}); + +test('WorkerPool - run executes immediately when under thread limit', async () => { + const pool = new WorkerPool(); + pools.push(pool); + + // This will run immediately since we're under the limit + const promise = pool.run('web', {}, 2, {}); + + // Thread count should increment + assert.equal(pool.getActiveThreadCount(), 1); + + // Clean up - we expect this to fail since we don't have real generator setup + await assert.rejects(promise); +}); + +test('WorkerPool - run queues task when at thread limit', async () => { + const pool = new WorkerPool(); + pools.push(pool); + + // Set to max threads + pool.changeActiveThreadCount(2); + + const promise = pool.run('web', {}, 2, {}); + + // Should be queued, not executed + assert.equal(pool.queue.length, 1); + assert.equal(pool.getActiveThreadCount(), 2); + + // Free up a thread to allow queue processing + pool.changeActiveThreadCount(-1); + pool.processQueue(2); + + // Now it should be running + assert.equal(pool.getActiveThreadCount(), 2); + assert.equal(pool.queue.length, 0); + + // Clean up + await assert.rejects(promise); +}); + +test('WorkerPool - multiple run calls respect thread limit', () => { + const pool = new WorkerPool(); + pools.push(pool); + + const threads = 2; + + // Start 3 tasks with limit of 2 + const p1 = pool.run('web', {}, threads, {}); + const p2 = pool.run('web', {}, threads, {}); + const p3 = pool.run('web', {}, threads, {}); + + // First two should start, third should queue + assert.equal(pool.getActiveThreadCount(), 2); + assert.equal(pool.queue.length, 1); + + // Clean up - all will reject + return Promise.allSettled([p1, p2, p3]); +}); + +test('WorkerPool - run decrements count on worker error', async () => { + const pool = new WorkerPool(); + pools.push(pool); + + const promise = pool.run('nonexistent-generator', {}, 2, {}); + + // Thread count should increment initially + assert.equal(pool.getActiveThreadCount(), 1); + + // Wait for error + await assert.rejects(promise); + + // Thread count should decrement after error + assert.equal(pool.getActiveThreadCount(), 0); +}); + +test('WorkerPool - separate instances have separate thread counts', () => { + const pool1 = new WorkerPool(); + const pool2 = new WorkerPool(); + pools.push(pool1, pool2); + + pool1.changeActiveThreadCount(3); + pool2.changeActiveThreadCount(5); + + assert.equal(pool1.getActiveThreadCount(), 3); + assert.equal(pool2.getActiveThreadCount(), 5); +}); + +test('WorkerPool - separate instances have separate queues', () => { + const pool1 = new WorkerPool(); + const pool2 = new WorkerPool(); + pools.push(pool1, pool2); + + pool1.queue.push(() => {}); + pool1.queue.push(() => {}); + pool2.queue.push(() => {}); + + assert.equal(pool1.queue.length, 2); + assert.equal(pool2.queue.length, 1); +}); + +test('WorkerPool - processQueue handles empty function in queue', () => { + const pool = new WorkerPool(); + pools.push(pool); + + pool.queue.push(null); + pool.queue.push(undefined); + + // Should not throw + assert.doesNotThrow(() => pool.processQueue(2)); +}); + +test('WorkerPool - SharedArrayBuffer supports atomic operations', () => { + const pool = new WorkerPool(); + pools.push(pool); + + // Test atomic operations work as expected + Atomics.store(pool.activeThreads, 0, 10); + assert.equal(Atomics.load(pool.activeThreads, 0), 10); + + Atomics.add(pool.activeThreads, 0, 5); + assert.equal(Atomics.load(pool.activeThreads, 0), 15); + + Atomics.sub(pool.activeThreads, 0, 3); + assert.equal(Atomics.load(pool.activeThreads, 0), 12); +}); diff --git a/src/utils/__tests__/highlighter.test.mjs b/src/utils/__tests__/highlighter.test.mjs new file mode 100644 index 00000000..f4a089dd --- /dev/null +++ b/src/utils/__tests__/highlighter.test.mjs @@ -0,0 +1,391 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; + +import { h as createElement } from 'hastscript'; + +import rehypeShikiji, { highlighter } from '../highlighter.mjs'; + +test('highlighter - is created and available', () => { + assert.ok(highlighter); + assert.ok(highlighter.shiki); + assert.ok(typeof highlighter.shiki.codeToHast === 'function'); +}); + +test('rehypeShikiji - transforms code blocks with language', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'pre', + children: [ + { + type: 'element', + tagName: 'code', + properties: { className: ['language-javascript'] }, + children: [{ type: 'text', value: 'const x = 5;' }], + }, + ], + }, + ], + }; + + transformer(tree); + + // Should have replaced the original pre element + const preElement = tree.children[0]; + assert.equal(preElement.tagName, 'pre'); + + // Should have Shiki styling classes + assert.ok(preElement.properties.class.includes('shiki')); + + // Should have preserved the language class + assert.ok(preElement.properties.class.includes('language-javascript')); + + // Should have added copy button + const copyButton = preElement.children.find( + child => + child.tagName === 'button' && child.properties.class === 'copy-button' + ); + assert.ok(copyButton, 'Should have copy button'); +}); + +test('rehypeShikiji - skips elements without language prefix', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'pre', + children: [ + { + type: 'element', + tagName: 'code', + properties: { className: ['no-language'] }, + children: [{ type: 'text', value: 'plain text' }], + }, + ], + }, + ], + }; + + const originalPre = tree.children[0]; + transformer(tree); + + // Should not modify the tree structure + assert.equal(tree.children[0], originalPre); + assert.equal(tree.children[0].tagName, 'pre'); +}); + +test('rehypeShikiji - skips non-pre elements', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'div', + children: [{ type: 'text', value: 'not a code block' }], + }, + ], + }; + + const originalDiv = tree.children[0]; + transformer(tree); + + assert.equal(tree.children[0], originalDiv); +}); + +test('rehypeShikiji - handles empty pre element', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'pre', + children: [], + }, + ], + }; + + // Should not throw + assert.doesNotThrow(() => transformer(tree)); +}); + +test('rehypeShikiji - handles pre without code child', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'pre', + children: [{ type: 'text', value: 'text without code tag' }], + }, + ], + }; + + const originalPre = tree.children[0]; + transformer(tree); + + // Should not modify + assert.equal(tree.children[0], originalPre); +}); + +test('rehypeShikiji - handles multiple code blocks', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'pre', + children: [ + { + type: 'element', + tagName: 'code', + properties: { className: ['language-javascript'] }, + children: [{ type: 'text', value: 'const x = 1;' }], + }, + ], + }, + { + type: 'element', + tagName: 'pre', + children: [ + { + type: 'element', + tagName: 'code', + properties: { className: ['language-python'] }, + children: [{ type: 'text', value: 'x = 1' }], + }, + ], + }, + ], + }; + + transformer(tree); + + // Both should be transformed + assert.equal(tree.children.length, 2); + assert.ok(tree.children[0].properties.class.includes('language-javascript')); + assert.ok(tree.children[1].properties.class.includes('language-python')); +}); + +test('rehypeShikiji - creates switchable tabs for cjs/mjs pairs', () => { + const transformer = rehypeShikiji(); + + // Mock Shiki's codeToHast to return predictable structure + const tree = { + type: 'root', + children: [ + createElement( + 'pre', + { class: 'shiki language-cjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-cjs' }, [ + createElement('span', {}, 'const x = 1;'), + ]), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + createElement( + 'pre', + { class: 'shiki language-mjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-mjs' }, [ + createElement('span', {}, 'const x = 1;'), + ]), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + ], + }; + + transformer(tree); + + // Should have merged into one switchable element + assert.equal(tree.children.length, 1); + + const switchableElement = tree.children[0]; + assert.equal(switchableElement.tagName, 'pre'); + assert.ok(switchableElement.properties.class.includes('shiki')); + + // Should have checkbox + const checkbox = switchableElement.children.find( + child => child.tagName === 'input' && child.properties.type === 'checkbox' + ); + assert.ok(checkbox, 'Should have checkbox'); + + // Should have both code elements + const codeElements = switchableElement.children.filter( + child => child.tagName === 'code' + ); + assert.equal(codeElements.length, 2, 'Should have two code elements'); + + // Should have copy button + const copyButton = switchableElement.children.find( + child => + child.tagName === 'button' && child.properties.class === 'copy-button' + ); + assert.ok(copyButton, 'Should have copy button'); +}); + +test('rehypeShikiji - checkbox is checked when CJS is first', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + createElement( + 'pre', + { class: 'shiki language-cjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-cjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + createElement( + 'pre', + { class: 'shiki language-mjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-mjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + ], + }; + + transformer(tree); + + const checkbox = tree.children[0].children.find( + child => child.tagName === 'input' + ); + assert.equal(checkbox.properties.checked, true); +}); + +test('rehypeShikiji - checkbox is unchecked when MJS is first', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + createElement( + 'pre', + { class: 'shiki language-mjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-mjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + createElement( + 'pre', + { class: 'shiki language-cjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-cjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + ], + }; + + transformer(tree); + + const checkbox = tree.children[0].children.find( + child => child.tagName === 'input' + ); + assert.notEqual(checkbox.properties.checked, true); +}); + +test('rehypeShikiji - does not create tabs for single cjs block', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + createElement( + 'pre', + { class: 'shiki language-cjs', style: 'color: #000' }, + [ + createElement('code', { class: 'language-cjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + ], + }; + + const originalLength = tree.children.length; + transformer(tree); + + // Should not create switchable element for single block + assert.equal(tree.children.length, originalLength); +}); + +test('rehypeShikiji - preserves Shiki styling in switchable tabs', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + createElement( + 'pre', + { class: 'shiki language-cjs', style: 'background-color: #fff' }, + [ + createElement('code', { class: 'language-cjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + createElement( + 'pre', + { class: 'shiki language-mjs', style: 'background-color: #fff' }, + [ + createElement('code', { class: 'language-mjs' }, 'const x = 1;'), + createElement('button', { class: 'copy-button' }, 'copy'), + ] + ), + ], + }; + + transformer(tree); + + // Style should be preserved from code blocks + const switchableElement = tree.children[0]; + assert.ok(switchableElement.properties.style); +}); + +test('rehypeShikiji - handles code with no className', () => { + const transformer = rehypeShikiji(); + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'pre', + children: [ + { + type: 'element', + tagName: 'code', + properties: {}, + children: [{ type: 'text', value: 'plain code' }], + }, + ], + }, + ], + }; + + const originalPre = tree.children[0]; + transformer(tree); + + // Should not modify since no className + assert.equal(tree.children[0], originalPre); +}); From f7291e8a32d55a9a3db727a4ef8b65659f5f9f96 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 19:04:39 +0100 Subject: [PATCH 08/16] chore: doc update --- src/utils/safeWrite.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/safeWrite.mjs b/src/utils/safeWrite.mjs index 8875aef8..e915e1db 100644 --- a/src/utils/safeWrite.mjs +++ b/src/utils/safeWrite.mjs @@ -5,7 +5,7 @@ import { writeFile } from 'node:fs/promises'; /** * Writes a file only if the content has changed, avoiding unnecessary writes. - * Uses stat comparison (size and mtime) to determine if file needs updating. + * Uses stat comparison (size) to determine if file needs updating. * * @param {string} filePath - The path to the file * @param {string | Buffer} content - The content to write From ff822dba64133c6620a96fc8e9468c18cdd2eb80 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 19:41:16 +0100 Subject: [PATCH 09/16] chore: removed all unrelated changes --- src/generators/web/template.html | 2 +- .../web/utils/__tests__/bundle.test.mjs | 248 ----------- .../web/utils/__tests__/css.test.mjs | 245 ----------- .../web/utils/__tests__/generate.test.mjs | 124 ------ .../web/utils/__tests__/processing.test.mjs | 97 ----- src/generators/web/utils/bundle.mjs | 96 ++--- src/generators/web/utils/processing.mjs | 25 +- src/threading/__tests__/index.test.mjs | 249 ----------- src/utils/__tests__/highlighter.test.mjs | 391 ------------------ 9 files changed, 59 insertions(+), 1418 deletions(-) delete mode 100644 src/generators/web/utils/__tests__/bundle.test.mjs delete mode 100644 src/generators/web/utils/__tests__/css.test.mjs delete mode 100644 src/generators/web/utils/__tests__/generate.test.mjs delete mode 100644 src/generators/web/utils/__tests__/processing.test.mjs delete mode 100644 src/threading/__tests__/index.test.mjs delete mode 100644 src/utils/__tests__/highlighter.test.mjs diff --git a/src/generators/web/template.html b/src/generators/web/template.html index 4e6ce145..85635076 100644 --- a/src/generators/web/template.html +++ b/src/generators/web/template.html @@ -21,6 +21,6 @@
{{dehydrated}}
- + diff --git a/src/generators/web/utils/__tests__/bundle.test.mjs b/src/generators/web/utils/__tests__/bundle.test.mjs deleted file mode 100644 index 2053ed1a..00000000 --- a/src/generators/web/utils/__tests__/bundle.test.mjs +++ /dev/null @@ -1,248 +0,0 @@ -'use strict'; - -import assert from 'node:assert'; -import { describe, it } from 'node:test'; - -import bundleCode from '../bundle.mjs'; - -describe('bundleCode', () => { - describe('server builds', () => { - it('should bundle a single server entry', async () => { - const code = ` - import { createElement } from 'preact'; - const element = createElement('div', null, 'Hello'); - return 'output'; - `; - - const result = await bundleCode(code, { server: true }); - - assert.ok(result.jsMap, 'should have jsMap'); - assert.ok(result.jsMap['entrypoint.jsx'], 'should have entrypoint.jsx'); - assert.ok( - result.jsMap['entrypoint.jsx'].includes('Hello'), - 'should include the code content' - ); - assert.strictEqual(result.css, '', 'should have empty CSS'); - assert.strictEqual(result.jsChunks.length, 0, 'should have no JS chunks'); - assert.strictEqual( - result.importMapHtml, - '', - 'should have empty import map' - ); - }); - - it('should handle external dependencies', async () => { - const code = ` - import { render } from 'preact-render-to-string'; - import { createElement } from 'preact'; - const element = createElement('div', null, 'Test'); - return render(element); - `; - - const result = await bundleCode(code, { server: true }); - - assert.ok(result.jsMap['entrypoint.jsx'], 'should bundle successfully'); - // External dependencies should not be bundled - assert.ok( - result.jsMap['entrypoint.jsx'].includes('require'), - 'should use require for external deps' - ); - }); - - it('should apply SERVER define flag', async () => { - const code = ` - const value = SERVER ? 'server-side' : 'client-side'; - return value; - `; - - const result = await bundleCode(code, { server: true }); - - // SERVER should be replaced with 'true' string, which will then be evaluated - assert.ok(result.jsMap, 'should have jsMap'); - assert.ok( - result.jsMap['entrypoint.jsx'] !== undefined, - 'should have entrypoint.jsx in jsMap' - ); - // Code might be empty if optimization removed everything, that's okay - assert.ok( - typeof result.jsMap['entrypoint.jsx'] === 'string', - 'should be a string' - ); - }); - }); - - describe('client builds', () => { - it('should bundle a single client entry', async () => { - const code = ` - import { createElement } from 'preact'; - const element = createElement('div', null, 'Hello'); - export default element; - `; - - const result = await bundleCode(code, { server: false }); - - assert.ok(result.jsMap, 'should have jsMap'); - assert.ok(result.jsMap['entrypoint.jsx'], 'should have entrypoint.jsx'); - assert.strictEqual(typeof result.css, 'string', 'should have CSS string'); - assert.ok(Array.isArray(result.jsChunks), 'should have jsChunks array'); - assert.strictEqual( - typeof result.importMapHtml, - 'string', - 'should have importMapHtml' - ); - }); - - it('should bundle multiple client entries', async () => { - const codeMap = new Map([ - [ - 'entry1.jsx', - `import { createElement } from 'preact'; export default createElement('div', null, 'One');`, - ], - [ - 'entry2.jsx', - `import { createElement } from 'preact'; export default createElement('div', null, 'Two');`, - ], - ]); - - const result = await bundleCode(codeMap, { server: false }); - - assert.ok(result.jsMap['entry1.jsx'], 'should have entry1.jsx'); - assert.ok(result.jsMap['entry2.jsx'], 'should have entry2.jsx'); - assert.ok( - result.jsMap['entry1.jsx'].includes('One'), - 'entry1 should include its content' - ); - assert.ok( - result.jsMap['entry2.jsx'].includes('Two'), - 'entry2 should include its content' - ); - }); - - it('should handle module imports', async () => { - const code = ` - import { createElement } from 'preact'; - export default createElement('div', null, 'Test'); - `; - - const result = await bundleCode(code, { server: false }); - - assert.ok(result.jsMap['entrypoint.jsx'], 'should have JS'); - // CSS handling depends on the cssLoader plugin - assert.strictEqual( - typeof result.css, - 'string', - 'should have CSS property' - ); - }); - - it('should apply CLIENT define flag', async () => { - const code = ` - if (CLIENT) { - console.log('client-side'); - } else { - console.log('server-side'); - } - `; - - const result = await bundleCode(code, { server: false }); - - assert.ok( - result.jsMap['entrypoint.jsx'].includes('client-side') || - result.jsMap['entrypoint.jsx'].includes('true'), - 'should process CLIENT flag' - ); - }); - - it('should minify client builds', async () => { - const code = ` - import { createElement } from 'preact'; - // This is a comment that should be removed - const veryLongVariableName = 'value'; - export default createElement('div', null, veryLongVariableName); - `; - - const result = await bundleCode(code, { server: false }); - - // Minified code should not contain comments and should be shorter - assert.ok( - !result.jsMap['entrypoint.jsx'].includes('This is a comment'), - 'should remove comments' - ); - }); - - it('should generate import map for multiple entries', async () => { - const codeMap = new Map([ - [ - 'page1.jsx', - `import { createElement } from 'preact'; export default createElement('div', null, 'Page 1');`, - ], - [ - 'page2.jsx', - `import { createElement } from 'preact'; export default createElement('div', null, 'Page 2');`, - ], - ]); - - const result = await bundleCode(codeMap, { server: false }); - - assert.ok( - result.importMapHtml.includes('importmap'), - 'should contain importmap script tag' - ); - // Import map should not contain virtual entries - assert.ok( - !result.importMapHtml.includes('_virtual_'), - 'should not include virtual entries' - ); - assert.ok( - !result.importMapHtml.includes('entrypoint'), - 'should not include entrypoint in importmap' - ); - }); - }); - - describe('code splitting', () => { - it('should support multiple entries in client builds', async () => { - const codeMap = new Map([ - [ - 'entry1.jsx', - ` - import { createElement } from 'preact'; - export default createElement('div', null, 'Entry 1'); - `, - ], - [ - 'entry2.jsx', - ` - import { createElement } from 'preact'; - export default createElement('div', null, 'Entry 2'); - `, - ], - ]); - - const result = await bundleCode(codeMap, { server: false }); - - assert.ok(result.jsMap['entry1.jsx'], 'should have entry1'); - assert.ok(result.jsMap['entry2.jsx'], 'should have entry2'); - // Code splitting behavior depends on rolldown configuration - assert.ok(Array.isArray(result.jsChunks), 'should have jsChunks array'); - }); - }); - - describe('virtual entries', () => { - it('should handle Map input correctly', async () => { - const codeMap = new Map([['test.jsx', `export default 'test';`]]); - - const result = await bundleCode(codeMap, { server: false }); - - assert.ok(result.jsMap['test.jsx'], 'should handle Map key'); - }); - - it('should handle string input correctly', async () => { - const code = `export default 'test';`; - - const result = await bundleCode(code, { server: true }); - - assert.ok(result.jsMap['entrypoint.jsx'], 'should create entrypoint.jsx'); - }); - }); -}); diff --git a/src/generators/web/utils/__tests__/css.test.mjs b/src/generators/web/utils/__tests__/css.test.mjs deleted file mode 100644 index 05fba19f..00000000 --- a/src/generators/web/utils/__tests__/css.test.mjs +++ /dev/null @@ -1,245 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { after, before, test } from 'node:test'; - -import cssLoader from '../css.mjs'; - -// Create a temporary directory for test files -const testDir = join(tmpdir(), `css-test-${Date.now()}`); - -before(async () => { - await mkdir(testDir, { recursive: true }); -}); - -after(async () => { - await rm(testDir, { recursive: true, force: true }); -}); - -test('cssLoader - plugin has required properties', () => { - const plugin = cssLoader(); - - assert.equal(plugin.name, 'css-loader'); - assert.ok(plugin.load); - assert.ok(plugin.load.handler); - assert.ok(plugin.load.filter); - assert.ok(plugin.buildEnd); -}); - -test('cssLoader - filter matches .module.css files', () => { - const plugin = cssLoader(); - const { filter } = plugin.load; - - assert.ok(filter.id.include.test('/path/to/styles.module.css')); - assert.ok(filter.id.include.test('component.module.css')); - assert.ok(!filter.id.include.test('styles.css')); - assert.ok(!filter.id.include.test('styles.module.scss')); -}); - -test('cssLoader - processes CSS module file', async () => { - const plugin = cssLoader(); - const testFile = join(testDir, 'test.module.css'); - - // Write a simple CSS module file - await writeFile( - testFile, - `.button { - color: blue; - background: white; -} -.container { - padding: 10px; -}` - ); - - const result = await plugin.load.handler(testFile); - - // Should return JS module with exports - assert.ok(result.code); - assert.equal(result.moduleType, 'js'); - - // Parse the exported default object - const exportMatch = result.code.match(/export default (.+);/); - assert.ok(exportMatch, 'Should export an object'); - - const exports = JSON.parse(exportMatch[1]); - assert.ok(exports.button, 'Should have button class'); - assert.ok(exports.container, 'Should have container class'); - - // Scoped names should not be the original names - assert.notEqual(exports.button, 'button'); - assert.notEqual(exports.container, 'container'); -}); - -test('cssLoader - caches processed files', async () => { - const plugin = cssLoader(); - const testFile = join(testDir, 'cached.module.css'); - - await writeFile(testFile, '.test { color: red; }'); - - // First call - const result1 = await plugin.load.handler(testFile); - - // Second call (should hit cache) - const result2 = await plugin.load.handler(testFile); - - // Both results should be identical - assert.equal(result1.code, result2.code); - assert.equal(result1.moduleType, result2.moduleType); -}); - -test('cssLoader - collects CSS chunks', async () => { - const plugin = cssLoader(); - const testFile1 = join(testDir, 'chunk1.module.css'); - const testFile2 = join(testDir, 'chunk2.module.css'); - - await writeFile(testFile1, '.class1 { color: red; }'); - await writeFile(testFile2, '.class2 { color: blue; }'); - - // Process both files - await plugin.load.handler(testFile1); - await plugin.load.handler(testFile2); - - // Mock emitFile to capture output - const emittedFiles = []; - const mockContext = { - emitFile(file) { - emittedFiles.push(file); - }, - }; - - // Call buildEnd with mock context - plugin.buildEnd.call(mockContext); - - // Should emit one CSS file - assert.equal(emittedFiles.length, 1); - assert.equal(emittedFiles[0].type, 'asset'); - assert.equal(emittedFiles[0].name, 'styles.css'); - - // CSS should contain both classes (with scoped names) - const cssContent = emittedFiles[0].source; - assert.ok(typeof cssContent === 'string'); - assert.ok(cssContent.length > 0); -}); - -test('cssLoader - handles multiple CSS modules with different class names', async () => { - const plugin = cssLoader(); - const testFile1 = join(testDir, 'buttons.module.css'); - const testFile2 = join(testDir, 'layout.module.css'); - - await writeFile( - testFile1, - `.primary { background: blue; } -.secondary { background: gray; }` - ); - - await writeFile( - testFile2, - `.header { height: 60px; } -.footer { height: 40px; }` - ); - - const result1 = await plugin.load.handler(testFile1); - const result2 = await plugin.load.handler(testFile2); - - // Parse exports from both modules - const exports1 = JSON.parse(result1.code.match(/export default (.+);/)[1]); - const exports2 = JSON.parse(result2.code.match(/export default (.+);/)[1]); - - // Each should have their own classes - assert.ok(exports1.primary); - assert.ok(exports1.secondary); - assert.ok(exports2.header); - assert.ok(exports2.footer); - - // Classes from different files should not overlap - assert.ok(!exports1.header); - assert.ok(!exports2.primary); -}); - -test('cssLoader - buildEnd skips emitting when no CSS processed', () => { - const plugin = cssLoader(); - - const emittedFiles = []; - const mockContext = { - emitFile(file) { - emittedFiles.push(file); - }, - }; - - // Call buildEnd without processing any CSS files - plugin.buildEnd.call(mockContext); - - // Should not emit anything - assert.equal(emittedFiles.length, 0); -}); - -test('cssLoader - handles CSS with pseudo-selectors', async () => { - const plugin = cssLoader(); - const testFile = join(testDir, 'pseudo.module.css'); - - await writeFile( - testFile, - `.button:hover { - color: red; -} -.button:active { - color: darkred; -}` - ); - - const result = await plugin.load.handler(testFile); - const exports = JSON.parse(result.code.match(/export default (.+);/)[1]); - - assert.ok(exports.button); -}); - -test('cssLoader - processes CSS with multiple classes on same element', async () => { - const plugin = cssLoader(); - const testFile = join(testDir, 'multi.module.css'); - - await writeFile( - testFile, - `.btn { padding: 10px; } -.btnPrimary { background: blue; } -.btnSecondary { background: gray; }` - ); - - const result = await plugin.load.handler(testFile); - const exports = JSON.parse(result.code.match(/export default (.+);/)[1]); - - assert.ok(exports.btn); - assert.ok(exports.btnPrimary); - assert.ok(exports.btnSecondary); -}); - -test('cssLoader - separate plugin instances have separate caches', async () => { - const plugin1 = cssLoader(); - const plugin2 = cssLoader(); - - const testFile = join(testDir, 'separate.module.css'); - await writeFile(testFile, '.test { color: green; }'); - - await plugin1.load.handler(testFile); - - // Mock emitFile for both plugins - const emitted1 = []; - const emitted2 = []; - - plugin1.buildEnd.call({ - emitFile(file) { - emitted1.push(file); - }, - }); - - plugin2.buildEnd.call({ - emitFile(file) { - emitted2.push(file); - }, - }); - - // Plugin 1 should emit CSS, plugin 2 should not (separate chunk tracking) - assert.equal(emitted1.length, 1); - assert.equal(emitted2.length, 0); -}); diff --git a/src/generators/web/utils/__tests__/generate.test.mjs b/src/generators/web/utils/__tests__/generate.test.mjs deleted file mode 100644 index 2a73b521..00000000 --- a/src/generators/web/utils/__tests__/generate.test.mjs +++ /dev/null @@ -1,124 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { test } from 'node:test'; - -import createProgramGenerators, { - createImportDeclaration, -} from '../generate.mjs'; - -test('createImportDeclaration - side-effect-only import (no name)', () => { - const result = createImportDeclaration(null, './styles.css'); - assert.equal(result, 'import "./styles.css";'); -}); - -test('createImportDeclaration - default import', () => { - const result = createImportDeclaration('React', 'react', true); - assert.equal(result, 'import React from "react";'); -}); - -test('createImportDeclaration - default import with useDefault=true (default)', () => { - const result = createImportDeclaration('Component', './component.jsx'); - assert.equal(result, 'import Component from "./component.jsx";'); -}); - -test('createImportDeclaration - named import', () => { - const result = createImportDeclaration('useState', 'react', false); - assert.equal(result, 'import { useState } from "react";'); -}); - -test('createImportDeclaration - handles backslashes in path', () => { - const result = createImportDeclaration('Comp', 'C:\\path\\to\\file.jsx'); - assert.equal(result, 'import Comp from "C:\\\\path\\\\to\\\\file.jsx";'); -}); - -test('createImportDeclaration - handles backslashes in side-effect import', () => { - const result = createImportDeclaration(null, 'D:\\styles\\main.css'); - assert.equal(result, 'import "D:\\\\styles\\\\main.css";'); -}); - -test('buildClientProgram - generates hydration code', () => { - const { buildClientProgram } = createProgramGenerators(); - const componentCode = ''; - const result = buildClientProgram(componentCode); - - // Check for key imports and hydration call - assert.match(result, /import.*from "preact"/); - assert.match(result, /import { hydrate } from "preact"/); - assert.match( - result, - /hydrate\(, document\.getElementById\("root"\)\);/ - ); - assert.match(result, /import ".*\/ui\/index\.css"/); -}); - -test('buildClientProgram - includes JSX component imports', () => { - const { buildClientProgram } = createProgramGenerators(); - const result = buildClientProgram(''); - - // Should include imports from JSX_IMPORTS - assert.match(result, /import NavBar from/); - assert.match(result, /import SideBar from/); - assert.match(result, /import CodeBox from/); -}); - -test('buildClientProgram - includes named imports for MDX components', () => { - const { buildClientProgram } = createProgramGenerators(); - const result = buildClientProgram(''); - - // Should include named imports with curly braces - assert.match(result, /import { MDXTooltip } from/); -}); - -test('buildServerProgram - generates SSR code', () => { - const { buildServerProgram } = createProgramGenerators(); - const componentCode = ''; - const result = buildServerProgram(componentCode); - - // Check for SSR imports and render call - assert.match(result, /import { render } from "preact-render-to-string"/); - assert.match(result, /return render\(\);/); -}); - -test('buildServerProgram - includes JSX component imports', () => { - const { buildServerProgram } = createProgramGenerators(); - const result = buildServerProgram(''); - - // Should include imports from JSX_IMPORTS - assert.match(result, /import NavBar from/); - assert.match(result, /import MetaBar from/); - assert.match(result, /import CodeTabs from/); -}); - -test('buildServerProgram - uses newlines for readability', () => { - const { buildServerProgram } = createProgramGenerators(); - const result = buildServerProgram(''); - - // Server program should have newline separators - assert.match(result, /\n/); - const lines = result.split('\n'); - assert.ok(lines.length > 3, 'Should have multiple lines'); -}); - -test('buildClientProgram - concatenates without newlines', () => { - const { buildClientProgram } = createProgramGenerators(); - const result = buildClientProgram(''); - - // Client program concatenates all imports together - // Check that imports appear immediately one after another - assert.ok(!result.includes('\n'), 'Should not contain newlines'); -}); - -test('buildServerProgram - returns value from render', () => { - const { buildServerProgram } = createProgramGenerators(); - const result = buildServerProgram(''); - - // Should return the result of render() - assert.match(result, /return render\(\);/); -}); - -test('createProgramGenerators - returns both generator functions', () => { - const generators = createProgramGenerators(); - - assert.ok(typeof generators === 'object'); - assert.ok(typeof generators.buildClientProgram === 'function'); - assert.ok(typeof generators.buildServerProgram === 'function'); -}); diff --git a/src/generators/web/utils/__tests__/processing.test.mjs b/src/generators/web/utils/__tests__/processing.test.mjs deleted file mode 100644 index 9e332fd2..00000000 --- a/src/generators/web/utils/__tests__/processing.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -'use strict'; - -import assert from 'node:assert'; -import { createRequire } from 'node:module'; -import { describe, it } from 'node:test'; - -import { executeServerCode } from '../processing.mjs'; - -// Mock require function for testing -const mockRequire = createRequire(import.meta.url); - -describe('executeServerCode', () => { - it('should execute single server code and return dehydrated HTML', async () => { - const serverCodeMap = new Map([['test.jsx', `return '
Test
';`]]); - - const result = await executeServerCode(serverCodeMap, mockRequire); - - assert.ok(result instanceof Map, 'should return a Map'); - assert.strictEqual(result.size, 1, 'should have one entry'); - // The result might be undefined if bundling fails, but the Map should exist - assert.ok(result.has('test.jsx'), 'should have test.jsx key'); - }); - - it('should execute multiple server code entries', async () => { - const serverCodeMap = new Map([ - ['entry1.jsx', `return '
Entry 1
';`], - ['entry2.jsx', `return '
Entry 2
';`], - ]); - - const result = await executeServerCode(serverCodeMap, mockRequire); - - assert.strictEqual(result.size, 2, 'should have two entries'); - assert.ok(result.has('entry1.jsx'), 'should have entry1.jsx'); - assert.ok(result.has('entry2.jsx'), 'should have entry2.jsx'); - }); - - it('should provide require function to executed code', async () => { - // This code tries to use require - const serverCodeMap = new Map([ - [ - 'require-test.jsx', - ` - const preact = require('preact'); - return typeof preact !== 'undefined' ? 'success' : 'fail'; - `, - ], - ]); - - const result = await executeServerCode(serverCodeMap, mockRequire); - - const output = result.get('require-test.jsx'); - assert.ok(output, 'should have output'); - }); - - it('should handle server code that uses external packages', async () => { - const serverCodeMap = new Map([ - [ - 'external.jsx', - ` - const { render } = require('preact-render-to-string'); - const { createElement } = require('preact'); - const el = createElement('span', null, 'External'); - return render(el); - `, - ], - ]); - - const result = await executeServerCode(serverCodeMap, mockRequire); - - const html = result.get('external.jsx'); - assert.ok(html, 'should execute code with external deps'); - }); - - it('should isolate execution contexts between entries', async () => { - const serverCodeMap = new Map([ - ['context1.jsx', `const x = 'first'; return x;`], - ['context2.jsx', `const x = 'second'; return x;`], - ]); - - const result = await executeServerCode(serverCodeMap, mockRequire); - - assert.strictEqual(result.size, 2, 'should have two entries'); - assert.ok(result.has('context1.jsx'), 'should have context1.jsx'); - assert.ok(result.has('context2.jsx'), 'should have context2.jsx'); - // Each should have executed independently - const result1 = result.get('context1.jsx'); - const result2 = result.get('context2.jsx'); - // Results might be undefined if bundling produces empty code, but keys should exist - if (result1 !== undefined && result2 !== undefined) { - assert.notStrictEqual( - result1, - result2, - 'should have different outputs if both exist' - ); - } - }); -}); diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index 7963d944..141a3aaf 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -41,43 +41,54 @@ export default async function bundleCode(codeMap, { server = false } = {}) { // Output configuration output: { - // CommonJS for Node.js server, ESM for browser with code splitting support - format: server ? 'cjs' : 'esm', + // Output module format: + // - "cjs" for CommonJS (used in Node.js environments) + // - "iife" for browser environments (self-contained script tag) + format: server ? 'cjs' : 'iife', - // Minify only browser builds to reduce file size + // Minify output only for browser builds to optimize file size. + // Server builds are usually not minified to preserve stack traces and debuggability. minify: !server, // Environment-specific output configuration ...(server ? serverOutputConfig : {}), }, - // Target platform affects polyfills, globals, and bundling behavior + // Platform informs Rolldown of the environment-specific code behavior: + // - 'node' enables things like `require`, and skips polyfills. + // - 'browser' enables inlining of polyfills and uses native browser features. platform: server ? 'node' : 'browser', - // External dependencies (not bundled) for server builds - // These must be available in the Node.js runtime environment + // External dependencies to exclude from bundling. + // These are expected to be available at runtime in the server environment. + // This reduces bundle size and avoids bundling shared server libs. external: server ? ['preact', 'preact-render-to-string', '@node-core/ui-components'] : [], - // Transform and define configuration - transform: { - // Compile-time constants replaced during bundling - // Update types.d.ts if these change - define: { - // Static data as a JSON literal - __STATIC_DATA__: staticData, - - // Environment flags for conditional logic and tree-shaking - SERVER: String(server), - CLIENT: String(!server), - }, - - // Use automatic JSX runtime (no need to import React/Preact) - jsx: 'react-jsx', + // Inject global compile-time constants that will be replaced in code. + // These are useful for tree-shaking and conditional branching. + // Be sure to update type declarations (`types.d.ts`) if these change. + define: { + // Static data injected directly into the bundle (as a literal or serialized JSON). + __STATIC_DATA__: staticData, + + // Boolean flags used for conditional logic in source code: + // Example: `if (SERVER) {...}` or `if (CLIENT) {...}` + // These flags help split logic for server/client environments. + // Unused branches will be removed via tree-shaking. + SERVER: String(server), + CLIENT: String(!server), }, - // Module resolution: alias React imports to Preact + // JSX transformation configuration. + // `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`. + // Since we're using Preact via aliasing, this setting works well with `preact/compat`. + jsx: 'react-jsx', + + // Module resolution aliases. + // This tells the bundler to use `preact/compat` wherever `react` or `react-dom` is imported. + // Allows you to write React-style code but ship much smaller Preact bundles. resolve: { alias: { react: 'preact/compat', @@ -85,12 +96,13 @@ export default async function bundleCode(codeMap, { server = false } = {}) { }, }, - // Build plugins + // Array of plugins to apply during the build. plugins: [ // Virtual plugin: provides in-memory modules from codeMap virtual(Object.fromEntries(codeMap)), - // CSS loader: collects and bundles imported CSS files + // Load CSS imports via the custom plugin. + // This plugin will collect imported CSS files and return them as `source` chunks. cssLoader(), // Extract and transform the import map generated by Rolldown @@ -123,37 +135,17 @@ export default async function bundleCode(codeMap, { server = false } = {}) { write: false, }); - // Separate entry chunks (main modules) from other chunks (CSS, code-split JS) - const entryChunks = []; - const otherChunks = []; - - for (const chunk of result.output) { - const chunkTarget = - chunk.type === 'chunk' && chunk.isEntry ? entryChunks : otherChunks; - - chunkTarget.push(chunk); - } - // Separate CSS assets from JavaScript chunks - const cssFiles = otherChunks.filter(chunk => chunk.type === 'asset'); - const jsChunks = otherChunks.filter(chunk => chunk.type === 'chunk'); - - // Create a map of entry code by original fileName - const jsMap = {}; - - for (const chunk of entryChunks) { - // Extract original fileName from virtual module ID - const originalFileName = - chunk.facadeModuleId?.split('/').pop() || chunk.fileName; - - // Remove virtual: prefix from module IDs - jsMap[originalFileName.replace('\x00virtual:', '')] = chunk.code; - } + const cssFiles = result.output.filter(chunk => chunk.type === 'asset'); + const jsChunks = result.output.filter(chunk => chunk.type === 'chunk'); return { - jsMap, - jsChunks: jsChunks.map(({ fileName, code }) => ({ fileName, code })), - css: cssFiles.map(f => f.source).join(''), importMapHtml, + css: cssFiles.map(f => f.source).join(''), + jsChunks: jsChunks.map(({ fileName, code, isEntry }) => ({ + fileName: fileName.replace('_virtual_', ''), + code, + isEntry, + })), }; } diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index c20a53c1..36e07614 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import HTMLMinifier from '@minify-html/node'; import { jsx, toJs } from 'estree-util-to-js'; @@ -19,18 +21,21 @@ export async function executeServerCode(serverCodeMap, requireFn) { const dehydratedMap = new Map(); // Bundle all server-side code, which may produce code-split chunks - const { jsMap, jsChunks } = await bundleCode(serverCodeMap, { server: true }); + const { jsChunks } = await bundleCode(serverCodeMap, { server: true }); + + const entryChunks = jsChunks.filter(c => c.isEntry); + const otherChunks = jsChunks.filter(c => !c.isEntry); // Create enhanced require function that can resolve code-split chunks - const enhancedRequire = createEnhancedRequire(jsChunks, requireFn); + const enhancedRequire = createEnhancedRequire(otherChunks, requireFn); // Execute each bundled entry and collect dehydrated HTML results - for (const [fileName, serverCode] of Object.entries(jsMap)) { + for (const chunk of entryChunks) { // Create and execute function with enhanced require for chunk resolution - const executedFunction = new Function('require', serverCode); + const executedFunction = new Function('require', chunk.code); // Execute the function - result is the dehydrated HTML from server-side rendering - dehydratedMap.set(fileName, executedFunction(enhancedRequire)); + dehydratedMap.set(chunk.fileName, executedFunction(enhancedRequire)); } return dehydratedMap; @@ -78,25 +83,23 @@ export async function processJSXEntries( } // Execute all server code at once to get dehydrated HTML - const dehydratedMap = await executeServerCode(serverCodeMap, requireFn); + const serverBundle = await executeServerCode(serverCodeMap, requireFn); // Bundle all client code at once (with code splitting for shared chunks) const clientBundle = await bundleCode(clientCodeMap); // Process each entry to create final HTML const results = entries.map(entry => { - const fileName = `${entry.data.api}.jsx`; - const dehydrated = dehydratedMap.get(fileName); - const mainJsCode = clientBundle.jsMap[fileName]; + const fileName = `${entry.data.api}.js`; const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`; // Replace template placeholders with actual content const renderedHtml = template .replace('{{title}}', title) - .replace('{{dehydrated}}', dehydrated ?? '') + .replace('{{dehydrated}}', serverBundle.get(fileName) ?? '') .replace('{{importMap}}', clientBundle.importMapHtml) - .replace('{{mainJsCode}}', () => mainJsCode); + .replace('{{mainJsSrc}}', `./${fileName}?${randomUUID()}`); // Minify HTML (input must be a Buffer) const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); diff --git a/src/threading/__tests__/index.test.mjs b/src/threading/__tests__/index.test.mjs deleted file mode 100644 index c5846b38..00000000 --- a/src/threading/__tests__/index.test.mjs +++ /dev/null @@ -1,249 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { after, test } from 'node:test'; - -import WorkerPool from '../index.mjs'; - -// Keep track of pools for cleanup -const pools = []; - -after(() => { - // Allow time for any pending workers to complete - return new Promise(resolve => setTimeout(resolve, 100)); -}); - -test('WorkerPool - constructor initializes properties', () => { - const pool = new WorkerPool(); - pools.push(pool); - - assert.ok(pool.sharedBuffer instanceof SharedArrayBuffer); - assert.ok(pool.activeThreads instanceof Int32Array); - assert.ok(Array.isArray(pool.queue)); - assert.equal(pool.queue.length, 0); -}); - -test('WorkerPool - getActiveThreadCount returns initial zero', () => { - const pool = new WorkerPool(); - pools.push(pool); - - assert.equal(pool.getActiveThreadCount(), 0); -}); - -test('WorkerPool - changeActiveThreadCount increments count', () => { - const pool = new WorkerPool(); - pools.push(pool); - - pool.changeActiveThreadCount(1); - assert.equal(pool.getActiveThreadCount(), 1); - - pool.changeActiveThreadCount(2); - assert.equal(pool.getActiveThreadCount(), 3); -}); - -test('WorkerPool - changeActiveThreadCount decrements count', () => { - const pool = new WorkerPool(); - pools.push(pool); - - pool.changeActiveThreadCount(5); - assert.equal(pool.getActiveThreadCount(), 5); - - pool.changeActiveThreadCount(-2); - assert.equal(pool.getActiveThreadCount(), 3); -}); - -test('WorkerPool - changeActiveThreadCount is atomic', () => { - const pool = new WorkerPool(); - pools.push(pool); - - // Simulate concurrent operations - pool.changeActiveThreadCount(1); - pool.changeActiveThreadCount(1); - pool.changeActiveThreadCount(-1); - - assert.equal(pool.getActiveThreadCount(), 1); -}); - -test('WorkerPool - processQueue does nothing when queue is empty', () => { - const pool = new WorkerPool(); - pools.push(pool); - - // Should not throw - assert.doesNotThrow(() => pool.processQueue(2)); - assert.equal(pool.queue.length, 0); -}); - -test('WorkerPool - processQueue does nothing when thread limit reached', () => { - const pool = new WorkerPool(); - pools.push(pool); - - let executed = false; - pool.queue.push(() => { - executed = true; - }); - - // Set active threads to max - pool.changeActiveThreadCount(2); - - pool.processQueue(2); - - // Should not execute queued task - assert.equal(executed, false); - assert.equal(pool.queue.length, 1); -}); - -test('WorkerPool - processQueue executes next task when capacity available', () => { - const pool = new WorkerPool(); - pools.push(pool); - - let executed = false; - pool.queue.push(() => { - executed = true; - }); - - pool.changeActiveThreadCount(1); - pool.processQueue(2); - - // Should execute queued task - assert.equal(executed, true); - assert.equal(pool.queue.length, 0); -}); - -test('WorkerPool - processQueue shifts only one task from queue', () => { - const pool = new WorkerPool(); - pools.push(pool); - - let count = 0; - pool.queue.push(() => count++); - pool.queue.push(() => count++); - pool.queue.push(() => count++); - - pool.processQueue(10); - - // Should only process one task - assert.equal(count, 1); - assert.equal(pool.queue.length, 2); -}); - -test('WorkerPool - run executes immediately when under thread limit', async () => { - const pool = new WorkerPool(); - pools.push(pool); - - // This will run immediately since we're under the limit - const promise = pool.run('web', {}, 2, {}); - - // Thread count should increment - assert.equal(pool.getActiveThreadCount(), 1); - - // Clean up - we expect this to fail since we don't have real generator setup - await assert.rejects(promise); -}); - -test('WorkerPool - run queues task when at thread limit', async () => { - const pool = new WorkerPool(); - pools.push(pool); - - // Set to max threads - pool.changeActiveThreadCount(2); - - const promise = pool.run('web', {}, 2, {}); - - // Should be queued, not executed - assert.equal(pool.queue.length, 1); - assert.equal(pool.getActiveThreadCount(), 2); - - // Free up a thread to allow queue processing - pool.changeActiveThreadCount(-1); - pool.processQueue(2); - - // Now it should be running - assert.equal(pool.getActiveThreadCount(), 2); - assert.equal(pool.queue.length, 0); - - // Clean up - await assert.rejects(promise); -}); - -test('WorkerPool - multiple run calls respect thread limit', () => { - const pool = new WorkerPool(); - pools.push(pool); - - const threads = 2; - - // Start 3 tasks with limit of 2 - const p1 = pool.run('web', {}, threads, {}); - const p2 = pool.run('web', {}, threads, {}); - const p3 = pool.run('web', {}, threads, {}); - - // First two should start, third should queue - assert.equal(pool.getActiveThreadCount(), 2); - assert.equal(pool.queue.length, 1); - - // Clean up - all will reject - return Promise.allSettled([p1, p2, p3]); -}); - -test('WorkerPool - run decrements count on worker error', async () => { - const pool = new WorkerPool(); - pools.push(pool); - - const promise = pool.run('nonexistent-generator', {}, 2, {}); - - // Thread count should increment initially - assert.equal(pool.getActiveThreadCount(), 1); - - // Wait for error - await assert.rejects(promise); - - // Thread count should decrement after error - assert.equal(pool.getActiveThreadCount(), 0); -}); - -test('WorkerPool - separate instances have separate thread counts', () => { - const pool1 = new WorkerPool(); - const pool2 = new WorkerPool(); - pools.push(pool1, pool2); - - pool1.changeActiveThreadCount(3); - pool2.changeActiveThreadCount(5); - - assert.equal(pool1.getActiveThreadCount(), 3); - assert.equal(pool2.getActiveThreadCount(), 5); -}); - -test('WorkerPool - separate instances have separate queues', () => { - const pool1 = new WorkerPool(); - const pool2 = new WorkerPool(); - pools.push(pool1, pool2); - - pool1.queue.push(() => {}); - pool1.queue.push(() => {}); - pool2.queue.push(() => {}); - - assert.equal(pool1.queue.length, 2); - assert.equal(pool2.queue.length, 1); -}); - -test('WorkerPool - processQueue handles empty function in queue', () => { - const pool = new WorkerPool(); - pools.push(pool); - - pool.queue.push(null); - pool.queue.push(undefined); - - // Should not throw - assert.doesNotThrow(() => pool.processQueue(2)); -}); - -test('WorkerPool - SharedArrayBuffer supports atomic operations', () => { - const pool = new WorkerPool(); - pools.push(pool); - - // Test atomic operations work as expected - Atomics.store(pool.activeThreads, 0, 10); - assert.equal(Atomics.load(pool.activeThreads, 0), 10); - - Atomics.add(pool.activeThreads, 0, 5); - assert.equal(Atomics.load(pool.activeThreads, 0), 15); - - Atomics.sub(pool.activeThreads, 0, 3); - assert.equal(Atomics.load(pool.activeThreads, 0), 12); -}); diff --git a/src/utils/__tests__/highlighter.test.mjs b/src/utils/__tests__/highlighter.test.mjs deleted file mode 100644 index f4a089dd..00000000 --- a/src/utils/__tests__/highlighter.test.mjs +++ /dev/null @@ -1,391 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { test } from 'node:test'; - -import { h as createElement } from 'hastscript'; - -import rehypeShikiji, { highlighter } from '../highlighter.mjs'; - -test('highlighter - is created and available', () => { - assert.ok(highlighter); - assert.ok(highlighter.shiki); - assert.ok(typeof highlighter.shiki.codeToHast === 'function'); -}); - -test('rehypeShikiji - transforms code blocks with language', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'pre', - children: [ - { - type: 'element', - tagName: 'code', - properties: { className: ['language-javascript'] }, - children: [{ type: 'text', value: 'const x = 5;' }], - }, - ], - }, - ], - }; - - transformer(tree); - - // Should have replaced the original pre element - const preElement = tree.children[0]; - assert.equal(preElement.tagName, 'pre'); - - // Should have Shiki styling classes - assert.ok(preElement.properties.class.includes('shiki')); - - // Should have preserved the language class - assert.ok(preElement.properties.class.includes('language-javascript')); - - // Should have added copy button - const copyButton = preElement.children.find( - child => - child.tagName === 'button' && child.properties.class === 'copy-button' - ); - assert.ok(copyButton, 'Should have copy button'); -}); - -test('rehypeShikiji - skips elements without language prefix', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'pre', - children: [ - { - type: 'element', - tagName: 'code', - properties: { className: ['no-language'] }, - children: [{ type: 'text', value: 'plain text' }], - }, - ], - }, - ], - }; - - const originalPre = tree.children[0]; - transformer(tree); - - // Should not modify the tree structure - assert.equal(tree.children[0], originalPre); - assert.equal(tree.children[0].tagName, 'pre'); -}); - -test('rehypeShikiji - skips non-pre elements', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'div', - children: [{ type: 'text', value: 'not a code block' }], - }, - ], - }; - - const originalDiv = tree.children[0]; - transformer(tree); - - assert.equal(tree.children[0], originalDiv); -}); - -test('rehypeShikiji - handles empty pre element', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'pre', - children: [], - }, - ], - }; - - // Should not throw - assert.doesNotThrow(() => transformer(tree)); -}); - -test('rehypeShikiji - handles pre without code child', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'pre', - children: [{ type: 'text', value: 'text without code tag' }], - }, - ], - }; - - const originalPre = tree.children[0]; - transformer(tree); - - // Should not modify - assert.equal(tree.children[0], originalPre); -}); - -test('rehypeShikiji - handles multiple code blocks', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'pre', - children: [ - { - type: 'element', - tagName: 'code', - properties: { className: ['language-javascript'] }, - children: [{ type: 'text', value: 'const x = 1;' }], - }, - ], - }, - { - type: 'element', - tagName: 'pre', - children: [ - { - type: 'element', - tagName: 'code', - properties: { className: ['language-python'] }, - children: [{ type: 'text', value: 'x = 1' }], - }, - ], - }, - ], - }; - - transformer(tree); - - // Both should be transformed - assert.equal(tree.children.length, 2); - assert.ok(tree.children[0].properties.class.includes('language-javascript')); - assert.ok(tree.children[1].properties.class.includes('language-python')); -}); - -test('rehypeShikiji - creates switchable tabs for cjs/mjs pairs', () => { - const transformer = rehypeShikiji(); - - // Mock Shiki's codeToHast to return predictable structure - const tree = { - type: 'root', - children: [ - createElement( - 'pre', - { class: 'shiki language-cjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-cjs' }, [ - createElement('span', {}, 'const x = 1;'), - ]), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - createElement( - 'pre', - { class: 'shiki language-mjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-mjs' }, [ - createElement('span', {}, 'const x = 1;'), - ]), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - ], - }; - - transformer(tree); - - // Should have merged into one switchable element - assert.equal(tree.children.length, 1); - - const switchableElement = tree.children[0]; - assert.equal(switchableElement.tagName, 'pre'); - assert.ok(switchableElement.properties.class.includes('shiki')); - - // Should have checkbox - const checkbox = switchableElement.children.find( - child => child.tagName === 'input' && child.properties.type === 'checkbox' - ); - assert.ok(checkbox, 'Should have checkbox'); - - // Should have both code elements - const codeElements = switchableElement.children.filter( - child => child.tagName === 'code' - ); - assert.equal(codeElements.length, 2, 'Should have two code elements'); - - // Should have copy button - const copyButton = switchableElement.children.find( - child => - child.tagName === 'button' && child.properties.class === 'copy-button' - ); - assert.ok(copyButton, 'Should have copy button'); -}); - -test('rehypeShikiji - checkbox is checked when CJS is first', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - createElement( - 'pre', - { class: 'shiki language-cjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-cjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - createElement( - 'pre', - { class: 'shiki language-mjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-mjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - ], - }; - - transformer(tree); - - const checkbox = tree.children[0].children.find( - child => child.tagName === 'input' - ); - assert.equal(checkbox.properties.checked, true); -}); - -test('rehypeShikiji - checkbox is unchecked when MJS is first', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - createElement( - 'pre', - { class: 'shiki language-mjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-mjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - createElement( - 'pre', - { class: 'shiki language-cjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-cjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - ], - }; - - transformer(tree); - - const checkbox = tree.children[0].children.find( - child => child.tagName === 'input' - ); - assert.notEqual(checkbox.properties.checked, true); -}); - -test('rehypeShikiji - does not create tabs for single cjs block', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - createElement( - 'pre', - { class: 'shiki language-cjs', style: 'color: #000' }, - [ - createElement('code', { class: 'language-cjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - ], - }; - - const originalLength = tree.children.length; - transformer(tree); - - // Should not create switchable element for single block - assert.equal(tree.children.length, originalLength); -}); - -test('rehypeShikiji - preserves Shiki styling in switchable tabs', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - createElement( - 'pre', - { class: 'shiki language-cjs', style: 'background-color: #fff' }, - [ - createElement('code', { class: 'language-cjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - createElement( - 'pre', - { class: 'shiki language-mjs', style: 'background-color: #fff' }, - [ - createElement('code', { class: 'language-mjs' }, 'const x = 1;'), - createElement('button', { class: 'copy-button' }, 'copy'), - ] - ), - ], - }; - - transformer(tree); - - // Style should be preserved from code blocks - const switchableElement = tree.children[0]; - assert.ok(switchableElement.properties.style); -}); - -test('rehypeShikiji - handles code with no className', () => { - const transformer = rehypeShikiji(); - - const tree = { - type: 'root', - children: [ - { - type: 'element', - tagName: 'pre', - children: [ - { - type: 'element', - tagName: 'code', - properties: {}, - children: [{ type: 'text', value: 'plain code' }], - }, - ], - }, - ], - }; - - const originalPre = tree.children[0]; - transformer(tree); - - // Should not modify since no className - assert.equal(tree.children[0], originalPre); -}); From 0c42c975548089be0967a35208ba9f3c88b659ed Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 19:45:10 +0100 Subject: [PATCH 10/16] chore: removed unnecessary changes --- src/generators/legacy-html/index.mjs | 2 +- .../utils/__tests__/safeCopy.test.mjs | 0 .../legacy-html}/utils/safeCopy.mjs | 0 src/utils/__tests__/safeWrite.test.mjs | 146 ------------------ src/utils/safeWrite.mjs | 34 ---- 5 files changed, 1 insertion(+), 181 deletions(-) rename src/{ => generators/legacy-html}/utils/__tests__/safeCopy.test.mjs (100%) rename src/{ => generators/legacy-html}/utils/safeCopy.mjs (100%) delete mode 100644 src/utils/__tests__/safeWrite.test.mjs delete mode 100644 src/utils/safeWrite.mjs diff --git a/src/generators/legacy-html/index.mjs b/src/generators/legacy-html/index.mjs index f7a28407..36b1e8d9 100644 --- a/src/generators/legacy-html/index.mjs +++ b/src/generators/legacy-html/index.mjs @@ -7,10 +7,10 @@ import HTMLMinifier from '@minify-html/node'; import buildContent from './utils/buildContent.mjs'; import dropdowns from './utils/buildDropdowns.mjs'; +import { safeCopy } from './utils/safeCopy.mjs'; import tableOfContents from './utils/tableOfContents.mjs'; import { groupNodesByModule } from '../../utils/generators.mjs'; import { getRemarkRehype } from '../../utils/remark.mjs'; -import { safeCopy } from '../../utils/safeCopy.mjs'; /** * @typedef {{ diff --git a/src/utils/__tests__/safeCopy.test.mjs b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs similarity index 100% rename from src/utils/__tests__/safeCopy.test.mjs rename to src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs diff --git a/src/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs similarity index 100% rename from src/utils/safeCopy.mjs rename to src/generators/legacy-html/utils/safeCopy.mjs diff --git a/src/utils/__tests__/safeWrite.test.mjs b/src/utils/__tests__/safeWrite.test.mjs deleted file mode 100644 index dcabcb8a..00000000 --- a/src/utils/__tests__/safeWrite.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -'use strict'; - -import assert from 'node:assert'; -import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; - -import { safeWrite } from '../safeWrite.mjs'; - -describe('safeWrite', () => { - const testDir = join(import.meta.dirname, 'test-safe-write'); - - beforeEach(async () => { - // Create test directory - await mkdir(testDir, { recursive: true }); - }); - - afterEach(async () => { - // Clean up test directory - await rm(testDir, { recursive: true, force: true }); - }); - - it('should write a new file and return true', async () => { - const filePath = join(testDir, 'new-file.txt'); - const content = 'Hello, World!'; - - const result = await safeWrite(filePath, content); - - assert.strictEqual(result, true); - const written = await readFile(filePath, 'utf-8'); - assert.strictEqual(written, content); - }); - - it('should skip writing when content size matches and return false', async () => { - const filePath = join(testDir, 'existing-file.txt'); - const content = 'Same content'; - - // Write file initially - await writeFile(filePath, content, 'utf-8'); - const initialStat = await stat(filePath); - - // Wait a bit to ensure mtime would be different if written - await new Promise(resolve => setTimeout(resolve, 10)); - - // Try to write same content - const result = await safeWrite(filePath, content); - - assert.strictEqual(result, false); - const finalStat = await stat(filePath); - // mtime should be the same since we skipped the write - assert.strictEqual( - initialStat.mtimeMs, - finalStat.mtimeMs, - 'File should not have been modified' - ); - }); - - it('should write when content size differs and return true', async () => { - const filePath = join(testDir, 'different-size.txt'); - const initialContent = 'Short'; - const newContent = 'Much longer content'; - - await writeFile(filePath, initialContent, 'utf-8'); - const initialStat = await stat(filePath); - - // Wait to ensure mtime would be different - await new Promise(resolve => setTimeout(resolve, 10)); - - const result = await safeWrite(filePath, newContent); - - assert.strictEqual(result, true); - const written = await readFile(filePath, 'utf-8'); - assert.strictEqual(written, newContent); - - const finalStat = await stat(filePath); - assert.notStrictEqual( - initialStat.mtimeMs, - finalStat.mtimeMs, - 'File should have been modified' - ); - }); - - it('should handle Buffer content', async () => { - const filePath = join(testDir, 'buffer-file.txt'); - const content = Buffer.from('Buffer content', 'utf-8'); - - const result = await safeWrite(filePath, content); - - assert.strictEqual(result, true); - const written = await readFile(filePath); - assert.deepStrictEqual(written, content); - }); - - it('should create parent directories if they do not exist', async () => { - const nestedPath = join(testDir, 'nested', 'dir', 'file.txt'); - const content = 'Nested file'; - - // Parent directories don't exist yet - await assert.rejects(async () => { - await stat(join(testDir, 'nested')); - }); - - // safeWrite should handle the write even though mkdir is not called - // (writeFile will fail if parent doesn't exist, so we expect this to throw) - await assert.rejects( - async () => await safeWrite(nestedPath, content), - /ENOENT/ - ); - - // Create parent dirs and try again - await mkdir(join(testDir, 'nested', 'dir'), { recursive: true }); - const result = await safeWrite(nestedPath, content); - - assert.strictEqual(result, true); - const written = await readFile(nestedPath, 'utf-8'); - assert.strictEqual(written, content); - }); - - it('should handle different encodings', async () => { - const filePath = join(testDir, 'encoded.txt'); - const content = 'Encoded content: éàü'; - - const result = await safeWrite(filePath, content, 'utf-8'); - - assert.strictEqual(result, true); - const written = await readFile(filePath, 'utf-8'); - assert.strictEqual(written, content); - }); - - it('should write when file exists but content is different (edge case)', async () => { - const filePath = join(testDir, 'edge-case.txt'); - // Create two strings with same byte length but different content - const content1 = 'abc'; - const content2 = 'xyz'; - - await writeFile(filePath, content1, 'utf-8'); - - // Since sizes match, safeWrite will skip (this is by design - a heuristic) - const result = await safeWrite(filePath, content2); - - assert.strictEqual(result, false); - const written = await readFile(filePath, 'utf-8'); - // Content remains as original since write was skipped - assert.strictEqual(written, content1); - }); -}); diff --git a/src/utils/safeWrite.mjs b/src/utils/safeWrite.mjs deleted file mode 100644 index e915e1db..00000000 --- a/src/utils/safeWrite.mjs +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; - -import { statSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; - -/** - * Writes a file only if the content has changed, avoiding unnecessary writes. - * Uses stat comparison (size) to determine if file needs updating. - * - * @param {string} filePath - The path to the file - * @param {string | Buffer} content - The content to write - * @param {string} encoding - File encoding (default: 'utf-8') - * @returns {Promise} True if file was written, false if skipped - */ -export async function safeWrite(filePath, content, encoding = 'utf-8') { - // Check if file exists - const stat = statSync(filePath, { throwIfNoEntry: false }); - - if (stat !== undefined) { - const newSize = Buffer.byteLength(content, encoding); - - // Skip write if size matches (content likely hasn't changed) - // This is a heuristic - same size doesn't guarantee same content, - // but for generated files it's a good indicator - if (stat.size === newSize) { - return false; - } - } - - // Write the file (either new or changed) - await writeFile(filePath, content, encoding); - - return true; -} From 34f03731ccd652ad2f459f71494a230ec85d3918 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 19:46:05 +0100 Subject: [PATCH 11/16] chore: get out of here --- src/generators/web/index.mjs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index a8bd0150..c4bae8ce 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -1,10 +1,9 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; import createASTBuilder from './utils/generate.mjs'; import { processJSXEntries } from './utils/processing.mjs'; -import { safeWrite } from '../../utils/safeWrite.mjs'; /** * Web generator - transforms JSX AST entries into complete web bundles. @@ -57,16 +56,16 @@ export default { if (output) { // Write HTML files for (const { html, api } of results) { - await safeWrite(join(output, `${api}.html`), html, 'utf-8'); + await writeFile(join(output, `${api}.html`), html, 'utf-8'); } // Write code-split JavaScript chunks for (const chunk of jsChunks) { - await safeWrite(join(output, chunk.fileName), chunk.code, 'utf-8'); + await writeFile(join(output, chunk.fileName), chunk.code, 'utf-8'); } // Write CSS bundle - await safeWrite(join(output, 'styles.css'), css, 'utf-8'); + await writeFile(join(output, 'styles.css'), css, 'utf-8'); } // Return HTML and CSS for each entry From c0ed0711b40727f77996090ea9abbb3b5862cf47 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 19:53:04 +0100 Subject: [PATCH 12/16] chore: fix issue caused during applying old comments --- src/generators/web/utils/bundle.mjs | 44 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index 141a3aaf..cc0d67b7 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -26,7 +26,7 @@ export default async function bundleCode(codeMap, { server = false } = {}) { const clientExperimentalConfig = { // Generate an import map for cache-busted module resolution in browsers // https://rolldown.rs/options/experimental#chunkimportmap - chunkImportMap: !server && { + chunkImportMap: { baseUrl: './', fileName: 'importmap.json', }, @@ -43,8 +43,8 @@ export default async function bundleCode(codeMap, { server = false } = {}) { output: { // Output module format: // - "cjs" for CommonJS (used in Node.js environments) - // - "iife" for browser environments (self-contained script tag) - format: server ? 'cjs' : 'iife', + // - "esm" for browser environments (Using Chunk Code-Splitting) + format: server ? 'cjs' : 'esm', // Minify output only for browser builds to optimize file size. // Server builds are usually not minified to preserve stack traces and debuggability. @@ -66,25 +66,27 @@ export default async function bundleCode(codeMap, { server = false } = {}) { ? ['preact', 'preact-render-to-string', '@node-core/ui-components'] : [], - // Inject global compile-time constants that will be replaced in code. - // These are useful for tree-shaking and conditional branching. - // Be sure to update type declarations (`types.d.ts`) if these change. - define: { - // Static data injected directly into the bundle (as a literal or serialized JSON). - __STATIC_DATA__: staticData, - - // Boolean flags used for conditional logic in source code: - // Example: `if (SERVER) {...}` or `if (CLIENT) {...}` - // These flags help split logic for server/client environments. - // Unused branches will be removed via tree-shaking. - SERVER: String(server), - CLIENT: String(!server), - }, + transform: { + // Inject global compile-time constants that will be replaced in code. + // These are useful for tree-shaking and conditional branching. + // Be sure to update type declarations (`types.d.ts`) if these change. + define: { + // Static data injected directly into the bundle (as a literal or serialized JSON). + __STATIC_DATA__: staticData, + + // Boolean flags used for conditional logic in source code: + // Example: `if (SERVER) {...}` or `if (CLIENT) {...}` + // These flags help split logic for server/client environments. + // Unused branches will be removed via tree-shaking. + SERVER: String(server), + CLIENT: String(!server), + }, - // JSX transformation configuration. - // `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`. - // Since we're using Preact via aliasing, this setting works well with `preact/compat`. - jsx: 'react-jsx', + // JSX transformation configuration. + // `'react-jsx'` enables the automatic JSX runtime, which doesn't require `import React`. + // Since we're using Preact via aliasing, this setting works well with `preact/compat`. + jsx: 'react-jsx', + }, // Module resolution aliases. // This tells the bundler to use `preact/compat` wherever `react` or `react-dom` is imported. From b4b49537514373b7c4614511b7de3c22ef69b903 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 20:21:55 +0100 Subject: [PATCH 13/16] chore: code review changes --- src/generators/web/template.html | 2 +- src/generators/web/utils/bundle.mjs | 66 ++++++------------------ src/generators/web/utils/chunkHelper.mjs | 11 ++-- src/generators/web/utils/processing.mjs | 31 +++++------ 4 files changed, 34 insertions(+), 76 deletions(-) diff --git a/src/generators/web/template.html b/src/generators/web/template.html index 85635076..30578fb9 100644 --- a/src/generators/web/template.html +++ b/src/generators/web/template.html @@ -21,6 +21,6 @@
{{dehydrated}}
- + diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index cc0d67b7..00b3a5f5 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -13,31 +13,14 @@ import staticData from './data.mjs'; * @param {boolean} [options.server=false] - Whether this is a server-side build. */ export default async function bundleCode(codeMap, { server = false } = {}) { - // Store the import map HTML for later extraction - let importMapHtml = ''; - - /** @type {import('rolldown').OutputOptions} */ - const serverOutputConfig = { - // Inline all dynamic imports to create a single self-contained bundle - inlineDynamicImports: true, - }; - - /** @type {import('rolldown').InputOptions['experimental']} */ - const clientExperimentalConfig = { - // Generate an import map for cache-busted module resolution in browsers - // https://rolldown.rs/options/experimental#chunkimportmap - chunkImportMap: { - baseUrl: './', - fileName: 'importmap.json', - }, - }; - const result = await build({ // Entry points: array of virtual module names that the virtual plugin provides input: Array.from(codeMap.keys()), // Experimental features: import maps for client, none for server - experimental: server ? {} : clientExperimentalConfig, + experimental: { + chunkImportMap: !server, + }, // Output configuration output: { @@ -50,8 +33,8 @@ export default async function bundleCode(codeMap, { server = false } = {}) { // Server builds are usually not minified to preserve stack traces and debuggability. minify: !server, - // Environment-specific output configuration - ...(server ? serverOutputConfig : {}), + // Within server builds we want to ensure dynamic imports get inlined whenever possible. + inlineDynamicImports: server, }, // Platform informs Rolldown of the environment-specific code behavior: @@ -106,28 +89,6 @@ export default async function bundleCode(codeMap, { server = false } = {}) { // Load CSS imports via the custom plugin. // This plugin will collect imported CSS files and return them as `source` chunks. cssLoader(), - - // Extract and transform the import map generated by Rolldown - { - name: 'extract-import-map', - /** - * Extracts import map from bundle and converts to HTML script tag. - * - * @param {import('rolldown').NormalizedOutputOptions} _ - Output options (unused). - * @param {import('rolldown').OutputBundle} bundle - Bundle object containing all output chunks. - */ - generateBundle(_, bundle) { - const chunkImportMap = bundle['importmap.json']; - - if (chunkImportMap?.type === 'asset') { - // Convert to HTML script tag for inline inclusion - importMapHtml = ``; - - // Remove from bundle to prevent writing as separate file - delete bundle['importmap.json']; - } - }, - }, ], // Enable tree-shaking to remove unused code @@ -138,16 +99,21 @@ export default async function bundleCode(codeMap, { server = false } = {}) { }); // Separate CSS assets from JavaScript chunks - const cssFiles = result.output.filter(chunk => chunk.type === 'asset'); - const jsChunks = result.output.filter(chunk => chunk.type === 'chunk'); + const assets = result.output.filter(c => c.type === 'asset'); + const chunks = result.output.filter(c => c.type === 'chunk'); + + const importMap = assets.find(c => c.fileName === 'importmap.json'); return { - importMapHtml, - css: cssFiles.map(f => f.source).join(''), - jsChunks: jsChunks.map(({ fileName, code, isEntry }) => ({ + css: assets + .filter(c => c.fileName.endsWith('.css')) + .map(f => f.source) + .join(''), + jsChunks: chunks.map(({ fileName, code, isEntry }) => ({ fileName: fileName.replace('_virtual_', ''), - code, isEntry, + code, })), + importMapHtml: ``, }; } diff --git a/src/generators/web/utils/chunkHelper.mjs b/src/generators/web/utils/chunkHelper.mjs index b3950b41..31babf1a 100644 --- a/src/generators/web/utils/chunkHelper.mjs +++ b/src/generators/web/utils/chunkHelper.mjs @@ -17,11 +17,10 @@ export function createEnhancedRequire(jsChunks, requireFn) { * @param {string} modulePath - Module path to require. * @returns {*} Module exports. */ - const enhancedRequire = modulePath => { + const chunkedRequire = modulePath => { // Check virtual file system first for code-split chunks if (chunkModules[modulePath]) { - const moduleExports = {}; - const module = { exports: moduleExports }; + const mod = { exports: {} }; // Execute chunk code in isolated context with its own module.exports const chunkFn = new Function( @@ -31,14 +30,14 @@ export function createEnhancedRequire(jsChunks, requireFn) { chunkModules[modulePath] ); - chunkFn(module, moduleExports, enhancedRequire); + chunkFn(mod, mod.exports, chunkedRequire); - return module.exports; + return mod.exports; } // Fall back to Node.js require for external packages return requireFn(modulePath); }; - return enhancedRequire; + return chunkedRequire; } diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 36e07614..68229472 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -42,21 +42,14 @@ export async function executeServerCode(serverCodeMap, requireFn) { } /** - * Processes multiple JSX AST entries to generate complete HTML pages with SSR content, - * client-side JavaScript bundles (with code splitting), and CSS. + * Processes a single JSX AST (Abstract Syntax Tree) entry to generate a complete + * HTML page, including server-side rendered content, client-side JavaScript, and CSS. * - * This function: - * 1. Converts JSX AST to JavaScript code for both server and client - * 2. Executes server code to get dehydrated (server-rendered) HTML - * 3. Bundles client code with code splitting and import maps - * 4. Injects everything into HTML template and minifies - * - * @param {import('../../jsx-ast/utils/buildContent.mjs').JSXContent[]} entries - JSX AST entries to process. - * @param {string} template - HTML template string with placeholders: {{title}}, {{dehydrated}}, {{importMap}}, {{mainJsCode}}. - * @param {ReturnType} astBuilders - AST generator functions (buildServerProgram, buildClientProgram). - * @param {ReturnType} requireFn - Node.js require function. - * @param {Object} options - Processing options. - * @param {string} options.version - Documentation version string. + * @param {Array} entries - The JSX AST entry to process. + * @param {string} template - The HTML template string that serves as the base for the output page. + * @param {ReturnType} astBuilders - The AST generators + * @param {version} version - The version to generator the documentation for + * @param {ReturnType} requireFn - A Node.js `require` function. */ export async function processJSXEntries( entries, @@ -89,22 +82,22 @@ export async function processJSXEntries( const clientBundle = await bundleCode(clientCodeMap); // Process each entry to create final HTML - const results = entries.map(entry => { - const fileName = `${entry.data.api}.js`; + const results = entries.map(({ data }) => { + const fileName = `${data.api}.js`; - const title = `${entry.data.heading.data.name} | Node.js v${version} Documentation`; + const title = `${data.heading.data.name} | Node.js v${version} Documentation`; // Replace template placeholders with actual content const renderedHtml = template .replace('{{title}}', title) .replace('{{dehydrated}}', serverBundle.get(fileName) ?? '') .replace('{{importMap}}', clientBundle.importMapHtml) - .replace('{{mainJsSrc}}', `./${fileName}?${randomUUID()}`); + .replace('{{entrypoint}}', `./${fileName}?${randomUUID()}`); // Minify HTML (input must be a Buffer) const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); - return { html: finalHTMLBuffer, api: entry.data.api }; + return { html: finalHTMLBuffer, api: data.api }; }); return { From 10e9e3e42d434cb78708ac129266cadf5203577a Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 10 Nov 2025 22:10:39 +0100 Subject: [PATCH 14/16] chore: code review changes --- src/generators/web/index.mjs | 4 +-- src/generators/web/template.html | 3 +- src/generators/web/ui/components/CodeBox.jsx | 6 ++-- src/generators/web/utils/bundle.mjs | 4 +-- .../web/utils/{chunkHelper.mjs => chunks.mjs} | 6 ++-- src/generators/web/utils/processing.mjs | 30 ++++++++----------- 6 files changed, 23 insertions(+), 30 deletions(-) rename src/generators/web/utils/{chunkHelper.mjs => chunks.mjs} (84%) diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index c4bae8ce..71f2f015 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -44,7 +44,7 @@ export default { const requireFn = createRequire(import.meta.url); // Process all entries: convert JSX to HTML/CSS/JS - const { results, css, jsChunks } = await processJSXEntries( + const { results, css, chunks } = await processJSXEntries( entries, template, astBuilders, @@ -60,7 +60,7 @@ export default { } // Write code-split JavaScript chunks - for (const chunk of jsChunks) { + for (const chunk of chunks) { await writeFile(join(output, chunk.fileName), chunk.code, 'utf-8'); } diff --git a/src/generators/web/template.html b/src/generators/web/template.html index 30578fb9..466ad3f0 100644 --- a/src/generators/web/template.html +++ b/src/generators/web/template.html @@ -15,8 +15,7 @@ - - {{importMap}} + diff --git a/src/generators/web/ui/components/CodeBox.jsx b/src/generators/web/ui/components/CodeBox.jsx index 361d8742..c24f9132 100644 --- a/src/generators/web/ui/components/CodeBox.jsx +++ b/src/generators/web/ui/components/CodeBox.jsx @@ -20,7 +20,7 @@ export const getLanguageDisplayName = language => { }; /** @param {import('react').PropsWithChildren<{ className: string }>} props */ -export default ({ className, children, ...props }) => { +export default ({ className, ...props }) => { const matches = className?.match(/language-(?[a-zA-Z]+)/); const language = matches?.groups?.language ?? ''; @@ -48,8 +48,6 @@ export default ({ className, children, ...props }) => { className={className} buttonText="Copy to clipboard" {...props} - > - {children} -
+ /> ); }; diff --git a/src/generators/web/utils/bundle.mjs b/src/generators/web/utils/bundle.mjs index 00b3a5f5..ce492fc5 100644 --- a/src/generators/web/utils/bundle.mjs +++ b/src/generators/web/utils/bundle.mjs @@ -109,11 +109,11 @@ export default async function bundleCode(codeMap, { server = false } = {}) { .filter(c => c.fileName.endsWith('.css')) .map(f => f.source) .join(''), - jsChunks: chunks.map(({ fileName, code, isEntry }) => ({ + chunks: chunks.map(({ fileName, code, isEntry }) => ({ fileName: fileName.replace('_virtual_', ''), isEntry, code, })), - importMapHtml: ``, + importMap: importMap?.source.toString(), }; } diff --git a/src/generators/web/utils/chunkHelper.mjs b/src/generators/web/utils/chunks.mjs similarity index 84% rename from src/generators/web/utils/chunkHelper.mjs rename to src/generators/web/utils/chunks.mjs index 31babf1a..143dfa8c 100644 --- a/src/generators/web/utils/chunkHelper.mjs +++ b/src/generators/web/utils/chunks.mjs @@ -2,13 +2,13 @@ * Creates an enhanced require function that can resolve code-split chunks * from a virtual file system before falling back to Node.js require. * - * @param {Array<{fileName: string, code: string}>} jsChunks - Array of code-split chunks from bundler. + * @param {Array<{fileName: string, code: string}>} chunks - Array of code-split chunks from bundler. * @param {ReturnType} requireFn - Node.js require function for external packages. */ -export function createEnhancedRequire(jsChunks, requireFn) { +export function createChunkedRequire(chunks, requireFn) { // Create a virtual file system from code-split chunks const chunkModules = Object.fromEntries( - jsChunks.map(c => [`./${c.fileName}`, c.code]) + chunks.map(c => [`./${c.fileName}`, c.code]) ); /** diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 68229472..823dc8aa 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -4,7 +4,7 @@ import HTMLMinifier from '@minify-html/node'; import { jsx, toJs } from 'estree-util-to-js'; import bundleCode from './bundle.mjs'; -import { createEnhancedRequire } from './chunkHelper.mjs'; +import { createChunkedRequire } from './chunks.mjs'; /** * Executes server-side JavaScript code in an isolated context with virtual module support. @@ -21,13 +21,13 @@ export async function executeServerCode(serverCodeMap, requireFn) { const dehydratedMap = new Map(); // Bundle all server-side code, which may produce code-split chunks - const { jsChunks } = await bundleCode(serverCodeMap, { server: true }); + const { chunks } = await bundleCode(serverCodeMap, { server: true }); - const entryChunks = jsChunks.filter(c => c.isEntry); - const otherChunks = jsChunks.filter(c => !c.isEntry); + const entryChunks = chunks.filter(c => c.isEntry); + const otherChunks = chunks.filter(c => !c.isEntry); // Create enhanced require function that can resolve code-split chunks - const enhancedRequire = createEnhancedRequire(otherChunks, requireFn); + const enhancedRequire = createChunkedRequire(otherChunks, requireFn); // Execute each bundled entry and collect dehydrated HTML results for (const chunk of entryChunks) { @@ -81,28 +81,24 @@ export async function processJSXEntries( // Bundle all client code at once (with code splitting for shared chunks) const clientBundle = await bundleCode(clientCodeMap); - // Process each entry to create final HTML - const results = entries.map(({ data }) => { - const fileName = `${data.api}.js`; + const titleSuffix = `Node.js v${version} Documentation`; - const title = `${data.heading.data.name} | Node.js v${version} Documentation`; + // Process each entry to create final HTML + const results = entries.map(({ data: { api, heading } }) => { + const fileName = `${api}.js`; // Replace template placeholders with actual content const renderedHtml = template - .replace('{{title}}', title) + .replace('{{title}}', `${heading.data.name} | ${titleSuffix}`) .replace('{{dehydrated}}', serverBundle.get(fileName) ?? '') - .replace('{{importMap}}', clientBundle.importMapHtml) + .replace('{{importMap}}', clientBundle.importMap ?? '') .replace('{{entrypoint}}', `./${fileName}?${randomUUID()}`); // Minify HTML (input must be a Buffer) const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {}); - return { html: finalHTMLBuffer, api: data.api }; + return { html: finalHTMLBuffer, api }; }); - return { - results, - css: clientBundle.css ?? '', - jsChunks: clientBundle.jsChunks, - }; + return { results, ...clientBundle }; } From f539c8d85d82c51af5b6b9c9b8ec30236e815b06 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Tue, 11 Nov 2025 02:57:13 +0100 Subject: [PATCH 15/16] chore: speculation rules --- src/generators/web/constants.mjs | 11 +++++++++++ src/generators/web/template.html | 1 + src/generators/web/ui/components/SideBar/index.jsx | 3 ++- src/generators/web/utils/processing.mjs | 6 +++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 92e4e4fa..abf45db9 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -80,3 +80,14 @@ export const JSX_IMPORTS = { source: '@node-core/ui-components/Providers/NotificationProvider', }, }; + +/** + * Specification rules for resource hints like prerendering and prefetching. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API + */ +export const SPECULATION_RULES = { + prefetch: [ + { where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'eager' }, + ], + prerender: [{ where: { href_matches: '/*.html' }, eagerness: 'moderate' }], +}; diff --git a/src/generators/web/template.html b/src/generators/web/template.html index 466ad3f0..ad106dc3 100644 --- a/src/generators/web/template.html +++ b/src/generators/web/template.html @@ -16,6 +16,7 @@ + diff --git a/src/generators/web/ui/components/SideBar/index.jsx b/src/generators/web/ui/components/SideBar/index.jsx index 2ab00f4e..2868f4c3 100644 --- a/src/generators/web/ui/components/SideBar/index.jsx +++ b/src/generators/web/ui/components/SideBar/index.jsx @@ -1,4 +1,4 @@ -import Select from '@node-core/ui-components/Common/Select/index.js'; +import Select from '@node-core/ui-components/Common/Select'; import SideBar from '@node-core/ui-components/Containers/Sidebar'; import styles from './index.module.css'; @@ -31,6 +31,7 @@ export default ({ versions, pathname, currentVersion, docPages }) => ( }, ]} onSelect={redirect} + as={props => } >