diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 308d8e934f..372110d6d6 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -144,7 +144,7 @@ jobs: echo "COVFILES=$COVFILES" >> $GITHUB_ENV - name: Send Coverage to Codecov if: ${{ env.COVFILES != '' }} - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ env.COVFILES }} diff --git a/package-lock.json b/package-lock.json index ca6d4c00d5..969972d96b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2913,24 +2913,24 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, "node_modules/@lit-labs/ssr-dom-shim": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz", - "integrity": "sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz", + "integrity": "sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==" }, "node_modules/@lit/react": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.2.tgz", - "integrity": "sha512-UJ5TQ46DPcJDIzyjbwbj6Iye0XcpCxL2yb03zcWq1BpWchpXS3Z0BPVhg7zDfZLF6JemPml8u/gt/+KwJ/23sg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.3.tgz", + "integrity": "sha512-RGoPMrAPbFjQFXFbfmYdotw000DyChehTim+d562HRXvFGw//KxouI8jNOcc3Kw/1uqUA1SJqXFtKKxK0NUrww==", "peerDependencies": { "@types/react": "17 || 18" } }, "node_modules/@lit/reactive-element": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.3.tgz", - "integrity": "sha512-e067EuTNNgOHm1tZcc0Ia7TCzD/9ZpoPegHKgesrGK6pSDRGkGDAQbYuQclqLPIoJ9eC8Kb9mYtGryWcM5AywA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", + "integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==", "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.2" + "@lit-labs/ssr-dom-shim": "^1.2.0" } }, "node_modules/@microsoft/tsdoc": { @@ -3444,10 +3444,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.2.tgz", - "integrity": "sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==", - "dev": true, + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.0.tgz", + "integrity": "sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ==", "engines": { "node": ">=14.0.0" } @@ -3560,9 +3559,9 @@ "dev": true }, "node_modules/@testing-library/react": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.0.tgz", - "integrity": "sha512-7uBnPHyOG6nDGCzv8SLeJbSa33ZoYw7swYpSLIgJvBALdq7l9zPNk33om4USrxy1lKTxXaVfufzLmq83WNfWIw==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.1.tgz", + "integrity": "sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", @@ -3684,6 +3683,15 @@ "@types/chai": "*" } }, + "node_modules/@types/chai-like": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/chai-like/-/chai-like-1.1.3.tgz", + "integrity": "sha512-AEGBQz8wcPhvytKR5EP3HiQrmUeg6HP/ZgNnGWnLaQA4fyZ7kDS1/wbSBLN4CBTMobK4wM2SpksVWzTXWQ8r3w==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/co-body": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@types/co-body/-/co-body-6.1.3.tgz", @@ -3757,6 +3765,12 @@ "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==", "dev": true }, + "node_modules/@types/deep-equal-in-any-order": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/deep-equal-in-any-order/-/deep-equal-in-any-order-1.0.3.tgz", + "integrity": "sha512-jT0O3hAILDKeKbdWJ9FZLD0Xdfhz7hMvfyFlRWpirjiEVr8G+GZ4kVIzPIqM6x6Rpp93TNPgOAed4XmvcuV6Qg==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -3770,9 +3784,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.42", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.42.tgz", - "integrity": "sha512-ckM3jm2bf/MfB3+spLPWYPUH573plBFwpOhqQ2WottxYV85j1HQFlxmnTq57X1yHY9awZPig06hL/cLMgNWHIQ==", + "version": "4.17.43", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", + "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", "dev": true, "dependencies": { "@types/node": "*", @@ -3898,9 +3912,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.13.tgz", - "integrity": "sha512-5G4zQwdiQBSWYTDAH1ctw2eidqdhMJaNsiIDKHFr55ihz5Trl2qqR8fdrT732yPBho5gkNxXm67OxWFBqX9aPg==", + "version": "20.11.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", + "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -3936,9 +3950,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.48", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", - "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", + "version": "18.2.53", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.53.tgz", + "integrity": "sha512-52IHsMDT8qATp9B9zoOyobW8W3/0QhaJQTw1HwRj0UY2yBpCAQ7+S/CqHYQ8niAm3p4ji+rWUQ9UCib0GxQ60w==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4708,6 +4722,10 @@ "resolved": "packages/ts/core", "link": true }, + "node_modules/@vaadin/hilla-file-router": { + "resolved": "packages/ts/hilla-file-router", + "link": true + }, "node_modules/@vaadin/hilla-generator-cli": { "resolved": "packages/ts/generator-cli", "link": true @@ -5924,13 +5942,16 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5964,6 +5985,25 @@ "node": ">=8" } }, + "node_modules/array.prototype.filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", + "integrity": "sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", @@ -6020,17 +6060,18 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -6108,9 +6149,9 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.6.tgz", + "integrity": "sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==", "dev": true, "engines": { "node": ">= 0.4" @@ -6540,9 +6581,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001581", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz", - "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==", + "version": "1.0.30001584", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz", + "integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==", "dev": true, "funding": [ { @@ -6616,6 +6657,15 @@ "chai": ">= 3" } }, + "node_modules/chai-like": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chai-like/-/chai-like-1.1.1.tgz", + "integrity": "sha512-VKa9z/SnhXhkT1zIjtPACFWSoWsqVoaz1Vg+ecrKo5DCKVlgL30F/pEyEvXPBOVwCgLZcWUleCM/C1okaKdTTA==", + "dev": true, + "peerDependencies": { + "chai": "2 - 4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7583,6 +7633,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-equal-in-any-order": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-2.0.6.tgz", + "integrity": "sha512-RfnWHQzph10YrUjvWwhd15Dne8ciSJcZ3U6OD7owPwiVwsdE5IFSoZGg8rlwJD11ES+9H5y8j3fCofviRHOqLQ==", + "dev": true, + "dependencies": { + "lodash.mapvalues": "^4.6.0", + "sort-any": "^2.0.0" + } + }, "node_modules/deep-equal/node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -7880,9 +7940,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.651", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.651.tgz", - "integrity": "sha512-jjks7Xx+4I7dslwsbaFocSwqBbGHQmuXBJUK9QBZTIrzPq3pzn6Uf2szFSP728FtLYE3ldiccmlkOM/zhGKCpA==", + "version": "1.4.656", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.656.tgz", + "integrity": "sha512-9AQB5eFTHyR3Gvt2t/NwR0le2jBSUNwCnMbUCejFWHD+so4tH40/dRLgoE+jxlPeWS43XJewyvCv+I8LPMl49Q==", "dev": true }, "node_modules/emoji-regex": { @@ -8049,6 +8109,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", @@ -9368,16 +9443,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.3.tgz", + "integrity": "sha512-JIcZczvcMVE7AUOP+X72bh8HqHBRxFdz5PDHYtNG/lE3yk9b3KZBJlwFcTyPYjg3L4RLLmZJzvjxhaZVapxFrQ==", "dev": true, "dependencies": { + "es-errors": "^1.0.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9694,12 +9773,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -9915,9 +9994,9 @@ ] }, "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "dev": true, "engines": { "node": ">= 4" @@ -10010,14 +10089,16 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10329,12 +10410,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -11233,29 +11314,29 @@ "dev": true }, "node_modules/lit": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.1.1.tgz", - "integrity": "sha512-hF1y4K58+Gqrz+aAPS0DNBwPqPrg6P04DuWK52eMkt/SM9Qe9keWLcFgRcEKOLuDlRZlDsDbNL37Vr7ew1VCuw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.1.2.tgz", + "integrity": "sha512-VZx5iAyMtX7CV4K8iTLdCkMaYZ7ipjJZ0JcSdJ0zIdGxxyurjIn7yuuSxNBD7QmjvcNJwr0JS4cAdAtsy7gZ6w==", "dependencies": { - "@lit/reactive-element": "^2.0.0", - "lit-element": "^4.0.0", - "lit-html": "^3.1.0" + "@lit/reactive-element": "^2.0.4", + "lit-element": "^4.0.4", + "lit-html": "^3.1.2" } }, "node_modules/lit-element": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.3.tgz", - "integrity": "sha512-2vhidmC7gGLfnVx41P8UZpzyS0Fb8wYhS5RCm16cMW3oERO0Khd3EsKwtRpOnttuByI5rURjT2dfoA7NlInCNw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.4.tgz", + "integrity": "sha512-98CvgulX6eCPs6TyAIQoJZBCQPo80rgXR+dVBs61cstJXqtI+USQZAbA4gFHh6L/mxBx9MrgPLHLsUgDUHAcCQ==", "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.2", - "@lit/reactive-element": "^2.0.0", - "lit-html": "^3.1.0" + "@lit-labs/ssr-dom-shim": "^1.2.0", + "@lit/reactive-element": "^2.0.4", + "lit-html": "^3.1.2" } }, "node_modules/lit-html": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.1.tgz", - "integrity": "sha512-x/EwfGk2D/f4odSFM40hcGumzqoKv0/SUh6fBO+1Ragez81APrcAMPo1jIrCDd9Sn+Z4CT867HWKViByvkDZUA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.2.tgz", + "integrity": "sha512-3OBZSUrPnAHoKJ9AMjRL/m01YJxQMf+TMHanNtTHG68ubjnZxK0RFl102DPzsw4mWnHibfZIBJm3LWCZ/LmMvg==", "dependencies": { "@types/trusted-types": "^2.0.2" } @@ -11311,6 +11392,12 @@ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "dev": true }, + "node_modules/lodash.mapvalues": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", + "integrity": "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -11417,7 +11504,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -11453,9 +11539,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.6", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.6.tgz", + "integrity": "sha512-n62qCLbPjNjyo+owKtveQxZFZTBm+Ms6YoGD23Wew6Vw337PElFNifQpknPruVRQV57kVShPnLGo9vWxVhpPvA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -12268,15 +12354,16 @@ } }, "node_modules/object.groupby": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", - "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.2.tgz", + "integrity": "sha512-bzBq58S+x+uo0VjurFT0UktpKHOZmv4/xePiOA1nbB9pMqpGK7rUPNgf+1YC+7mE+0HzhTMqNUuCqvKhj6FnBw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1" + "array.prototype.filter": "^1.0.3", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.0.0" } }, "node_modules/object.values": { @@ -12673,9 +12760,9 @@ } }, "node_modules/pino": { - "version": "8.17.2", - "resolved": "https://registry.npmjs.org/pino/-/pino-8.17.2.tgz", - "integrity": "sha512-LA6qKgeDMLr2ux2y/YiUt47EfgQ+S9LznBWOJdN3q1dx2sv0ziDLUBeVpyVv17TEcGCBuWf0zNtg3M5m1NhhWQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.18.0.tgz", + "integrity": "sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==", "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", @@ -13305,9 +13392,9 @@ } }, "node_modules/prettier": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", - "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -13519,7 +13606,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13547,12 +13633,11 @@ "dev": true }, "node_modules/react-router": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.3.tgz", - "integrity": "sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==", - "dev": true, + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.0.tgz", + "integrity": "sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg==", "dependencies": { - "@remix-run/router": "1.14.2" + "@remix-run/router": "1.15.0" }, "engines": { "node": ">=14.0.0" @@ -13562,13 +13647,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.3.tgz", - "integrity": "sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.0.tgz", + "integrity": "sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag==", "dev": true, "dependencies": { - "@remix-run/router": "1.14.2", - "react-router": "6.21.3" + "@remix-run/router": "1.15.0", + "react-router": "6.22.0" }, "engines": { "node": ">=14.0.0" @@ -14313,6 +14398,15 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sort-any": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/sort-any/-/sort-any-2.0.0.tgz", + "integrity": "sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15626,16 +15720,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.14.tgz", + "integrity": "sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", + "available-typed-arrays": "^1.0.6", + "call-bind": "^1.0.5", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -16435,6 +16529,102 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/ts/hilla-file-router": { + "name": "@vaadin/hilla-file-router", + "version": "24.4.0-alpha1", + "license": "Apache-2.0", + "dependencies": { + "@vaadin/hilla-generator-utils": "^24.4.0-alpha1", + "react": "^18.2.0" + }, + "devDependencies": { + "@esm-bundle/chai": "^4.3.4-fix.0", + "@types/chai-like": "^1.1.3", + "@types/deep-equal-in-any-order": "^1.0.3", + "@types/mocha": "^10.0.6", + "@types/sinon": "^17.0.3", + "chai-like": "^1.1.1", + "deep-equal-in-any-order": "^2.0.6", + "mocha": "^10.2.0", + "rimraf": "^5.0.5", + "sinon": "^17.0.1", + "type-fest": "^4.9.0", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "react-router": "^6.21.1" + } + }, + "packages/ts/hilla-file-router/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "packages/ts/hilla-file-router/node_modules/@types/sinon": { + "version": "17.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "packages/ts/hilla-file-router/node_modules/diff": { + "version": "5.1.0", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "packages/ts/hilla-file-router/node_modules/rimraf": { + "version": "5.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/ts/hilla-file-router/node_modules/sinon": { + "version": "17.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "packages/ts/hilla-file-router/node_modules/typescript": { + "version": "5.3.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/ts/lit-form": { "name": "@vaadin/hilla-lit-form", "version": "24.4.0-alpha2", diff --git a/packages/ts/generator-utils/src/ast.ts b/packages/ts/generator-utils/src/ast.ts index 55153eeb2d..09fd6ea265 100644 --- a/packages/ts/generator-utils/src/ast.ts +++ b/packages/ts/generator-utils/src/ast.ts @@ -1,5 +1,6 @@ import ts, { type Node, + type VisitResult, type SourceFile, type Statement, type TransformationContext, @@ -43,9 +44,19 @@ export function template( return selector?.(sourceFile.statements) ?? sourceFile.statements; } -export function transform(transformer: (node: Node) => Node): TransformerFactory { +export function transform( + transformer: (node: Node) => VisitResult, +): TransformerFactory { return (context: TransformationContext) => (root: T) => { - const visitor = (node: Node): Node => ts.visitEachChild(transformer(node), visitor, context); + const visitor = (node: Node): VisitResult => { + const transformed = transformer(node); + + if (transformed !== node) { + return transformed; + } + + return ts.visitEachChild(transformed, visitor, context); + }; return ts.visitEachChild(root, visitor, context); }; } diff --git a/packages/ts/hilla-file-router/.eslintrc b/packages/ts/hilla-file-router/.eslintrc new file mode 100644 index 0000000000..6ac92d4ca9 --- /dev/null +++ b/packages/ts/hilla-file-router/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": ["../../../.eslintrc"], + "parserOptions": { + "project": "./tsconfig.json" + } +} diff --git a/packages/ts/hilla-file-router/.lintstagedrc.js b/packages/ts/hilla-file-router/.lintstagedrc.js new file mode 100644 index 0000000000..937dc6639f --- /dev/null +++ b/packages/ts/hilla-file-router/.lintstagedrc.js @@ -0,0 +1,6 @@ +import { commands, extensions } from '../../../.lintstagedrc.js'; + +export default { + [`src/**/*.{${extensions}}`]: commands, + [`test/**/*.{${extensions}}`]: commands, +}; diff --git a/packages/ts/hilla-file-router/package.json b/packages/ts/hilla-file-router/package.json new file mode 100644 index 0000000000..bf683f2ab5 --- /dev/null +++ b/packages/ts/hilla-file-router/package.json @@ -0,0 +1,74 @@ +{ + "name": "@vaadin/hilla-file-router", + "version": "24.4.0-alpha2", + "description": "Hilla file-based router", + "main": "index.js", + "module": "index.js", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/vaadin/hilla.git", + "directory": "packages/ts/hilla-file-router" + }, + "keywords": [ + "Hilla", + "Vite", + "Plugin", + "File", + "Router", + "Routing" + ], + "scripts": { + "clean:build": "git clean -fx . -e .vite -e node_modules", + "build": "concurrently npm:build:*", + "build:esbuild": "tsx ../../../scripts/build.ts", + "build:dts": "tsc --isolatedModules -p tsconfig.build.json", + "build:copy": "cd src && copyfiles **/*.d.ts ..", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "test": "mocha test/**/*.spec.ts --config ../../../.mocharc.cjs", + "test:coverage": "c8 -c ../../../.c8rc.json npm test", + "typecheck": "tsc --noEmit" + }, + "exports": { + "./runtime.js": { + "default": "./runtime.js" + }, + "./vite-plugin-file-router.js": { + "default": "./vite-plugin-file-router.js" + } + }, + "author": "Vaadin Ltd", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/vaadin/hilla/issues" + }, + "homepage": "https://vaadin.com", + "files": [ + "*.{d.ts.map,d.ts,js.map,js}" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react-router": "^6.21.1" + }, + "devDependencies": { + "@esm-bundle/chai": "^4.3.4-fix.0", + "@types/chai-like": "^1.1.3", + "@types/deep-equal-in-any-order": "^1.0.3", + "@types/mocha": "^10.0.6", + "@types/sinon": "^17.0.3", + "chai-like": "^1.1.1", + "deep-equal-in-any-order": "^2.0.6", + "mocha": "^10.2.0", + "rimraf": "^5.0.5", + "sinon": "^17.0.1", + "type-fest": "^4.9.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "@vaadin/hilla-generator-utils": "^24.4.0-alpha1", + "react": "^18.2.0" + } +} diff --git a/packages/ts/hilla-file-router/src/runtime.ts b/packages/ts/hilla-file-router/src/runtime.ts new file mode 100644 index 0000000000..b77e045603 --- /dev/null +++ b/packages/ts/hilla-file-router/src/runtime.ts @@ -0,0 +1,42 @@ +import type { UIMatch } from '@remix-run/router'; +import { type ComponentType, createElement } from 'react'; +import { type RouteObject, useMatches } from 'react-router'; +import { + type AgnosticRoute, + transformRoute, + adjustViewTitle, + type ViewConfig, + extractComponentName, +} from './runtime/utils.js'; + +export type RouteModule

= Readonly<{ + default: ComponentType

; + config?: ViewConfig; +}>; + +/** + * Transforms generated routes into a format that can be used by React Router. + * + * @param routes - Generated routes + */ +export function toReactRouter(routes: AgnosticRoute): RouteObject { + return transformRoute( + routes, + (route) => route.children?.values(), + ({ path, module }, children) => + ({ + path, + element: module?.default ? createElement(module.default) : undefined, + children: children.length > 0 ? (children as RouteObject[]) : undefined, + handle: adjustViewTitle(module?.config, extractComponentName(module?.default)), + }) satisfies RouteObject, + ); +} + +/** + * Hook to return the {@link ViewConfig} for the current route. + */ +export function useViewConfig(): M | undefined { + const matches = useMatches() as ReadonlyArray>; + return matches[matches.length - 1]?.handle; +} diff --git a/packages/ts/hilla-file-router/src/runtime/utils.ts b/packages/ts/hilla-file-router/src/runtime/utils.ts new file mode 100644 index 0000000000..f05893f045 --- /dev/null +++ b/packages/ts/hilla-file-router/src/runtime/utils.ts @@ -0,0 +1,105 @@ +export type ViewConfig = Readonly<{ + /** + * View title used in the main layout header, as and as the default + * for the menu entry. If not defined, then the view function name is converted + * from CamelCase after removing any "View" postfix. + */ + title?: string; + + /** + * Same as in the explicit React Router configuration. + */ + rolesAllowed?: string[]; + + /** + * Allows overriding the route path configuration. Uses the same syntax as + * the path property with React Router. This can be used to define a route + * that conflicts with the file name conventions, e.g. /foo/index + */ + route?: string; + + /** + * Controls whether the view implementation will be lazy loaded the first time + * it's used or always included in the bundle. If set to undefined (which is + * the default), views mapped to / and /login will be eager and any other view + * will be lazy (this is in sync with defaults in Flow) + */ + lazy?: boolean; + + /** + * If set to false, then the route will not be registered with React Router, + * but it will still be included in the main menu and used to configure + * Spring Security + */ + register?: boolean; + + menu?: Readonly<{ + /** + * Title to use in the menu. Falls back the title property of the view + * itself if not defined. + */ + title?: string; + + /** + * Used to determine the order in the menu. Ties are resolved based on the + * used title. Entries without explicitly defined ordering are put below + * entries with an order. + */ + order?: number; + /** + * Set to true to explicitly exclude a view from the automatically + * populated menu. + */ + exclude?: boolean; + }>; +}>; + +export type AgnosticRoute<T> = Readonly<{ + path: string; + module?: T; + children?: ReadonlyArray<AgnosticRoute<T>>; +}>; + +export function transformRoute<T, U>( + route: T, + getChildren: (route: T) => IterableIterator<T> | null | undefined, + transformer: (route: T, children: readonly U[]) => U, +): U { + const children = getChildren(route); + + return transformer( + route, + children ? Array.from(children, (child) => transformRoute(child, getChildren, transformer)) : [], + ); +} + +export function extractComponentName(component?: unknown): string | undefined { + if ( + component && + (typeof component === 'object' || typeof component === 'function') && + 'name' in component && + typeof component.name === 'string' + ) { + return component.name; + } + + return undefined; +} + +const viewPattern = /view/giu; +const upperCaseSplitPattern = /(?=[A-Z])/gu; + +export function adjustViewTitle(config?: ViewConfig, componentName?: string): ViewConfig | undefined { + if (config?.title) { + return config; + } + + if (componentName) { + return { + ...config, + title: componentName.replace(viewPattern, '').split(upperCaseSplitPattern).join(' '), + }; + } + + return config; +} diff --git a/packages/ts/hilla-file-router/src/vite-plugin-file-router.ts b/packages/ts/hilla-file-router/src/vite-plugin-file-router.ts new file mode 100644 index 0000000000..a9155ff2a2 --- /dev/null +++ b/packages/ts/hilla-file-router/src/vite-plugin-file-router.ts @@ -0,0 +1,109 @@ +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { Plugin } from 'vite'; +import collectRoutesFromFS from './vite-plugin/collectRoutesFromFS.js'; +import createRoutesFromMeta from './vite-plugin/createRoutesFromMeta.js'; +import createViewConfigJson from './vite-plugin/createViewConfigJson.js'; + +export type PluginOptions = Readonly<{ + /** + * The base directory for the router. The folders and files in this directory + * will be used as route paths. + * + * @defaultValue `frontend/views` + */ + viewsDir?: URL | string; + /** + * The directory where the generated view file will be stored. + * + * @defaultValue `frontend/generated` + */ + generatedDir?: URL | string; + /** + * The list of extensions that will be collected as routes of the file-based + * router. + * + * @defaultValue `['.tsx', '.jsx', '.ts', '.js']` + */ + extensions?: readonly string[]; + /** + * The name of the export that will be used for the {@link ViewConfig} in the + * route file. + * + * @defaultValue `config` + */ + configExportName?: string; +}>; + +type RuntimeFileUrls = Readonly<{ + json: URL; + code: URL; +}>; + +async function generateRuntimeFiles(code: string, json: string, urls: RuntimeFileUrls) { + await Promise.all([writeFile(urls.json, json, 'utf-8'), writeFile(urls.code, code, 'utf-8')]); +} + +async function build( + viewsDir: URL, + outDir: URL, + generatedUrls: RuntimeFileUrls, + extensions: readonly string[], + configExportName: string, +): Promise<void> { + const routeMeta = await collectRoutesFromFS(viewsDir, { extensions }); + const runtimeRoutesCode = createRoutesFromMeta(routeMeta, outDir); + const viewConfigJson = await createViewConfigJson(routeMeta, configExportName); + + await generateRuntimeFiles(runtimeRoutesCode, viewConfigJson, generatedUrls); +} + +/** + * A Vite plugin that generates a router from the files in the specific directory. + * + * @param options - The plugin options. + * @returns A Vite plugin. + */ +export default function vitePluginFileSystemRouter({ + viewsDir = 'frontend/views/', + generatedDir = 'frontend/generated/', + extensions = ['.tsx', '.jsx', '.ts', '.js'], + configExportName = 'config', +}: PluginOptions = {}): Plugin { + let _viewsDir: URL; + let _generatedDir: URL; + let _outDir: URL; + let generatedUrls: RuntimeFileUrls; + + return { + name: 'vite-plugin-file-router', + configResolved({ root, build: { outDir } }) { + const _root = pathToFileURL(root); + _viewsDir = new URL(viewsDir, _root); + _generatedDir = new URL(generatedDir, _root); + _outDir = pathToFileURL(outDir); + generatedUrls = { + json: new URL('views.json', _outDir), + code: new URL('views.ts', _generatedDir), + }; + }, + async buildStart() { + await build(_viewsDir, _generatedDir, generatedUrls, extensions, configExportName); + }, + configureServer(server) { + const dir = fileURLToPath(_viewsDir); + + const changeListener = (file: string): void => { + if (!file.startsWith(dir)) { + return; + } + + build(_viewsDir, _outDir, generatedUrls, extensions, configExportName).catch((error) => console.error(error)); + }; + + server.watcher.on('add', changeListener); + server.watcher.on('change', changeListener); + server.watcher.on('unlink', changeListener); + }, + }; +} diff --git a/packages/ts/hilla-file-router/src/vite-plugin/collectRoutesFromFS.ts b/packages/ts/hilla-file-router/src/vite-plugin/collectRoutesFromFS.ts new file mode 100644 index 0000000000..0e5cf32b5e --- /dev/null +++ b/packages/ts/hilla-file-router/src/vite-plugin/collectRoutesFromFS.ts @@ -0,0 +1,62 @@ +import { opendir } from 'node:fs/promises'; +import { basename, extname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { cleanUp } from './utils.js'; + +export type RouteMeta = Readonly<{ + path: string; + file?: URL; + layout?: URL; + children: RouteMeta[]; +}>; + +export type CollectRoutesOptions = Readonly<{ + extensions: readonly string[]; + parent?: URL; +}>; + +const collator = new Intl.Collator('en-US'); + +export default async function collectRoutesFromFS( + dir: URL, + { extensions, parent = dir }: CollectRoutesOptions, +): Promise<RouteMeta> { + const path = relative(fileURLToPath(parent), fileURLToPath(dir)); + const children: RouteMeta[] = []; + let layout: URL | undefined; + + for await (const d of await opendir(dir)) { + if (d.isDirectory()) { + children.push(await collectRoutesFromFS(new URL(`${d.name}/`, dir), { extensions, parent: dir })); + } else if (d.isFile() && extensions.includes(extname(d.name))) { + const file = new URL(d.name, dir); + const name = basename(d.name, extname(d.name)); + + if (name.startsWith('$')) { + if (name === '$layout') { + layout = file; + } else if (name === '$index') { + children.push({ + path: '', + file, + children: [], + }); + } else { + throw new Error('Symbol "$" is reserved for special files; only "$layout" and "$index" are allowed'); + } + } else if (!name.startsWith('_')) { + children.push({ + path: name, + file, + children: [], + }); + } + } + } + + return { + path, + layout, + children: children.sort(({ path: a }, { path: b }) => collator.compare(cleanUp(a), cleanUp(b))), + }; +} diff --git a/packages/ts/hilla-file-router/src/vite-plugin/createRoutesFromMeta.ts b/packages/ts/hilla-file-router/src/vite-plugin/createRoutesFromMeta.ts new file mode 100644 index 0000000000..6643a52aa6 --- /dev/null +++ b/packages/ts/hilla-file-router/src/vite-plugin/createRoutesFromMeta.ts @@ -0,0 +1,95 @@ +import { extname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { template, transform as transformer } from '@vaadin/hilla-generator-utils/ast.js'; +import createSourceFile from '@vaadin/hilla-generator-utils/createSourceFile.js'; +import ts, { + type ImportDeclaration, + type ObjectLiteralExpression, + type StringLiteral, + type VariableStatement, +} from 'typescript'; +import { transformRoute } from '../runtime/utils.js'; +import type { RouteMeta } from './collectRoutesFromFS.js'; +import { convertFSPatternToURLPatternString } from './utils.js'; + +const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + +function relativize(url: URL, generatedDir: URL): string { + const result = relative(fileURLToPath(generatedDir), fileURLToPath(url)); + + if (!result.startsWith('.')) { + return `./${result}`; + } + + return result; +} + +function createImport(mod: string, file: string): ImportDeclaration { + const path = `${file.substring(0, file.lastIndexOf('.'))}.js`; + return template(`import * as ${mod} from '${path}';\n`, ([statement]) => statement as ts.ImportDeclaration); +} + +function createRouteData( + path: string, + mod: string | undefined, + children: readonly ObjectLiteralExpression[], +): ObjectLiteralExpression { + return template( + `const route = { + path: '${path}', + ${mod ? `module: ${mod}` : ''} + ${children.length > 0 ? `children: CHILDREN,` : ''} +}`, + ([statement]) => + (statement as VariableStatement).declarationList.declarations[0].initializer as ObjectLiteralExpression, + [ + transformer((node) => + ts.isIdentifier(node) && node.text === 'CHILDREN' ? ts.factory.createArrayLiteralExpression(children) : node, + ), + ], + ); +} + +export default function createRoutesFromMeta(views: RouteMeta, generatedDir: URL): string { + const imports: ImportDeclaration[] = []; + let id = 0; + + const routes = transformRoute<RouteMeta, ObjectLiteralExpression>( + views, + (view) => view.children.values(), + ({ file, layout, path }, children) => { + const currentId = id; + id += 1; + + let mod: string | undefined; + if (file) { + mod = `Page${currentId}`; + imports.push(createImport(mod, relativize(file, generatedDir))); + } else if (layout) { + mod = `Layout${currentId}`; + imports.push(createImport(mod, relativize(layout, generatedDir))); + } + + return createRouteData(convertFSPatternToURLPatternString(path), mod, children); + }, + ); + + const routeDeclaration = template( + `import a from 'IMPORTS'; + +const routes = ROUTE; + +export default routes; +`, + [ + transformer((node) => + ts.isImportDeclaration(node) && (node.moduleSpecifier as StringLiteral).text === 'IMPORTS' ? imports : node, + ), + transformer((node) => (ts.isIdentifier(node) && node.text === 'ROUTE' ? routes : node)), + ], + ); + + const file = createSourceFile(routeDeclaration, 'views.ts'); + + return printer.printFile(file); +} diff --git a/packages/ts/hilla-file-router/src/vite-plugin/createViewConfigJson.ts b/packages/ts/hilla-file-router/src/vite-plugin/createViewConfigJson.ts new file mode 100644 index 0000000000..17b3c7f628 --- /dev/null +++ b/packages/ts/hilla-file-router/src/vite-plugin/createViewConfigJson.ts @@ -0,0 +1,70 @@ +import { readFile } from 'node:fs/promises'; +import { Script } from 'node:vm'; +import ts, { type Node } from 'typescript'; +import { adjustViewTitle, type ViewConfig } from '../runtime/utils.js'; +import type { RouteMeta } from './collectRoutesFromFS.js'; +import { convertFSPatternToURLPatternString } from './utils.js'; + +function* traverse( + views: RouteMeta, + parents: readonly RouteMeta[] = [], +): Generator<readonly RouteMeta[], undefined, undefined> { + const chain = [...parents, views]; + + if (views.children.length === 0) { + yield chain; + } + + for (const child of views.children) { + yield* traverse(child, chain); + } +} + +function* walkAST(node: Node): Generator<Node> { + yield node; + + for (const child of node.getChildren()) { + yield* walkAST(child); + } +} + +export default async function createViewConfigJson(views: RouteMeta, configExportName: string): Promise<string> { + const res = await Promise.all( + Array.from(traverse(views), async (branch) => { + const configs = await Promise.all( + branch + .filter(({ file, layout }) => !!file || !!layout) + .map(({ file, layout }) => file ?? layout!) + .map(async (path) => { + const file = ts.createSourceFile('f.ts', await readFile(path, 'utf8'), ts.ScriptTarget.ESNext, true); + let config: ViewConfig | undefined; + let waitingForIdentifier = false; + let componentName: string | undefined; + + for (const node of walkAST(file)) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === configExportName) { + if (node.initializer && ts.isObjectLiteralExpression(node.initializer)) { + const code = node.initializer.getText(file); + const script = new Script(`(${code})`); + config = script.runInThisContext() as ViewConfig; + } + } else if (node.getText(file).includes('export default')) { + waitingForIdentifier = true; + } else if (waitingForIdentifier && ts.isIdentifier(node)) { + componentName = node.text; + } + } + + return adjustViewTitle(config, componentName); + }), + ); + + const key = branch.map(({ path }) => convertFSPatternToURLPatternString(path)).join('/'); + const value = configs[configs.length - 1]; + + return [key, value] satisfies readonly [string, ViewConfig | undefined]; + }), + ); + + return JSON.stringify(Object.fromEntries(res)); +} diff --git a/packages/ts/hilla-file-router/src/vite-plugin/utils.ts b/packages/ts/hilla-file-router/src/vite-plugin/utils.ts new file mode 100644 index 0000000000..b221cada98 --- /dev/null +++ b/packages/ts/hilla-file-router/src/vite-plugin/utils.ts @@ -0,0 +1,36 @@ +const restParamPattern = /\{\.{3}(.+)\}/gu; +const optionalParamPattern = /\{{2}(.+)\}{2}/gu; +const paramPattern = /\{(.+)\}/gu; + +/** + * Converts a file system pattern to a URL pattern string. + * + * @param fsPattern - a string representing a file system pattern: + * - `{param}` - for a required single parameter; + * - `{{param}}` - for an optional single parameter; + * - `{...rest}` - for multiple parameters, including none. + * + * @returns a string representing a URL pattern, respectively: + * - `:param`; + * - `:param?`; + * - `*`. + */ +export function convertFSPatternToURLPatternString(fsPattern: string): string { + return ( + fsPattern + // /url/{...rest}/page -> /url/*/page + .replaceAll(restParamPattern, '*') + // /url/{{param}}/page -> /url/:param?/page + .replaceAll(optionalParamPattern, ':$1?') + // /url/{param}/page -> /url/:param/page + .replaceAll(paramPattern, ':$1') + ); +} + +/** + * A small helper function that clears route path of the control characters in + * order to sort the routes alphabetically. + */ +export function cleanUp(path: string): string { + return path.replaceAll(restParamPattern, '$1').replaceAll(optionalParamPattern, '$1').replaceAll(paramPattern, '$1'); +} diff --git a/packages/ts/hilla-file-router/test/collectRoutesFromFS.spec.ts b/packages/ts/hilla-file-router/test/collectRoutesFromFS.spec.ts new file mode 100644 index 0000000000..f79e19401a --- /dev/null +++ b/packages/ts/hilla-file-router/test/collectRoutesFromFS.spec.ts @@ -0,0 +1,44 @@ +import { fileURLToPath } from 'node:url'; +import { expect, use } from '@esm-bundle/chai'; +import deepEqualInAnyOrder from 'deep-equal-in-any-order'; +import { rimraf } from 'rimraf'; +import collectRoutesFromFS from '../src/vite-plugin/collectRoutesFromFS.js'; +import { createTestingRouteFiles, createTestingRouteMeta, createTmpDir } from './utils.js'; + +use(deepEqualInAnyOrder); + +describe('@vaadin/hilla-file-router', () => { + describe('collectFileRoutes', () => { + const extensions = ['.tsx', '.jsx', '.ts', '.js']; + let tmp: URL; + + before(async () => { + tmp = await createTmpDir(); + await createTestingRouteFiles(tmp); + }); + + after(async () => { + await rimraf(fileURLToPath(tmp)); + }); + + it('should build a route tree', async () => { + // root + // ├── profile + // │ ├── account + // │ │ ├── layout.tsx + // │ │ └── security + // │ │ ├── password.tsx + // │ │ └── two-factor-auth.tsx + // │ ├── friends + // │ │ ├── layout.tsx + // │ │ ├── list.tsx + // │ │ └── {user}.tsx + // │ ├── index.tsx + // │ └── layout.tsx + // └── about.tsx + const result = await collectRoutesFromFS(tmp, { extensions }); + + expect(result).to.deep.equals(createTestingRouteMeta(tmp)); + }); + }); +}); diff --git a/packages/ts/hilla-file-router/test/createRoutesFromMeta.spec.ts b/packages/ts/hilla-file-router/test/createRoutesFromMeta.spec.ts new file mode 100644 index 0000000000..24e10ff939 --- /dev/null +++ b/packages/ts/hilla-file-router/test/createRoutesFromMeta.spec.ts @@ -0,0 +1,70 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect } from '@esm-bundle/chai'; +import type { RouteMeta } from '../src/vite-plugin/collectRoutesFromFS.js'; +import createRoutesFromMeta from '../src/vite-plugin/createRoutesFromMeta.js'; +import { createTestingRouteMeta } from './utils.js'; + +describe('@vaadin/hilla-file-router', () => { + describe('generateRoutes', () => { + let dir: URL; + let meta: RouteMeta; + + beforeEach(() => { + dir = pathToFileURL(join(tmpdir(), 'hilla-file-router/')); + meta = createTestingRouteMeta(new URL('./views/', dir)); + }); + + it('should generate a framework-agnostic tree of routes', () => { + const generated = createRoutesFromMeta(meta, new URL('./out/', dir)); + + expect(generated).to.equal(`import * as Page0 from "../views/about.js"; +import * as Page1 from "../views/profile/$index.js"; +import * as Page2 from "../views/profile/account/security/password.js"; +import * as Page3 from "../views/profile/account/security/two-factor-auth.js"; +import * as Layout5 from "../views/profile/account/$layout.js"; +import * as Page6 from "../views/profile/friends/list.js"; +import * as Page7 from "../views/profile/friends/{user}.js"; +import * as Layout8 from "../views/profile/friends/$layout.js"; +const routes = { + path: "", + children: [{ + path: "about", + module: Page0 + }, { + path: "profile", + children: [{ + path: "", + module: Page1 + }, { + path: "account", + module: Layout5, + children: [{ + path: "security", + children: [{ + path: "password", + module: Page2 + }, { + path: "two-factor-auth", + module: Page3 + }], + }], + }, { + path: "friends", + module: Layout8, + children: [{ + path: "list", + module: Page6 + }, { + path: ":user", + module: Page7 + }], + }], + }], +}; +export default routes; +`); + }); + }); +}); diff --git a/packages/ts/hilla-file-router/test/createViewConfigJson.spec.ts b/packages/ts/hilla-file-router/test/createViewConfigJson.spec.ts new file mode 100644 index 0000000000..6a8115c072 --- /dev/null +++ b/packages/ts/hilla-file-router/test/createViewConfigJson.spec.ts @@ -0,0 +1,43 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { expect } from '@esm-bundle/chai'; +import { rimraf } from 'rimraf'; +import type { RouteMeta } from '../src/vite-plugin/collectRoutesFromFS.js'; +import createViewConfigJson from '../src/vite-plugin/createViewConfigJson.js'; +import { createTestingRouteFiles, createTestingRouteMeta, createTmpDir } from './utils.js'; + +describe('@vaadin/hilla-file-router', () => { + describe('generateJson', () => { + let tmp: URL; + let meta: RouteMeta; + + before(async () => { + tmp = await createTmpDir(); + await createTestingRouteFiles(tmp); + }); + + after(async () => { + await rimraf(fileURLToPath(tmp)); + }); + + beforeEach(() => { + meta = createTestingRouteMeta(tmp); + }); + + it('should generate a JSON representation of the route tree', async () => { + const generated = await createViewConfigJson(meta, 'config'); + + expect(generated).to.equal( + JSON.stringify({ + '/about': { title: 'About' }, + '/profile/': { title: 'Profile' }, + '/profile/account/security/password': { title: 'Password' }, + '/profile/account/security/two-factor-auth': { title: 'Two Factor Auth' }, + '/profile/friends/list': { title: 'List' }, + '/profile/friends/:user': { title: 'User' }, + }), + ); + }); + }); +}); diff --git a/packages/ts/hilla-file-router/test/runtime.spec.tsx b/packages/ts/hilla-file-router/test/runtime.spec.tsx new file mode 100644 index 0000000000..c211935804 --- /dev/null +++ b/packages/ts/hilla-file-router/test/runtime.spec.tsx @@ -0,0 +1,113 @@ +import { expect, use } from '@esm-bundle/chai'; +import chaiLike from 'chai-like'; +import type { JSX } from 'react'; +import type { AgnosticRoute } from '../src/runtime/utils.js'; +import { type RouteModule, toReactRouter } from '../src/runtime.js'; + +use(chaiLike); + +describe('@vaadin/hilla-file-router', () => { + describe('react', () => { + function About(): JSX.Element { + return <></>; + } + + About.config = { title: 'About' }; + + function Friends(): JSX.Element { + return <></>; + } + + Friends.config = { title: 'Friends' }; + + function FriendsList(): JSX.Element { + return <></>; + } + + FriendsList.config = { title: 'Friends List' }; + + function Friend(): JSX.Element { + return <></>; + } + + Friend.config = { title: 'Friend' }; + + it('should be able to convert an agnostic routes to React Router routes', () => { + const routes: AgnosticRoute<RouteModule> = { + path: '', + children: [ + { + path: 'about', + module: { + default: About, + config: About.config, + }, + }, + { + path: 'profile', + children: [ + { + path: 'friends', + module: { + default: Friends, + config: Friends.config, + }, + children: [ + { + path: 'list', + module: { + default: FriendsList, + config: FriendsList.config, + }, + }, + { + path: '{user}', + module: { + default: Friend, + config: Friend.config, + }, + }, + ], + }, + ], + }, + ], + }; + + const result = toReactRouter(routes); + + expect(result).to.be.like({ + path: '', + children: [ + { + path: 'about', + element: <About />, + handle: About.config, + }, + { + path: 'profile', + children: [ + { + path: 'friends', + element: <Friends />, + handle: Friends.config, + children: [ + { + path: 'list', + element: <FriendsList />, + handle: FriendsList.config, + }, + { + path: '{user}', + element: <Friend />, + handle: Friend.config, + }, + ], + }, + ], + }, + ], + }); + }); + }); +}); diff --git a/packages/ts/hilla-file-router/test/utils.ts b/packages/ts/hilla-file-router/test/utils.ts new file mode 100644 index 0000000000..0690bfe232 --- /dev/null +++ b/packages/ts/hilla-file-router/test/utils.ts @@ -0,0 +1,105 @@ +import { appendFile, mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { RouteMeta } from '../src/vite-plugin/collectRoutesFromFS.js'; + +export async function createTmpDir(): Promise<URL> { + return pathToFileURL(`${await mkdtemp(join(tmpdir(), 'hilla-file-router-'))}/`); +} + +export async function createTestingRouteFiles(dir: URL): Promise<void> { + await Promise.all([ + mkdir(new URL('profile/account/security/', dir), { recursive: true }), + mkdir(new URL('profile/friends/', dir), { recursive: true }), + ]); + await Promise.all([ + appendFile( + new URL('profile/account/$layout.tsx', dir), + "export const config = { title: 'Account' };\nexport default function AccountLayout() {};", + ), + appendFile(new URL('profile/account/security/password.jsx', dir), 'export default function Password() {};'), + appendFile(new URL('profile/account/security/password.scss', dir), ''), + appendFile( + new URL('profile/account/security/two-factor-auth.ts', dir), + 'export default function TwoFactorAuth() {};', + ), + appendFile(new URL('profile/friends/$layout.tsx', dir), 'export default function FriendsLayout() {};'), + appendFile( + new URL('profile/friends/list.js', dir), + "export const config = { title: 'List' };\nexport default function List() {};", + ), + appendFile( + new URL('profile/friends/{user}.tsx', dir), + "export const config = { title: 'User' };\nexport default function User() {};", + ), + appendFile( + new URL('profile/$index.tsx', dir), + "export const config = { title: 'Profile' };\nexport default function Profile() {};", + ), + appendFile(new URL('profile/index.css', dir), ''), + appendFile( + new URL('about.tsx', dir), + "export const config = { title: 'About' };\nexport default function About() {};", + ), + ]); +} + +export function createTestingRouteMeta(dir: URL): RouteMeta { + return { + path: '', + layout: undefined, + children: [ + { + path: 'about', + file: new URL('about.tsx', dir), + children: [], + }, + { + path: 'profile', + layout: undefined, + children: [ + { path: '', file: new URL('profile/$index.tsx', dir), children: [] }, + { + path: 'account', + layout: new URL('profile/account/$layout.tsx', dir), + children: [ + { + path: 'security', + layout: undefined, + children: [ + { + path: 'password', + file: new URL('profile/account/security/password.jsx', dir), + children: [], + }, + { + path: 'two-factor-auth', + file: new URL('profile/account/security/two-factor-auth.ts', dir), + children: [], + }, + ], + }, + ], + }, + { + path: 'friends', + layout: new URL('profile/friends/$layout.tsx', dir), + children: [ + { + path: 'list', + file: new URL('profile/friends/list.js', dir), + children: [], + }, + { + path: '{user}', + file: new URL('profile/friends/{user}.tsx', dir), + children: [], + }, + ], + }, + ], + }, + ], + }; +} diff --git a/packages/ts/hilla-file-router/tsconfig.build.json b/packages/ts/hilla-file-router/tsconfig.build.json new file mode 100644 index 0000000000..a57d153410 --- /dev/null +++ b/packages/ts/hilla-file-router/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "." + }, + "include": ["src"] +} diff --git a/packages/ts/hilla-file-router/tsconfig.json b/packages/ts/hilla-file-router/tsconfig.json new file mode 100644 index 0000000000..2845bdae52 --- /dev/null +++ b/packages/ts/hilla-file-router/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + }, + "include": ["src", "test"], + "exclude": ["test/**/*.snap.ts"] +}