mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-27 13:52:47 -07:00
Compare commits
629 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 567d2d0ea1 | |||
| 32d7d83d79 | |||
| 0a2ee1ab02 | |||
| 77c9e1f7c8 | |||
| 183f1c498d | |||
| 3e07d57956 | |||
| d5c0dd4365 | |||
| 4d187e506f | |||
| 71d3440d23 | |||
| 4856af992b | |||
| 6ccc91c4a8 | |||
| 468bc48a0c | |||
| 1ef6a9dc90 | |||
| 27c546d8e3 | |||
| fbaf411992 | |||
| 403d548a07 | |||
| be48dbda09 | |||
| 038d9e4a46 | |||
| a5dd7cb537 | |||
| 8ac2132a2e | |||
| bc49ec3193 | |||
| 022b548af1 | |||
| a1f5811eda | |||
| 5990cf3d27 | |||
| c0719c6edb | |||
| 9404946269 | |||
| eab8d67203 | |||
| a9a76f8b09 | |||
| f7ba80c5c4 | |||
| a4667c4c2c | |||
| 998a3191b4 | |||
| 89ad91a055 | |||
| d810047ddd | |||
| 19c73f7747 | |||
| 2f2d772882 | |||
| 32e300f730 | |||
| 2f31d14194 | |||
| 980aa0d07f | |||
| 07351587f3 | |||
| 3027b828a8 | |||
| eef2cd26e0 | |||
| 8190915e3a | |||
| dd157d6ef9 | |||
| 58f4ecb260 | |||
| ab441d1fa7 | |||
| 7538031261 | |||
| 18f66ff79b | |||
| b48f7244b7 | |||
| 22844a37fa | |||
| bc444929ed | |||
| 0813c14e15 | |||
| 5331cc0c8c | |||
| ba89a7e388 | |||
| 90a4d20436 | |||
| d938e82998 | |||
| fc15c01c90 | |||
| ffdf0f91ac | |||
| fa6b2f4c89 | |||
| 245b5c535b | |||
| 2bb0bdb2b0 | |||
| e368cc5e23 | |||
| eb4966e88e | |||
| 64c4c1174c | |||
| 315225184d | |||
| 39fcc33600 | |||
| 1adf380798 | |||
| ac20a83e7e | |||
| b17521063a | |||
| f449c4537e | |||
| 3c413c38bd | |||
| 750e4b6f22 | |||
| e6e455e966 | |||
| 54de22d8c4 | |||
| 40005c4e02 | |||
| 32a76a2560 | |||
| f3cf9b05ae | |||
| c946c43462 | |||
| 2ecfb0d5c0 | |||
| 6b02c40ad4 | |||
| 3334bddeb2 | |||
| 0335c8c1ad | |||
| 0fa83fb1a2 | |||
| b24466fb11 | |||
| c0b4fa8717 | |||
| 16b8125206 | |||
| 8ce1292960 | |||
| dbc205a09c | |||
| 0e5fcd4b57 | |||
| 42b7a22a29 | |||
| 13983a5362 | |||
| 557f412e27 | |||
| f8f21b69b6 | |||
| 7b656ee961 | |||
| c044ab45a7 | |||
| eda328f861 | |||
| f75fce87cf | |||
| 7c0999a5fe | |||
| a7b5e92c46 | |||
| 4d91bc0a72 | |||
| 3b3f601ff1 | |||
| abb12e7b3d | |||
| 66c3d91dfa | |||
| 53ec3ae280 | |||
| f293a1dee8 | |||
| a786f11d28 | |||
| 604581c64a | |||
| ae6ece7415 | |||
| b0a7557d83 | |||
| fde01421dc | |||
| 497532e6fe | |||
| 724ac83429 | |||
| c7ca9ca8b4 | |||
| 7934a7cbab | |||
| 35b1de5d22 | |||
| 2cfad3de12 | |||
| 78a742c4c8 | |||
| 57f0c9fa20 | |||
| 8dde08892c | |||
| da5fac0af2 | |||
| 12aa12c94d | |||
| 6dbdafa888 | |||
| 97082840f8 | |||
| d562aa87a3 | |||
| 2301083edc | |||
| 1c45b29ed1 | |||
| bfd0041c6a | |||
| 9f14689ca0 | |||
| b32b192f70 | |||
| 486a254a1e | |||
| 67de777fd1 | |||
| 8fcebae4a9 | |||
| 77c902a7a5 | |||
| da194524ee | |||
| 81e7af6f5e | |||
| fd2357cedc | |||
| 9db6f02701 | |||
| 9b6f8098b6 | |||
| a322ce2fad | |||
| 73b7a8f056 | |||
| 0e6546d4d0 | |||
| bf77ed1794 | |||
| e34c10bc34 | |||
| 310579cf76 | |||
| 1ba94238ed | |||
| 5f6bad637c | |||
| 3063763667 | |||
| caf1a58a0b | |||
| 059f3d6476 | |||
| 303a177344 | |||
| d26918fc2f | |||
| 31f0a167e9 | |||
| d71185c9cb | |||
| 798f8f1bf8 | |||
| 06612f07b0 | |||
| 067535c2f6 | |||
| d679982949 | |||
| de5ac53cc3 | |||
| ca4a7b3466 | |||
| 0a4d1e2995 | |||
| d6fc18e2f6 | |||
| 8e04abbe42 | |||
| 2905cfae3d | |||
| b632cf43ba | |||
| 06bc3700b7 | |||
| d4648afe01 | |||
| 0653349c08 | |||
| 12e0937174 | |||
| d37b655add | |||
| e3b1c0e013 | |||
| be77068c73 | |||
| 907c92bedc | |||
| 6794ab20d3 | |||
| e00dad9200 | |||
| 9aa6aedee0 | |||
| 0114867fe6 | |||
| 4f58d42582 | |||
| 40add60fd8 | |||
| 9a4bad95ed | |||
| 3e28aeea27 | |||
| 58e48147b8 | |||
| 68eda84053 | |||
| 253f35dbbb | |||
| 0f72506f95 | |||
| ebe1abe422 | |||
| f5a7480f69 | |||
| c0139fddc8 | |||
| 9e08a949bb | |||
| d86c97979b | |||
| 4e266522b6 | |||
| a58a1a4da9 | |||
| 22c1dd04c2 | |||
| 74e34dbbf9 | |||
| 4f38de3dea | |||
| 9d97f2d345 | |||
| 938cf3d8c6 | |||
| e1443274e3 | |||
| b9f8c0312f | |||
| 674a817131 | |||
| 2cbe53f7b7 | |||
| f3033ef0d2 | |||
| 0a0fb8c7f8 | |||
| f72a1a9e5c | |||
| fb29a26246 | |||
| 79d4aff758 | |||
| 7ea8414d6c | |||
| 1a3c9a7fbe | |||
| cecf2ba8cc | |||
| eaaf2513d7 | |||
| fa4d435320 | |||
| 2aff72942c | |||
| 11666fc422 | |||
| aef456cbf2 | |||
| 97b9d71caa | |||
| 5fa38b9506 | |||
| 98e50da575 | |||
| c8cf0771d4 | |||
| 1c6e78ad57 | |||
| 2b6f8c16ce | |||
| 2403ef3d64 | |||
| 003c0766cf | |||
| 9d7e59d793 | |||
| 43f03adedf | |||
| 211c6401e9 | |||
| 01b90b327e | |||
| ad0b09c3df | |||
| 362e1be58d | |||
| eb9039daa3 | |||
| ff7433f434 | |||
| a4e207688a | |||
| fdc87ed8f1 | |||
| 0ab9c297a5 | |||
| 04d6fcc1e1 | |||
| bf18ac0ab0 | |||
| bbe9605f6f | |||
| bd34c194cb | |||
| c0aa707315 | |||
| b6fa448202 | |||
| 2e141be0fc | |||
| 93b5e3e68f | |||
| 39dac5065f | |||
| a16a313f71 | |||
| 82560ddc20 | |||
| 83f3cae6f0 | |||
| 99df4a5575 | |||
| d33c211a01 | |||
| 21ee89ada4 | |||
| dcedb1c6c0 | |||
| d5cc65983e | |||
| 3cb150b090 | |||
| 305ae59086 | |||
| 8fe8930def | |||
| 8730bdbfb9 | |||
| 3ee47e9481 | |||
| 4e649acd60 | |||
| 4768d70a8a | |||
| c7bff7ed09 | |||
| 3035d2a645 | |||
| 0750037385 | |||
| 8c4d61ee65 | |||
| 1a3ccb112e | |||
| 118172d27f | |||
| 69f1d85360 | |||
| f0d042c85b | |||
| 550830c232 | |||
| 6be12b4e58 | |||
| 33aa37ed7d | |||
| 43910fd75d | |||
| 5efcc86a63 | |||
| 17b61a5a41 | |||
| 5dae5c31b7 | |||
| 96deea7f31 | |||
| 7632e6a464 | |||
| 23e2a2e3ff | |||
| b19512dffc | |||
| 11c6fc820b | |||
| 1a8231851d | |||
| 1dfb83b1aa | |||
| 93cb1ae8ba | |||
| 631b168e05 | |||
| af8a43c93a | |||
| 875d12f514 | |||
| a9a2ef67ea | |||
| 5caf3ed6e7 | |||
| 580ec704f2 | |||
| a9918ae555 | |||
| 4b3f4bfab8 | |||
| fed3b1581b | |||
| 9b2f0aab32 | |||
| 818a6bd2ff | |||
| 94a43347a1 | |||
| 16d793c588 | |||
| 0322e5bd8d | |||
| cfe76a9ab3 | |||
| ff6014317b | |||
| 64220f2e27 | |||
| e75414abfc | |||
| d6fae42dc3 | |||
| 0db1245a6b | |||
| 91c0a9ec31 | |||
| 7670c37685 | |||
| ae41c5629c | |||
| 3e7fa05fce | |||
| 6833640c47 | |||
| 18541c45de | |||
| 45bb0dd0d2 | |||
| db0aad2c37 | |||
| d55ca12125 | |||
| ad2cada94b | |||
| f9545f513b | |||
| c75d83dc7c | |||
| 91236e5533 | |||
| 1356be248e | |||
| 7e8be92cd5 | |||
| c3fda4a3a5 | |||
| dfc66cfd50 | |||
| 0ef1252e1f | |||
| cf56b3a53e | |||
| 592f402ffd | |||
| 1dc77fc473 | |||
| 09cd772d74 | |||
| 1c69e10e6a | |||
| 1af18e4c91 | |||
| a3709609c5 | |||
| 70a79faee0 | |||
| 07cb7dae9d | |||
| 9a1fb9c4a1 | |||
| a3f7ed7f61 | |||
| 5302fd2779 | |||
| ccd4e02b6a | |||
| f6593097b6 | |||
| 230cd22bff | |||
| f4367f1755 | |||
| 7b7447f9a0 | |||
| d12d8404ec | |||
| d1b12173d5 | |||
| f3bf13da59 | |||
| f236b75f55 | |||
| 2434231f9b | |||
| b8b39e69a1 | |||
| f8158dfd8e | |||
| f1ab7df5ce | |||
| 986de49052 | |||
| d09ca5548a | |||
| 77e22c40b6 | |||
| de74647c43 | |||
| 498601c21a | |||
| e99f06ddf5 | |||
| f06af59168 | |||
| d50e3a7fc0 | |||
| 3e4e7ff1ab | |||
| 21ff3c8655 | |||
| 166e61bab4 | |||
| 93cd8bd8da | |||
| f5c1641af2 | |||
| 62ffed0624 | |||
| 8d0a4a7417 | |||
| b1b274ea12 | |||
| fc38865965 | |||
| 911adaaecb | |||
| 7626ed8d1d | |||
| 6135fd7e1b | |||
| 14bea36356 | |||
| 4abc163a59 | |||
| 5bb9cf1e7c | |||
| a0cdf408a1 | |||
| 8467506915 | |||
| 704255774d | |||
| 10bb7b1cca | |||
| 7f82af3d3b | |||
| d7c729c035 | |||
| 51fa904f9b | |||
| 6191e55745 | |||
| c6e93c266a | |||
| bcf7e5b5ab | |||
| 466f122cc4 | |||
| 539484ea25 | |||
| db1cee25bf | |||
| 96502b18d0 | |||
| c491ece8c5 | |||
| c76464bf8a | |||
| 08b75b1a79 | |||
| 90da869277 | |||
| 80c4affaa8 | |||
| aa087193fe | |||
| af7e51f69b | |||
| 1b62d8ddc2 | |||
| 0839579733 | |||
| 7f9bf08f76 | |||
| 79ec1c6922 | |||
| c68246df83 | |||
| abc0ad7120 | |||
| 4356ca1cff | |||
| d6215d009c | |||
| 0b501e95a5 | |||
| f157ff37e0 | |||
| 11d425faa9 | |||
| 5fc68a1926 | |||
| ec4577c187 | |||
| 8052de8ad5 | |||
| 2fda959cd6 | |||
| 05fa8a5ae2 | |||
| a341b41e9a | |||
| a2e0a4d78a | |||
| 4fb1d6077c | |||
| 53ab927544 | |||
| 897cc2daf2 | |||
| 73ff31daf5 | |||
| c387a9e17e | |||
| 1f92931076 | |||
| d3105a1532 | |||
| 7f50f31837 | |||
| 4c3e90ea4b | |||
| 8d80fc4aa9 | |||
| 78a04ffcf1 | |||
| bcb2c91823 | |||
| 9058f209dd | |||
| 1f172d583e | |||
| 439b4757df | |||
| 9f8809bd51 | |||
| 4e326650cc | |||
| e27be27692 | |||
| 88eb2a1897 | |||
| 8c84ef73c8 | |||
| 859e49f16c | |||
| 14ba8b0a23 | |||
| d7c70f75f3 | |||
| 2760e3ea08 | |||
| f2f041fe55 | |||
| 9ec9700ed4 | |||
| c6b047fc02 | |||
| 6727264e39 | |||
| d1a5fe234d | |||
| e84c7cd276 | |||
| 087813fab9 | |||
| 3a4e7398c6 | |||
| 1630fd7e71 | |||
| 4aa99beaff | |||
| eaa0ba28d8 | |||
| 3f3e1120b2 | |||
| 89d7a2c740 | |||
| 56c31f0204 | |||
| 338cfb5be2 | |||
| 43f6fe664b | |||
| 209c9fb7f7 | |||
| a562f67e8d | |||
| ef0fe5a425 | |||
| b50902d23e | |||
| 55490579ec | |||
| 957d08701f | |||
| 61e1aed35d | |||
| 2034f0ef5f | |||
| fcb92c7f0b | |||
| 28ea1ca1df | |||
| ebc1a458a9 | |||
| df92ef5296 | |||
| 253e0a8378 | |||
| a9fbe4eddd | |||
| cfccc638c6 | |||
| 9f1460cc26 | |||
| f0a4541420 | |||
| d0ce482680 | |||
| 40e2734a4e | |||
| 8552796343 | |||
| 6d72ffb876 | |||
| 65faaf0183 | |||
| 7f7c2e8d6f | |||
| 3be491baa7 | |||
| 48b974ae58 | |||
| c394898fcd | |||
| f22249f3e9 | |||
| 120cec52e9 | |||
| 535cc6b3d9 | |||
| 18a060e2bf | |||
| 7b8165cee6 | |||
| 6331eb64e6 | |||
| 3c38b551f0 | |||
| 8e761347f2 | |||
| e2d44e0e6a | |||
| 607da92c71 | |||
| ae0427604e | |||
| 2aa981c581 | |||
| 5d8ec82502 | |||
| fc9bb52c41 | |||
| 3224e4138f | |||
| 7ea28c4fd3 | |||
| acb5567f6a | |||
| cc877c7c11 | |||
| 87bdddf7cf | |||
| d939b64d64 | |||
| 3f64f71947 | |||
| aaae7db24d | |||
| 48c3934b6e | |||
| 258b4af0b1 | |||
| edfbd03f13 | |||
| 9cb0f62da7 | |||
| 9591960e31 | |||
| 132d8ebe8f | |||
| 4020073aca | |||
| 5025692220 | |||
| d04a4c4103 | |||
| 7e6dea68f4 | |||
| a046833414 | |||
| cfa59ca8bf | |||
| d470bd58f2 | |||
| ea82d2b1df | |||
| 93b44088a9 | |||
| 625c091fdc | |||
| e0c0447fb6 | |||
| b67a8e8537 | |||
| c9f1b02954 | |||
| 6b3bfca1f6 | |||
| 15e568594d | |||
| 089f41ee89 | |||
| 636c68e532 | |||
| 6d8a04dfb2 | |||
| 01d5aad8aa | |||
| 002733cd99 | |||
| d7a086dfb1 | |||
| d60c7dcd9b | |||
| 58baf8aa82 | |||
| 0a8504d9f2 | |||
| 934a83c0e2 | |||
| ea65ad042f | |||
| 3544cd2943 | |||
| e593707ce7 | |||
| 6dd0fc7410 | |||
| faf7bf29e2 | |||
| c5b5f6aefe | |||
| 8e48eec01d | |||
| e3c5cc882f | |||
| dfb1bebc6b | |||
| a40367f813 | |||
| 1a1b62698c | |||
| b36dc0b897 | |||
| a9eacf21f4 | |||
| 23d84b25be | |||
| 6c7cc62371 | |||
| 588326cc57 | |||
| ce654bf7a2 | |||
| a3bcb76105 | |||
| 4045ebc24e | |||
| 2be3710462 | |||
| fe3185c6dc | |||
| f10b813e1e | |||
| c67d4199a0 | |||
| d0a6c59c62 | |||
| 60f622542f | |||
| e7fcd64b3a | |||
| 3772e7307e | |||
| e853db164d | |||
| 509283adab | |||
| db56b7f2dc | |||
| acd663d79f | |||
| 3636268a71 | |||
| 6402f42d01 | |||
| 71ac19cbb9 | |||
| d99389a398 | |||
| 024c386af4 | |||
| d982c9c24b | |||
| 480973318d | |||
| a497937106 | |||
| 0d33150f30 | |||
| 94667b1454 | |||
| 2cbcd8ad70 | |||
| 9f40a55907 | |||
| 51e01ec142 | |||
| 63fc2b9855 | |||
| edd8a02423 | |||
| a0a81b514c | |||
| 92820804f8 | |||
| e7ea7ed1c4 | |||
| 7471c90f49 | |||
| f545a9c469 | |||
| 30073c226d | |||
| 1b1e91b6c0 | |||
| 71d7ccf27d | |||
| 3ca24ec87f | |||
| b2940d9fa8 | |||
| a7b7f0eac2 | |||
| 4859916edd | |||
| ad3e19e965 | |||
| 03dead1083 | |||
| 341fba6ca9 | |||
| 58c4890122 | |||
| af42e5ef21 | |||
| 5abff0384c | |||
| cc915560b8 | |||
| 9835a4c0d1 | |||
| 90782ae710 | |||
| 75c790dd6e | |||
| 8604858a61 | |||
| d1b6162d3a | |||
| a445644b47 | |||
| ebb4434506 | |||
| 36a53662a0 | |||
| 569d95dc9c | |||
| bc4c4e03f0 | |||
| 170c5ea098 | |||
| 3fd5f166e2 | |||
| a2285a78a1 | |||
| 1ae7f72de3 | |||
| 19782a2dbe | |||
| b755e658f5 | |||
| cc0279b964 | |||
| 099da549b5 | |||
| 92a76032eb | |||
| cd17eebf96 | |||
| 1726312d34 | |||
| 444d1479b0 | |||
| 2949ea4dfa | |||
| 8ab292b2f9 | |||
| 9d8bfd9826 | |||
| 8a6685a6d0 | |||
| 78990d3c08 | |||
| 227662489b | |||
| 5ce410f233 | |||
| 67d4e1bcac | |||
| 3e6aa540fa | |||
| b412371287 | |||
| f62fc0b2b9 | |||
| 35d8154649 | |||
| 6e794e9500 | |||
| e4d4a75b88 | |||
| bc7ffc2246 | |||
| 74e2f4e873 | |||
| d9d62a91f7 | |||
| 77b34fbb7b | |||
| d3040aa63f | |||
| 672e4b69d7 |
@@ -0,0 +1,10 @@
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"reportUnusedDisableDirectives": true,
|
||||
"ignorePatterns": ["**/build", "**/dist"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "import"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/stylistic",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"prettier",
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"tsconfigRootDir": ".",
|
||||
"project": true,
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 2020,
|
||||
},
|
||||
"settings": {
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"],
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": true,
|
||||
},
|
||||
"react": {
|
||||
"version": "detect",
|
||||
},
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/array-type": "error",
|
||||
"@typescript-eslint/ban-types": "error",
|
||||
"@typescript-eslint/ban-ts-comment": "error",
|
||||
"@typescript-eslint/consistent-type-definitions": "error",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ "prefer": "type-imports" },
|
||||
],
|
||||
"@typescript-eslint/explicit-module-boundary-types": "error",
|
||||
"@typescript-eslint/method-signature-style": ["error", "property"],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"selector": "typeParameter",
|
||||
"format": ["PascalCase"],
|
||||
"leadingUnderscore": "forbid",
|
||||
"trailingUnderscore": "forbid",
|
||||
"custom": {
|
||||
"regex": "^(T|T[A-Z][A-Za-z]+)$",
|
||||
"match": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-empty-function": "error",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/no-unnecessary-condition": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
"@typescript-eslint/no-inferrable-types": [
|
||||
"error",
|
||||
{ "ignoreParameters": true },
|
||||
],
|
||||
"import/default": "error",
|
||||
"import/export": "error",
|
||||
"import/namespace": "error",
|
||||
"import/newline-after-import": "error",
|
||||
"import/no-cycle": "error",
|
||||
"import/no-duplicates": "off",
|
||||
"import/no-named-as-default-member": "error",
|
||||
"import/no-unused-modules": "error",
|
||||
"import/order": [
|
||||
"off",
|
||||
{
|
||||
"groups": [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"object",
|
||||
"type",
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-case-declarations": "error",
|
||||
"no-empty": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-shadow": "error",
|
||||
"no-undef": "off",
|
||||
"sort-imports": "off",
|
||||
},
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
examples/** linguist-documentation
|
||||
*.css linguist-detectable=false
|
||||
*.scss linguist-detectable=false
|
||||
*.js linguist-detectable=false
|
||||
*.jsx linguist-detectable=true
|
||||
*.tsx linguist-detectable=true
|
||||
*.ts linguist-detectable=true
|
||||
*.rs linguist-detectable=true
|
||||
*.html linguist-detectable=false
|
||||
*.sh linguist-detectable=false
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Bug report
|
||||
description: File a bug report
|
||||
title: '[Bug]: '
|
||||
type: Bug
|
||||
labels: ['bug']
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behaviour
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to reproduce
|
||||
description: Steps to reproduce the behavior (code, example repository)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
label: System Info
|
||||
description: Output of `npx envinfo --system --npmPackages 'tuono' --binaries --browsers`
|
||||
render: shell
|
||||
placeholder: System, Binaries, Browsers
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: system-info-rust
|
||||
attributes:
|
||||
label: System info (Rust)
|
||||
description: Output of `rustc --version && cargo --version && tuono --version`
|
||||
render: shell
|
||||
placeholder: 'rustc, cargo and tuono binary version'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the bug here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ask a question / Get support
|
||||
url: https://discord.com/invite/khQzPa654B
|
||||
about: Ask questions and discuss with other community members
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: '[Feature request]: '
|
||||
type: Feature
|
||||
labels: ['enhancement']
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Install NodeJS Dependencies
|
||||
description: This is a composite GitHub Action that sets up pnpm, node and installs the project's dependencies.
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'Explicit node version. Otherwise fallback reading `.nvmrc`. Use in conjunction with matrix'
|
||||
required: false
|
||||
registry-url:
|
||||
description: 'https://github.com/actions/setup-node?tab=readme-ov-file#usage'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js (via input)
|
||||
if: ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Node.js (via .nvmrc)
|
||||
if: ${{ !inputs.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -0,0 +1,34 @@
|
||||
# @see https://github.com/actions/labeler
|
||||
|
||||
rust:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['crates/**', 'Cargo.toml']
|
||||
|
||||
typescript:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
[
|
||||
'devtools/**',
|
||||
'packages/**',
|
||||
'package.json',
|
||||
'pnpm-*.yaml',
|
||||
'eslint.config.js',
|
||||
'tsconfig.json',
|
||||
]
|
||||
|
||||
'CI/CD':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['.github/**']
|
||||
|
||||
'repo maintenance':
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
[
|
||||
'devtools/**',
|
||||
'.npmrc',
|
||||
'.nvmrc',
|
||||
'.prettierrc',
|
||||
'.prettierignore',
|
||||
'renovate.json',
|
||||
'turbo.json',
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
👋 Thank you for your Pull Request 🙏
|
||||
-->
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] I have read [Contributing > Pull requests](https://tuono.dev/documentation/contributing/pull-requests)
|
||||
|
||||
### Related issue
|
||||
|
||||
Fixes #0
|
||||
|
||||
<!-- Replace the content with "None" if you haven't an issue to link -->
|
||||
|
||||
### Overview
|
||||
|
||||
<!--
|
||||
|
||||
Explain the context and why you're making that change.
|
||||
What is the problem you're trying to solve?
|
||||
If a new feature is being added,
|
||||
describe the intended use case that feature fulfills.
|
||||
|
||||
-->
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Docker CI
|
||||
|
||||
# If we need support more OS refer to
|
||||
# https://github.com/tuono-labs/tuono/pull/332#issuecomment-2614439692
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Build and Test Docker Image on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Run Docker Compose
|
||||
run: |
|
||||
docker compose -f docker/compose.yml up --build -d
|
||||
|
||||
- name: Check if the container is running
|
||||
run: |
|
||||
docker ps --filter "name=tuono-source-container" --format "table {{.Names}}\t{{.Status}}"
|
||||
if ! docker ps --filter "name=tuono-source-container" --format "{{.Status}}" | grep -q "Up"; then
|
||||
echo "Error: Container 'tuono-source-container' is not running."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check commands inside container
|
||||
run: |
|
||||
docker exec tuono-source-container pnpm --version || { echo "Error: pnpm is not available."; exit 1; }
|
||||
docker exec tuono-source-container tuono --version || { echo "Error: tuono is not available."; exit 1; }
|
||||
|
||||
- name: Run cargo test
|
||||
run: docker exec tuono-source-container cargo test
|
||||
|
||||
- name: Install node dependencies in each tuono's sub-projets
|
||||
run: docker exec tuono-source-container bash -c 'for dir in /tuono/packages/*; do [ -d "$dir" ] && (cd "$dir" && yes | pnpm install --frozen-lockfile); done'
|
||||
|
||||
- name: Run pnpm test
|
||||
run: docker exec tuono-source-container pnpm test
|
||||
|
||||
- name: Tear Down Docker Compose
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f docker/compose.yml down --volumes
|
||||
@@ -0,0 +1,53 @@
|
||||
name: E2E CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E Test (${{ matrix.os }})
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
- 'windows-latest'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
ci_ok:
|
||||
name: E2E CI OK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs:
|
||||
- e2e
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Examples CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
example_list:
|
||||
name: Get examples names
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
|
||||
outputs:
|
||||
names: ${{ steps.find_folder.outputs.names }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- id: find_folder
|
||||
working-directory: examples
|
||||
# produces the list of folder inside examples without leading `./` and trailing `/`:
|
||||
# ```text
|
||||
# tuono-tutorial
|
||||
# with-tailwind
|
||||
# tuono-app
|
||||
# with-mdx
|
||||
# ```
|
||||
#
|
||||
# after, using `jq` the output is converted into a JSON array:
|
||||
# ```json
|
||||
# ["tuono-tutorial","with-tailwind","tuono-app","with-mdx"]
|
||||
# ```
|
||||
#
|
||||
# and added to `$GITHUB_OUTPUT` env variable:
|
||||
run: |
|
||||
NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
|
||||
echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check_example:
|
||||
needs:
|
||||
- example_list
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
example_name: ${{ fromJson(needs.example_list.outputs.names) }}
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
# to be added
|
||||
# - 'windows-latest'
|
||||
|
||||
# Only on this repo some examples build including plugins fails
|
||||
# However, if the project is downloaded via `tuono new`` it will work
|
||||
# @see https://github.com/tuono-labs/tuono/pull/605#pullrequestreview-2656262230
|
||||
# needs further investigations
|
||||
exclude:
|
||||
- example_name: with-tailwind
|
||||
|
||||
name: 'Check `${{ matrix.example_name }}` build on ${{ matrix.os }}'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Build Rust
|
||||
run: cargo build
|
||||
|
||||
- name: Build typescript
|
||||
run: pnpm run build
|
||||
|
||||
- name: Check "build"
|
||||
working-directory: ./examples/${{ matrix.example_name }}
|
||||
run: ../../target/debug/tuono build
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm run format
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
ci_ok:
|
||||
name: Examples CI OK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs:
|
||||
- example_list
|
||||
- check_example
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 'PR Labeler'
|
||||
|
||||
# @see https://github.com/actions/labeler
|
||||
# @see .github/labeler.yml
|
||||
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
name: 'Manage labels'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
# Add checkout step to load labeler.yml locally,
|
||||
# rather than from an API request
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,61 @@
|
||||
name: 'PR Title Checker'
|
||||
|
||||
# This workflow ensures that PR titles adhere to a pattern
|
||||
# similar to conventional commits standard
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
|
||||
jobs:
|
||||
check_pr_title:
|
||||
name: 'PR Title Checker'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check type
|
||||
id: type
|
||||
# @warning Keep in sync with https://github.com/tuono-labs/tuono-documentation/blob/main/src/routes/documentation/contributing/pull-requests.mdx
|
||||
run: |
|
||||
VALID_COMMIT_TYPES="chore|ci|docs|feat|fix|refactor|test"
|
||||
REGEX="^(${VALID_COMMIT_TYPES})(\(.*\))?!?: .*"
|
||||
|
||||
if ! [[ $PR_TITLE =~ $REGEX ]]; then
|
||||
echo "::error title=Type::The title has an incorrect type. Valid types are ${VALID_COMMIT_TYPES}"
|
||||
echo "result=fail" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::notice title=Type::The title is using a valid type"
|
||||
echo "result=ok" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check length
|
||||
id: length
|
||||
run: |
|
||||
MAX_LENGTH=100
|
||||
LENGTH=${#PR_TITLE}
|
||||
|
||||
if [ $LENGTH -gt $MAX_LENGTH ]; then
|
||||
echo "::error title=Length::The title is longer than $MAX_LENGTH characters"
|
||||
echo "result=fail" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::notice title=Length::The title length is within the maximum value of $MAX_LENGTH characters"
|
||||
echo "result=ok" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check result
|
||||
env:
|
||||
FAILURE: ${{ contains(join(steps.*.outputs.result, ','), 'fail') }}
|
||||
run: |
|
||||
echo "Failure: $FAILURE"
|
||||
if [ "$FAILURE" = "false" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,78 +1,68 @@
|
||||
name: Publish on npm and crates
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-on-npm-dry-run:
|
||||
publish_npm_dry_run:
|
||||
name: NPM publish (dry run)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish -r --no-git-checks --dry-run
|
||||
run: pnpm publish -r --no-git-checks --dry-run
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-on-crates-dry-run:
|
||||
publish_crates_dry_run:
|
||||
name: Crates publish (dry run)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- uses: katyo/publish-crates@v2
|
||||
with:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 'stable'
|
||||
|
||||
- uses: katyo/publish-crates@v2
|
||||
with:
|
||||
path: './crates/tuono'
|
||||
dry-run: true
|
||||
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
check-repo: true
|
||||
|
||||
publish-on-npm:
|
||||
needs: [publish-on-crates-dry-run, publish-on-npm-dry-run]
|
||||
publish_npm:
|
||||
name: NPM publish
|
||||
needs: [publish_npm_dry_run, publish_crates_dry_run]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Clone root README.md on tuono package
|
||||
run: cp README.md packages/tuono/README.md
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
@@ -81,17 +71,22 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-on-crates:
|
||||
needs: [publish-on-crates-dry-run, publish-on-npm-dry-run]
|
||||
publish_crates:
|
||||
name: Crates publish
|
||||
needs: [publish_npm_dry_run, publish_crates_dry_run]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- uses: katyo/publish-crates@v2
|
||||
with:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 'stable'
|
||||
|
||||
- uses: katyo/publish-crates@v2
|
||||
with:
|
||||
path: './crates/tuono'
|
||||
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
check-repo: true
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Repo root CI
|
||||
|
||||
# All workflows are triggered based on specific paths,
|
||||
# the root repository is something that is not applicable to rust nor typescript
|
||||
# so it has been moved in a standalone workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Check format
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Test project
|
||||
run: pnpm repo:root:format
|
||||
|
||||
ci_ok:
|
||||
name: Repo root CI OK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs:
|
||||
- format
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Build and test crates on ${{ matrix.os }} with rust ${{ matrix.toolchain }}
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
- 'windows-latest'
|
||||
toolchain:
|
||||
- 'stable'
|
||||
|
||||
# Test `beta` and `nightly` toolchains only on `ubuntu`
|
||||
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow#expanding-or-adding-matrix-configurations
|
||||
include:
|
||||
- toolchain: 'beta'
|
||||
os: 'ubuntu-latest'
|
||||
- toolchain: 'nightly'
|
||||
os: 'ubuntu-latest'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
|
||||
- run: cargo build --verbose
|
||||
- run: cargo test --verbose
|
||||
|
||||
lint_and_fmt:
|
||||
name: Check format and lint rust crates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
ci_ok:
|
||||
name: Rust CI OK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs:
|
||||
- build_and_test
|
||||
- lint_and_fmt
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'crates/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'crates/**'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
lint_and_fmt:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
build_and_test:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
name: Build and test rust crates
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
toolchain:
|
||||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||
- run: cargo build --verbose
|
||||
- run: cargo test --verbose
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Spell Checker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
spell_checking:
|
||||
name: LanguageTool
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Keep this value in order to show the error comments
|
||||
# also on edited files.
|
||||
fetch-depth: 2
|
||||
|
||||
# If the workflow is triggered by a push use check reporter
|
||||
#
|
||||
# If the workflow is triggered by a PR coming from the
|
||||
# same repository, the author has write access so we can
|
||||
# use pr-review reporter since the GITHUB_TOKEN will have review
|
||||
# write permissions
|
||||
#
|
||||
# Otherwise use annotation reporter that do not require any
|
||||
# particular permission
|
||||
#
|
||||
# Why this?
|
||||
# https://github.com/Valerioageno/tuono/pull/137#pullrequestreview-2455455272
|
||||
- name: Set reporter
|
||||
id: reporter
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
FROM_MAIN_REPO: ${{ github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name }}
|
||||
run: |
|
||||
echo "EVENT_NAME: $EVENT_NAME FROM_MAIN_REPO: $FROM_MAIN_REPO"
|
||||
if [ "$EVENT_NAME" == 'push' ]; then
|
||||
echo "name=github-check" >> $GITHUB_OUTPUT
|
||||
elif [ "$FROM_MAIN_REPO" == 'true' ]; then
|
||||
echo "name=github-pr-review" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "name=github-pr-annotations" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Review dog languagetool
|
||||
uses: reviewdog/action-languagetool@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Change reviewdog reporter if you need [github-pr-check,github-check,github-pr-review].
|
||||
reporter: ${{ steps.reporter.outputs.name }}
|
||||
# Change reporter level if you need.
|
||||
level: 'warning'
|
||||
patterns: '**/*.md **/*.txt **/*.mdx'
|
||||
# enabled_categories: "STYLE,PLAIN_ENGLISH,WHITESPACE_RULE,EN_QUOTES,DASH_RULE,WORD_CONTAINS_UNDERSCORE,UPPERCASE_SENTENCE_START,ARROWS,COMMA_PARENTHESIS_WHITESPACE,UNLIKELY_OPENING_PUNCTUATION,SENTENCE_WHITESPACE,EN_UNPAIRED_BRACKETS,PHRASE_REPETITION,PUNCTUATION_PARAGRAPH_END"
|
||||
disabled_categories: ''
|
||||
disabled_rules: ''
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Typescript CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Check build and test
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
- 'windows-latest'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
|
||||
- name: Test project
|
||||
run: pnpm test
|
||||
|
||||
lint_and_fmt:
|
||||
name: Check format and lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm run format
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
ci_ok:
|
||||
name: Typescript CI OK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs:
|
||||
- build_and_test
|
||||
- lint_and_fmt
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
@@ -1,66 +0,0 @@
|
||||
name: Typescript CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'packages/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'packages/**'
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
|
||||
- name: Test project
|
||||
run: pnpm test
|
||||
|
||||
fmt-lint-and-types:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Types
|
||||
run: pnpm types
|
||||
+8
-2
@@ -1,12 +1,14 @@
|
||||
## Node related ignores
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
|
||||
*/**/routeTree.gen.ts
|
||||
|
||||
pnpm-lock.yaml
|
||||
examples/*/pnpm-lock.yaml
|
||||
|
||||
examples/playground/
|
||||
vite.config.ts.timestamp*
|
||||
|
||||
## Rust related ignores
|
||||
|
||||
@@ -24,3 +26,7 @@ examples/playground/
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Files, generated by the code editors
|
||||
.vscode/
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
pnpm-lock.yaml
|
||||
|
||||
dist
|
||||
.tuono
|
||||
out
|
||||
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
packages/tuono-lazy-fn-vite-plugin/tests/sources/*
|
||||
|
||||
e2e/fixtures/*/test-results
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* @Valerioageno
|
||||
|
||||
+15
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
We, as members, contributors, and leaders, pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
- Focusing on what is best, not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
Advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct that could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
@@ -45,13 +45,13 @@ or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
not aligned with this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
An individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
+16
-11
@@ -1,21 +1,26 @@
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
# How to contribute to Tuono
|
||||
# How to Contribute to Tuono
|
||||
|
||||
## Contributions
|
||||
|
||||
## Contributions
|
||||
Any feature contribution or suggestion is strongly appreciated.
|
||||
Since the current project size there isn't yet a defined way to start a discussion. Consider to [open a new issue](https://github.com/Valerioageno/tuono/issues/new)
|
||||
or to reach me using my email address [valerioageno@yahoo.it](mailto:valerioageno@ahoo.it). I'm also available on twitter (X) DMs @valerioageno.
|
||||
|
||||
We are currently managing the tasks' prioritization in the [Road to V1](https://github.com/orgs/tuono-labs/projects/2) project. You can pick any task that you see in the `ready` column that is not already assigned.
|
||||
|
||||
If you want to contribute on something that is in the `backlog` column, or it hasn't been scoped yet, please drop a message in the [#general](https://discord.com/invite/khQzPa654B) discord channel so we can all have visibility of it.
|
||||
|
||||
Consider [opening a new issue](https://github.com/tuono-labs/tuono/issues/new/choose) if you find a bug.
|
||||
|
||||
## Bugs
|
||||
|
||||
**Did you find a bug?**
|
||||
- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/Valerioageno/tuono/issues).
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Valerioageno/tuono/issues/new). Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring.
|
||||
|
||||
- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/tuono-labs/tuono/issues).
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/tuono-labs/tuono/issues/new/choose). Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring.
|
||||
|
||||
**Did you write a patch that fixes a bug?**
|
||||
- Open a new GitHub pull request with the patch.
|
||||
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||
- The pull requests must pass all the CI pipelines
|
||||
|
||||
[Visit the documentation to learn how to open a pull request](https://tuono.dev/documentation/contributing/pull-requests)
|
||||
|
||||
+9
-6
@@ -1,12 +1,15 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/tuono",
|
||||
"crates/tuono_lib",
|
||||
"crates/tuono_lib_macros",
|
||||
"crates/tuono",
|
||||
"crates/tuono_internal",
|
||||
"crates/tuono_lib",
|
||||
"crates/tuono_lib_macros"
|
||||
]
|
||||
exclude = [
|
||||
"examples/playground",
|
||||
"examples/tuono",
|
||||
"examples/tutorial"
|
||||
"examples/with-mdx",
|
||||
"examples/tuono-app",
|
||||
"examples/tuono-tutorial",
|
||||
"examples/with-tailwind",
|
||||
"e2e/fixtures/base",
|
||||
]
|
||||
|
||||
@@ -1,98 +1,62 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px">
|
||||
</p>
|
||||
<h1 align="center">Tuono<br>The react/rust fullstack framework</h1>
|
||||
<p align="center">
|
||||
⚠️ This project is under heavy development. API might drastically change ⚠️
|
||||
</p>
|
||||
<div align="center">
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg" />
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg" />
|
||||
</div>
|
||||
[Documentation](https://tuono.dev) | [Tutorial](https://tuono.dev/documentation/tutorial) |
|
||||
[Discord](https://discord.com/invite/khQzPa654B) | [✨Contributing](https://tuono.dev/documentation/contributing)
|
||||
|
||||
<br>
|
||||
<br>
|
||||
# Tuono
|
||||
|
||||
<img align="right" src="https://raw.githubusercontent.com/tuono-labs/tuono/main/assets/logo.png" width="160px">
|
||||
|
||||
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||

|
||||

|
||||
|
||||
**Tuono is a full-stack web framework for building React applications using Rust as the backend with
|
||||
a strong focus on usability and performance.**
|
||||
|
||||
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||
Why Tuono? Just a badass name.
|
||||
|
||||
> Check out the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) to get started.
|
||||
If you have experience with [Next.js](https://nextjs.org/) you will feel home here.
|
||||
|
||||
## Introduction
|
||||
Some of its features are:
|
||||
|
||||
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be fullstack right? (no)**
|
||||
- 🟦 Native Typescript
|
||||
- 🌐 Next.js like Routing
|
||||
- 🍭 CSS/SCSS modules
|
||||
- 🧬 Server Side Rendering
|
||||
- 🔥 Hot Module Reload
|
||||
|
||||
Tuono is a fullstack React framework with the server side written in Rust.
|
||||
Because of this Tuono is extremely fast and the requests are handled by multithreaded Rust server.
|
||||
React is still React - it is just superpowered.
|
||||
## Documentation
|
||||
|
||||
**Rust is an hard language then writing server side code is hard as well right? (no again)**
|
||||
The [documentation](https://tuono.dev/) is available on
|
||||
[tuono.dev](https://tuono.dev/).
|
||||
|
||||
Tuono provides a collection of utilities to handle the server side code seamlessly with the React code.
|
||||
Each server side route is managed with a separate file alongside the React route. The routing is handled
|
||||
by Tuono based on the files defined within the `./src/routes` directory.
|
||||
## Installation
|
||||
|
||||
## Features
|
||||
Available on MacOS, Linux and Windows.
|
||||
|
||||
- 🟦 Typescript
|
||||
- 🌐 Routing
|
||||
- 🔥 Hot Module Reload
|
||||
- 🍭 CSS modules
|
||||
- 🧬 Server Side Rendering
|
||||
- 🧵 Multi thread backend
|
||||
- ⚙️ Build optimizations
|
||||
- Custom APIs*
|
||||
- Image optimization*
|
||||
- Server streamed content*
|
||||
|
||||
> *Development in progress
|
||||
```sh
|
||||
cargo install tuono
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
As already mentioned above I strongly suggest you to take a look at the
|
||||
[tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md).
|
||||
|
||||
Tuono is basically a CLI that provides all the commands to handle the fullstack project.
|
||||
To download it you need [cargo](https://doc.rust-lang.org/cargo/) which is the [rust](https://www.rust-lang.org/)
|
||||
package manager.
|
||||
|
||||
To download and install it you just need to run `cargo install tuono`.
|
||||
|
||||
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` flag - check the
|
||||
[examples](https://github.com/Valerioageno/tuono/tree/main/examples) folder).
|
||||
|
||||
Then to run the local development environment run inside the project folder `tuono dev`
|
||||
|
||||
Finally when the project will be ready to be deployed just run `tuono build` to create the final React assets
|
||||
and to set the server project to the `production` mode.
|
||||
|
||||
Now to execute it just run `cargo run --release`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- rust
|
||||
- cargo
|
||||
- node
|
||||
- npm/pnpm/yarn*
|
||||
|
||||
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||
|
||||
## Folder structure
|
||||
Create a new project with:
|
||||
|
||||
```sh
|
||||
tuono new [PROJECT_NAME]
|
||||
```
|
||||
├── package.json
|
||||
├── public
|
||||
├── src
|
||||
│ ├── routes
|
||||
│ └── styles
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
└── tsconfig.json
|
||||
|
||||
> You can optionally pass the --template (or -t) flag to directly start from a [template](https://github.com/tuono-labs/tuono/tree/main/examples)
|
||||
|
||||
Now just install the dependencies with you favourite JS package manager (i.e. `npm install`) and start the development environment
|
||||
|
||||
```sh
|
||||
tuono dev
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Any help or suggestion will be appreciated and encouraged.
|
||||
Check the [✨Contributing](https://tuono.dev/documentation/contributing) page
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+30
-12
@@ -1,17 +1,16 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
version = "0.19.7"
|
||||
edition = "2024"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
keywords = ["react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/tuono-labs/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
include = ["src/**/*.rs", "templates/*", "Cargo.toml"]
|
||||
|
||||
[lib]
|
||||
name = "tuono"
|
||||
@@ -19,14 +18,33 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.4", features = ["derive", "cargo"] }
|
||||
watchexec = "4.0.0"
|
||||
syn = { version = "2.0.100", features = ["full"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
|
||||
miette = "7.2.0"
|
||||
watchexec-signals = "3.0.0"
|
||||
|
||||
colored = "3.0.0"
|
||||
once_cell = "1.19.0"
|
||||
watchexec = "5.0.0"
|
||||
watchexec-signals = "4.0.0"
|
||||
watchexec-events = "4.0.0"
|
||||
watchexec-supervisor = "3.0.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
watchexec-supervisor = "2.0.0"
|
||||
glob = "0.3.1"
|
||||
regex = "1.10.4"
|
||||
reqwest = {version = "0.12.4", features =["blocking", "json"]}
|
||||
reqwest = { version = "0.12.4", features = ["blocking", "json"] }
|
||||
serde_json = "1.0"
|
||||
fs_extra = "1.3.0"
|
||||
http = "1.1.0"
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.19.7"}
|
||||
spinners = "4.1.1"
|
||||
console = "0.16.0"
|
||||
convert_case = "0.8.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6.2"
|
||||
tempfile = "3.14.0"
|
||||
assert_cmd = "2.0.16"
|
||||
serial_test = "3.0.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
use crate::mode::Mode;
|
||||
use crate::route::Route;
|
||||
use glob::{GlobError, glob};
|
||||
use http::Method;
|
||||
use std::collections::hash_set::HashSet;
|
||||
use std::collections::{HashMap, hash_map::Entry};
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::BufReader;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use tracing::error;
|
||||
use tuono_internal::config::Config;
|
||||
|
||||
const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"];
|
||||
const IGNORE_FILES: [&str; 1] = ["__layout"];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const ROUTES_FOLDER_PATH: &str = "\\src\\routes";
|
||||
#[cfg(target_os = "windows")]
|
||||
const BUILD_JS_SCRIPT: &str = ".\\node_modules\\.bin\\tuono-build-prod.cmd";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const BUILD_TUONO_CONFIG: &str = ".\\node_modules\\.bin\\tuono-build-config.cmd";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const ROUTES_FOLDER_PATH: &str = "/src/routes";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const BUILD_JS_SCRIPT: &str = "./node_modules/.bin/tuono-build-prod";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const BUILD_TUONO_CONFIG: &str = "./node_modules/.bin/tuono-build-config";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct App {
|
||||
pub route_map: HashMap<String, Route>,
|
||||
pub base_path: PathBuf,
|
||||
pub has_app_state: bool,
|
||||
pub config: Option<Config>,
|
||||
}
|
||||
|
||||
fn has_app_state(base_path: PathBuf) -> std::io::Result<bool> {
|
||||
let file = File::open(base_path.join("src/app.rs"))?;
|
||||
let mut buf_reader = BufReader::new(file);
|
||||
let mut contents = String::new();
|
||||
buf_reader.read_to_string(&mut contents)?;
|
||||
Ok(contents.contains("pub fn main"))
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let base_path = std::env::current_dir().expect("Failed to read current_dir");
|
||||
|
||||
let mut app = App {
|
||||
route_map: HashMap::new(),
|
||||
base_path: base_path.clone(),
|
||||
has_app_state: has_app_state(base_path).unwrap_or(false),
|
||||
config: None,
|
||||
};
|
||||
|
||||
app.collect_routes();
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
pub fn collect_routes(&mut self) {
|
||||
glob(
|
||||
self.base_path
|
||||
.join("src/routes/**/*.*")
|
||||
.to_str()
|
||||
.expect("Failed to glob routes folder"),
|
||||
)
|
||||
.expect("Failed to read glob pattern")
|
||||
.for_each(|entry| {
|
||||
if self.should_collect_route(&entry) {
|
||||
self.collect_route(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn should_collect_route(&self, entry: &Result<PathBuf, GlobError>) -> bool {
|
||||
let file_extension = entry
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.extension()
|
||||
.expect("Failed to read file extension");
|
||||
let file_name = entry
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.file_stem()
|
||||
.expect("Failed to read file name");
|
||||
|
||||
if IGNORE_EXTENSIONS.iter().any(|val| val == &file_extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if IGNORE_FILES.iter().any(|val| val == &file_name) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_route(&mut self, path_buf: Result<PathBuf, GlobError>) {
|
||||
let entry = path_buf.expect("Failed to read glob path");
|
||||
|
||||
let base_path_str = self
|
||||
.base_path
|
||||
.to_str()
|
||||
.expect("Failed to read as str base_path");
|
||||
let path = entry
|
||||
.to_str()
|
||||
.expect("Failed to read entry as str")
|
||||
.replace(&format!("{base_path_str}{ROUTES_FOLDER_PATH}"), "")
|
||||
// Cleanup windows paths
|
||||
.replace("\\", "/")
|
||||
.replace(".rs", "")
|
||||
.replace(".mdx", "")
|
||||
.replace(".tsx", "");
|
||||
|
||||
if entry.extension().expect("failed to read entry extension") == "rs" {
|
||||
if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) {
|
||||
let mut route = Route::new(path);
|
||||
route.update_axum_info();
|
||||
route_map.insert(route);
|
||||
} else {
|
||||
let route = self.route_map.get_mut(&path).unwrap();
|
||||
route.update_axum_info();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) {
|
||||
let route = Route::new(path);
|
||||
route_map.insert(route);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_dynamic_routes(&self) -> bool {
|
||||
self.route_map.iter().any(|(_, route)| route.is_dynamic)
|
||||
}
|
||||
|
||||
pub fn check_server_availability(&self, mode: Mode) {
|
||||
// At this point the config should be available
|
||||
let config = self.config.as_ref().unwrap();
|
||||
|
||||
let rust_listener =
|
||||
std::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port));
|
||||
|
||||
if let Err(_e) = rust_listener {
|
||||
eprintln!("Error: Failed to bind to port {}", config.server.port);
|
||||
eprintln!(
|
||||
"Please ensure that port {} is not already in use by another process or application.",
|
||||
config.server.port
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if mode == Mode::Dev {
|
||||
let vite_port = config.server.port + 1;
|
||||
let vite_listener =
|
||||
std::net::TcpListener::bind(format!("{}:{}", config.server.host, vite_port));
|
||||
|
||||
if let Err(_e) = vite_listener {
|
||||
eprintln!("Error: Failed to bind to port {vite_port}");
|
||||
eprintln!(
|
||||
"Please ensure that port {vite_port} is not already in use by another process or application."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_react_prod(&self) {
|
||||
if !Path::new(BUILD_JS_SCRIPT).exists() {
|
||||
error!("Failed to find the build script. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let output = Command::new(BUILD_JS_SCRIPT).output().unwrap_or_else(|_| {
|
||||
error!("Failed to build the react source");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
if !output.status.success() {
|
||||
error!("Failed to build the react source");
|
||||
error!("Error: {}", String::from_utf8_lossy(&output.stderr));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_rust_server(&self) -> Child {
|
||||
Command::new("cargo")
|
||||
.arg("run")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to run the rust server")
|
||||
}
|
||||
|
||||
pub fn build_tuono_config(&mut self) -> io::Result<std::process::Output> {
|
||||
if !Path::new(BUILD_TUONO_CONFIG).exists() {
|
||||
eprintln!("Failed to find the build script. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let config_build_command = Command::new(BUILD_TUONO_CONFIG)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output();
|
||||
|
||||
match Config::get() {
|
||||
Ok(config) => self.config = Some(config),
|
||||
Err(error) => {
|
||||
match error.kind() {
|
||||
io::ErrorKind::NotFound => eprintln!(
|
||||
"Failed to read config. Please run `npm install` to generate automatically."
|
||||
),
|
||||
_ => {
|
||||
error!("Failed to read config with the following error:");
|
||||
error!("{}", error);
|
||||
}
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
config_build_command
|
||||
}
|
||||
pub fn get_used_http_methods(&self) -> HashSet<Method> {
|
||||
let mut acc = HashSet::new();
|
||||
|
||||
for (_, route) in self.route_map.clone().into_iter() {
|
||||
if route.axum_info.is_some() {
|
||||
acc.insert(Method::GET);
|
||||
}
|
||||
if !route.is_api() {
|
||||
continue;
|
||||
}
|
||||
for method in route.api_data.unwrap().methods.into_iter() {
|
||||
acc.insert(method);
|
||||
}
|
||||
}
|
||||
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_collect_routes() {
|
||||
let mut app = App::new();
|
||||
#[cfg(target_os = "windows")]
|
||||
let base_path = "\\home\\user\\Documents\\tuono";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let base_path = "/home/user/Documents/tuono";
|
||||
|
||||
app.base_path = base_path.into();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let routes = [
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\about.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\index.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\index.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\[post].rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\handle-this.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\handle-this\\[post].rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\UPPERCASE.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\sitemap.xml.rs",
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/handle-this.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/handle-this/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs",
|
||||
"/home/user/Documents/tuono/src/routes/sitemap.xml.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index", "index"),
|
||||
("/about", "about"),
|
||||
("/posts/index", "posts_index"),
|
||||
("/posts/[post]", "posts_dyn_post"),
|
||||
("/posts/handle-this", "posts_handle_hyphen_this"),
|
||||
(
|
||||
"/posts/handle-this/[post]",
|
||||
"posts_handle_hyphen_this_dyn_post",
|
||||
),
|
||||
("/posts/UPPERCASE", "posts_uppercase"),
|
||||
("/sitemap.xml", "sitemap_dot_xml"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, module_import)| {
|
||||
assert_eq!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.unwrap()
|
||||
.axum_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.module_import,
|
||||
String::from(module_import)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_multi_level_axum_paths() {
|
||||
let mut app = App::new();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let base_path = "\\home\\user\\Documents\\tuono";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let base_path = "/home/user/Documents/tuono";
|
||||
|
||||
app.base_path = base_path.into();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let routes = [
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\about.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\index.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\index.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\any-post.rs",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\posts\\[post].rs",
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/any-post.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index", "/"),
|
||||
("/about", "/about"),
|
||||
("/posts/index", "/posts"),
|
||||
("/posts/any-post", "/posts/any-post"),
|
||||
("/posts/[post]", "/posts/{post}"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, expected_path)| {
|
||||
assert_eq!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.axum_route,
|
||||
String::from(expected_path)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_whitelisted_extensions() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.css",
|
||||
"/home/user/Documents/tuono/src/routes/index.scss",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.sass",
|
||||
];
|
||||
|
||||
routes.into_iter().for_each(|route| {
|
||||
if app.should_collect_route(&Ok(PathBuf::from(route))) {
|
||||
app.collect_route(Ok(PathBuf::from(route)))
|
||||
}
|
||||
});
|
||||
|
||||
assert!(app.route_map.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_whitelisted_files() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/__layout.tsx",
|
||||
"/home/user/Documents/tuono/src/routes/posts/__layout.tsx",
|
||||
];
|
||||
|
||||
routes.into_iter().for_each(|route| {
|
||||
if app.should_collect_route(&Ok(PathBuf::from(route))) {
|
||||
app.collect_route(Ok(PathBuf::from(route)))
|
||||
}
|
||||
});
|
||||
|
||||
assert!(app.route_map.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_parse_routes_with_server_handler() {
|
||||
let mut app = App::new();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let base_path = "\\home\\user\\Documents\\tuono";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let base_path = "/home/user/Documents/tuono";
|
||||
|
||||
app.base_path = base_path.into();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let routes = [
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\about.rs",
|
||||
"\\home\\user\\Documents/tuono\\src\\routes\\about.tsx",
|
||||
"\\home\\user\\Documents\\tuono\\src\\routes\\index.tsx",
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/about.tsx",
|
||||
"/home/user/Documents/tuono/src/routes/index.tsx",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [("/about", true), ("/index", false)];
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.for_each(|(path, expected_has_server_handler)| {
|
||||
if expected_has_server_handler {
|
||||
assert!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.is_some()
|
||||
)
|
||||
} else {
|
||||
assert!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.is_none()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_dynamic_routes_works() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(app.has_dynamic_routes());
|
||||
|
||||
let mut app2 = App::new();
|
||||
app2.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app2.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(app2.has_dynamic_routes());
|
||||
|
||||
let mut app3 = App::new();
|
||||
app3.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app3.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(!app3.has_dynamic_routes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing::{Level, span};
|
||||
|
||||
use crate::commands::{build, dev, new};
|
||||
use crate::mode::Mode;
|
||||
use crate::source_builder::SourceBuilder;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
/// Start the development environment
|
||||
Dev,
|
||||
/// Build the production assets
|
||||
Build {
|
||||
#[arg(short, long = "static")]
|
||||
/// Statically generate the website HTML
|
||||
ssg: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
/// Prevent to export the js assets
|
||||
no_js_emit: bool,
|
||||
},
|
||||
/// Scaffold a new project
|
||||
New {
|
||||
/// The folder in which load the project. Default is the current directory.
|
||||
folder_name: Option<String>,
|
||||
/// The template to use to scaffold the project. The template should match one of the tuono
|
||||
/// examples
|
||||
#[arg(short, long)]
|
||||
template: Option<String>,
|
||||
/// Load the latest commit available on the main branch
|
||||
#[arg(long)]
|
||||
head: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "The React/Rust full-stack framework")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
pub fn app() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.action {
|
||||
Actions::Dev => {
|
||||
let span = span!(Level::TRACE, "DEV");
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev)?;
|
||||
|
||||
source_builder.base_build()?;
|
||||
|
||||
source_builder.app.check_server_availability(Mode::Dev);
|
||||
|
||||
dev::watch(source_builder).unwrap();
|
||||
}
|
||||
Actions::Build { ssg, no_js_emit } => {
|
||||
let span = span!(Level::TRACE, "BUILD");
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let mut source_builder = SourceBuilder::new(Mode::Prod)?;
|
||||
source_builder.base_build()?;
|
||||
build::build(source_builder.app, ssg, no_js_emit);
|
||||
}
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
head,
|
||||
} => {
|
||||
let span = span!(Level::TRACE, "NEW");
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
new::create_new_project(folder_name, template, head);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use fs_extra::dir::{CopyOptions, copy};
|
||||
use spinners::{Spinner, Spinners};
|
||||
use std::path::PathBuf;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, trace};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
|
||||
fn exit_gracefully_with_error(msg: &str) -> ! {
|
||||
error!(msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
pub fn build(mut app: App, ssg: bool, no_js_emit: bool) {
|
||||
if no_js_emit {
|
||||
println!("Rust build successfully finished");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
if ssg && app.has_dynamic_routes() {
|
||||
// TODO: allow dynamic routes static generation
|
||||
println!("Cannot statically build dynamic routes");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
app.build_tuono_config()
|
||||
.unwrap_or_else(|_| exit_gracefully_with_error("Failed to build tuono.config.ts"));
|
||||
|
||||
let mut app_build_spinner = Spinner::new(Spinners::Dots, "Building app...".into());
|
||||
|
||||
app.check_server_availability(Mode::Prod);
|
||||
|
||||
app.build_react_prod();
|
||||
|
||||
// Remove the spinner
|
||||
app_build_spinner.stop_with_message("\u{2705}Build completed".into());
|
||||
|
||||
if ssg {
|
||||
let mut app_build_static_spinner =
|
||||
Spinner::new(Spinners::Dots, "Static site generation".into());
|
||||
|
||||
let mut exit_gracefully_with_error = |msg: &str| -> ! {
|
||||
app_build_static_spinner.stop_with_message("\u{274C} Build failed\n".into());
|
||||
exit_gracefully_with_error(msg)
|
||||
};
|
||||
|
||||
let static_dir = PathBuf::from("out/static");
|
||||
|
||||
if static_dir.is_dir() {
|
||||
std::fs::remove_dir_all(&static_dir).unwrap_or_else(|_| {
|
||||
exit_gracefully_with_error("Failed to clear the out/static folder")
|
||||
});
|
||||
}
|
||||
|
||||
std::fs::create_dir(&static_dir)
|
||||
.unwrap_or_else(|_| exit_gracefully_with_error("Failed to create static output dir"));
|
||||
|
||||
copy(
|
||||
"./out/client",
|
||||
static_dir,
|
||||
&CopyOptions::new().overwrite(true).content_only(true),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
exit_gracefully_with_error("Failed to clone assets into static output folder")
|
||||
});
|
||||
|
||||
// Start the server
|
||||
#[allow(clippy::zombie_processes)]
|
||||
let mut rust_server = app.run_rust_server();
|
||||
|
||||
let mut exit_and_shut_server = |msg: &str| -> ! {
|
||||
_ = rust_server.kill();
|
||||
exit_gracefully_with_error(msg)
|
||||
};
|
||||
|
||||
let reqwest_client = reqwest::blocking::Client::builder()
|
||||
.user_agent("")
|
||||
.build()
|
||||
.unwrap_or_else(|_| exit_and_shut_server("Failed to build reqwest client"));
|
||||
|
||||
// Wait for server
|
||||
let mut is_server_ready = false;
|
||||
let config = app.config.as_ref().unwrap();
|
||||
|
||||
while !is_server_ready {
|
||||
trace!("Checking server availability");
|
||||
|
||||
let server_url = match &config.server.origin {
|
||||
Some(origin) => origin.clone(),
|
||||
None => format!("http://{}:{}", config.server.host, config.server.port),
|
||||
};
|
||||
|
||||
if reqwest_client.get(&server_url).send().is_ok() {
|
||||
is_server_ready = true;
|
||||
} else {
|
||||
trace!("Server not ready yet. Sleeping for 1 second");
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
trace!("Server is ready, starting static site generation");
|
||||
|
||||
for (_, route) in app.route_map {
|
||||
if let Err(msg) = route.save_ssg_file(&reqwest_client) {
|
||||
exit_and_shut_server(&msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Close server
|
||||
let _ = rust_server.kill();
|
||||
|
||||
app_build_static_spinner
|
||||
.stop_with_message("\u{2705}Static site generation completed".into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::error;
|
||||
use tuono_internal::tuono_println;
|
||||
use watchexec_events::Tag;
|
||||
use watchexec_events::filekind::FileEventKind;
|
||||
|
||||
use crate::process_manager::{ProcessId, ProcessManager};
|
||||
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
|
||||
use crate::source_builder::SourceBuilder;
|
||||
use console::Term;
|
||||
use spinners::{Spinner, Spinners};
|
||||
|
||||
fn is_css_module(file_name: Cow<str>) -> bool {
|
||||
static RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^.*\.module\.(css|scss|sass|less|styl|stylus)$").unwrap());
|
||||
RE.is_match(&file_name)
|
||||
}
|
||||
|
||||
fn ssr_reload_needed(path: &Path) -> bool {
|
||||
let file_name_starts_with_env = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().starts_with(".env"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let file_path = path.to_string_lossy();
|
||||
|
||||
file_name_starts_with_env
|
||||
|| file_path.ends_with("sx")
|
||||
|| file_path.ends_with("mdx")
|
||||
// When a CSS module is modified
|
||||
// also the class names get modified.
|
||||
|| is_css_module(file_path)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::await_holding_lock,
|
||||
reason = "At this point there is no other thread waiting for the lock"
|
||||
)]
|
||||
async unsafe fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.start_dev_processes().await
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_existing_env_files() -> Vec<String> {
|
||||
if let Ok(dir) = fs::read_dir("./") {
|
||||
dir.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_name().to_string_lossy().starts_with(".env"))
|
||||
.map(|entry| entry.path().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
|
||||
let source_builder = RwLock::new(source_builder);
|
||||
let term = Term::stdout();
|
||||
let mut sp = Spinner::new(Spinners::Dots, "Starting dev server...".into());
|
||||
|
||||
let process_manager = Arc::new(Mutex::new(ProcessManager::new()));
|
||||
|
||||
let env_files = detect_existing_env_files();
|
||||
|
||||
unsafe {
|
||||
// It is safe to call this function because here
|
||||
// only one thread is running and the lock is not
|
||||
// needed by any other thread.
|
||||
start_all_processes(process_manager.clone()).await;
|
||||
}
|
||||
|
||||
// Remove the spinner
|
||||
sp.stop();
|
||||
_ = term.clear_line();
|
||||
|
||||
// Server address log for production
|
||||
// is done on the server process.
|
||||
if let Ok(builder) = source_builder.read() {
|
||||
if let Ok(pm) = process_manager.lock() {
|
||||
pm.log_server_address(builder.app.config.clone().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let process_manager = process_manager.clone();
|
||||
// if Ctrl-C is received, quit
|
||||
if action.signals().any(|sig| sig == Signal::Interrupt) {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.abort_all();
|
||||
}
|
||||
|
||||
action.quit_gracefully(Signal::Quit, Duration::from_secs(30));
|
||||
return action;
|
||||
}
|
||||
|
||||
// The best way to trigger the processes is to use these
|
||||
// flags.
|
||||
// This because the same event can be triggered multiple times
|
||||
// and we don't want to restart the process multiple times for the same
|
||||
// purpose.
|
||||
let mut should_reload_ssr_bundle = false;
|
||||
let mut should_reload_rust_server = false;
|
||||
let mut should_refresh_axum_source = false;
|
||||
|
||||
// Using HashSet to avoid duplicates
|
||||
let mut paths_to_refresh_types: HashSet<PathBuf> = HashSet::new();
|
||||
let mut removed_files_from_types: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
for event in action.events.iter() {
|
||||
for event_type in event.tags.iter() {
|
||||
if let Tag::FileEventKind(kind) = event_type {
|
||||
match kind {
|
||||
FileEventKind::Remove(_) => event.paths().for_each(|(path, _)| {
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
removed_files_from_types.insert(path.to_path_buf());
|
||||
should_refresh_axum_source = true;
|
||||
}
|
||||
}),
|
||||
FileEventKind::Modify(_) => event.paths().for_each(|(path, _)| {
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
should_reload_rust_server = true;
|
||||
paths_to_refresh_types.insert(path.to_path_buf());
|
||||
}
|
||||
if ssr_reload_needed(path) {
|
||||
should_reload_ssr_bundle = true;
|
||||
}
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !paths_to_refresh_types.is_empty() {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
for path in paths_to_refresh_types {
|
||||
// There is no need to check here if the `Type` trait is
|
||||
// derived since it will be checked later by the TypeJar struct.
|
||||
builder.refresh_typescript_file(path)
|
||||
}
|
||||
if builder.generate_typescript_file().is_err() {
|
||||
error!("Failed to generate typescript file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if !removed_files_from_types.is_empty() {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
for path in removed_files_from_types {
|
||||
builder.remove_typescript_file(path);
|
||||
}
|
||||
if builder.generate_typescript_file().is_err() {
|
||||
error!("Failed to generate typescript file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload_rust_server || should_refresh_axum_source {
|
||||
tuono_println!("Reloading...");
|
||||
if should_refresh_axum_source {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
builder.app.collect_routes();
|
||||
_ = builder.refresh_axum_source();
|
||||
}
|
||||
}
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.restart_process(ProcessId::RunRustDevServer);
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload_ssr_bundle {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.restart_process(ProcessId::BuildReactSSRSrc);
|
||||
}
|
||||
}
|
||||
|
||||
action
|
||||
})?;
|
||||
|
||||
// watch the current directory and all types of .env file
|
||||
let mut paths_to_watch = vec!["./src".to_string()];
|
||||
paths_to_watch.extend(env_files);
|
||||
|
||||
wx.config.pathset(paths_to_watch);
|
||||
|
||||
let _ = wx.main().await.into_diagnostic()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod dev;
|
||||
pub mod new;
|
||||
@@ -0,0 +1,330 @@
|
||||
use clap::crate_version;
|
||||
use reqwest::blocking;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions, create_dir};
|
||||
use std::io::{self, prelude::*};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::trace;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
enum GithubFileType {
|
||||
#[serde(rename = "blob")]
|
||||
Blob,
|
||||
#[serde(rename = "tree")]
|
||||
Tree,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubTagObject {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubTagResponse {
|
||||
object: GithubTagObject,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubTreeResponse<T> {
|
||||
tree: Vec<T>,
|
||||
}
|
||||
|
||||
fn exit_with_error(message: &str) -> ! {
|
||||
eprintln!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubFile {
|
||||
path: String,
|
||||
#[serde(rename(deserialize = "type"))]
|
||||
element_type: GithubFileType,
|
||||
}
|
||||
|
||||
fn create_file(path: PathBuf, content: String) -> std::io::Result<()> {
|
||||
let mut file = File::create(&path).unwrap_or_else(|err| {
|
||||
exit_with_error(&format!(
|
||||
"Failed to create file {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
));
|
||||
});
|
||||
let _ = file.write_all(content.as_bytes());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_new_project(
|
||||
folder_name: Option<String>,
|
||||
template: Option<String>,
|
||||
select_head: Option<bool>,
|
||||
) {
|
||||
let folder = folder_name.unwrap_or(".".to_string());
|
||||
|
||||
let github_api_base_url =
|
||||
env::var("__INTERNAL_TUONO_TEST").unwrap_or("https://api.github.com".to_string());
|
||||
|
||||
let github_raw_base_url = env::var("__INTERNAL_TUONO_TEST")
|
||||
.unwrap_or("https://raw.githubusercontent.com".to_string());
|
||||
|
||||
// In case of missing select the tuono example
|
||||
let template = template.unwrap_or("tuono-app".to_string());
|
||||
let client = blocking::Client::builder()
|
||||
.user_agent("")
|
||||
.build()
|
||||
.unwrap_or_else(|_| exit_with_error("Error: Failed to build request client"));
|
||||
|
||||
// This string does not include the "v" version prefix
|
||||
let cli_version: &str = crate_version!();
|
||||
|
||||
let tree_url: String =
|
||||
generate_tree_url(select_head, &client, cli_version, &github_api_base_url);
|
||||
|
||||
let res_tree = client
|
||||
.get(tree_url)
|
||||
.send()
|
||||
.unwrap_or_else(|_| {
|
||||
exit_with_error(&format!(
|
||||
"Failed to call the tagged commit tree github API for v{cli_version}"
|
||||
))
|
||||
})
|
||||
.json::<GithubTreeResponse<GithubFile>>()
|
||||
.expect("Failed to parse the tree structure");
|
||||
|
||||
let new_project_files = res_tree
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|GithubFile { path, .. }| path.starts_with(&format!("examples/{template}/")))
|
||||
.collect::<Vec<&GithubFile>>();
|
||||
|
||||
if new_project_files.is_empty() {
|
||||
eprintln!("Error: Template '{template}' not found");
|
||||
println!(
|
||||
"Hint: you can view the available templates at https://github.com/tuono-labs/tuono/tree/main/examples"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if folder != "." {
|
||||
if Path::new(&folder).exists() {
|
||||
eprintln!("Error: Directory '{folder}' already exists");
|
||||
println!(
|
||||
"Hint: you can scaffold a tuono project within an existing folder with 'cd {folder} && tuono new .'"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
create_dir(&folder).unwrap();
|
||||
}
|
||||
|
||||
let folder_name = PathBuf::from(&folder);
|
||||
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
||||
|
||||
let folder_path = current_dir.join(folder_name);
|
||||
|
||||
create_directories(&new_project_files, &folder_path, &template)
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to create directories: {err}")));
|
||||
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
} in new_project_files.iter()
|
||||
{
|
||||
if let GithubFileType::Blob = element_type {
|
||||
let url =
|
||||
generate_raw_content_url(select_head, cli_version, path, &github_raw_base_url);
|
||||
|
||||
let file_content = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|_| exit_with_error("Failed to call the folder github API"))
|
||||
.and_then(|response| {
|
||||
response
|
||||
.text()
|
||||
.map_err(|_| exit_with_error("Failed to parse the repo structure"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let file_path = folder_path.join(&path);
|
||||
|
||||
if let Err(err) = create_file(file_path, file_content) {
|
||||
exit_with_error(&format!("Failed to create file: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_package_json_version(&folder_path).expect("Failed to update package.json version");
|
||||
update_cargo_toml_version(&folder_path).expect("Failed to update Cargo.toml version");
|
||||
|
||||
init_new_git_repo(&folder_path);
|
||||
|
||||
outro(folder);
|
||||
}
|
||||
|
||||
fn generate_raw_content_url(
|
||||
select_head: Option<bool>,
|
||||
cli_version: &str,
|
||||
path: &String,
|
||||
url: &str,
|
||||
) -> String {
|
||||
let tag = if select_head.unwrap_or(false) {
|
||||
"/main"
|
||||
} else {
|
||||
&format!("/tuono-labs/tuono/v{cli_version}")
|
||||
};
|
||||
format!("{url}{tag}/{path}")
|
||||
}
|
||||
|
||||
fn generate_tree_url(
|
||||
select_head: Option<bool>,
|
||||
client: &Client,
|
||||
cli_version: &str,
|
||||
url: &str,
|
||||
) -> String {
|
||||
if select_head.unwrap_or(false) {
|
||||
format!("{url}/repos/tuono-labs/tuono/git/trees/main?recursive=1")
|
||||
} else {
|
||||
// This string does not include the "v" version prefix
|
||||
let res_tag = client
|
||||
.get(format!(
|
||||
"{url}/repos/tuono-labs/tuono/git/ref/tags/v{cli_version}"
|
||||
))
|
||||
.send()
|
||||
.unwrap_or_else(|_| {
|
||||
exit_with_error("Failed to call the tag github API for v{cli_version}")
|
||||
})
|
||||
.json::<GithubTagResponse>()
|
||||
.unwrap_or_else(|_| exit_with_error("Failed to parse the tag response"));
|
||||
|
||||
format!(
|
||||
"{url}/repos/tuono-labs/tuono/git/trees/{}?recursive=1",
|
||||
res_tag.object.sha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_directories(
|
||||
new_project_files: &[&GithubFile],
|
||||
folder_path: &Path,
|
||||
template: &String,
|
||||
) -> io::Result<()> {
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
} in new_project_files.iter()
|
||||
{
|
||||
if let GithubFileType::Tree = element_type {
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let dir_path = folder_path.join(&path);
|
||||
if let Err(e) = create_dir(&dir_path) {
|
||||
eprintln!("Failed to create directory {}: {}", dir_path.display(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn update_package_json_version(folder_path: &Path) -> io::Result<()> {
|
||||
let v = crate_version!();
|
||||
let package_json_path = folder_path.join(PathBuf::from("package.json"));
|
||||
let package_json = fs::read_to_string(&package_json_path)
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read package.json: {err}")));
|
||||
let package_json = package_json.replace("link:../../packages/tuono", v);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(package_json_path)
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open package.json: {err}")));
|
||||
|
||||
file.write_all(package_json.as_bytes())
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to package.json: {err}")));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
|
||||
let v = crate_version!();
|
||||
let cargo_toml_path = folder_path.join(PathBuf::from("Cargo.toml"));
|
||||
let cargo_toml = fs::read_to_string(&cargo_toml_path)
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read Cargo.toml: {err}")));
|
||||
let cargo_toml = cargo_toml.replace(
|
||||
"{ path = \"../../crates/tuono_lib/\" }",
|
||||
&format!("\"{v}\""),
|
||||
);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(cargo_toml_path)
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open Cargo.toml: {err}")));
|
||||
|
||||
file.write_all(cargo_toml.as_bytes())
|
||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to Cargo.toml: {err}")));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_new_git_repo(folder_path: &Path) {
|
||||
if let Ok(output) = Command::new("git").arg("init").arg(folder_path).output() {
|
||||
if !output.status.success() {
|
||||
trace!("Failed to initialise a new git repo")
|
||||
}
|
||||
} else {
|
||||
trace!("Failed to initialise a new git repo")
|
||||
}
|
||||
}
|
||||
|
||||
fn outro(folder_name: String) {
|
||||
println!("Success! 🎉");
|
||||
|
||||
if folder_name != "." {
|
||||
println!("\nGo to the project directory:");
|
||||
println!("cd {folder_name}/");
|
||||
}
|
||||
|
||||
println!("\nInstall the dependencies:");
|
||||
println!("npm install");
|
||||
|
||||
println!("\nRun the local environment:");
|
||||
println!("tuono dev");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn generate_valid_content_url_from_head() {
|
||||
let expected = format!(
|
||||
"{}/{}/{}",
|
||||
"http://localhost:3000", "main", "examples/tuono-app"
|
||||
);
|
||||
let generated = generate_raw_content_url(
|
||||
Some(true),
|
||||
crate_version!(),
|
||||
&String::from("examples/tuono-app"),
|
||||
"http://localhost:3000",
|
||||
);
|
||||
assert_eq!(expected, generated)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_valid_content_url_from_cli_version() {
|
||||
let expected = format!(
|
||||
"{}/{}/{}",
|
||||
"http://localhost:3000",
|
||||
&format!("tuono-labs/tuono/v{}", crate_version!()),
|
||||
"examples/tuono-app"
|
||||
);
|
||||
let generated = generate_raw_content_url(
|
||||
Some(false),
|
||||
crate_version!(),
|
||||
&String::from("examples/tuono-app"),
|
||||
"http://localhost:3000",
|
||||
);
|
||||
assert_eq!(expected, generated)
|
||||
}
|
||||
}
|
||||
+12
-64
@@ -1,66 +1,14 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::process::Command;
|
||||
//! ## Tuono
|
||||
//! Tuono is a full-stack web framework for building React applications using Rust as the backend with a strong focus on usability and performance.
|
||||
//!
|
||||
//! You can find the full documentation at [tuono.dev](https://tuono.dev/)
|
||||
|
||||
mod app;
|
||||
pub mod cli;
|
||||
mod commands;
|
||||
mod mode;
|
||||
mod process_manager;
|
||||
mod route;
|
||||
mod source_builder;
|
||||
use source_builder::{bundle_axum_source, create_client_entry_files};
|
||||
|
||||
use crate::source_builder::{check_tuono_folder, Mode};
|
||||
|
||||
mod scaffold_project;
|
||||
mod watch;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
/// Start the development environment
|
||||
Dev,
|
||||
/// Build the production assets
|
||||
Build,
|
||||
/// Scaffold a new project
|
||||
New {
|
||||
/// The folder in which load the project. Default is the current directory.
|
||||
folder_name: Option<String>,
|
||||
/// The template to use to scaffold the project. The template should match one of the tuono
|
||||
/// examples
|
||||
#[arg(short, long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "The react/rust fullstack framework")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn init_tuono_folder(mode: Mode) -> std::io::Result<()> {
|
||||
check_tuono_folder()?;
|
||||
bundle_axum_source(mode)?;
|
||||
create_client_entry_files()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cli() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.action {
|
||||
Actions::Dev => {
|
||||
init_tuono_folder(Mode::Dev)?;
|
||||
watch::watch().unwrap();
|
||||
}
|
||||
Actions::Build => {
|
||||
init_tuono_folder(Mode::Prod)?;
|
||||
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
|
||||
let _ = &vite_build.output()?;
|
||||
}
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
mod symbols;
|
||||
mod typescript;
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
use tuono::cli;
|
||||
use tracing::error;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tuono::cli::app;
|
||||
|
||||
fn main() {
|
||||
cli().unwrap();
|
||||
tracing_subscriber::fmt()
|
||||
// Time not needed since the execution is synchronous
|
||||
.without_time()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
if let Err(e) = app() {
|
||||
// Generic error.
|
||||
// Recoverable errors should be managed locally
|
||||
error!("Failed to run the tuono CLI: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
pub enum Mode {
|
||||
Prod,
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn as_str<'a>(&self) -> &'a str {
|
||||
if *self == Mode::Dev {
|
||||
return "Mode::Dev";
|
||||
}
|
||||
"Mode::Prod"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_correctly_print_the_mode_as_str() {
|
||||
let dev = Mode::Dev.as_str();
|
||||
let prod = Mode::Prod.as_str();
|
||||
assert_eq!(dev, "Mode::Dev");
|
||||
assert_eq!(prod, "Mode::Prod");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use clap::crate_version;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
use tracing::trace;
|
||||
use tuono_internal::config::Config;
|
||||
use tuono_internal::tuono_println;
|
||||
use watchexec_supervisor::command::{Command, Program};
|
||||
use watchexec_supervisor::job::{Job, start_job};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_WATCH_BIN_SRC: &str = "node_modules\\.bin\\tuono-dev-watch.cmd";
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_SSR_BIN_SRC: &str = "node_modules\\.bin\\tuono-dev-ssr.cmd";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_WATCH_BIN_SRC: &str = "node_modules/.bin/tuono-dev-watch";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_SSR_BIN_SRC: &str = "node_modules/.bin/tuono-dev-ssr";
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||
pub enum ProcessId {
|
||||
WatchReactSrc,
|
||||
RunRustDevServer,
|
||||
BuildRustSrc,
|
||||
BuildReactSSRSrc,
|
||||
}
|
||||
|
||||
// This struct manages all the processes spawned by the dev server
|
||||
// That are not the dev server itself
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessManager {
|
||||
processes: HashMap<ProcessId, (Job, JoinHandle<()>)>,
|
||||
}
|
||||
|
||||
impl ProcessManager {
|
||||
pub fn new() -> Self {
|
||||
trace!("Creating process manager");
|
||||
if !Path::new(DEV_SSR_BIN_SRC).exists() {
|
||||
eprintln!("Failed to find script to run dev watch. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let mut processes = HashMap::new();
|
||||
processes.insert(
|
||||
ProcessId::WatchReactSrc,
|
||||
start_supervisor_job(DEV_WATCH_BIN_SRC, Vec::new()),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
ProcessId::RunRustDevServer,
|
||||
start_supervisor_job("cargo", vec!["run", "-q"]),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
ProcessId::BuildRustSrc,
|
||||
start_supervisor_job("cargo", vec!["build", "-q"]),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
ProcessId::BuildReactSSRSrc,
|
||||
start_supervisor_job(DEV_SSR_BIN_SRC, Vec::new()),
|
||||
);
|
||||
|
||||
Self { processes }
|
||||
}
|
||||
|
||||
pub fn start_process(&mut self, id: ProcessId) {
|
||||
trace!("start process {:?}", id);
|
||||
if let Some((job, _)) = self.processes.get(&id) {
|
||||
job.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Start all the processes needed for the dev server
|
||||
pub async fn start_dev_processes(&mut self) {
|
||||
trace!("Starting dev processes");
|
||||
self.start_process(ProcessId::WatchReactSrc);
|
||||
self.start_process(ProcessId::BuildRustSrc);
|
||||
self.start_process(ProcessId::BuildReactSSRSrc);
|
||||
|
||||
self.wait_for_process(ProcessId::BuildRustSrc).await;
|
||||
self.wait_for_process(ProcessId::BuildReactSSRSrc).await;
|
||||
|
||||
// This needs to start after having built the rust and react sources
|
||||
self.start_process(ProcessId::RunRustDevServer);
|
||||
}
|
||||
|
||||
pub fn log_server_address(&self, config: Config) {
|
||||
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
||||
// Format the server address as a valid URL so that it becomes clickable in the CLI
|
||||
// @see https://github.com/tuono-labs/tuono/issues/460
|
||||
let server_base_url = format!("http://{server_address}");
|
||||
|
||||
println!();
|
||||
tuono_println!("⚡ Tuono v{}", crate_version!());
|
||||
|
||||
tuono_println!("Development server at: {}\n", server_base_url.blue().bold());
|
||||
if let Some(origin) = config.server.origin {
|
||||
tuono_println!("Origin: {}\n", origin.blue().bold());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restart_process(&mut self, id: ProcessId) {
|
||||
trace!("Restarting process {:?}", id);
|
||||
if let Some((job, _)) = self.processes.get(&id) {
|
||||
job.stop();
|
||||
job.start();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_process(&mut self, id: ProcessId) {
|
||||
trace!("Waiting for process {:?}", id);
|
||||
if let Some((job, _)) = self.processes.get(&id) {
|
||||
job.to_wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abort_all(&mut self) {
|
||||
trace!("Aborting all processes");
|
||||
for (_, (job, handle)) in self.processes.iter() {
|
||||
job.stop();
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_supervisor_job(prog: &str, args: Vec<&str>) -> (Job, JoinHandle<()>) {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: prog.into(),
|
||||
args: args.into_iter().map(|arg| arg.to_string()).collect(),
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use http::Method;
|
||||
use regex::Regex;
|
||||
use reqwest::Url;
|
||||
use reqwest::blocking::Client;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use tracing::trace;
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct AxumInfo {
|
||||
// Path for importing the module
|
||||
pub module_import: String,
|
||||
// path for the the axum router
|
||||
pub axum_route: String,
|
||||
}
|
||||
|
||||
impl AxumInfo {
|
||||
pub fn new(route: &Route) -> Self {
|
||||
// Remove first slash
|
||||
let mut module = route.path.chars();
|
||||
module.next();
|
||||
|
||||
let axum_route = if route.path.ends_with("/index") {
|
||||
route.path.replace("/index", "")
|
||||
} else {
|
||||
route.path.clone()
|
||||
};
|
||||
|
||||
let module_import = module
|
||||
.as_str()
|
||||
.to_string()
|
||||
.replace('/', "_")
|
||||
.replace('.', "_dot_")
|
||||
.replace('-', "_hyphen_")
|
||||
.to_lowercase();
|
||||
|
||||
if axum_route.is_empty() {
|
||||
return AxumInfo {
|
||||
module_import,
|
||||
axum_route: "/".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if route.is_dynamic {
|
||||
let dyn_re = Regex::new(r"\[(.*?)\]").expect("Failed to create dyn regex");
|
||||
let catch_all_re =
|
||||
Regex::new(r"\{\.\.\.(.*?)\}").expect("Failed to create catch all regex");
|
||||
|
||||
let dyn_result = dyn_re.replace_all(&axum_route, |caps: ®ex::Captures| {
|
||||
format!("{{{}}}", &caps[1])
|
||||
});
|
||||
|
||||
let axum_route = catch_all_re
|
||||
.replace_all(&dyn_result, |caps: ®ex::Captures| {
|
||||
format!("{{*{}}}", &caps[1])
|
||||
})
|
||||
.to_string();
|
||||
|
||||
return AxumInfo {
|
||||
module_import: module
|
||||
.as_str()
|
||||
.to_string()
|
||||
.replace('/', "_")
|
||||
.replace('-', "_hyphen_")
|
||||
.replace('[', "dyn_")
|
||||
.replace("...", "catch_all_")
|
||||
.replace(']', ""),
|
||||
axum_route,
|
||||
};
|
||||
}
|
||||
|
||||
AxumInfo {
|
||||
module_import,
|
||||
axum_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: to be extended with common scenarios
|
||||
const NO_HTML_EXTENSIONS: [&str; 2] = ["xml", "txt"];
|
||||
|
||||
// TODO: Refine this function to catch
|
||||
// if the methods are commented.
|
||||
fn read_http_methods_from_file(path: &String) -> Vec<Method> {
|
||||
let regex = Regex::new(r"tuono_lib::api\((.*?)\)]").expect("Failed to create API regex");
|
||||
|
||||
let file = fs_extra::file::read_to_string(path).expect("Failed to read API file");
|
||||
|
||||
regex
|
||||
.find_iter(&file)
|
||||
.map(|proc_macro| {
|
||||
let http_method = proc_macro
|
||||
.as_str()
|
||||
// Extract just the element surrounded by the phrantesist.
|
||||
.replace("tuono_lib::api(", "")
|
||||
.replace(")]", "");
|
||||
Method::from_str(http_method.to_uppercase().as_str()).unwrap_or(Method::GET)
|
||||
})
|
||||
.collect::<Vec<Method>>()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct ApiData {
|
||||
pub methods: Vec<Method>,
|
||||
}
|
||||
|
||||
impl ApiData {
|
||||
pub fn new(path: &String) -> Option<Self> {
|
||||
if !path.starts_with("/api/") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base_path = std::env::current_dir().expect("Failed to get the base_path");
|
||||
|
||||
let file_path = base_path
|
||||
.join(format!("src/routes{path}.rs"))
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let methods = read_http_methods_from_file(&file_path);
|
||||
|
||||
Some(ApiData { methods })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct Route {
|
||||
path: String,
|
||||
pub is_dynamic: bool,
|
||||
pub axum_info: Option<AxumInfo>,
|
||||
pub api_data: Option<ApiData>,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn new(cleaned_path: String) -> Self {
|
||||
Route {
|
||||
path: cleaned_path.clone(),
|
||||
axum_info: None,
|
||||
is_dynamic: has_dynamic_path(&cleaned_path),
|
||||
api_data: ApiData::new(&cleaned_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_api(&self) -> bool {
|
||||
self.api_data.is_some()
|
||||
}
|
||||
|
||||
pub fn update_axum_info(&mut self) {
|
||||
self.axum_info = Some(AxumInfo::new(self))
|
||||
}
|
||||
|
||||
pub fn save_ssg_file(&self, reqwest: &Client) -> Result<(), String> {
|
||||
if self.is_api() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = &self.path.replace("index", "");
|
||||
|
||||
let url = format!("http://localhost:3000{path}");
|
||||
|
||||
trace!("Requesting the page: {}", url);
|
||||
let mut response = match reqwest.get(format!("http://localhost:3000{path}")).send() {
|
||||
Ok(response) => response,
|
||||
Err(_) => return Err(format!("Failed to get the response: {url}")),
|
||||
};
|
||||
|
||||
let file_path = self.output_file_path();
|
||||
|
||||
let parent_dir = match file_path.parent() {
|
||||
Some(parent_dir) => parent_dir,
|
||||
None => {
|
||||
return Err(format!("Failed to get the parent directory {file_path:?}"));
|
||||
}
|
||||
};
|
||||
|
||||
if !parent_dir.is_dir() {
|
||||
if let Err(err) = create_all(parent_dir, false) {
|
||||
return Err(format!(
|
||||
"Failed to create the parent directory {parent_dir:?}\nError: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
trace!("Saving the HTML file: {:?}", file_path);
|
||||
|
||||
let mut file = match File::create(&file_path) {
|
||||
Ok(file) => file,
|
||||
Err(_) => return Err(format!("Failed to create the file: {file_path:?}")),
|
||||
};
|
||||
|
||||
if io::copy(&mut response, &mut file).is_err() {
|
||||
return Err(format!("Failed to write the file: {file_path:?}"));
|
||||
}
|
||||
|
||||
// Saving also the server response
|
||||
if self.axum_info.is_some() {
|
||||
trace!("The route is an axum route, saving the JSON file");
|
||||
|
||||
let data_file_path = PathBuf::from(&format!("out/static/__tuono/data{path}.json"));
|
||||
|
||||
let data_parent_dir = match data_file_path.parent() {
|
||||
Some(parent_dir) => parent_dir,
|
||||
None => {
|
||||
return Err(format!(
|
||||
"Failed to get the parent directory {data_file_path:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !data_parent_dir.is_dir() {
|
||||
if let Err(err) = create_all(data_parent_dir, false) {
|
||||
return Err(format!(
|
||||
"Failed to create the parent directory {data_parent_dir:?}\n Error: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let base = Url::parse("http://localhost:3000/__tuono/data").unwrap();
|
||||
|
||||
let path = if path == "/" { "" } else { path };
|
||||
|
||||
let pathname = &format!("/__tuono/data{path}");
|
||||
|
||||
let url = base
|
||||
.join(pathname)
|
||||
.expect("Failed to build the reqwest URL");
|
||||
|
||||
trace!("Requesting the JSON file: {}", url);
|
||||
|
||||
let mut response = match reqwest.get(url.clone()).send() {
|
||||
Ok(response) => {
|
||||
trace!("Successfully got the response for: {url}");
|
||||
response
|
||||
}
|
||||
Err(_) => return Err(format!("Failed to get the response: {url}")),
|
||||
};
|
||||
|
||||
let mut data_file = match File::create(&data_file_path) {
|
||||
Ok(file) => file,
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Failed to create the JSON file: {data_file_path:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if io::copy(&mut response, &mut data_file).is_err() {
|
||||
return Err("Failed to write the JSON file".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_file_path(&self) -> PathBuf {
|
||||
let cleaned_path = self.path.replace("index", "");
|
||||
|
||||
if NO_HTML_EXTENSIONS
|
||||
.iter()
|
||||
.any(|extension| self.path.ends_with(extension))
|
||||
{
|
||||
return PathBuf::from(format!("out/static{cleaned_path}"));
|
||||
}
|
||||
|
||||
PathBuf::from(format!("out/static{cleaned_path}/index.html"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_find_dynamic_paths() {
|
||||
let routes = [
|
||||
("/home/user/Documents/tuono/src/routes/about.rs", false),
|
||||
("/home/user/Documents/tuono/src/routes/index.rs", false),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_allow_index_in_route_name() {
|
||||
let index_route = AxumInfo::new(&Route::new("/index".to_string()));
|
||||
let index_page_route = AxumInfo::new(&Route::new("/index-page".to_string()));
|
||||
|
||||
assert_eq!(index_route.axum_route, "/");
|
||||
assert_eq!(index_route.module_import, "index");
|
||||
|
||||
assert_eq!(index_page_route.axum_route, "/index-page");
|
||||
assert_eq!(index_page_route.module_import, "index_hyphen_page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_create_the_axum_infos() {
|
||||
let info = AxumInfo::new(&Route::new("/index".to_string()));
|
||||
|
||||
assert_eq!(info.axum_route, "/");
|
||||
assert_eq!(info.module_import, "index");
|
||||
|
||||
let dyn_info = AxumInfo::new(&Route::new("/[posts]".to_string()));
|
||||
|
||||
assert_eq!(dyn_info.axum_route, "/{posts}");
|
||||
assert_eq!(dyn_info.module_import, "dyn_posts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_define_the_correct_html_build_path() {
|
||||
let routes = [
|
||||
("/index", "out/static/index.html"),
|
||||
("/documentation", "out/static/documentation/index.html"),
|
||||
("/sitemap.xml", "out/static/sitemap.xml"),
|
||||
("/robot.txt", "out/static/robot.txt"),
|
||||
(
|
||||
"/documentation/routing",
|
||||
"out/static/documentation/routing/index.html",
|
||||
),
|
||||
];
|
||||
|
||||
for (path, html) in routes {
|
||||
let route = Route::new(path.to_string());
|
||||
|
||||
assert_eq!(route.output_file_path(), PathBuf::from(html))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use clap::crate_version;
|
||||
use reqwest::blocking;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs::{self, create_dir, File, OpenOptions};
|
||||
use std::io::{self, prelude::*};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const GITHUB_TUONO_REPO_URL: &str =
|
||||
"https://api.github.com/repos/Valerioageno/tuono/git/trees/main?recursive=1";
|
||||
|
||||
const GITHUB_RAW_CONTENT_URL: &str = "https://raw.githubusercontent.com/Valerioageno/tuono/main/";
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
enum GithubFileType {
|
||||
#[serde(rename = "blob")]
|
||||
Blob,
|
||||
#[serde(rename = "tree")]
|
||||
Tree,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubResponse<T> {
|
||||
tree: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubFile {
|
||||
path: String,
|
||||
#[serde(rename(deserialize = "type"))]
|
||||
element_type: GithubFileType,
|
||||
}
|
||||
|
||||
fn create_file(path: PathBuf, content: String) -> std::io::Result<()> {
|
||||
let mut file = File::create(path)?;
|
||||
let _ = file.write_all(content.as_bytes());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_new_project(folder_name: Option<String>, template: Option<String>) {
|
||||
let folder = folder_name.unwrap_or(".".to_string());
|
||||
|
||||
// In case of missing select the tuono example
|
||||
let template = template.unwrap_or("tuono".to_string());
|
||||
|
||||
let client = blocking::Client::builder()
|
||||
.user_agent("")
|
||||
.build()
|
||||
.expect("Failed to build reqwest client");
|
||||
|
||||
let res = client
|
||||
.get(GITHUB_TUONO_REPO_URL)
|
||||
.send()
|
||||
.expect("Failed to call the folder github API")
|
||||
.json::<GithubResponse<GithubFile>>()
|
||||
.expect("Failed to parse the repo structure");
|
||||
|
||||
let new_project_files = res
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|GithubFile { path, .. }| path.starts_with(&format!("examples/{template}/")))
|
||||
.collect::<Vec<&GithubFile>>();
|
||||
|
||||
if new_project_files.is_empty() {
|
||||
println!("Template not found: {template}");
|
||||
return;
|
||||
}
|
||||
|
||||
if folder != "." {
|
||||
create_dir(&folder).unwrap();
|
||||
}
|
||||
|
||||
let folder_name = PathBuf::from(&folder);
|
||||
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
||||
|
||||
let folder_path = current_dir.join(folder_name);
|
||||
|
||||
create_directories(&new_project_files, &folder_path, &template)
|
||||
.expect("Failed to create directories");
|
||||
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
} in new_project_files.iter()
|
||||
{
|
||||
if let GithubFileType::Blob = element_type {
|
||||
let file_content = client
|
||||
.get(format!("{GITHUB_RAW_CONTENT_URL}{path}"))
|
||||
.send()
|
||||
.expect("Failed to call the folder github API")
|
||||
.text()
|
||||
.expect("Failed to parse the repo structure");
|
||||
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let file_path = folder_path.join(&path);
|
||||
|
||||
create_file(file_path, file_content).expect("failed to create file");
|
||||
}
|
||||
}
|
||||
|
||||
update_package_json_version(&folder_path).expect("Failed to update package.json version");
|
||||
update_cargo_toml_version(&folder_path).expect("Failed to update Cargo.toml version");
|
||||
outro(folder);
|
||||
}
|
||||
|
||||
fn create_directories(
|
||||
new_project_files: &[&GithubFile],
|
||||
folder_path: &Path,
|
||||
template: &String,
|
||||
) -> io::Result<()> {
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
} in new_project_files.iter()
|
||||
{
|
||||
if let GithubFileType::Tree = element_type {
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let dir_path = folder_path.join(&path);
|
||||
create_dir(&dir_path).unwrap();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_package_json_version(folder_path: &Path) -> io::Result<()> {
|
||||
let v = crate_version!();
|
||||
let package_json_path = folder_path.join(PathBuf::from("package.json"));
|
||||
let package_json = fs::read_to_string(&package_json_path)?;
|
||||
let package_json = package_json.replace("link:../../packages/tuono", v);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(package_json_path)?;
|
||||
|
||||
file.write_all(package_json.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
|
||||
let v = crate_version!();
|
||||
let cargo_toml_path = folder_path.join(PathBuf::from("Cargo.toml"));
|
||||
let cargo_toml = fs::read_to_string(&cargo_toml_path)?;
|
||||
let cargo_toml =
|
||||
cargo_toml.replace("{ path = \"../../crates/tuono_lib/\"}", &format!("\"{v}\""));
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(cargo_toml_path)?;
|
||||
|
||||
file.write_all(cargo_toml.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn outro(folder_name: String) {
|
||||
println!("Success! 🎉");
|
||||
|
||||
if folder_name != "." {
|
||||
println!("\nGo to the project directory:");
|
||||
println!("cd {folder_name}/");
|
||||
}
|
||||
|
||||
println!("\nInstall the dependencies:");
|
||||
println!("npm install");
|
||||
|
||||
println!("\nRun the local environment:");
|
||||
println!("tuono dev");
|
||||
}
|
||||
+293
-325
@@ -1,247 +1,253 @@
|
||||
use glob::glob;
|
||||
use glob::GlobError;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Prod,
|
||||
Dev,
|
||||
}
|
||||
use clap::crate_version;
|
||||
use tracing::error;
|
||||
|
||||
impl Mode {
|
||||
pub fn as_str<'a>(&self) -> &'a str {
|
||||
if *self == Mode::Dev {
|
||||
return "Mode::Dev";
|
||||
}
|
||||
"Mode::Prod"
|
||||
}
|
||||
}
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::route::AxumInfo;
|
||||
use crate::route::Route;
|
||||
use crate::typescript::TypesJar;
|
||||
|
||||
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { serverSideRendering } from 'tuono/ssr'
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FALLBACK_HTML: &str = include_str!("../templates/fallback.html");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const SERVER_ENTRY_DATA: &str = include_str!("../templates/server.ts");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const CLIENT_ENTRY_DATA: &str = include_str!("../templates/client.ts");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const AXUM_ENTRY_POINT: &str = include_str!("../templates/server.rs");
|
||||
|
||||
export const renderFn = serverSideRendering(routeTree)
|
||||
";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const MAIN_FILE_PATH: &str = "./.tuono/main.rs";
|
||||
|
||||
pub const CLIENT_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import 'vite/modulepreload-polyfill'
|
||||
import { hydrate } from 'tuono/hydration'
|
||||
import '../src/styles/global.css'
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FALLBACK_HTML_PATH: &str = "./.tuono/index.html";
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
hydrate(routeTree)
|
||||
";
|
||||
|
||||
pub const AXUM_ENTRY_POINT: &str = r##"
|
||||
// File automatically generated
|
||||
// Do not manually change it
|
||||
|
||||
use tuono_lib::{tokio, Mode, Server, axum::Router, axum::routing::get};
|
||||
use reqwest::Client;
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let fetch = Client::new();
|
||||
|
||||
let router = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
.with_state(fetch);
|
||||
|
||||
Server::init(router, MODE).start().await
|
||||
}
|
||||
"##;
|
||||
|
||||
const ROOT_FOLDER: &str = "src/routes";
|
||||
const ROUTE_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Route {
|
||||
/// Every module import is the path with a _ instead of /
|
||||
pub module_import: String,
|
||||
pub axum_route: String,
|
||||
#[cfg(target_os = "windows")]
|
||||
const FALLBACK_HTML: &str = include_str!("..\\templates\\fallback.html");
|
||||
#[cfg(target_os = "windows")]
|
||||
const SERVER_ENTRY_DATA: &str = include_str!("..\\templates\\server.ts");
|
||||
#[cfg(target_os = "windows")]
|
||||
const CLIENT_ENTRY_DATA: &str = include_str!("..\\templates\\client.ts");
|
||||
#[cfg(target_os = "windows")]
|
||||
const AXUM_ENTRY_POINT: &str = include_str!("..\\templates\\server.rs");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const MAIN_FILE_PATH: &str = ".\\.tuono\\main.rs";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const FALLBACK_HTML_PATH: &str = ".\\.tuono\\index.html";
|
||||
|
||||
// Use this function to instruct the users on how to
|
||||
// fix their setup to make tuono work
|
||||
fn recoverable_error(message: &str) -> ! {
|
||||
error!("{}", message);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn new(path: &str) -> Self {
|
||||
let route_name = path.replace(".rs", "");
|
||||
// Remove first slash
|
||||
let mut module = route_name.as_str().chars();
|
||||
module.next();
|
||||
|
||||
let axum_route = path.replace("/index.rs", "").replace(".rs", "");
|
||||
|
||||
if axum_route.is_empty() {
|
||||
return Route {
|
||||
module_import: module.as_str().to_string().replace('/', "_"),
|
||||
axum_route: "/".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if has_dynamic_path(&route_name) {
|
||||
return Route {
|
||||
module_import: module
|
||||
.as_str()
|
||||
.to_string()
|
||||
.replace('/', "_")
|
||||
.replace('[', "dyn_")
|
||||
.replace(']', ""),
|
||||
axum_route: axum_route.replace('[', ":").replace(']', ""),
|
||||
};
|
||||
}
|
||||
|
||||
Route {
|
||||
module_import: module.as_str().to_string().replace('/', "_").to_lowercase(),
|
||||
axum_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceBuilder {
|
||||
route_map: HashMap<PathBuf, Route>,
|
||||
// Struct to build the source code
|
||||
// on both "dev" and "build" commands
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SourceBuilder {
|
||||
pub app: App,
|
||||
mode: Mode,
|
||||
base_path: PathBuf,
|
||||
types_jar: TypesJar,
|
||||
}
|
||||
|
||||
impl SourceBuilder {
|
||||
pub fn new() -> Self {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
pub fn new(mode: Mode) -> io::Result<Self> {
|
||||
if !PathBuf::from("tuono.config.ts").exists() {
|
||||
recoverable_error("Cannot find tuono.config.ts - is this a tuono project?");
|
||||
}
|
||||
|
||||
SourceBuilder {
|
||||
route_map: HashMap::new(),
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
fs::create_dir(dev_folder)?;
|
||||
}
|
||||
|
||||
let app = App::new();
|
||||
|
||||
let base_path = std::env::current_dir()?;
|
||||
|
||||
Ok(Self {
|
||||
app,
|
||||
mode,
|
||||
types_jar: TypesJar::from(&base_path),
|
||||
base_path,
|
||||
})
|
||||
}
|
||||
|
||||
// Build the source code needed for both build and dev
|
||||
pub fn base_build(&mut self) -> io::Result<()> {
|
||||
let mode = self.mode.clone();
|
||||
|
||||
self.refresh_axum_source()?;
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?;
|
||||
self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?;
|
||||
|
||||
self.types_jar.generate_typescript_file(&self.base_path)?;
|
||||
|
||||
if mode == Mode::Dev {
|
||||
self.app.build_tuono_config()?;
|
||||
let fallback_html = self.build_html_fallback();
|
||||
self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_axum_source(&self) -> String {
|
||||
let Self { app, mode, .. } = &self;
|
||||
|
||||
let src = AXUM_ENTRY_POINT
|
||||
.replace("\r", "")
|
||||
.replace("// ROUTE_BUILDER\n", &self.create_routes_declaration())
|
||||
.replace("// MODULE_IMPORTS\n", &self.create_modules_declaration())
|
||||
.replace("/*VERSION*/", crate_version!())
|
||||
.replace("/*MODE*/", mode.as_str())
|
||||
.replace(
|
||||
"//MAIN_FILE_IMPORT//",
|
||||
if app.has_app_state {
|
||||
r#"#[path="../src/app.rs"]
|
||||
mod tuono_main_state;
|
||||
"#
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"//MAIN_FILE_DEFINITION//",
|
||||
if app.has_app_state {
|
||||
"let user_custom_state = tuono_main_state::main();"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"//MAIN_FILE_USAGE//",
|
||||
if app.has_app_state {
|
||||
".with_state(user_custom_state)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
|
||||
let mut import_http_handler = String::new();
|
||||
|
||||
let used_http_methods = app.get_used_http_methods();
|
||||
|
||||
for method in used_http_methods.into_iter() {
|
||||
let method = method.to_string().to_lowercase();
|
||||
import_http_handler.push_str(&format!("use tuono_lib::axum::routing::{method};\n"))
|
||||
}
|
||||
|
||||
src.replace("// AXUM_GET_ROUTE_HANDLER", &import_http_handler)
|
||||
}
|
||||
|
||||
pub fn refresh_axum_source(&self) -> io::Result<()> {
|
||||
let axum_source = self.generate_axum_source();
|
||||
|
||||
self.create_file(PathBuf::from(MAIN_FILE_PATH), &axum_source)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_file(&self, path: PathBuf, content: &str) -> io::Result<()> {
|
||||
let mut data_file = fs::File::create(self.base_path.join(path))?;
|
||||
|
||||
data_file.write_all(content.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn refresh_typescript_file(&mut self, path: PathBuf) {
|
||||
self.types_jar.refresh_file(path);
|
||||
}
|
||||
|
||||
pub fn remove_typescript_file(&mut self, path: PathBuf) {
|
||||
self.types_jar.remove_file(path);
|
||||
}
|
||||
|
||||
pub fn generate_typescript_file(&mut self) -> io::Result<()> {
|
||||
self.types_jar.generate_typescript_file(&self.base_path)
|
||||
}
|
||||
|
||||
fn create_routes_declaration(&self) -> String {
|
||||
let routes = &self.app.route_map;
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
|
||||
for (_, route) in routes.iter() {
|
||||
let Route { axum_info, .. } = &route;
|
||||
|
||||
if axum_info.is_some() {
|
||||
let AxumInfo {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = axum_info.as_ref().unwrap();
|
||||
|
||||
if !route.is_api() {
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::tuono_internal_route))"#
|
||||
));
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}", get({module_import}::tuono_internal_api))"#
|
||||
));
|
||||
} else {
|
||||
for method in route.api_data.as_ref().unwrap().methods.clone() {
|
||||
let method = method.to_string().to_lowercase();
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", {method}({module_import}::{method}_tuono_internal_api))"#
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
fn create_modules_declaration(&self) -> String {
|
||||
let routes = &self.app.route_map;
|
||||
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||
|
||||
for (path, route) in routes.iter() {
|
||||
if route.axum_info.is_some() {
|
||||
let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROUTE_FOLDER}{path}.rs"]
|
||||
mod {module_import};
|
||||
"#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
fn build_html_fallback(&self) -> String {
|
||||
if let Some(config) = &self.app.config.as_ref() {
|
||||
if let Some(origin) = &config.server.origin {
|
||||
FALLBACK_HTML.replace("[BASE_URL]", origin)
|
||||
} else {
|
||||
let url = format!("http://{}:{}", config.server.host, config.server.port);
|
||||
FALLBACK_HTML.replace("[BASE_URL]", url.as_str())
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_routes(&mut self) {
|
||||
glob(self.base_path.join("src/routes/**/*.rs").to_str().unwrap())
|
||||
.unwrap()
|
||||
.for_each(|entry| self.collect_route(entry))
|
||||
}
|
||||
|
||||
fn collect_route(&mut self, path_buf: Result<PathBuf, GlobError>) {
|
||||
let entry = path_buf.unwrap();
|
||||
let base_path_str = self.base_path.to_str().unwrap();
|
||||
let path = entry
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace(&format!("{base_path_str}/src/routes"), "");
|
||||
|
||||
let route = Route::new(&path);
|
||||
|
||||
self.route_map.insert(PathBuf::from(path), route);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_main_file(base_path: &Path, bundled_file: &String) {
|
||||
let mut data_file =
|
||||
fs::File::create(base_path.join(".tuono/main.rs")).expect("creation failed");
|
||||
|
||||
data_file
|
||||
.write_all(bundled_file.as_bytes())
|
||||
.expect("write failed");
|
||||
}
|
||||
|
||||
fn create_routes_declaration(routes: &HashMap<PathBuf, Route>) -> String {
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
|
||||
for (_, route) in routes.iter() {
|
||||
let Route {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = &route;
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::route))"#
|
||||
));
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}", get({module_import}::api))"#
|
||||
));
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
fn create_modules_declaration(routes: &HashMap<PathBuf, Route>) -> String {
|
||||
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||
|
||||
for (path, route) in routes.iter() {
|
||||
let module_name = &route.module_import;
|
||||
let path_str = path.to_str().unwrap();
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROOT_FOLDER}{path_str}"]
|
||||
mod {module_name};
|
||||
"#
|
||||
))
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
pub fn bundle_axum_source(mode: Mode) -> io::Result<()> {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
|
||||
source_builder.collect_routes();
|
||||
|
||||
let bundled_file = generate_axum_source(&source_builder, mode);
|
||||
|
||||
create_main_file(&base_path, &bundled_file);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String {
|
||||
AXUM_ENTRY_POINT
|
||||
.replace(
|
||||
"// ROUTE_BUILDER\n",
|
||||
&create_routes_declaration(&source_builder.route_map),
|
||||
)
|
||||
.replace(
|
||||
"// MODULE_IMPORTS\n",
|
||||
&create_modules_declaration(&source_builder.route_map),
|
||||
)
|
||||
.replace("/*MODE*/", mode.as_str())
|
||||
}
|
||||
|
||||
pub fn check_tuono_folder() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
fs::create_dir(dev_folder)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_client_entry_files() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
|
||||
let mut server_entry = fs::File::create(dev_folder.join("server-main.tsx"))?;
|
||||
let mut client_entry = fs::File::create(dev_folder.join("client-main.tsx"))?;
|
||||
|
||||
server_entry.write_all(SERVER_ENTRY_DATA.as_bytes())?;
|
||||
client_entry.write_all(CLIENT_ENTRY_DATA.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -249,117 +255,79 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_find_dynamic_paths() {
|
||||
let routes = [
|
||||
("/home/user/Documents/tuono/src/routes/about.rs", false),
|
||||
("/home/user/Documents/tuono/src/routes/index.rs", false),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_collect_routes() {
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index.rs", "index"),
|
||||
("/about.rs", "about"),
|
||||
("/posts/index.rs", "posts_index"),
|
||||
("/posts/[post].rs", "posts_dyn_post"),
|
||||
("/posts/UPPERCASE.rs", "posts_uppercase"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, module_import)| {
|
||||
assert_eq!(
|
||||
source_builder
|
||||
.route_map
|
||||
.get(&PathBuf::from(path))
|
||||
.unwrap()
|
||||
.module_import,
|
||||
String::from(module_import)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_multi_level_axum_paths() {
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/any-post.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index.rs", "/"),
|
||||
("/about.rs", "/about"),
|
||||
("/posts/index.rs", "/posts"),
|
||||
("/posts/any-post.rs", "/posts/any-post"),
|
||||
("/posts/[post].rs", "/posts/:post"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, expected_path)| {
|
||||
assert_eq!(
|
||||
source_builder
|
||||
.route_map
|
||||
.get(&PathBuf::from(path))
|
||||
.unwrap()
|
||||
.axum_route,
|
||||
String::from(expected_path)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_set_the_correct_mode() {
|
||||
let source_builder = SourceBuilder::new();
|
||||
let dev_bundle = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
let prod_bundle = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Prod,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||
|
||||
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);
|
||||
|
||||
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_print_the_mode_as_str() {
|
||||
let dev = Mode::Dev.as_str();
|
||||
let prod = Mode::Prod.as_str();
|
||||
assert_eq!(dev, "Mode::Dev");
|
||||
assert_eq!(prod, "Mode::Prod");
|
||||
fn should_not_load_the_axum_get_function() {
|
||||
let dev_bundle = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_axum_get_function() {
|
||||
let mut source_builder = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
};
|
||||
|
||||
let mut route = Route::new(String::from("index.tsx"));
|
||||
route.update_axum_info();
|
||||
|
||||
source_builder
|
||||
.app
|
||||
.route_map
|
||||
.insert(String::from("index.rs"), route);
|
||||
|
||||
let dev_bundle = source_builder.generate_axum_source();
|
||||
|
||||
assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_fallback_html_with_default_config() {
|
||||
let mut app = App::new();
|
||||
app.config = Some(Default::default());
|
||||
|
||||
let source_builder = SourceBuilder {
|
||||
app,
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
};
|
||||
|
||||
let fallback_html = source_builder.build_html_fallback();
|
||||
|
||||
assert!(fallback_html.contains("http://localhost:3000/vite-server/@react-refresh"));
|
||||
assert!(fallback_html.contains("http://localhost:3000/vite-server/@vite/client"));
|
||||
assert!(fallback_html.contains("http://localhost:3000/vite-server/client-main.tsx"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use syn::Ident;
|
||||
|
||||
pub struct Symbol(&'static str);
|
||||
|
||||
// The trait name that allow automatic struct/enum/type conversion
|
||||
// to typescript
|
||||
pub const TYPE_TRAIT: Symbol = Symbol("Type");
|
||||
|
||||
impl PartialEq<Symbol> for Ident {
|
||||
fn eq(&self, word: &Symbol) -> bool {
|
||||
self == word.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Symbol> for &Ident {
|
||||
fn eq(&self, word: &Symbol) -> bool {
|
||||
*self == word.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Symbol {
|
||||
type Target = &'static str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::utils::has_derive_type;
|
||||
use crate::typescript::parser::{parse_enum, parse_struct};
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use tracing::trace;
|
||||
|
||||
/// Represents all the valid typescript types found in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTypes {
|
||||
/// Rust file when the type was found
|
||||
pub file_path: PathBuf,
|
||||
/// All the types found in the file
|
||||
/// ready to be printed in the typescript file
|
||||
pub types_as_string: String,
|
||||
/// The types found in the file.
|
||||
/// Used to check that the types are not duplicated across files.
|
||||
pub types: Vec<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<(PathBuf, String)> for FileTypes {
|
||||
type Error = Box<dyn Error>;
|
||||
|
||||
fn try_from((file_path, file_str): (PathBuf, String)) -> Result<Self, Self::Error> {
|
||||
trace!("Parsing file: {:?}", &file_path);
|
||||
let file = syn::parse_file(&file_str)?;
|
||||
|
||||
let mut types_as_string = String::new();
|
||||
let mut types = Vec::new();
|
||||
|
||||
for item in file.items {
|
||||
match item {
|
||||
syn::Item::Struct(element) => {
|
||||
if !has_derive_type(&element.attrs) {
|
||||
continue;
|
||||
}
|
||||
trace!("Found struct in file: {:?}", &file_path);
|
||||
let (struct_name, typescript_definition) = parse_struct(&element);
|
||||
types_as_string.push_str(&typescript_definition);
|
||||
types.push(struct_name);
|
||||
}
|
||||
syn::Item::Enum(element) => {
|
||||
if !has_derive_type(&element.attrs) {
|
||||
continue;
|
||||
}
|
||||
trace!("Found enum in file: {:?}", &file_path);
|
||||
|
||||
let (enum_name, typescript_definition) = parse_enum(&element);
|
||||
types_as_string.push_str(&typescript_definition);
|
||||
types.push(enum_name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if types_as_string.is_empty() {
|
||||
return Err("No types found in the file".into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
file_path,
|
||||
types_as_string,
|
||||
types,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_creates_type_from_pathbuf_and_string() {
|
||||
let file_path = PathBuf::from("src/types.rs");
|
||||
let file_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
field1: String,
|
||||
field2: i32,
|
||||
}
|
||||
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
"#
|
||||
.to_string();
|
||||
|
||||
let ttype = FileTypes::try_from((file_path.clone(), file_str)).unwrap();
|
||||
|
||||
assert_eq!(ttype.file_path, file_path);
|
||||
assert_eq!(
|
||||
ttype.types_as_string,
|
||||
"export interface MyStruct {\n field1: string;\n field2: number;\n}\nexport type MyEnum = \"Variant1\" | \"Variant2\";\n"
|
||||
);
|
||||
assert_eq!(ttype.types, vec!["MyStruct", "MyEnum"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod file_types;
|
||||
pub mod parser;
|
||||
|
||||
mod types_jar;
|
||||
pub mod utils;
|
||||
|
||||
pub use file_types::*;
|
||||
pub use types_jar::*;
|
||||
@@ -0,0 +1,6 @@
|
||||
mod parse_enum;
|
||||
mod parse_struct;
|
||||
pub mod utils;
|
||||
|
||||
pub use parse_enum::*;
|
||||
pub use parse_struct::*;
|
||||
@@ -0,0 +1,240 @@
|
||||
use crate::typescript::parser::utils::{
|
||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
||||
rust_to_typescript_type, should_skip_element,
|
||||
};
|
||||
|
||||
/// Parse a rust enum and returns a tuple of the enum name and the
|
||||
/// enum compiled to a typescript type
|
||||
pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) {
|
||||
let enum_name = element.ident.to_string();
|
||||
|
||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
||||
let mut enum_variants: Vec<String> = Vec::new();
|
||||
|
||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
||||
|
||||
for variant in &element.variants {
|
||||
if should_skip_element(&variant.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parsed_variant = rename_option.transform(variant.ident.to_string());
|
||||
|
||||
match &variant.fields {
|
||||
syn::Fields::Named(field) => {
|
||||
parsed_variant = format!("{{\"{parsed_variant}\": {{ ");
|
||||
let mut variant_fields: Vec<String> = Vec::new();
|
||||
|
||||
for field in &field.named {
|
||||
if should_skip_element(&field.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let field_name = rename_option.transform(get_field_name(field));
|
||||
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
variant_fields.push(format!("{field_name}: {field_type}"));
|
||||
}
|
||||
enum_variants.push(format!(
|
||||
"{parsed_variant}{} }}}}",
|
||||
variant_fields.join(", ")
|
||||
));
|
||||
}
|
||||
syn::Fields::Unnamed(field) => {
|
||||
let mut variant_fields: Vec<String> = Vec::new();
|
||||
for field in &field.unnamed {
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
variant_fields.push(field_type);
|
||||
}
|
||||
enum_variants.push(format!(
|
||||
"{{\"{parsed_variant}\": [{}]}}",
|
||||
variant_fields.join(", ")
|
||||
));
|
||||
}
|
||||
syn::Fields::Unit => {
|
||||
enum_variants.push(format!("\"{parsed_variant}\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let enum_type = format!(
|
||||
"export type {enum_name}{generics} = {};\n",
|
||||
enum_variants.join(" | ")
|
||||
);
|
||||
(enum_name, enum_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_a_simple_enum() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
Variant3
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Variant1\" | \"Variant2\" | \"Variant3\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_apply_rename_all_modifier() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
Name,
|
||||
UserAge
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_skips_a_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
Name,
|
||||
UserAge,
|
||||
#[serde(skip)]
|
||||
SkipMe
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_struct_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User {
|
||||
name: String,
|
||||
age: u32,
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": { name: string, age: number }};\n"
|
||||
);
|
||||
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User {
|
||||
name: String,
|
||||
#[serde(skip)]
|
||||
age: u32,
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": { name: string }};\n"
|
||||
);
|
||||
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Request {
|
||||
body: Bytes
|
||||
},
|
||||
Response{
|
||||
payload: Bytes
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = {\"Request\": { body: Bytes }} | {\"Response\": { payload: Bytes }};\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_generic_type() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum<T> {
|
||||
Id,
|
||||
User {
|
||||
name: T,
|
||||
age: u32
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum<T> = \"Id\" | {\"User\": { name: T, age: number }};\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_tuple_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User(String, u32),
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": [string, number]};\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::typescript::parser::utils::{
|
||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
||||
rust_to_typescript_type,
|
||||
};
|
||||
|
||||
use super::utils::should_skip_element;
|
||||
|
||||
/// Parse a rust struct and returns a tuple of the struct name and the
|
||||
/// struct compiled to a typescript interface
|
||||
pub fn parse_struct(element: &syn::ItemStruct) -> (String, String) {
|
||||
let struct_name = element.ident.to_string();
|
||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
||||
|
||||
let mut fields_as_string = format!("export interface {struct_name}{generics} {{\n");
|
||||
|
||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
||||
|
||||
for field in &element.fields {
|
||||
if should_skip_element(&field.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let field_name = get_field_name(field);
|
||||
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
|
||||
let field_name = rename_option.transform(field_name);
|
||||
|
||||
fields_as_string.push_str(&format!(" {field_name}: {field_type};\n"));
|
||||
}
|
||||
|
||||
fields_as_string.push_str("}\n");
|
||||
|
||||
(struct_name, fields_as_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_struct() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct<'a, T, R>{
|
||||
field_1: &str,
|
||||
field2: i32,
|
||||
field3: Option<String>,
|
||||
field4: Vec<i32>,
|
||||
record: HashMap<&'a str, i32>,
|
||||
user: User,
|
||||
generic: T,
|
||||
generic2: HashMap<&mut str, R>,
|
||||
btree: BTreeMap<String, i32>,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct<T, R> {\n field_1: string;\n field2: number;\n field3: string | null;\n field4: number[];\n record: Record<string, number>;\n user: User;\n generic: T;\n generic2: Record<string, R>;\n btree: Record<string, number>;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_tuple_fields() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
tuple: (i32, i32, String, User),
|
||||
|
||||
}"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n tuple: [number, number, string, User];\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_struct_with_no_generics() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
field_1: &str,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_1: string;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_turn_the_keys_camel_case() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n fieldOne: string;\n fieldTwo: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_turn_the_keys_pascal_case() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n FieldOne: string;\n FieldTwo: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_retrieve_the_serde_rename_option() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let rename_option: RenameSerdeOptions =
|
||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
||||
|
||||
assert_eq!(rename_option, RenameSerdeOptions::PascalCase);
|
||||
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
}"#;
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let rename_option: RenameSerdeOptions =
|
||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
||||
assert_eq!(rename_option, RenameSerdeOptions::CamelCase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_identifies_skip_fields() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
#[serde(skip)]
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
#[serde(skip_serializing)]
|
||||
field_three: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_two: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_override_a_field_name() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
#[serde(rename = "field_one")]
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_one: number;\n}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use convert_case::{Case, Casing};
|
||||
use std::str::FromStr;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{GenericArgument, GenericParam, PathArguments};
|
||||
|
||||
fn type_to_typescript(type_name: &str) -> &str {
|
||||
match type_name {
|
||||
"i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
|
||||
| "isize" | "usize" => "number",
|
||||
"str" | "String" | "char" => "string",
|
||||
"bool" => "boolean",
|
||||
_ => type_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_field_name(field: &syn::Field) -> String {
|
||||
let field_name: String = parse_serde_attribute(&field.attrs, "rename");
|
||||
|
||||
if !field_name.is_empty() {
|
||||
return field_name;
|
||||
}
|
||||
|
||||
if let Some(field) = field.ident.as_ref() {
|
||||
field.to_string()
|
||||
} else {
|
||||
String::from("unknown")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the struct generics and return them collected into a "<...>" string.
|
||||
/// If no generics are present, return an empty string.
|
||||
pub fn parse_generics_to_typescript_string(generics: Punctuated<GenericParam, Comma>) -> String {
|
||||
let generics = generics
|
||||
.iter()
|
||||
.map(|param| {
|
||||
if let syn::GenericParam::Type(type_param) = param {
|
||||
type_param.ident.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if !generics.is_empty() {
|
||||
return format!("<{}>", generics.join(", "));
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Parse any "assign" `#[serde(... = "...")]` attribute and return the value of the specified
|
||||
/// attribute.
|
||||
pub fn parse_serde_attribute<T>(attrs: &[syn::Attribute], attribute_name: &str) -> T
|
||||
where
|
||||
T: Default + FromStr,
|
||||
{
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("serde") {
|
||||
if let Ok(meta) = attr.parse_args::<syn::Expr>() {
|
||||
match meta {
|
||||
syn::Expr::Assign(assign) => {
|
||||
if let syn::Expr::Path(path) = *assign.left {
|
||||
if !path.path.is_ident(attribute_name) {
|
||||
return T::default();
|
||||
}
|
||||
}
|
||||
if let syn::Expr::Lit(syn::ExprLit {
|
||||
lit: syn::Lit::Str(lit_str),
|
||||
..
|
||||
}) = *assign.right
|
||||
{
|
||||
return T::from_str(&lit_str.value()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
_ => return T::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T::default()
|
||||
}
|
||||
|
||||
/// Check if the element should be skipped based on the presence of
|
||||
/// `#[serde(skip)]` or `#[serde(skip_serializing)]` attributes.
|
||||
pub fn should_skip_element(attrs: &[syn::Attribute]) -> bool {
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("serde") {
|
||||
if let Ok(meta) = attr.parse_args::<syn::Ident>() {
|
||||
if meta == "skip" || meta == "skip_serializing" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn rust_to_typescript_type(ty: &syn::Type) -> String {
|
||||
match ty {
|
||||
syn::Type::Tuple(tuple) => {
|
||||
let inner_types: Vec<String> =
|
||||
tuple.elems.iter().map(rust_to_typescript_type).collect();
|
||||
format!("[{}]", inner_types.join(", "))
|
||||
}
|
||||
syn::Type::Path(type_path) => {
|
||||
if let Some(last_segment) = type_path.path.segments.last() {
|
||||
let outer_type = last_segment.ident.to_string();
|
||||
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
|
||||
let inner_types: Vec<String> = args
|
||||
.args
|
||||
.iter()
|
||||
.filter_map(|arg| {
|
||||
if let GenericArgument::Type(inner_type) = arg {
|
||||
match inner_type {
|
||||
syn::Type::Path(inner_type_path) => {
|
||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
||||
}
|
||||
syn::Type::Reference(reference) => {
|
||||
if let syn::Type::Path(inner_type_path) = &*reference.elem {
|
||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
||||
} else {
|
||||
Some("unknown".to_string())
|
||||
}
|
||||
}
|
||||
_ => Some("unknown".to_string()),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
match outer_type.as_str() {
|
||||
"Option" => {
|
||||
format!("{} | null", type_to_typescript(&inner_types[0]))
|
||||
}
|
||||
"Vec" => {
|
||||
format!("{}[]", type_to_typescript(&inner_types[0]))
|
||||
}
|
||||
"HashMap" | "BTreeMap" => {
|
||||
format!(
|
||||
"Record<{}, {}>",
|
||||
type_to_typescript(&inner_types[0]),
|
||||
type_to_typescript(&inner_types[1])
|
||||
)
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
type_to_typescript(&outer_type).to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
syn::Type::Reference(reference) => {
|
||||
// Ignore lifetimes and treat references as their base type
|
||||
if let syn::Type::Path(type_path) = &*reference.elem {
|
||||
if let Some(base_type) = type_path.path.segments.last() {
|
||||
type_to_typescript(&base_type.ident.to_string()).to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// This enum matches serde's RenameRule enum
|
||||
#[derive(Debug, Eq, PartialEq, Default)]
|
||||
pub enum RenameSerdeOptions {
|
||||
#[default]
|
||||
None,
|
||||
LowerCase,
|
||||
UpperCase,
|
||||
PascalCase,
|
||||
CamelCase,
|
||||
SnakeCase,
|
||||
ScreamingSnakeCase,
|
||||
KebabCase,
|
||||
ScreamingKebabCase,
|
||||
}
|
||||
|
||||
impl FromStr for RenameSerdeOptions {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
match input {
|
||||
"lowercase" => Ok(Self::LowerCase),
|
||||
"UPPERCASE" => Ok(Self::UpperCase),
|
||||
"PascalCase" => Ok(Self::PascalCase),
|
||||
"camelCase" => Ok(Self::CamelCase),
|
||||
"snake_case" => Ok(Self::SnakeCase),
|
||||
"SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase),
|
||||
"kebab-case" => Ok(Self::KebabCase),
|
||||
"SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameSerdeOptions {
|
||||
pub fn transform(&self, input: String) -> String {
|
||||
match self {
|
||||
Self::LowerCase => input.to_lowercase(),
|
||||
Self::UpperCase => input.to_uppercase(),
|
||||
Self::CamelCase => input.to_case(Case::Camel),
|
||||
Self::PascalCase => input.to_case(Case::Pascal),
|
||||
_ => input,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use crate::symbols::TYPE_TRAIT;
|
||||
use crate::typescript::FileTypes;
|
||||
use glob::glob;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::read_to_string;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, trace};
|
||||
use tuono_internal::tuono_println;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TypesJar {
|
||||
types: Vec<FileTypes>,
|
||||
should_generate_typescript_file: bool,
|
||||
}
|
||||
|
||||
impl TypesJar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
types: Vec::new(),
|
||||
should_generate_typescript_file: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypesJar {
|
||||
/// Removes all types from the jar that are
|
||||
/// present in the provided `file_path`.
|
||||
/// This function is triggered when a file is deleted
|
||||
pub fn remove_file(&mut self, file_path: PathBuf) {
|
||||
self.should_generate_typescript_file = true;
|
||||
self.types.retain(|ttype| ttype.file_path != file_path);
|
||||
}
|
||||
|
||||
pub fn refresh_file(&mut self, path: PathBuf) {
|
||||
if let Ok(file_str) = read_to_string(&path) {
|
||||
if file_str.contains(*TYPE_TRAIT) {
|
||||
if let Ok(ttype) = FileTypes::try_from((path.clone(), file_str)) {
|
||||
if Some(&ttype) == self.types.iter().find(|t| t.file_path == path) {
|
||||
// The new file exactly matches the old one
|
||||
trace!("File already exists in jar: {:?}", path);
|
||||
return;
|
||||
}
|
||||
trace!("Refreshing: {:?} type", ttype.types);
|
||||
|
||||
self.should_generate_typescript_file = true;
|
||||
self.remove_file(path);
|
||||
self.types.push(ttype);
|
||||
} else {
|
||||
error!("Failed to parse file: {:?}", path);
|
||||
}
|
||||
} else {
|
||||
// Check if the file exist. In case it is it means that the user
|
||||
// removed the "Type" derived trait. Hence we have to remove it from
|
||||
// the jar
|
||||
self.remove_file(path.clone());
|
||||
}
|
||||
} else {
|
||||
error!("Failed to read file: {:?}", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_duplicate_types(&self) -> HashMap<String, (PathBuf, PathBuf)> {
|
||||
trace!("Checking for duplicated types");
|
||||
let mut duplicates: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
|
||||
let mut paths: Vec<&PathBuf> = Vec::new();
|
||||
let mut types: Vec<&Vec<String>> = Vec::new();
|
||||
|
||||
for file_types in self.types.iter() {
|
||||
paths.push(&file_types.file_path);
|
||||
types.push(&file_types.types);
|
||||
}
|
||||
|
||||
for i in 0..types.len() {
|
||||
for j in (i + 1)..types.len() {
|
||||
let types_i = types[i];
|
||||
let types_j = types[j];
|
||||
|
||||
for type_i in types_i {
|
||||
if types_j.contains(type_i) {
|
||||
duplicates.insert(type_i.clone(), (paths[j].clone(), paths[i].clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duplicates
|
||||
}
|
||||
|
||||
fn log_duplicated_types(&self) {
|
||||
let duplicates = self.check_duplicate_types();
|
||||
let base_path = env::current_dir().unwrap_or_default();
|
||||
let base_path_str = base_path.to_string_lossy();
|
||||
|
||||
for (type_name, file_paths) in duplicates.iter() {
|
||||
let first_file_path = file_paths
|
||||
.0
|
||||
.to_string_lossy()
|
||||
.replace(&base_path_str.to_string(), "");
|
||||
let second_file_path = file_paths
|
||||
.1
|
||||
.to_string_lossy()
|
||||
.replace(&base_path_str.to_string(), "");
|
||||
|
||||
tuono_println!("Duplicate \"{}\" type found in files:\n", type_name);
|
||||
tuono_println!("- {}", first_file_path);
|
||||
tuono_println!("- {}\n", second_file_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the string containing all the typescript types
|
||||
/// found in the jar.
|
||||
fn generate_typescript(&self) -> String {
|
||||
self.log_duplicated_types();
|
||||
let mut typescript = String::from("declare module \"tuono/types\" {\n");
|
||||
for ttype in &self.types {
|
||||
typescript.push_str(&format!(
|
||||
"// START [{}]\n",
|
||||
ttype.file_path.to_string_lossy()
|
||||
));
|
||||
typescript.push_str(&ttype.types_as_string);
|
||||
typescript.push_str(&format!("// END [{}]\n", ttype.file_path.to_string_lossy()));
|
||||
}
|
||||
typescript.push_str("}\n");
|
||||
typescript
|
||||
}
|
||||
|
||||
pub fn generate_typescript_file(&mut self, base_path: &Path) -> std::io::Result<()> {
|
||||
if !self.should_generate_typescript_file {
|
||||
trace!("No need to create typescript module file");
|
||||
return Ok(());
|
||||
}
|
||||
self.should_generate_typescript_file = false;
|
||||
trace!("Creating typescript module file");
|
||||
let typescript = self.generate_typescript();
|
||||
let typescript_file_path = base_path.join(".tuono").join("types.ts");
|
||||
std::fs::write(typescript_file_path, typescript)?;
|
||||
|
||||
trace!("Typescript module file created");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PathBuf> for TypesJar {
|
||||
/// Fill the TypesJar with all the Rust files found within
|
||||
/// the provided `base_path`.
|
||||
fn from(base_path: &PathBuf) -> Self {
|
||||
let mut jar = Self::new();
|
||||
|
||||
if let Some(path) = base_path.join("src/**/*.rs").to_str() {
|
||||
if let Ok(files) = glob(path) {
|
||||
files.for_each(|path| {
|
||||
let file_path = path.unwrap_or_default();
|
||||
if let Ok(file_str) = read_to_string(&file_path) {
|
||||
if !file_str.contains(*TYPE_TRAIT) {
|
||||
return;
|
||||
}
|
||||
if let Ok(ttype) = FileTypes::try_from((file_path.clone(), file_str)) {
|
||||
jar.types.push(ttype);
|
||||
} else {
|
||||
error!("Failed to parse file: {:?}", file_path);
|
||||
}
|
||||
} else {
|
||||
error!("Failed to read file: {:?}", file_path);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
error!("Failed to read glob pattern: {:?}", path);
|
||||
}
|
||||
}
|
||||
jar
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_finds_duplicate_types() {
|
||||
let file_type1 = FileTypes {
|
||||
file_path: PathBuf::from("src/types1.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![
|
||||
String::from("Type1"),
|
||||
String::from("Type2"),
|
||||
String::from("Type3"),
|
||||
],
|
||||
};
|
||||
let file_type2 = FileTypes {
|
||||
file_path: PathBuf::from("src/types2.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![
|
||||
String::from("Type1"),
|
||||
String::from("Type2"),
|
||||
String::from("Type4"),
|
||||
],
|
||||
};
|
||||
|
||||
let file_type3 = FileTypes {
|
||||
file_path: PathBuf::from("src/types2.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![String::from("Type3")],
|
||||
};
|
||||
|
||||
let mut jar = TypesJar::new();
|
||||
jar.types.push(file_type1);
|
||||
jar.types.push(file_type2);
|
||||
jar.types.push(file_type3);
|
||||
|
||||
let result = jar.check_duplicate_types();
|
||||
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type1".to_string())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type2".to_string())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type3".to_string())
|
||||
);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type4".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_removes_types() {
|
||||
let mut jar = TypesJar::new();
|
||||
let file_path = PathBuf::from("src/types.rs");
|
||||
let file_type = FileTypes {
|
||||
file_path: file_path.clone(),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![String::from("Type1")],
|
||||
};
|
||||
assert!(jar.should_generate_typescript_file);
|
||||
//Force file generation to false
|
||||
jar.should_generate_typescript_file = false;
|
||||
|
||||
jar.types.push(file_type);
|
||||
jar.remove_file(file_path.clone());
|
||||
|
||||
assert!(jar.should_generate_typescript_file);
|
||||
assert_eq!(jar.types.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::symbols::TYPE_TRAIT;
|
||||
use syn::{Attribute, Meta};
|
||||
|
||||
pub fn has_derive_type(attrs: &[Attribute]) -> bool {
|
||||
for attr in attrs {
|
||||
if let Meta::List(meta_list) = &attr.meta {
|
||||
if meta_list.path.is_ident("derive") {
|
||||
for nested_meta in meta_list
|
||||
.parse_args_with(
|
||||
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Meta::Path(path) = nested_meta {
|
||||
if path.is_ident(&TYPE_TRAIT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use syn::{ItemEnum, ItemStruct, ItemType, parse_quote};
|
||||
|
||||
#[test]
|
||||
fn it_correctly_checks_if_derive_type_is_present() {
|
||||
let test_struct: ItemStruct = parse_quote! {
|
||||
#[derive(Type)]
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_struct.attrs));
|
||||
|
||||
let test_type: ItemType = parse_quote! {
|
||||
#[derive(Type)]
|
||||
type MyType = i32;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_type.attrs));
|
||||
|
||||
let test_enum: ItemEnum = parse_quote! {
|
||||
#[derive(Type)]
|
||||
enum MyEnunType {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_enum.attrs));
|
||||
|
||||
let test_struct_without_type: ItemStruct = parse_quote! {
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(!has_derive_type(&test_struct_without_type.attrs));
|
||||
|
||||
let multi_derived_trait: ItemStruct = parse_quote! {
|
||||
#[derive(Type, Serialize, Debug)]
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&multi_derived_trait.attrs));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use watchexec_supervisor::command::{Command, Program};
|
||||
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
use watchexec_supervisor::job::{start_job, Job};
|
||||
|
||||
use crate::source_builder::{bundle_axum_source, Mode};
|
||||
|
||||
fn watch_react_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "node_modules/.bin/tuono-dev-watch".into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
fn build_rust_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "cargo".into(),
|
||||
args: vec!["run".to_string(), "-q".to_string()],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
fn build_react_ssr_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "node_modules/.bin/tuono-dev-ssr".into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch() -> Result<()> {
|
||||
println!("Starting development environment...");
|
||||
watch_react_src().start().await;
|
||||
|
||||
let run_server = build_rust_src();
|
||||
|
||||
let build_ssr_bundle = build_react_ssr_src();
|
||||
|
||||
build_ssr_bundle.start().await;
|
||||
|
||||
run_server.start().await;
|
||||
|
||||
build_ssr_bundle.to_wait().await;
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let mut should_reload = false;
|
||||
|
||||
for event in action.events.iter() {
|
||||
for path in event.paths() {
|
||||
if path.0.to_string_lossy().ends_with(".rs")
|
||||
// Either tsx and jsx
|
||||
|| path.0.to_string_lossy().ends_with("sx")
|
||||
{
|
||||
should_reload = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload {
|
||||
println!("Reloading...");
|
||||
run_server.stop();
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source(Mode::Dev).expect("Failed to bunlde rust source");
|
||||
run_server.start();
|
||||
println!("Ready!");
|
||||
}
|
||||
|
||||
// if Ctrl-C is received, quit
|
||||
if action.signals().any(|sig| sig == Signal::Interrupt) {
|
||||
action.quit();
|
||||
}
|
||||
|
||||
action
|
||||
})?;
|
||||
|
||||
// watch the current directory
|
||||
wx.config.pathset(["./src"]);
|
||||
|
||||
let _ = wx.main().await.into_diagnostic()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import 'vite/modulepreload-polyfill'
|
||||
import { hydrate } from 'tuono/hydration'
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
hydrate(routeTree)
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
window['__TUONO_SERVER_PAYLOAD__'] = [SERVER_PAYLOAD]
|
||||
</script>
|
||||
<script type="module" async>
|
||||
import RefreshRuntime from '[BASE_URL]/vite-server/@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => { }
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" async src="[BASE_URL]/vite-server/@vite/client"></script>
|
||||
<script type="module" async src="[BASE_URL]/vite-server/client-main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
// File automatically generated
|
||||
// Do not manually change it
|
||||
|
||||
use tuono_lib::{tokio, Mode, Server, axum::Router, tuono_internal_init_v8_platform};
|
||||
// AXUM_GET_ROUTE_HANDLER
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
//MAIN_FILE_IMPORT//
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tuono_internal_init_v8_platform();
|
||||
|
||||
if MODE == Mode::Prod {
|
||||
println!("\n ⚡ Tuono v/*VERSION*/");
|
||||
}
|
||||
|
||||
//MAIN_FILE_DEFINITION//
|
||||
|
||||
let router = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
//MAIN_FILE_USAGE//;
|
||||
|
||||
Server::init(router, MODE).await.start().await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import { serverSideRendering } from 'tuono/ssr'
|
||||
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const renderFn = serverSideRendering(routeTree)
|
||||
@@ -0,0 +1,253 @@
|
||||
use assert_cmd::Command;
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use tracing::Level;
|
||||
|
||||
mod utils;
|
||||
use utils::temp_tuono_project::TempTuonoProject;
|
||||
|
||||
const POST_API_FILE: &str = r"#[tuono_lib::api(POST)]";
|
||||
const GET_API_FILE: &str = r"#[tuono_lib::api(GET)]";
|
||||
|
||||
fn tracing_message(level: Level, module: &str, message: &str) -> String {
|
||||
format!("\x1b[31m{level}\x1b[0m \x1b[2mtuono::{module}\x1b[0m\x1b[2m:\x1b[0m {message}\n")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const BUILD_TUONO_CONFIG: &str = ".\\node_modules\\.bin\\tuono-build-config.cmd";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const BUILD_TUONO_CONFIG: &str = "./node_modules/.bin/tuono-build-config";
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_successfully_create_the_index_route() {
|
||||
let temp_tuono_project = TempTuonoProject::new();
|
||||
|
||||
temp_tuono_project.add_file("./src/routes/index.rs");
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.arg("--no-js-emit")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
|
||||
|
||||
let temp_main_rs_content =
|
||||
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
|
||||
|
||||
assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/index.rs"]"#));
|
||||
assert!(temp_main_rs_content.contains("mod index;"));
|
||||
|
||||
assert!(temp_main_rs_content
|
||||
.contains(r#".route("/", get(index::tuono_internal_route)).route("/__tuono/data/", get(index::tuono_internal_api))"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_successfully_create_an_api_route() {
|
||||
let temp_tuono_project = TempTuonoProject::new();
|
||||
|
||||
temp_tuono_project.add_file_with_content("./src/routes/api/health_check.rs", POST_API_FILE);
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.arg("--no-js-emit")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
|
||||
|
||||
let temp_main_rs_content =
|
||||
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
|
||||
|
||||
assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/api/health_check.rs"]"#));
|
||||
assert!(temp_main_rs_content.contains("mod api_health_check;"));
|
||||
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/api/health_check", post(api_health_check::post_tuono_internal_api))"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_successfully_create_multiple_api_for_the_same_file() {
|
||||
let temp_tuono_project = TempTuonoProject::new();
|
||||
|
||||
temp_tuono_project.add_file_with_content(
|
||||
"./src/routes/api/health_check.rs",
|
||||
&format!("{POST_API_FILE}{GET_API_FILE}"),
|
||||
);
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.arg("--no-js-emit")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
|
||||
|
||||
let temp_main_rs_content =
|
||||
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
|
||||
|
||||
assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/api/health_check.rs"]"#));
|
||||
assert!(temp_main_rs_content.contains("mod api_health_check;"));
|
||||
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/api/health_check", post(api_health_check::post_tuono_internal_api))"#
|
||||
));
|
||||
assert!(
|
||||
temp_main_rs_content.contains(
|
||||
r#".route("/api/health_check", get(api_health_check::get_tuono_internal_api))"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_successfully_import_mixed_case_routes() {
|
||||
let temp_tuono_project = TempTuonoProject::new();
|
||||
|
||||
for method in ["get", "post", "put", "delete", "patch"] {
|
||||
temp_tuono_project.add_file_with_content(
|
||||
&format!("./src/routes/api/{method}_lower.rs"),
|
||||
&format!(r"#[tuono_lib::api({method})]"),
|
||||
);
|
||||
temp_tuono_project.add_file_with_content(
|
||||
&format!("./src/routes/api/{method}_upper.rs"),
|
||||
&format!(r"#[tuono_lib::api({})]", method.to_uppercase()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.arg("--no-js-emit")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
|
||||
|
||||
let temp_main_rs_content =
|
||||
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
|
||||
|
||||
for method in ["get", "post", "put", "delete", "patch"] {
|
||||
let expected = format!(r#"use tuono_lib::axum::routing::{method};"#);
|
||||
let imports = temp_main_rs_content.match_indices(&expected);
|
||||
assert_eq!(imports.count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_successfully_create_catch_all_routes() {
|
||||
let temp_tuono_project = TempTuonoProject::new();
|
||||
|
||||
temp_tuono_project.add_file("./src/routes/[...all_routes].rs");
|
||||
|
||||
temp_tuono_project.add_file_with_content("./src/routes/api/[...all_apis].rs", POST_API_FILE);
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.arg("--no-js-emit")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
|
||||
|
||||
let temp_main_rs_content =
|
||||
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
|
||||
|
||||
assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/api/[...all_apis].rs"]"#));
|
||||
assert!(temp_main_rs_content.contains("mod api_dyn_catch_all_all_apis;"));
|
||||
|
||||
assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/[...all_routes].rs"]"#));
|
||||
assert!(temp_main_rs_content.contains("mod dyn_catch_all_all_routes;"));
|
||||
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/api/{*all_apis}", post(api_dyn_catch_all_all_apis::post_tuono_internal_api))"#
|
||||
));
|
||||
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/{*all_routes}", get(dyn_catch_all_all_routes::tuono_internal_route))"#
|
||||
));
|
||||
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/{*all_routes}", get(dyn_catch_all_all_routes::tuono_internal_route))"#
|
||||
));
|
||||
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/__tuono/data/{*all_routes}", get(dyn_catch_all_all_routes::tuono_internal_api))"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_fails_without_installed_build_config_script() {
|
||||
let _guard = TempTuonoProject::new();
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr("Failed to find the build script. Please run `npm install`\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn it_fails_without_installed_build_script() {
|
||||
let temp_tuono_project = TempTuonoProject::new();
|
||||
temp_tuono_project.add_file_with_content(BUILD_TUONO_CONFIG, "#!/bin/bash");
|
||||
Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(BUILD_TUONO_CONFIG)
|
||||
.assert()
|
||||
.success();
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
|
||||
test_tuono_build
|
||||
.arg("build")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr("Failed to read config. Please run `npm install` to generate automatically.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn dev_fails_with_no_config() {
|
||||
let _temp_tuono_project = TempTuonoProject::new_with_no_config();
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("dev")
|
||||
.assert()
|
||||
.failure()
|
||||
.stdout(tracing_message(
|
||||
Level::ERROR,
|
||||
"source_builder",
|
||||
"Cannot find tuono.config.ts - is this a tuono project?",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn build_fails_with_no_config() {
|
||||
let _temp_tuono_project = TempTuonoProject::new_with_no_config();
|
||||
|
||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
||||
test_tuono_build
|
||||
.arg("dev")
|
||||
.assert()
|
||||
.failure()
|
||||
.stdout(tracing_message(
|
||||
Level::ERROR,
|
||||
"source_builder",
|
||||
"Cannot find tuono.config.ts - is this a tuono project?",
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use assert_cmd::Command;
|
||||
use clap::crate_version;
|
||||
use serial_test::serial;
|
||||
|
||||
use std::fs;
|
||||
|
||||
mod utils;
|
||||
|
||||
use utils::mock_github_endpoint::GitHubServerMock;
|
||||
use utils::temp_tuono_project::TempTuonoProject;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_creates_a_new_project_and_replace_the_versions_in_the_manifest() {
|
||||
let GitHubServerMock { env_vars, .. } = GitHubServerMock::new().await;
|
||||
|
||||
let temp_project = TempTuonoProject::new();
|
||||
|
||||
let project_folder = "my-project";
|
||||
|
||||
let mut cmd = Command::cargo_bin("tuono").unwrap();
|
||||
|
||||
cmd.arg("new")
|
||||
.arg(project_folder)
|
||||
.envs(env_vars)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let main_rs_path = temp_project
|
||||
.path()
|
||||
.join(project_folder)
|
||||
.join("src")
|
||||
.join("main.rs");
|
||||
|
||||
let cargo_toml_path = temp_project.path().join(project_folder).join("Cargo.toml");
|
||||
let package_json_path = temp_project
|
||||
.path()
|
||||
.join(project_folder)
|
||||
.join("package.json");
|
||||
|
||||
let main_rs_content = fs::read_to_string(main_rs_path).unwrap();
|
||||
let cargo_toml_content = fs::read_to_string(cargo_toml_path).unwrap();
|
||||
let package_json_content = fs::read_to_string(package_json_path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
main_rs_content,
|
||||
"fn main() { println!(\"Hello, world!\"); }"
|
||||
);
|
||||
|
||||
let version = crate_version!();
|
||||
|
||||
let expected_cargo_toml_content =
|
||||
format!("[package] name = \"tuono-tutorial\" [dependencies] tuono_lib = \"{version}\"");
|
||||
|
||||
assert_eq!(cargo_toml_content, expected_cargo_toml_content);
|
||||
|
||||
let expected_package_json_content =
|
||||
format!("{{\"name\": \"tuono-app\", \"dependencies\": {{ \"tuono\": \"{version}\" }}}}");
|
||||
|
||||
assert_eq!(package_json_content, expected_package_json_content);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use clap::crate_version;
|
||||
use std::env;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct GitHubServerMock {
|
||||
pub server: MockServer,
|
||||
pub env_vars: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl GitHubServerMock {
|
||||
pub async fn new() -> Self {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let tuono_version = crate_version!();
|
||||
|
||||
let sha = "1234567890abcdef";
|
||||
|
||||
let sha_response_template = ResponseTemplate::new(200).set_body_raw(
|
||||
format!("{{ \"object\": {{ \"sha\": \"{sha}\" }} }}"),
|
||||
"application/json",
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"repos/tuono-labs/tuono/git/ref/tags/v{tuono_version}"
|
||||
)))
|
||||
.respond_with(sha_response_template)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tree_response_template = ResponseTemplate::new(200).set_body_raw(
|
||||
r#"{
|
||||
"tree": [
|
||||
{
|
||||
"path": "examples/tuono-app/src",
|
||||
"type": "tree"
|
||||
},
|
||||
{
|
||||
"path": "examples/tuono-app/src/main.rs",
|
||||
"type": "blob"
|
||||
},
|
||||
{
|
||||
"path": "examples/tuono-app/Cargo.toml",
|
||||
"type": "blob"
|
||||
},
|
||||
{
|
||||
"path": "examples/tuono-app/package.json",
|
||||
"type": "blob"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
"application/json",
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("repos/tuono-labs/tuono/git/trees/{sha}")))
|
||||
.respond_with(tree_response_template)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let file_response_template =
|
||||
|content: &str| ResponseTemplate::new(200).set_body_raw(content, "text/plain");
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"tuono-labs/tuono/v{tuono_version}/examples/tuono-app/src/main.rs"
|
||||
)))
|
||||
.respond_with(file_response_template(
|
||||
"fn main() { println!(\"Hello, world!\"); }",
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"tuono-labs/tuono/v{tuono_version}/examples/tuono-app/Cargo.toml"
|
||||
)))
|
||||
.respond_with(file_response_template(
|
||||
"[package] name = \"tuono-tutorial\" [dependencies] tuono_lib = { path = \"../../crates/tuono_lib/\" }"
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"tuono-labs/tuono/v{tuono_version}/examples/tuono-app/package.json"
|
||||
)))
|
||||
.respond_with(file_response_template(
|
||||
r#"{"name": "tuono-app", "dependencies": { "tuono": "link:../../packages/tuono" }}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let env_vars = vec![("__INTERNAL_TUONO_TEST".to_string(), server.uri())];
|
||||
GitHubServerMock { server, env_vars }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod mock_github_endpoint;
|
||||
pub mod temp_tuono_project;
|
||||
@@ -0,0 +1,70 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TempTuonoProject {
|
||||
original_dir: PathBuf,
|
||||
temp_dir: TempDir,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TempTuonoProject {
|
||||
pub fn new() -> Self {
|
||||
let project = TempTuonoProject::new_with_no_config();
|
||||
|
||||
project.add_file("./tuono.config.ts");
|
||||
|
||||
project
|
||||
}
|
||||
|
||||
pub fn new_with_no_config() -> Self {
|
||||
let original_dir = env::current_dir().expect("Failed to read current_dir");
|
||||
let temp_dir = tempdir().expect("Failed to create temp_dir");
|
||||
|
||||
env::set_current_dir(temp_dir.path()).expect("Failed to change current dir into temp_dir");
|
||||
|
||||
TempTuonoProject {
|
||||
original_dir,
|
||||
temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
self.temp_dir.path()
|
||||
}
|
||||
|
||||
pub fn add_file(&self, path: &str) -> File {
|
||||
let path = PathBuf::from(path);
|
||||
create_all(
|
||||
path.parent().expect("Route path does not have any parent"),
|
||||
false,
|
||||
)
|
||||
.expect("Failed to create parent route directory");
|
||||
File::create(path).expect("Failed to create the route file")
|
||||
}
|
||||
|
||||
pub fn add_file_with_content<'a>(&self, path: &'a str, content: &'a str) {
|
||||
let path = PathBuf::from(path);
|
||||
create_all(
|
||||
path.parent().expect("Route path does not have any parent"),
|
||||
false,
|
||||
)
|
||||
.expect("Failed to create parent route directory");
|
||||
|
||||
let mut file = File::create(path).expect("Failed to create the route file");
|
||||
file.write_all(content.as_bytes())
|
||||
.expect("Failed to write into API file");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempTuonoProject {
|
||||
fn drop(&mut self) {
|
||||
// Set back the current dir in the previous state
|
||||
env::set_current_dir(&self.original_dir)
|
||||
.expect("Failed to restore the original directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "tuono_internal"
|
||||
version = "0.19.7"
|
||||
edition = "2024"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/tuono-labs/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/**/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.137"
|
||||
|
||||
[dev-dependencies]
|
||||
fs_extra = "1.3.0"
|
||||
tempfile = "3.14.0"
|
||||
assert_cmd = "2.0.16"
|
||||
serial_test = "3.0.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
# tuono_internal
|
||||
|
||||
This crate is a collection of internal utilities for the Tuono project. It is
|
||||
not intended for use outside of the project.
|
||||
@@ -0,0 +1,49 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::read_to_string;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub origin: Option<String>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
ServerConfig {
|
||||
host: "localhost".to_string(),
|
||||
origin: None,
|
||||
port: 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn get() -> io::Result<Config> {
|
||||
let config_file = read_to_string(PathBuf::from_iter([".tuono", "config", "config.json"]))?;
|
||||
|
||||
serde_json::from_str(&config_file)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = Config::default();
|
||||
|
||||
assert_eq!(config.server.host, "localhost".to_string());
|
||||
assert_eq!(config.server.origin, None);
|
||||
assert_eq!(config.server.port, 3000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod config;
|
||||
pub mod tuono_println;
|
||||
@@ -0,0 +1,11 @@
|
||||
#[macro_export]
|
||||
/// Log a message in the terminal using the custom tuono formatter.
|
||||
/// The messages printed with this macro should inform or
|
||||
/// guide the user.
|
||||
///
|
||||
/// The debug/error messages should be printed using the `tracing` crate
|
||||
macro_rules! tuono_println {
|
||||
($($arg:tt)*) => {{
|
||||
println!(" {}", format!($($arg)*));
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
mod utils;
|
||||
use serial_test::serial;
|
||||
use std::io::ErrorKind;
|
||||
use tuono_internal::config::Config;
|
||||
use utils::TempTuonoProject;
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn should_correctly_read_the_config_file() {
|
||||
let folder = TempTuonoProject::new();
|
||||
|
||||
folder.add_file_with_content(
|
||||
"./.tuono/config/config.json",
|
||||
r#"{ "server": {"host": "localhost", "port": 3000}}"#,
|
||||
);
|
||||
|
||||
let config = Config::get();
|
||||
|
||||
assert!(config.is_ok());
|
||||
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.server.host, "localhost");
|
||||
assert_eq!(config.server.origin, None);
|
||||
assert_eq!(config.server.port, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn should_correctly_read_the_config_file_with_origin() {
|
||||
let folder = TempTuonoProject::new();
|
||||
|
||||
folder.add_file_with_content(
|
||||
"./.tuono/config/config.json",
|
||||
r#"{ "server": {"host": "localhost", "origin": "https://tuono.localhost", "port": 3000}}"#,
|
||||
);
|
||||
|
||||
let config = Config::get();
|
||||
|
||||
assert!(config.is_ok());
|
||||
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.server.host, "localhost".to_string());
|
||||
assert_eq!(
|
||||
config.server.origin,
|
||||
Some("https://tuono.localhost".to_string())
|
||||
);
|
||||
assert_eq!(config.server.port, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn should_fail_if_the_file_does_not_exist() {
|
||||
TempTuonoProject::new();
|
||||
|
||||
let config = Config::get();
|
||||
|
||||
assert!(config.is_err());
|
||||
|
||||
assert_eq!(config.err().unwrap().kind(), ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn should_fail_if_the_file_is_not_json() {
|
||||
let folder = TempTuonoProject::new();
|
||||
|
||||
folder.add_file_with_content("./.tuono/config/config.json", "INVALID JSON");
|
||||
|
||||
let config = Config::get();
|
||||
|
||||
assert!(config.is_err());
|
||||
|
||||
assert_eq!(config.err().unwrap().kind(), ErrorKind::InvalidData);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TempTuonoProject {
|
||||
original_dir: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
// Required for dropping the temp_dir when this struct drops
|
||||
temp_dir: TempDir,
|
||||
}
|
||||
|
||||
impl Default for TempTuonoProject {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TempTuonoProject {
|
||||
pub fn new() -> Self {
|
||||
let original_dir = env::current_dir().expect("Failed to read current_dir");
|
||||
let temp_dir = tempdir().expect("Failed to create temp_dir");
|
||||
|
||||
env::set_current_dir(temp_dir.path()).expect("Failed to change current dir into temp_dir");
|
||||
|
||||
TempTuonoProject {
|
||||
original_dir,
|
||||
temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file_with_content<'a>(&self, path: &'a str, content: &'a str) {
|
||||
let path = PathBuf::from(path);
|
||||
create_all(
|
||||
path.parent().expect("File path does not have any parent"),
|
||||
false,
|
||||
)
|
||||
.expect("Failed to create parent file directories");
|
||||
|
||||
let mut file = File::create(path).expect("Failed to create the file");
|
||||
file.write_all(content.as_bytes())
|
||||
.expect("Failed to write into the file");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempTuonoProject {
|
||||
fn drop(&mut self) {
|
||||
// Set back the current dir in the previous state
|
||||
env::set_current_dir(&self.original_dir)
|
||||
.expect("Failed to restore the original directory.");
|
||||
}
|
||||
}
|
||||
+28
-14
@@ -1,34 +1,48 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
version = "0.19.7"
|
||||
edition = "2024"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/tuono-labs/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"src/**/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "tuono_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
ssr_rs = "0.5.5"
|
||||
axum = {version = "0.7.5", features = ["json"]}
|
||||
ssr_rs = "0.8.3"
|
||||
axum = {version = "0.8.1", features = ["json", "ws"]}
|
||||
axum-extra = {version = "0.10.0", features = ["cookie"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
erased-serde = "0.4.5"
|
||||
serde_json = "1.0"
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.3.1"}
|
||||
serde_urlencoded = "0.7.1"
|
||||
reqwest = {version = "0.12.4", features = ["json", "stream"]}
|
||||
once_cell = "1.19.0"
|
||||
lazy_static = "1.5.0"
|
||||
regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
tower-http = {version = "0.6.0", features = ["fs"]}
|
||||
colored = "3.0.0"
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.7"}
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.19.7"}
|
||||
# Match the same version used by axum
|
||||
tokio-tungstenite = "0.27.0"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
tungstenite = "0.27.0"
|
||||
http = "1.1.0"
|
||||
pin-project = "1.1.7"
|
||||
tower = "0.5.1"
|
||||
|
||||
[dev-dependencies]
|
||||
fs_extra = "1.3.0"
|
||||
tempfile = "3.14.0"
|
||||
serial_test = "3.0.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{ssr::Js, Payload};
|
||||
use crate::{Payload, ssr::Js};
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use std::collections::HashMap;
|
||||
@@ -7,15 +7,15 @@ pub async fn catch_all(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
request: Request,
|
||||
) -> Html<String> {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = crate::Request::new(pathname, headers, params);
|
||||
let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params, None);
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = Payload::new(&req, &"").client_payload().unwrap();
|
||||
|
||||
let result = Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)));
|
||||
let result = Js::render_to_string(Some(&payload));
|
||||
|
||||
match result {
|
||||
Ok(html) => Html(html),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
use once_cell::sync::OnceCell;
|
||||
use tuono_internal::config::Config;
|
||||
|
||||
pub static GLOBAL_CONFIG: OnceCell<Config> = OnceCell::new();
|
||||
@@ -0,0 +1,301 @@
|
||||
use crate::mode::Mode;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
/// Read the env variables from the .env files
|
||||
/// and set them in the OS env
|
||||
///
|
||||
/// This function is unsafe because it modifies the OS env variables (which needs
|
||||
/// to be done in a single-threaded context).
|
||||
pub unsafe fn load_env_vars(mode: Mode) {
|
||||
let mut env_files = vec![String::from(".env"), String::from(".env.local")];
|
||||
|
||||
let mode_name = match mode {
|
||||
Mode::Dev => "development",
|
||||
Mode::Prod => "production",
|
||||
};
|
||||
|
||||
env_files.push(format!(".env.{mode_name}"));
|
||||
env_files.push(String::from(".env.local"));
|
||||
env_files.push(format!(".env.{mode_name}.local"));
|
||||
|
||||
let system_env_names: HashSet<String> = env::vars().map(|(k, _)| k).collect();
|
||||
|
||||
for env_file in env_files {
|
||||
if let Ok(contents) = fs::read_to_string(env_file) {
|
||||
for line in contents.lines() {
|
||||
if let Some((key, mut value)) = line.split_once('=') {
|
||||
if value.starts_with('"') && value.ends_with('"') {
|
||||
value = &value[1..value.len() - 1];
|
||||
}
|
||||
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim().to_string();
|
||||
|
||||
if system_env_names.contains(&key) {
|
||||
continue; // Skip if key exists in system env
|
||||
}
|
||||
|
||||
unsafe {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mode::Mode;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
struct MockEnv {
|
||||
files: Vec<String>,
|
||||
vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MockEnv {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Vec::new(),
|
||||
vars: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_system_var(&mut self, k: &str, v: &str) {
|
||||
self.vars.insert(k.to_string(), v.to_string());
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_env_file(&mut self, file_name: &str, contents: &str) {
|
||||
self.files.push(file_name.to_string());
|
||||
fs::write(file_name, contents).expect("Failed to write test .env file");
|
||||
}
|
||||
|
||||
pub fn capture_keys(&mut self, keys: &[&str]) {
|
||||
for key in keys {
|
||||
if let Ok(val) = env::var(key) {
|
||||
self.vars.insert(key.to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockEnv {
|
||||
fn drop(&mut self) {
|
||||
for file in self.files.iter() {
|
||||
let _ = fs::remove_file(file.as_str());
|
||||
}
|
||||
for key in self.vars.keys() {
|
||||
unsafe {
|
||||
env::remove_var(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_system_env_var_precedence() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.add_system_var("TEST_KEY", "system_value");
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=file_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "system_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_specific_env_var_precedence_dev() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "development_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_specific_env_var_precedence_prod() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Prod);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "production_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_local_env_var_precedence() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.local", "TEST_KEY=local_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "local_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_local_env_var_precedence_dev() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
|
||||
mock_env.setup_env_file(".env.development.local", "TEST_KEY=local_dev_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "local_dev_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_local_env_var_precedence_prod() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
|
||||
mock_env.setup_env_file(".env.production.local", "TEST_KEY=local_prod_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Prod);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "local_prod_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_ignores_files_from_other_mode() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
|
||||
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Prod);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "production_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_empty_env_file() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
assert!(env::var("NON_EXISTENT_KEY").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_malformed_env_entries() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "INVALID_LINE\nMISSING_EQUALS_SIGN");
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["INVALID_LINE", "MISSING_EQUALS_SIGN"]);
|
||||
|
||||
assert!(env::var("INVALID_LINE").is_err());
|
||||
assert!(env::var("MISSING_EQUALS_SIGN").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_quoted_values_parsing() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", r#"TEST_KEY="quoted_value""#);
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "quoted_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_non_existent_env_file() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["NON_EXISTENT_KEY"]);
|
||||
|
||||
assert!(env::var("NON_EXISTENT_KEY").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_multiple_env_vars() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "KEY1=value1\nKEY2=value2");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["KEY1", "KEY2"]);
|
||||
|
||||
assert_eq!(env::var("KEY1").unwrap(), "value1");
|
||||
assert_eq!(env::var("KEY2").unwrap(), "value2");
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,31 @@
|
||||
//! ## Tuono
|
||||
//! Tuono is a full-stack web framework for building React applications using Rust as the backend with a strong focus on usability and performance.
|
||||
//!
|
||||
//! You can find the full documentation at [tuono.dev](https://tuono.dev/)
|
||||
|
||||
mod catch_all;
|
||||
mod config;
|
||||
mod env;
|
||||
mod manifest;
|
||||
mod mode;
|
||||
mod payload;
|
||||
mod request;
|
||||
mod response;
|
||||
mod server;
|
||||
mod services;
|
||||
mod ssr;
|
||||
mod vite_reverse_proxy;
|
||||
mod vite_websocket_proxy;
|
||||
|
||||
pub use mode::Mode;
|
||||
pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::{Props, Response};
|
||||
pub use server::Server;
|
||||
pub use tuono_lib_macros::handler;
|
||||
pub use server::{Server, tuono_internal_init_v8_platform};
|
||||
pub use tuono_lib_macros::{Type, api, handler};
|
||||
|
||||
// Re-exports
|
||||
pub use axum;
|
||||
pub use axum_extra::extract::cookie;
|
||||
pub use ssr_rs::Ssr;
|
||||
pub use tokio;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use once_cell::sync::OnceCell;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
@@ -7,93 +9,513 @@ use std::path::PathBuf;
|
||||
|
||||
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct BundleInfo {
|
||||
pub file: String,
|
||||
pub css: Vec<String>,
|
||||
fn has_dynamic_path(pathname: &str) -> bool {
|
||||
static RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\[(.*?)\]").expect("Invalid regex for dynamic path detection"));
|
||||
RE.is_match(pathname)
|
||||
}
|
||||
|
||||
/// Manifest is the mapping between the vite output bundled files
|
||||
/// ViteManifest is the mapping between the vite output bundled files
|
||||
/// and the originals.
|
||||
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
||||
pub static MANIFEST: OnceCell<HashMap<String, BundleInfo>> = OnceCell::new();
|
||||
pub type ViteManifest = HashMap<String, BundleInfo>;
|
||||
|
||||
pub fn load_manifest() {
|
||||
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH)).unwrap();
|
||||
let reader = BufReader::new(file);
|
||||
let manifest = serde_json::from_reader(reader).unwrap();
|
||||
MANIFEST.set(remap_manifest_keys(manifest)).unwrap();
|
||||
fn empty_vector() -> Vec<String> {
|
||||
Vec::with_capacity(0)
|
||||
}
|
||||
|
||||
fn remap_manifest_keys(manifest: HashMap<String, BundleInfo>) -> HashMap<String, BundleInfo> {
|
||||
let mut new_hashmap = HashMap::new();
|
||||
/// Interface representing the bundle information
|
||||
/// as they are in the vite manifest.json.
|
||||
///
|
||||
/// Used for deserialization
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BundleInfo {
|
||||
pub file: String,
|
||||
#[serde(default = "empty_vector")]
|
||||
pub css: Vec<String>,
|
||||
#[serde(default = "empty_vector")]
|
||||
pub imports: Vec<String>,
|
||||
// TODO: Add also dynamic imports
|
||||
}
|
||||
|
||||
manifest.keys().for_each(|key| {
|
||||
let new_key = key
|
||||
.replace("../src/routes", "")
|
||||
.replace(".tsx", "")
|
||||
.replace(".jsx", "")
|
||||
.replace("index", "");
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RouteBundle {
|
||||
pub css_files: Vec<String>,
|
||||
pub js_files: Vec<String>,
|
||||
}
|
||||
|
||||
new_hashmap.insert(new_key, manifest.get(key).unwrap().clone());
|
||||
});
|
||||
#[derive(Debug)]
|
||||
pub struct Manifest {
|
||||
/// The mapping between the route and the bundle
|
||||
bundles: HashMap<String, RouteBundle>,
|
||||
}
|
||||
|
||||
new_hashmap
|
||||
fn clean_route_path(path: String) -> String {
|
||||
let path = path
|
||||
.replace("../src/routes", "")
|
||||
.replace(".tsx", "")
|
||||
.replace(".mdx", "")
|
||||
.replace(".md", "")
|
||||
.replace(".jsx", "");
|
||||
|
||||
if path == "/index" {
|
||||
return "/".to_string();
|
||||
}
|
||||
|
||||
path.replace("/index", "")
|
||||
}
|
||||
|
||||
impl From<ViteManifest> for Manifest {
|
||||
fn from(manifest: ViteManifest) -> Self {
|
||||
let mut bundles = HashMap::new();
|
||||
let client_main = manifest
|
||||
.get("client-main.tsx")
|
||||
// client-main.tsx is the entry point and always exists
|
||||
.expect("client-main.tsx not found in the manifest")
|
||||
.clone();
|
||||
|
||||
for (key, bundle) in &manifest {
|
||||
if key.contains("__layout") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if key == "client-main.tsx" {
|
||||
bundles.insert(
|
||||
"client-main".to_string(),
|
||||
RouteBundle {
|
||||
css_files: bundle.css.clone(),
|
||||
js_files: vec![bundle.file.clone()],
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let route = clean_route_path(key.clone());
|
||||
|
||||
// Skip components/utils files
|
||||
if !route.starts_with("/") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let css_files = [bundle.css.clone(), client_main.css.clone()].concat();
|
||||
let js_files = vec![bundle.file.clone(), client_main.file.clone()];
|
||||
|
||||
let mut route_bundle = RouteBundle {
|
||||
css_files,
|
||||
js_files,
|
||||
};
|
||||
|
||||
// the imports bundle always contains at least the client-main
|
||||
if bundle.imports.len() > 1 {
|
||||
for import in &bundle.imports {
|
||||
if import == "client-main.tsx" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(import_bundle) = manifest.get(import) {
|
||||
route_bundle.js_files.push(import_bundle.file.clone());
|
||||
route_bundle.css_files.extend(import_bundle.css.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bundles.insert(route, route_bundle);
|
||||
}
|
||||
|
||||
// Add __layout imports
|
||||
for (key, layout_bundle) in &manifest {
|
||||
let route = clean_route_path(key.clone());
|
||||
if route.contains("__layout") {
|
||||
let path_included_in_layout = route.replace("__layout", "");
|
||||
|
||||
let mut layout_css_files: Vec<String> = Vec::new();
|
||||
let mut layout_js_files: Vec<String> = Vec::new();
|
||||
|
||||
for import in &layout_bundle.imports {
|
||||
if import == "client-main.tsx" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(import_bundle) = manifest.get(import) {
|
||||
layout_js_files.push(import_bundle.file.clone());
|
||||
layout_css_files.extend(import_bundle.css.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (key, route_bundles) in &mut bundles {
|
||||
if key.starts_with(path_included_in_layout.as_str()) {
|
||||
route_bundles.js_files.push(layout_bundle.file.clone());
|
||||
route_bundles.css_files.extend(layout_bundle.css.clone());
|
||||
route_bundles.js_files.extend(layout_js_files.clone());
|
||||
route_bundles.css_files.extend(layout_css_files.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Manifest { bundles }
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// This method adds the route specific bundles to the server
|
||||
/// side rendered HTML.
|
||||
///
|
||||
/// The same matching algorithm is implemented on the client side in
|
||||
/// this file (packages/tuono/src/router/components/Matches.ts).
|
||||
///
|
||||
/// Optimizations should occour on both.
|
||||
pub fn get_bundle_from_pathname(&self, pathname: &str) -> RouteBundle {
|
||||
// Exact match
|
||||
if let Some(bundle) = self.bundles.get(pathname) {
|
||||
return bundle.clone();
|
||||
}
|
||||
|
||||
let dynamic_routes = self
|
||||
.bundles
|
||||
.keys()
|
||||
.filter(|path| has_dynamic_path(path))
|
||||
.collect::<Vec<&String>>();
|
||||
|
||||
if !dynamic_routes.is_empty() {
|
||||
let path_segments = pathname
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
'_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() {
|
||||
let dyn_route_segments = dyn_route
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
let mut route_segments_collector: Vec<&str> = Vec::new();
|
||||
|
||||
for i in 0..dyn_route_segments.len() {
|
||||
// Catch all dynamic route
|
||||
if dyn_route_segments[i].starts_with("[...") {
|
||||
route_segments_collector.push(dyn_route_segments[i]);
|
||||
|
||||
let manifest_key = route_segments_collector.join("/");
|
||||
|
||||
let route_data = self.bundles.get(&format!("/{manifest_key}"));
|
||||
|
||||
if let Some(data) = route_data {
|
||||
return data.clone();
|
||||
}
|
||||
break '_dynamic_routes_loop;
|
||||
}
|
||||
if path_segments.len() == i {
|
||||
break;
|
||||
}
|
||||
if dyn_route_segments[i] == path_segments[i]
|
||||
|| has_dynamic_path(dyn_route_segments[i])
|
||||
{
|
||||
route_segments_collector.push(dyn_route_segments[i])
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if route_segments_collector.len() == path_segments.len() {
|
||||
let manifest_key = route_segments_collector.join("/");
|
||||
|
||||
let route_data = self.bundles.get(&format!("/{manifest_key}"));
|
||||
if let Some(data) = route_data {
|
||||
return data.clone();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No dynamic routes, return the client main bundle
|
||||
if let Some(bundle) = self.bundles.get("client-main") {
|
||||
return bundle.clone();
|
||||
}
|
||||
|
||||
// This should never happen because client-main always exists
|
||||
RouteBundle::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub static MANIFEST: OnceCell<Manifest> = OnceCell::new();
|
||||
|
||||
/// Load the vite manifest from the file system and set it in the MANIFEST
|
||||
/// static variable.
|
||||
pub fn load_manifest() -> std::io::Result<()> {
|
||||
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH))?;
|
||||
let reader = BufReader::new(file);
|
||||
let manifest: ViteManifest = serde_json::from_reader(reader)?;
|
||||
MANIFEST
|
||||
.set(Manifest::from(manifest))
|
||||
.map_err(|_| std::io::Error::other("Failed to set the manifest"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// This manifest is an example of a complex vite manifest.json
|
||||
// It includes dynamic routes, static routes, catch all routes, nested
|
||||
// __layout and shared components.
|
||||
const MANIFEST_EXAMPLE: &str = r#"{
|
||||
"../src/routes/about.tsx": {
|
||||
"file": "assets/about-C3UqHfGb.js",
|
||||
"name": "about",
|
||||
"src": "../src/routes/about.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx",
|
||||
"_FileWithCssOnly.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/about-DUhMJ_Ze.css"
|
||||
]
|
||||
},
|
||||
"_FileWithCssOnly.js": {
|
||||
"file": "assets/FileWithCssOnly.js",
|
||||
"name": "FileWithCssOnly",
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/FileWithCssOnly.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/catch_all/[...slug].tsx": {
|
||||
"file": "assets/_...slug_-CpJyPnPj.js",
|
||||
"name": "_...slug_",
|
||||
"src": "../src/routes/catch_all/[...slug].tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/_..-CipbPoTl.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/index.tsx": {
|
||||
"file": "assets/index-B3tnHOzi.js",
|
||||
"name": "index",
|
||||
"src": "../src/routes/index.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-CynfArjF.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/pokemons/[pokemon]/[type].tsx": {
|
||||
"file": "assets/_type_-B-sJOcVJ.js",
|
||||
"name": "_type_",
|
||||
"src": "../src/routes/pokemons/[pokemon]/[type].tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx",
|
||||
"_PokemonView-jNGFFO0j.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/_type_-B8vgxybx.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/pokemons/[pokemon]/index.tsx": {
|
||||
"file": "assets/index-ByRBj7WK.js",
|
||||
"name": "index",
|
||||
"src": "../src/routes/pokemons/[pokemon]/index.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx",
|
||||
"_PokemonView-jNGFFO0j.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-CM86zKWq.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/pokemons/__layout.tsx": {
|
||||
"file": "assets/__layout-2v3JiSeL.js",
|
||||
"name": "__layout",
|
||||
"src": "../src/routes/pokemons/__layout.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/__layout-CXGGqNw5.css"
|
||||
]
|
||||
},
|
||||
"_PokemonView-BcJZaQaO.css": {
|
||||
"file": "assets/PokemonView-BcJZaQaO.css",
|
||||
"src": "_PokemonView-BcJZaQaO.css"
|
||||
},
|
||||
"_PokemonView-jNGFFO0j.js": {
|
||||
"file": "assets/PokemonView-jNGFFO0j.js",
|
||||
"name": "PokemonView",
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/PokemonView-BcJZaQaO.css"
|
||||
]
|
||||
},
|
||||
"client-main.tsx": {
|
||||
"file": "assets/client-main-DOdr9gvl.js",
|
||||
"name": "client-main",
|
||||
"src": "client-main.tsx",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"../src/routes/pokemons/__layout.tsx",
|
||||
"../src/routes/about.tsx",
|
||||
"../src/routes/index.tsx",
|
||||
"../src/routes/catch_all/[...slug].tsx",
|
||||
"../src/routes/pokemons/[pokemon]/[type].tsx",
|
||||
"../src/routes/pokemons/[pokemon]/index.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/client-main-BS7N-NIa.css"
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn should_correctly_remap_the_manifest() {
|
||||
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
||||
parsed_manifest.insert(
|
||||
"../src/routes/index.tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/index.js".to_string(),
|
||||
css: vec!["assets/index.css".to_string()],
|
||||
},
|
||||
);
|
||||
parsed_manifest.insert(
|
||||
"../src/routes/about.jsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/about.js".to_string(),
|
||||
css: vec!["assets/about.css".to_string()],
|
||||
},
|
||||
);
|
||||
parsed_manifest.insert(
|
||||
"../src/routes/posts/[post].tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/posts/[post].js".to_string(),
|
||||
css: vec!["assets/posts/[post].css".to_string()],
|
||||
},
|
||||
);
|
||||
parsed_manifest.insert(
|
||||
"client-main.tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/main.js".to_string(),
|
||||
css: vec!["assets/main.css".to_string()],
|
||||
},
|
||||
);
|
||||
fn it_correctly_cleans_the_route_path() {
|
||||
let cleaned_path = clean_route_path("../src/routes/index.tsx".to_string());
|
||||
assert_eq!(cleaned_path, "/");
|
||||
|
||||
let remapped = remap_manifest_keys(parsed_manifest);
|
||||
let cleaned_path =
|
||||
clean_route_path("../src/routes/pokemons/[pokemon]/index.tsx".to_string());
|
||||
assert_eq!(cleaned_path, "/pokemons/[pokemon]");
|
||||
|
||||
let cleaned_path = clean_route_path("../src/routes/pokemons/__layout.tsx".to_string());
|
||||
assert_eq!(cleaned_path, "/pokemons/__layout");
|
||||
|
||||
let cleaned_path =
|
||||
clean_route_path("../src/routes/pokemons/[pokemon]/[type].mdx".to_string());
|
||||
assert_eq!(cleaned_path, "/pokemons/[pokemon]/[type]");
|
||||
|
||||
let cleaned_path = clean_route_path("../src/routes/about.md".to_string());
|
||||
assert_eq!(cleaned_path, "/about");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correctly_parse_the_manifest_json() {
|
||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
||||
|
||||
let manifest = Manifest::from(parsed_manifest);
|
||||
assert_eq!(manifest.bundles.len(), 6);
|
||||
let index_route = manifest.get_bundle_from_pathname("/");
|
||||
|
||||
assert_eq!(
|
||||
remapped.get("/").unwrap().file,
|
||||
"assets/index.js".to_string()
|
||||
index_route.css_files,
|
||||
vec![
|
||||
"assets/index-CynfArjF.css",
|
||||
"assets/client-main-BS7N-NIa.css"
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
index_route.js_files,
|
||||
vec!["assets/index-B3tnHOzi.js", "assets/client-main-DOdr9gvl.js"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_correct_single_dyn_path() {
|
||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
||||
|
||||
let manifest = Manifest::from(parsed_manifest);
|
||||
let nested_route = manifest.get_bundle_from_pathname("/pokemons/ditto");
|
||||
|
||||
assert_eq!(
|
||||
nested_route.css_files,
|
||||
vec![
|
||||
"assets/index-CM86zKWq.css",
|
||||
"assets/client-main-BS7N-NIa.css",
|
||||
"assets/PokemonView-BcJZaQaO.css",
|
||||
"assets/__layout-CXGGqNw5.css"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remapped.get("/about").unwrap().file,
|
||||
"assets/about.js".to_string()
|
||||
nested_route.js_files,
|
||||
vec![
|
||||
"assets/index-ByRBj7WK.js",
|
||||
"assets/client-main-DOdr9gvl.js",
|
||||
"assets/PokemonView-jNGFFO0j.js",
|
||||
"assets/__layout-2v3JiSeL.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_correct_nested_dyn_path_bundles() {
|
||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
||||
|
||||
let manifest = Manifest::from(parsed_manifest);
|
||||
let route = manifest.get_bundle_from_pathname("/pokemons/charizard/fire");
|
||||
|
||||
assert_eq!(
|
||||
route.css_files,
|
||||
vec![
|
||||
"assets/_type_-B8vgxybx.css",
|
||||
"assets/client-main-BS7N-NIa.css",
|
||||
"assets/PokemonView-BcJZaQaO.css",
|
||||
"assets/__layout-CXGGqNw5.css"
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
route.js_files,
|
||||
vec![
|
||||
"assets/_type_-B-sJOcVJ.js",
|
||||
"assets/client-main-DOdr9gvl.js",
|
||||
"assets/PokemonView-jNGFFO0j.js",
|
||||
"assets/__layout-2v3JiSeL.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn should_load_the_correct_catch_all_bundles() {
|
||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
||||
|
||||
let manifest = Manifest::from(parsed_manifest);
|
||||
let route = manifest.get_bundle_from_pathname("/catch_all/some/random/path");
|
||||
|
||||
assert_eq!(
|
||||
route.css_files,
|
||||
vec!["assets/_..-CipbPoTl.css", "assets/client-main-BS7N-NIa.css"]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
route.js_files,
|
||||
vec![
|
||||
"assets/_...slug_-CpJyPnPj.js",
|
||||
"assets/client-main-DOdr9gvl.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn should_load_the_defined_path_bundles() {
|
||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
||||
|
||||
let manifest = Manifest::from(parsed_manifest);
|
||||
let route = manifest.get_bundle_from_pathname("/about");
|
||||
assert_eq!(
|
||||
route.css_files,
|
||||
vec![
|
||||
"assets/about-DUhMJ_Ze.css",
|
||||
"assets/client-main-BS7N-NIa.css",
|
||||
"assets/FileWithCssOnly.css"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remapped.get("/posts/[post]").unwrap().file,
|
||||
"assets/posts/[post].js".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
remapped.get("client-main").unwrap().file,
|
||||
"assets/main.js".to_string()
|
||||
route.js_files,
|
||||
vec![
|
||||
"assets/about-C3UqHfGb.js",
|
||||
"assets/client-main-DOdr9gvl.js",
|
||||
"assets/FileWithCssOnly.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+99
-220
@@ -1,36 +1,47 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use crate::manifest::MANIFEST;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use erased_serde::Serialize;
|
||||
use regex::Regex;
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
use tuono_internal::config::ServerConfig;
|
||||
|
||||
use crate::request::{Location, Request};
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
#[derive(SerdeSerialize)]
|
||||
/// This is the payload sent to the client for hydration
|
||||
pub struct Payload<'a> {
|
||||
router: Location,
|
||||
props: &'a dyn Serialize,
|
||||
location: Location,
|
||||
data: &'a dyn Serialize,
|
||||
mode: Mode,
|
||||
#[serde(rename(serialize = "jsBundles"))]
|
||||
js_bundles: Option<Vec<&'a String>>,
|
||||
js_bundles: Option<Vec<String>>,
|
||||
#[serde(rename(serialize = "cssBundles"))]
|
||||
css_bundles: Option<Vec<&'a String>>,
|
||||
css_bundles: Option<Vec<String>>,
|
||||
#[serde(rename(serialize = "devServerConfig"))]
|
||||
dev_server_config: Option<&'a ServerConfig>,
|
||||
}
|
||||
|
||||
impl<'a> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
pub fn new(req: &'a Request, data: &'a dyn Serialize) -> Payload<'a> {
|
||||
let config = GLOBAL_CONFIG
|
||||
.get()
|
||||
.expect("Failed to load the current config");
|
||||
|
||||
let mode = *GLOBAL_MODE.get().expect("Failed to load the current mode");
|
||||
|
||||
let dev_server_config = if mode == Mode::Dev {
|
||||
Some(&config.server)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Payload {
|
||||
router: req.location(),
|
||||
props,
|
||||
mode: *GLOBAL_MODE.get().expect("Failed to load the current mode"),
|
||||
location: req.location(),
|
||||
data,
|
||||
mode,
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
dev_server_config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,88 +52,11 @@ impl<'a> Payload<'a> {
|
||||
serde_json::to_string(&self)
|
||||
}
|
||||
|
||||
/// This method adds the route specific bundles to the server
|
||||
/// side rendered HTML.
|
||||
///
|
||||
/// The same matching algorithm is implemented on the client side in
|
||||
/// this file (packages/tuono/src/router/components/Matches.ts).
|
||||
///
|
||||
/// Optimizations should occour on both.
|
||||
fn add_bundle_sources(&mut self) {
|
||||
// Manifest should always be loaded. The load happen before starting
|
||||
// the server.
|
||||
let manifest = MANIFEST.get().expect("Failed to load manifest");
|
||||
|
||||
// The main bundle should always exist.
|
||||
// The extension should be tsx even with JS only projects.
|
||||
let main_bundle = manifest
|
||||
.get("client-main")
|
||||
.expect("Failed to get client-main bundle");
|
||||
|
||||
let mut js_bundles_sources = vec![&main_bundle.file];
|
||||
let mut css_bundles_sources = main_bundle.css.iter().collect::<Vec<&String>>();
|
||||
|
||||
let pathname = &self.router.pathname();
|
||||
|
||||
let bundle_data = manifest.get(*pathname);
|
||||
|
||||
if let Some(data) = bundle_data {
|
||||
js_bundles_sources.push(&data.file);
|
||||
|
||||
data.css
|
||||
.iter()
|
||||
.for_each(|source| css_bundles_sources.push(source))
|
||||
} else {
|
||||
let dynamic_routes = manifest
|
||||
.keys()
|
||||
.filter(|path| has_dynamic_path(path))
|
||||
.collect::<Vec<&String>>();
|
||||
|
||||
if !dynamic_routes.is_empty() {
|
||||
let path_segments = pathname
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
for dyn_route in dynamic_routes.iter() {
|
||||
let dyn_route_segments = dyn_route
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
let mut route_segments_collector: Vec<&str> = vec![];
|
||||
|
||||
for i in 0..dyn_route_segments.len() {
|
||||
if path_segments.len() == i {
|
||||
break;
|
||||
}
|
||||
if dyn_route_segments[i] == path_segments[i]
|
||||
|| has_dynamic_path(dyn_route_segments[i])
|
||||
{
|
||||
route_segments_collector.push(dyn_route_segments[i])
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if route_segments_collector.len() == path_segments.len() {
|
||||
let manifest_key = route_segments_collector.join("/");
|
||||
|
||||
let route_data = manifest.get(&format!("/{manifest_key}"));
|
||||
if let Some(data) = route_data {
|
||||
js_bundles_sources.push(&data.file);
|
||||
data.css
|
||||
.iter()
|
||||
.for_each(|source| css_bundles_sources.push(source))
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.js_bundles = Some(js_bundles_sources);
|
||||
self.css_bundles = Some(css_bundles_sources);
|
||||
let manifest = MANIFEST.get().expect("Manifest not loaded");
|
||||
let bundles = manifest.get_bundle_from_pathname(self.location.pathname());
|
||||
self.js_bundles = Some(bundles.js_files);
|
||||
self.css_bundles = Some(bundles.css_files);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,73 +64,81 @@ impl<'a> Payload<'a> {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::manifest::ViteManifest;
|
||||
use axum::http::Uri;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::manifest::BundleInfo;
|
||||
const MANIFEST_EXAMPLE: &str = r#"{
|
||||
"../src/routes/index.tsx": {
|
||||
"file": "assets/index-D-yFyCZo.js",
|
||||
"name": "index",
|
||||
"src": "../src/routes/index.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-CynfArjF.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/pokemons/[pokemon].tsx": {
|
||||
"file": "assets/_pokemon_-DlFInatQ.js",
|
||||
"name": "_pokemon_",
|
||||
"src": "../src/routes/pokemons/[pokemon].tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/_pokemon_-BcJZaQaO.css"
|
||||
]
|
||||
},
|
||||
"../src/routes/pokemons/__layout.tsx": {
|
||||
"file": "assets/__layout-BFnT3M7X.js",
|
||||
"name": "__layout",
|
||||
"src": "../src/routes/pokemons/__layout.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"client-main.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/__layout-CXGGqNw5.css"
|
||||
]
|
||||
},
|
||||
"client-main.tsx": {
|
||||
"file": "assets/client-main-B9g1NVV7.js",
|
||||
"name": "client-main",
|
||||
"src": "client-main.tsx",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"../src/routes/pokemons/__layout.tsx",
|
||||
"../src/routes/index.tsx",
|
||||
"../src/routes/pokemons/[pokemon].tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/client-main-BS7N-NIa.css"
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'static> {
|
||||
let mut manifest_mock = HashMap::new();
|
||||
manifest_mock.insert(
|
||||
"client-main".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/bundled-file.js".to_string(),
|
||||
css: vec!["assets/bundled-file.css".to_string()],
|
||||
},
|
||||
);
|
||||
manifest_mock.insert(
|
||||
"/".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/index.js".to_string(),
|
||||
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'_> {
|
||||
let manifest_mock = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE)
|
||||
.expect("Failed to parse the manifest example");
|
||||
MANIFEST.get_or_init(|| manifest_mock.into());
|
||||
|
||||
css: vec!["assets/index.css".to_string()],
|
||||
},
|
||||
);
|
||||
manifest_mock.insert(
|
||||
"/posts/[post]".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/posts/[post].js".to_string(),
|
||||
let uri = uri
|
||||
.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
|
||||
css: vec!["assets/posts/[post].css".to_string()],
|
||||
},
|
||||
);
|
||||
manifest_mock.insert(
|
||||
"/posts/[post]/[comment]".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/posts/[post]/[comment].js".to_string(),
|
||||
|
||||
css: vec!["assets/posts/[post]/[comment].css".to_string()],
|
||||
},
|
||||
);
|
||||
manifest_mock.insert(
|
||||
"/posts/custom-post".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/custom-post.js".to_string(),
|
||||
|
||||
css: vec!["assets/custom-post.css".to_string()],
|
||||
},
|
||||
);
|
||||
manifest_mock.insert(
|
||||
"/about".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/about.js".to_string(),
|
||||
|
||||
css: vec!["assets/about.css".to_string()],
|
||||
},
|
||||
);
|
||||
MANIFEST.get_or_init(|| manifest_mock);
|
||||
let location = Location::from(
|
||||
&uri.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap(),
|
||||
);
|
||||
let location = Location::from(uri);
|
||||
|
||||
Payload {
|
||||
router: location,
|
||||
props: &None::<Option<()>>,
|
||||
location,
|
||||
data: &None::<Option<()>>,
|
||||
mode,
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
dev_server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,15 +150,15 @@ mod tests {
|
||||
assert_eq!(
|
||||
payload.js_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.js".to_string(),
|
||||
&"assets/index.js".to_string()
|
||||
"assets/index-D-yFyCZo.js".to_string(),
|
||||
"assets/client-main-B9g1NVV7.js".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
payload.css_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.css".to_string(),
|
||||
&"assets/index.css".to_string()
|
||||
"assets/index-CynfArjF.css".to_string(),
|
||||
"assets/client-main-BS7N-NIa.css".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -228,67 +170,4 @@ mod tests {
|
||||
assert!(payload.js_bundles.is_none());
|
||||
assert!(payload.css_bundles.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_correct_single_dyn_path_bundles() {
|
||||
let mut payload = prepare_payload(Some("http://localhost:3000/posts/a-post"), Mode::Prod);
|
||||
let _ = payload.client_payload();
|
||||
assert_eq!(
|
||||
payload.js_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.js".to_string(),
|
||||
&"assets/posts/[post].js".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
payload.css_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.css".to_string(),
|
||||
&"assets/posts/[post].css".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_correct_nested_dyn_path_bundles() {
|
||||
let mut payload = prepare_payload(
|
||||
Some("http://localhost:3000/posts/a-post/a-comment"),
|
||||
Mode::Prod,
|
||||
);
|
||||
let _ = payload.client_payload();
|
||||
assert_eq!(
|
||||
payload.js_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.js".to_string(),
|
||||
&"assets/posts/[post]/[comment].js".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
payload.css_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.css".to_string(),
|
||||
&"assets/posts/[post]/[comment].css".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_defined_path_bundles() {
|
||||
let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod);
|
||||
let _ = payload.client_payload();
|
||||
assert_eq!(
|
||||
payload.js_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.js".to_string(),
|
||||
&"assets/about.js".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
payload.css_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.css".to_string(),
|
||||
&"assets/about.css".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+187
-19
@@ -1,16 +1,16 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Location must match client side interface
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Location {
|
||||
href: String,
|
||||
pathname: String,
|
||||
search: HashMap<String, String>,
|
||||
#[serde(rename(serialize = "searchStr"))]
|
||||
search_str: String,
|
||||
hash: String,
|
||||
search: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
@@ -19,40 +19,208 @@ impl Location {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Uri> for Location {
|
||||
fn from(uri: &Uri) -> Self {
|
||||
impl From<Uri> for Location {
|
||||
fn from(uri: Uri) -> Self {
|
||||
let query = uri.query().unwrap_or("");
|
||||
Location {
|
||||
// TODO: build correct href
|
||||
href: uri.to_string(),
|
||||
pathname: uri.path().to_string(),
|
||||
// TODO: handler search map
|
||||
search: HashMap::new(),
|
||||
search_str: uri.query().unwrap_or("").to_string(),
|
||||
hash: "".to_string(),
|
||||
search_str: query.to_string(),
|
||||
search: serde_urlencoded::from_str(query).unwrap_or(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request<'a> {
|
||||
uri: &'a Uri,
|
||||
pub headers: &'a HeaderMap,
|
||||
pub struct Request {
|
||||
pub uri: Uri,
|
||||
pub headers: HeaderMap,
|
||||
pub params: HashMap<String, String>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> Request<'a> {
|
||||
impl Request {
|
||||
pub fn new(
|
||||
uri: &'a Uri,
|
||||
headers: &'a HeaderMap,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
params: HashMap<String, String>,
|
||||
) -> Request<'a> {
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Request {
|
||||
Request {
|
||||
uri,
|
||||
headers,
|
||||
params,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Location {
|
||||
Location::from(self.uri)
|
||||
Location::from(self.uri.to_owned())
|
||||
}
|
||||
|
||||
pub fn body<'de, T: Deserialize<'de>>(&'de self) -> Result<T, BodyParseError> {
|
||||
if let Some(body) = &self.body {
|
||||
let body = serde_json::from_slice::<T>(body)?;
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
Err(BodyParseError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Failed to read body",
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn form_data<T>(&self) -> Result<T, BodyParseError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let content_type = self
|
||||
.headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if !content_type.contains("application/x-www-form-urlencoded") {
|
||||
return Err(BodyParseError::ContentType(
|
||||
"Invalid content type, expected application/x-www-form-urlencoded".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let body = self.body.as_ref().ok_or_else(|| {
|
||||
BodyParseError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Missing request body",
|
||||
))
|
||||
})?;
|
||||
|
||||
serde_urlencoded::from_bytes::<T>(body).map_err(BodyParseError::UrlEncoded)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BodyParseError {
|
||||
Io(std::io::Error),
|
||||
Serde(serde_json::Error),
|
||||
UrlEncoded(serde_urlencoded::de::Error),
|
||||
ContentType(String),
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for BodyParseError {
|
||||
fn from(err: serde_json::Error) -> BodyParseError {
|
||||
BodyParseError::Serde(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FakeBody {
|
||||
field1: bool,
|
||||
field2: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FormData {
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_body() {
|
||||
let request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
Some(r#"{"field1": true, "field2": "hello"}"#.as_bytes().to_vec()),
|
||||
);
|
||||
|
||||
let body: FakeBody = request.body().expect("Failed to parse body");
|
||||
|
||||
assert!(body.field1);
|
||||
assert_eq!(body.field2, "hello".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_trigger_an_error_when_body_is_invalid() {
|
||||
let request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
Some(r#"{"field1": true"#.as_bytes().to_vec()),
|
||||
);
|
||||
|
||||
let body: Result<FakeBody, BodyParseError> = request.body();
|
||||
|
||||
assert!(body.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_form_data() {
|
||||
let mut request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
request.headers.insert(
|
||||
"content-type",
|
||||
"application/x-www-form-urlencoded".parse().unwrap(),
|
||||
);
|
||||
|
||||
request.body = Some("name=John+Doe&email=john%40example.com".as_bytes().to_vec());
|
||||
|
||||
let form_data: Result<FormData, BodyParseError> = request.form_data();
|
||||
|
||||
assert!(form_data.is_ok());
|
||||
let data = form_data.unwrap();
|
||||
assert_eq!(data.name, "John Doe");
|
||||
assert_eq!(data.email, Some("john@example.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_rejects_wrong_form_content_type() {
|
||||
let mut request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
request
|
||||
.headers
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
|
||||
request.headers.insert(
|
||||
"body",
|
||||
"name=John+Doe&email=john%40example.com".parse().unwrap(),
|
||||
);
|
||||
|
||||
let form_data: Result<FormData, BodyParseError> = request.form_data();
|
||||
|
||||
assert!(form_data.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_handles_missing_form_body() {
|
||||
let mut request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
request.headers.insert(
|
||||
"content-type",
|
||||
"application/x-www-form-urlencoded".parse().unwrap(),
|
||||
);
|
||||
|
||||
let form_data: Result<FormData, BodyParseError> = request.form_data();
|
||||
|
||||
assert!(form_data.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
use crate::Request;
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse};
|
||||
use crate::{Payload, ssr::Js};
|
||||
use axum::Json;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Redirect};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use erased_serde::Serialize;
|
||||
|
||||
pub struct Props {
|
||||
data: Box<dyn Serialize>,
|
||||
http_code: StatusCode,
|
||||
cookies: CookieJar,
|
||||
}
|
||||
|
||||
pub enum Response {
|
||||
Redirect(String),
|
||||
Props(Props),
|
||||
// TODO: improve this tuple to support a more generic IntoResponse
|
||||
Custom((StatusCode, HeaderMap, String)),
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -55,44 +59,102 @@ impl Props {
|
||||
Props {
|
||||
data: Box::new(data),
|
||||
http_code: StatusCode::OK,
|
||||
cookies: CookieJar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&mut self, http_code: StatusCode) {
|
||||
self.http_code = http_code;
|
||||
}
|
||||
|
||||
pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self {
|
||||
Props {
|
||||
data: Box::new(data),
|
||||
http_code,
|
||||
cookies: CookieJar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_cookie(&mut self, cookie: Cookie) {
|
||||
let jar = self.cookies.clone().add(cookie.into_owned());
|
||||
self.cookies = jar
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn render_to_string(&self, req: Request) -> AxumResponse {
|
||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse + use<> {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
let payload = Payload::new(&req, data).client_payload().unwrap();
|
||||
Self::Props(Props {
|
||||
data,
|
||||
http_code,
|
||||
cookies,
|
||||
}) => {
|
||||
let payload = Payload::new(&req, data.as_ref()).client_payload().unwrap();
|
||||
|
||||
match Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) {
|
||||
Ok(html) => (*http_code, Html(html)).into_response(),
|
||||
Err(_) => {
|
||||
(*http_code, Html("500 Internal server error".to_string())).into_response()
|
||||
}
|
||||
match Js::render_to_string(Some(&payload)) {
|
||||
Ok(html) => (*http_code, cookies.clone(), Html(html)),
|
||||
Err(_) => (
|
||||
*http_code,
|
||||
cookies.clone(),
|
||||
Html("500 Internal server error".to_string()),
|
||||
),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
Self::Redirect(to) => Redirect::permanent(to).into_response(),
|
||||
Self::Custom(response) => response.clone().into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json(&self) -> impl IntoResponse {
|
||||
pub fn json(&self) -> impl IntoResponse + use<> {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
(*http_code, Json(JsonResponse::new(data))).into_response()
|
||||
}
|
||||
Self::Props(Props {
|
||||
data,
|
||||
http_code,
|
||||
cookies,
|
||||
}) => (
|
||||
*http_code,
|
||||
cookies.clone(),
|
||||
Json(JsonResponse::new(data.as_ref())),
|
||||
)
|
||||
.into_response(),
|
||||
Self::Redirect(destination) => (
|
||||
StatusCode::PERMANENT_REDIRECT,
|
||||
Json(JsonResponse::new_redirect(destination.to_string())),
|
||||
)
|
||||
.into_response(),
|
||||
// Custom never needs the "data" response since its scope
|
||||
// is outside the react domain
|
||||
Self::Custom(_) => (StatusCode::OK, Json("{}")).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_update_the_props_status_and_cookie() {
|
||||
let mut props = Props::new("{}");
|
||||
props.status(StatusCode::NOT_FOUND);
|
||||
props.add_cookie(Cookie::new("test", "cookie"));
|
||||
assert_eq!(props.http_code, StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
props.cookies.get("test").unwrap(),
|
||||
&Cookie::new("test", "cookie")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_add_a_cookie_jar() {
|
||||
let mut props = Props::new("{}");
|
||||
props.status(StatusCode::NOT_FOUND);
|
||||
props.add_cookie(Cookie::new("test", "cookie"));
|
||||
assert_eq!(props.http_code, StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
props.cookies.get("test").unwrap(),
|
||||
&Cookie::new("test", "cookie")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,117 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use crate::manifest::load_manifest;
|
||||
use axum::routing::{get, Router};
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use axum::routing::{Router, get};
|
||||
use colored::Colorize;
|
||||
use ssr_rs::Ssr;
|
||||
use tower_http::services::ServeDir;
|
||||
use tuono_internal::config::Config;
|
||||
use tuono_internal::tuono_println;
|
||||
|
||||
use crate::catch_all::catch_all;
|
||||
use crate::env::load_env_vars;
|
||||
use crate::{
|
||||
catch_all::catch_all, services::logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy,
|
||||
vite_websocket_proxy::vite_websocket_proxy,
|
||||
};
|
||||
|
||||
const DEV_PUBLIC_DIR: &str = "public";
|
||||
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||
|
||||
pub fn tuono_internal_init_v8_platform() {
|
||||
Ssr::create_platform();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Server {
|
||||
router: Router,
|
||||
mode: Mode,
|
||||
pub listener: tokio::net::TcpListener,
|
||||
pub address: String,
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn init(router: Router, mode: Mode) -> Server {
|
||||
Ssr::create_platform();
|
||||
fn display_start_message(&self) {
|
||||
// Format the server address as a valid URL so that it becomes clickable in the CLI
|
||||
// @see https://github.com/tuono-labs/tuono/issues/460
|
||||
let server_base_url = format!("http://{}", self.address);
|
||||
|
||||
GLOBAL_MODE.set(mode).unwrap();
|
||||
// In order to avoid multiple logs on `tuono dev`
|
||||
// the server address prompt for tuono dev is made on the CLI process
|
||||
if self.mode == Mode::Prod {
|
||||
tuono_println!("Production server at: {}\n", server_base_url.blue().bold());
|
||||
if let Some(origin) = &self.origin {
|
||||
tuono_println!("Origin: {}\n", origin.blue().bold());
|
||||
}
|
||||
} else {
|
||||
tuono_println!("Ready\n");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init(router: Router, mode: Mode) -> Server {
|
||||
let config = Config::get().expect("[SERVER] Failed to load config");
|
||||
|
||||
let _ = GLOBAL_MODE.set(mode);
|
||||
let _ = GLOBAL_CONFIG.set(config.clone());
|
||||
|
||||
if mode == Mode::Prod {
|
||||
load_manifest()
|
||||
if let Err(err) = load_manifest() {
|
||||
tuono_println!("Failed to load vite manifest: {}", err.to_string().red());
|
||||
}
|
||||
}
|
||||
|
||||
Server { router, mode }
|
||||
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
||||
|
||||
unsafe {
|
||||
// This function is unsafe because it modifies the OS env variables
|
||||
// which is not thread-safe.
|
||||
// However, we are using it in a controlled environment which hasn't
|
||||
// spawned any threads yet.
|
||||
load_env_vars(mode);
|
||||
}
|
||||
|
||||
Server {
|
||||
router,
|
||||
mode,
|
||||
address: server_address.clone(),
|
||||
origin: config.server.origin.clone(),
|
||||
listener: tokio::net::TcpListener::bind(&server_address)
|
||||
.await
|
||||
.expect("[SERVER] Failed to bind to address"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self) {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
pub async fn start(self) {
|
||||
self.display_start_message();
|
||||
|
||||
if self.mode == Mode::Dev {
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.layer(LoggerLayer::new())
|
||||
.route("/vite-server/", get(vite_websocket_proxy))
|
||||
.route("/vite-server/{*path}", get(vite_reverse_proxy))
|
||||
.fallback_service(
|
||||
ServeDir::new(DEV_PUBLIC_DIR)
|
||||
.fallback(get(catch_all).layer(LoggerLayer::new())),
|
||||
);
|
||||
|
||||
axum::serve(self.listener, router)
|
||||
.await
|
||||
.expect("Failed to serve development server");
|
||||
} else {
|
||||
println!("\nProduction app ready at http://localhost:3000/");
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.layer(LoggerLayer::new())
|
||||
.fallback_service(
|
||||
ServeDir::new(PROD_PUBLIC_DIR)
|
||||
.fallback(get(catch_all).layer(LoggerLayer::new())),
|
||||
);
|
||||
|
||||
axum::serve(self.listener, router)
|
||||
.await
|
||||
.expect("Failed to serve production server");
|
||||
}
|
||||
|
||||
let public_dir = if self.mode == Mode::Dev {
|
||||
DEV_PUBLIC_DIR
|
||||
} else {
|
||||
PROD_PUBLIC_DIR
|
||||
};
|
||||
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.fallback_service(ServeDir::new(public_dir).fallback(get(catch_all)));
|
||||
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use colored::Colorize;
|
||||
use http::{Request, Response, method::Method};
|
||||
use pin_project::pin_project;
|
||||
use std::fmt::Debug;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::time::Instant;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LoggerLayer {}
|
||||
|
||||
impl LoggerLayer {
|
||||
pub fn new() -> Self {
|
||||
LoggerLayer {}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for LoggerLayer {
|
||||
type Service = Logger<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
Logger::new(inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Logger<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> Logger<S> {
|
||||
pub fn new(inner: S) -> Self {
|
||||
Logger { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for Logger<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
|
||||
ResBody: Default,
|
||||
<S as Service<Request<ReqBody>>>::Error: Debug,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = LoggerFuture<S::Future>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
let method = req.method().clone();
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
LoggerFuture {
|
||||
future: self.inner.call(req),
|
||||
method,
|
||||
path,
|
||||
start: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pin_project]
|
||||
pub struct LoggerFuture<F> {
|
||||
#[pin]
|
||||
future: F,
|
||||
method: Method,
|
||||
path: String,
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl<F, B, E> Future for LoggerFuture<F>
|
||||
where
|
||||
F: Future<Output = Result<Response<B>, E>>,
|
||||
B: Default,
|
||||
E: Debug,
|
||||
{
|
||||
type Output = Result<Response<B>, E>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
let res: F::Output = match this.future.poll(cx) {
|
||||
Poll::Ready(res) => res,
|
||||
Poll::Pending => return Poll::Pending,
|
||||
};
|
||||
|
||||
if this.path.starts_with("/__tuono/data") {
|
||||
return Poll::Ready(res);
|
||||
}
|
||||
|
||||
let status_code = res.as_ref().unwrap().status();
|
||||
|
||||
println!(
|
||||
" {} {} {} in {}ms",
|
||||
this.method,
|
||||
this.path,
|
||||
status_code.as_str().green(),
|
||||
this.start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
Poll::Ready(res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod logger;
|
||||
+59
-18
@@ -1,27 +1,68 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use lazy_static::lazy_static;
|
||||
use ssr_rs::Ssr;
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use ssr_rs::{Ssr, SsrError};
|
||||
use std::cell::RefCell;
|
||||
use std::fs::read_to_string;
|
||||
use std::path::PathBuf;
|
||||
|
||||
lazy_static! {
|
||||
static ref BUNDLE_PATH: &'static str = {
|
||||
if GLOBAL_MODE.get().unwrap() == &Mode::Dev {
|
||||
return "./.tuono/server/dev-server.js";
|
||||
}
|
||||
"./out/server/prod-server.js"
|
||||
};
|
||||
}
|
||||
|
||||
/// For the server side rendering we need to split the implementation between dev and prod.
|
||||
/// This completely remove the multi-thread optimization on dev but allow the dev server to
|
||||
/// update the SSR result without reloading the whole server.
|
||||
pub struct Js;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const PROD_BUNDLE_PATH: &str = ".\\out\\server\\prod-server.js";
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_BUNDLE_PATH: &str = ".\\.tuono\\server\\dev-server.js";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const PROD_BUNDLE_PATH: &str = "./out/server/prod-server.js";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_BUNDLE_PATH: &str = "./.tuono/server/dev-server.js";
|
||||
|
||||
impl Js {
|
||||
thread_local! {
|
||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from(*BUNDLE_PATH)).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
pub fn render_to_string(payload: Option<&str>) -> Result<String, SsrError> {
|
||||
let mode = GLOBAL_MODE.get().expect("Failed to get GLOBAL_MODE");
|
||||
|
||||
if *mode == Mode::Dev {
|
||||
DevJs::render_to_string(payload)
|
||||
} else {
|
||||
ProdJs::SSR.with(|ssr| ssr.borrow_mut().render_to_string(payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProdJs;
|
||||
|
||||
impl ProdJs {
|
||||
thread_local! {
|
||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DevJs;
|
||||
|
||||
impl DevJs {
|
||||
pub fn render_to_string(params: Option<&str>) -> Result<String, SsrError> {
|
||||
let bundle_path = read_to_string(PathBuf::from(DEV_BUNDLE_PATH));
|
||||
|
||||
if let Ok(source) = bundle_path {
|
||||
let ssr = Ssr::from(source, "");
|
||||
if let Ok(mut ssr) = ssr {
|
||||
ssr.render_to_string(params)
|
||||
} else {
|
||||
let fallback_html = read_to_string(PathBuf::from("./.tuono/index.html"))
|
||||
.unwrap_or("Fallback HTML not loaded".to_string());
|
||||
Ok(fallback_html.replace("[SERVER_PAYLOAD]", params.unwrap_or("")))
|
||||
}
|
||||
} else {
|
||||
let fallback_html = read_to_string(PathBuf::from("./.tuono/index.html"))
|
||||
.unwrap_or("Fallback HTML not loaded".to_string());
|
||||
|
||||
Ok(fallback_html.replace("[SERVER_PAYLOAD]", params.unwrap_or("")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, Query};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use reqwest::Client;
|
||||
|
||||
pub async fn vite_reverse_proxy(
|
||||
Path(path): Path<String>,
|
||||
query: Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let client = Client::new();
|
||||
|
||||
let config = GLOBAL_CONFIG
|
||||
.get()
|
||||
.expect("Failed to get the internal config");
|
||||
|
||||
let vite_url = format!(
|
||||
"http://{}:{}/vite-server",
|
||||
config.server.host,
|
||||
config.server.port + 1
|
||||
);
|
||||
|
||||
let query_string = query
|
||||
.0
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let query_string = if query_string.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{query_string}")
|
||||
};
|
||||
|
||||
match client
|
||||
.get(format!("{vite_url}/{path}{query_string}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
let mut response_builder = Response::builder().status(res.status().as_u16());
|
||||
|
||||
{
|
||||
let headers = response_builder.headers_mut().unwrap();
|
||||
res.headers().into_iter().for_each(|(name, value)| {
|
||||
let name = HeaderName::from_bytes(name.as_ref()).unwrap();
|
||||
let value = HeaderValue::from_bytes(value.as_ref()).unwrap();
|
||||
headers.insert(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
response_builder
|
||||
.body(Body::from_stream(res.bytes_stream()))
|
||||
.unwrap()
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use axum::extract::ws::{self, Utf8Bytes as AxumUtf8Bytes, WebSocket, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::{Error, Message};
|
||||
use tungstenite::ClientRequestBuilder;
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
|
||||
const VITE_WS_PROTOCOL: &str = "vite-hmr";
|
||||
|
||||
/// This is the entry point to proxy the vite's WebSocket.
|
||||
/// The proxy is needed for allowing all the development requests to point
|
||||
/// to localhost:3000/*. This enabled the framework to be developed in a Docker
|
||||
/// environment with just the 3000 port exposed.
|
||||
pub async fn vite_websocket_proxy(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||
ws.protocols([VITE_WS_PROTOCOL]).on_upgrade(handle_socket)
|
||||
}
|
||||
|
||||
async fn handle_socket(mut tuono_socket: WebSocket) {
|
||||
// Send back a message to confirm connection
|
||||
if tuono_socket
|
||||
.send(ws::Message::Ping("tuono connected".into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// If is error close the connection
|
||||
return;
|
||||
}
|
||||
|
||||
let config = GLOBAL_CONFIG
|
||||
.get()
|
||||
.expect("Failed to get the internal config");
|
||||
|
||||
let vite_ws = format!(
|
||||
"ws://{}:{}/vite-server/",
|
||||
config.server.host,
|
||||
config.server.port + 1
|
||||
);
|
||||
|
||||
let vite_ws_request = ClientRequestBuilder::new(vite_ws.parse().unwrap())
|
||||
.with_sub_protocol(VITE_WS_PROTOCOL)
|
||||
.into_client_request()
|
||||
.expect("Failed to create vite WS request");
|
||||
|
||||
let vite_socket = match connect_async(vite_ws_request).await {
|
||||
Ok((stream, _)) => {
|
||||
// Connected to vite's WebSocket
|
||||
stream
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect to vite's WebSocket. Error: {e}");
|
||||
// As fallback vite automatically connect to port 3001.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut vite_sender, mut vite_receiver) = vite_socket.split();
|
||||
let (mut tuono_sender, mut tuono_receiver) = tuono_socket.split();
|
||||
|
||||
// Handle browser messages.
|
||||
// Every message gets forwarded to the vite WebSocket
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = tuono_receiver.next().await {
|
||||
if let Ok(msg) = msg {
|
||||
let msg_to_vite = match msg.clone() {
|
||||
ws::Message::Text(str) => Message::Text(str.to_string().into()),
|
||||
ws::Message::Pong(payload) => Message::Pong(payload),
|
||||
ws::Message::Ping(payload) => Message::Ping(payload),
|
||||
ws::Message::Binary(payload) => Message::Binary(payload),
|
||||
// Hard to match axum and tungstenite close payload.
|
||||
// Not a priority
|
||||
ws::Message::Close(_) => Message::Close(None),
|
||||
};
|
||||
|
||||
vite_sender
|
||||
.send(msg_to_vite)
|
||||
.await
|
||||
.expect("Failed to tunnel msg to vite's WebSocket");
|
||||
|
||||
msg
|
||||
} else {
|
||||
// Close browser's WebSocket connection.
|
||||
return;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Handle vite messages.
|
||||
// Every message gets forwarded to the browser.
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = vite_receiver.next().await {
|
||||
let msg_to_browser = match msg {
|
||||
Message::Text(str) => ws::Message::Text(AxumUtf8Bytes::from(str.to_string())),
|
||||
Message::Ping(payload) => ws::Message::Ping(payload),
|
||||
Message::Pong(payload) => ws::Message::Pong(payload),
|
||||
Message::Binary(payload) => ws::Message::Binary(payload),
|
||||
// Hard to match axum and tungstenite close payload.
|
||||
// Not a priority
|
||||
Message::Close(_) => ws::Message::Close(None),
|
||||
_ => {
|
||||
eprintln!("Unexpected message from the vite WebSocket to the browser: {msg:?}");
|
||||
ws::Message::Text("Unhandled".to_string().into())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = tuono_sender.send(msg_to_browser).await {
|
||||
if err.to_string() != Error::AlreadyClosed.to_string() {
|
||||
eprintln!("Failed to send back message from vite to browser: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
pub mod utils;
|
||||
@@ -0,0 +1,225 @@
|
||||
mod utils;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::utils::mock_server::MockTuonoServer;
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_endpoint_work() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/health_check"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// Assert
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(Some(0), response.content_length());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn not_found_route() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/not-found"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// TODO: This should return a 404 status code
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(
|
||||
response.text().await.unwrap(),
|
||||
"<h1>404 Not found</h1><a href=\"/\">Return home</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn index_html_route() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// TODO: This should return a 404 status code
|
||||
assert!(response.status().is_success());
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.starts_with("<!DOCTYPE html>")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_route_route() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/tuono/data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// TODO: This should return a 404 status code
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(
|
||||
response.text().await.unwrap(),
|
||||
"{\"data\":\"{}\",\"info\":{\"redirect_destination\":null}}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_reads_the_catch_all_path_parameter() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/catch_all/url_parameter"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "url_parameter");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_reads_the_path_parameter() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/dynamic/url_parameter"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "url_parameter");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_reads_an_env_var() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/env"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "foobar");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_parses_the_http_body() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.post(format!("{server_url}/api/post"))
|
||||
.body(r#"{"data":"payload"}"#)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "payload");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_parses_the_form_encoded_url() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let mut form_params = HashMap::new();
|
||||
form_params.insert("data", "payload");
|
||||
|
||||
let response = client
|
||||
.post(format!("{server_url}/api/form_data"))
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "payload");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[tuono_lib::api(GET)]
|
||||
async fn read_catch_all_parameter(req: Request) -> String {
|
||||
let param = req
|
||||
.params
|
||||
.get("catch_all")
|
||||
.expect("Failed to get the catch_all param");
|
||||
|
||||
param.to_string()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[tuono_lib::api(GET)]
|
||||
async fn read_dynamic_parameter(req: Request) -> String {
|
||||
let param = req
|
||||
.params
|
||||
.get("parameter")
|
||||
.expect("Failed to get the catch_all param");
|
||||
|
||||
param.to_string()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use std::env;
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[tuono_lib::api(GET)]
|
||||
pub async fn test_env(_req: Request) -> String {
|
||||
env::var("MY_TEST_KEY").unwrap_or("error".parse().unwrap())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde::Deserialize;
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::api(POST)]
|
||||
async fn form_data(req: Request) -> String {
|
||||
let form = req.form_data::<Payload>().unwrap();
|
||||
form.data
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use tuono_lib::Request;
|
||||
use tuono_lib::axum::http::StatusCode;
|
||||
|
||||
#[tuono_lib::api(GET)]
|
||||
async fn health_check(_req: Request) -> StatusCode {
|
||||
StatusCode::OK
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::{env, fs};
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tuono_lib::axum::routing::{get, post};
|
||||
use tuono_lib::{Mode, Server, axum::Router, tuono_internal_init_v8_platform};
|
||||
|
||||
use crate::utils::catch_all::get_tuono_internal_api as catch_all;
|
||||
use crate::utils::dynamic_parameter::get_tuono_internal_api as dynamic_parameter;
|
||||
use crate::utils::env::get_tuono_internal_api as test_env;
|
||||
use crate::utils::form_data::post_tuono_internal_api as form_data_api;
|
||||
use crate::utils::health_check::get_tuono_internal_api as health_check;
|
||||
use crate::utils::post_api::post_tuono_internal_api as post_api;
|
||||
use crate::utils::route as html_route;
|
||||
use crate::utils::route::tuono_internal_api as route_api;
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
fn init_v8() {
|
||||
INIT.call_once(|| {
|
||||
tuono_internal_init_v8_platform();
|
||||
})
|
||||
}
|
||||
|
||||
fn add_file_with_content<'a>(path: &'a str, content: &'a str) {
|
||||
let path = PathBuf::from(path);
|
||||
create_all(
|
||||
path.parent().expect("File path does not have any parent"),
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"Failed to create parent file directories: {}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
|
||||
let mut file = File::create(path).expect("Failed to create the file");
|
||||
file.write_all(content.as_bytes())
|
||||
.expect("Failed to write into the file");
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MockTuonoServer {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
original_dir: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
// Required for dropping the temp_dir when this struct drops
|
||||
temp_dir: TempDir,
|
||||
}
|
||||
|
||||
impl MockTuonoServer {
|
||||
pub async fn spawn() -> Self {
|
||||
init_v8();
|
||||
let original_dir = env::current_dir().expect("Failed to read current_dir");
|
||||
let temp_dir = tempdir().expect("Failed to create temp_dir");
|
||||
|
||||
let react_prod_build = fs::read_to_string("./tests/assets/fake_react_build.js")
|
||||
.expect("Failed to read fake_react_build.js");
|
||||
|
||||
env::set_current_dir(temp_dir.path()).expect("Failed to change current dir into temp_dir");
|
||||
|
||||
add_file_with_content(
|
||||
"./.tuono/config/config.json",
|
||||
r#"{"server": {"host": "127.0.0.1", "port": 0}}"#,
|
||||
);
|
||||
|
||||
add_file_with_content("./out/server/prod-server.js", react_prod_build.as_str());
|
||||
|
||||
add_file_with_content(
|
||||
"./out/client/.vite/manifest.json",
|
||||
r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#,
|
||||
);
|
||||
|
||||
add_file_with_content("./.env", r#"MY_TEST_KEY="foobar""#);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/", get(html_route::tuono_internal_route))
|
||||
.route("/tuono/data", get(html_route::tuono_internal_api))
|
||||
.route("/health_check", get(health_check))
|
||||
.route("/route-api", get(route_api))
|
||||
.route("/catch_all/{*catch_all}", get(catch_all))
|
||||
.route("/dynamic/{parameter}", get(dynamic_parameter))
|
||||
.route("/api/post", post(post_api))
|
||||
.route("/api/form_data", post(form_data_api))
|
||||
.route("/env", get(test_env));
|
||||
|
||||
let server = Server::init(router, Mode::Prod).await;
|
||||
|
||||
let socket = server
|
||||
.listener
|
||||
.local_addr()
|
||||
.expect("Failed to extract test server socket");
|
||||
|
||||
_ = tokio::spawn(server.start());
|
||||
|
||||
MockTuonoServer {
|
||||
address: socket.ip().to_string(),
|
||||
port: socket.port(),
|
||||
original_dir,
|
||||
temp_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockTuonoServer {
|
||||
fn drop(&mut self) {
|
||||
// Set back the current dir in the previous state
|
||||
env::set_current_dir(&self.original_dir)
|
||||
.expect("Failed to restore the original directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod catch_all;
|
||||
pub mod dynamic_parameter;
|
||||
pub mod env;
|
||||
pub mod form_data;
|
||||
pub mod health_check;
|
||||
pub mod mock_server;
|
||||
pub mod post_api;
|
||||
pub mod route;
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde::Deserialize;
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::api(POST)]
|
||||
async fn health_check(req: Request) -> String {
|
||||
req.body::<Payload>().unwrap().data
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn route(_: Request) -> Response {
|
||||
Response::Props(Props::new("{}"))
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
version = "0.19.7"
|
||||
edition = "2024"
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/tuono-labs/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"src/**/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
|
||||
[lib]
|
||||
crate_type = ["proc-macro"]
|
||||
proc-marco = true
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
syn = { version = "1.0.59", features = ["full"] }
|
||||
syn = { version = "2.0.0", features = ["full"] }
|
||||
quote = "1.0"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::utils::{
|
||||
crate_application_state_extractor, create_struct_fn_arg, import_main_application_state,
|
||||
params_argument, request_argument,
|
||||
};
|
||||
use proc_macro::{Span, TokenStream};
|
||||
use quote::quote;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{FnArg, Ident, ItemFn, Pat, parse_macro_input};
|
||||
|
||||
pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let item = parse_macro_input!(item as ItemFn);
|
||||
let http_method = parse_macro_input!(attrs as Ident)
|
||||
.to_string()
|
||||
.to_lowercase();
|
||||
|
||||
let api_fn_name = Ident::new(
|
||||
&format!("{http_method}_tuono_internal_api"),
|
||||
Span::call_site().into(),
|
||||
);
|
||||
|
||||
let fn_name = &item.sig.ident;
|
||||
let return_type = &item.sig.output;
|
||||
|
||||
let mut argument_names: Punctuated<Pat, Comma> = Punctuated::new();
|
||||
let mut axum_arguments: Punctuated<FnArg, Comma> = Punctuated::new();
|
||||
|
||||
// Fn Arguments minus the first which always is the request
|
||||
for (i, arg) in item.sig.inputs.iter().enumerate() {
|
||||
if i == 0 {
|
||||
axum_arguments.insert(i, params_argument());
|
||||
continue;
|
||||
}
|
||||
|
||||
if i == 1 {
|
||||
axum_arguments.insert(1, create_struct_fn_arg())
|
||||
}
|
||||
|
||||
if let FnArg::Typed(pat_type) = arg {
|
||||
let index = i - 1;
|
||||
let argument_name = *pat_type.pat.clone();
|
||||
argument_names.insert(index, argument_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
axum_arguments.insert(axum_arguments.len(), request_argument());
|
||||
|
||||
let application_state_extractor = crate_application_state_extractor(argument_names.clone());
|
||||
let application_state_import = import_main_application_state(argument_names.clone());
|
||||
|
||||
let modified_request = if http_method == "post"
|
||||
|| http_method == "put"
|
||||
|| http_method == "patch"
|
||||
{
|
||||
quote! {
|
||||
let (parts, body) = request.into_parts();
|
||||
let path = parts.uri.clone();
|
||||
let headers = parts.headers.clone();
|
||||
|
||||
let body = tuono_lib::axum::body::to_bytes(body, usize::MAX).await.unwrap_or(Vec::new().into()).to_vec();
|
||||
|
||||
let req = tuono_lib::Request::new(path, headers, params, Some(body));
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(request.uri().to_owned(), request.headers().to_owned(), params, None);
|
||||
}
|
||||
};
|
||||
|
||||
quote! {
|
||||
#application_state_import
|
||||
|
||||
#item
|
||||
|
||||
pub async fn #api_fn_name(#axum_arguments)#return_type {
|
||||
|
||||
#application_state_extractor
|
||||
|
||||
#modified_request
|
||||
|
||||
#fn_name(req.clone(), #argument_names).await
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user