mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 05:42:47 -07:00
Compare commits
778 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 946df5d221 | |||
| 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 | |||
| 9cd90ba62a | |||
| b6d0bb13ef | |||
| 7401a59346 | |||
| 622ae93d68 | |||
| b4207feb7e | |||
| 332fdb70ca | |||
| b9abfb64c0 | |||
| 3ca8861d85 | |||
| 0c93aab13f | |||
| 208b49cfc8 | |||
| db0a2d9d1d | |||
| c123e93433 | |||
| 35557d296d | |||
| d0d6550d7e | |||
| 38d9be2bb0 | |||
| fcb33d208c | |||
| f831ad6c7a | |||
| 3bfed5fabc | |||
| e1ce3ba2fd | |||
| a31f048ffd | |||
| 9c707466db | |||
| 4ff899729b | |||
| a8345d3509 | |||
| 1c3d6e2d78 | |||
| dde5ae7827 | |||
| aeef19cda3 | |||
| adec05c6f8 | |||
| 2c1e8f7db8 | |||
| 2c1e8f0cc2 | |||
| 5d434aeb68 | |||
| 54e7d3a397 | |||
| 7937291281 | |||
| 777f932266 | |||
| f37cb701b3 | |||
| 6498905377 | |||
| ed6c75fe37 | |||
| f759960641 | |||
| 75e8e51464 | |||
| 75ee140525 | |||
| 8d09637a7a | |||
| 62bfd35fbd | |||
| 091c94d609 | |||
| a72656cb4b | |||
| b3ef9f0ee6 | |||
| 0e4debdb8a | |||
| 476fae0e70 | |||
| 3de9d1be66 | |||
| 00916f188d | |||
| fff5f92893 | |||
| 8b796fc1f4 | |||
| 5ac679785b | |||
| 38b6cc8c65 | |||
| abb2d658c6 | |||
| 3587c085b9 | |||
| 581a7d79b3 | |||
| e21b80a5d8 | |||
| e0fc19ecaf | |||
| 0be0ce4c15 | |||
| 792358ea9c | |||
| 26c0c2488d | |||
| 70b4b9c28f | |||
| 8b4db423b4 | |||
| 7d65ede581 | |||
| 5b11f68fbb | |||
| 0d33e6887b | |||
| 5bb620a590 | |||
| 469386388b | |||
| 8728499f90 | |||
| 2e82dde2c3 | |||
| 253dd44d0d | |||
| 821b38e269 | |||
| 753bd21c14 | |||
| 10757d4ef4 | |||
| d436637086 | |||
| 0b5679ee07 | |||
| 97b6cdfaaa | |||
| b10d01cae0 | |||
| 2262264cae | |||
| bedd23eed8 | |||
| 12a1b12bac | |||
| c141e0b843 | |||
| 4d12fd9d20 | |||
| 5322fe910a | |||
| 70a6aa1a67 | |||
| 8d2be4178b | |||
| e89f2f63f8 | |||
| f6e7ebd33e | |||
| 5ea209de0b | |||
| a4b2dd222d | |||
| c84f5d479b | |||
| 9251d445d3 | |||
| c1ff7e36a8 | |||
| 8dd8123fb1 | |||
| 7040fbfb3d | |||
| ee2abbd4e1 | |||
| 252c463300 | |||
| a10f205ccd | |||
| 7cfaf94146 | |||
| 7557df10f3 | |||
| 64c37f42c5 | |||
| 00594b7c3a | |||
| 930e7bc851 | |||
| d7d0001299 | |||
| 1c0b4bba04 | |||
| c12739d526 | |||
| 0780a6ec79 | |||
| 73e142a5d7 | |||
| d3cee9f1b2 | |||
| e6370cb7d4 | |||
| e2e6e313a5 | |||
| 2348576fa4 | |||
| 9550f8243c | |||
| 1d87a78393 | |||
| ee092194d1 | |||
| 47a02cc572 | |||
| 1849c49682 | |||
| 9f071d9e13 | |||
| b96fdc5c4e | |||
| 89ffcf0923 | |||
| b91210084f | |||
| cfdd59f5fb | |||
| 5a30f36eb0 | |||
| 322908145e | |||
| ec2fb17f7f | |||
| 9a0bb45a03 | |||
| 04216d93bf | |||
| 4ed6c9902f | |||
| 208c18c94a | |||
| d4c9b25b90 | |||
| 74968b27cc | |||
| db98e0e2fd | |||
| fc1d9013f8 | |||
| da75dc4a07 | |||
| 1ef1a852fc | |||
| aa5d4b689d | |||
| 65f43b3caf | |||
| 6ef526be73 | |||
| c55fe7dde4 | |||
| 6d994a1960 | |||
| b557820b6e | |||
| 4e1d0b6252 | |||
| 90de047a07 | |||
| ef98cfed90 | |||
| b21d2eddc0 | |||
| 4a8a24c708 | |||
| 73c94e5fbb | |||
| df80756c44 | |||
| 3830f18e2d | |||
| 289986b4af |
@@ -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": "error",
|
||||
"sort-imports": "off",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +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,36 +0,0 @@
|
||||
name: Publish package to npm
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
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: Install turbo
|
||||
run: pnpm install turbo --global
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish --no-git-checks --filter ./packages/tuono
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Publish on npm and crates
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish_npm_dry_run:
|
||||
name: NPM publish (dry run)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish -r --no-git-checks --dry-run
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish_crates_dry_run:
|
||||
name: Crates publish (dry run)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- 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_npm:
|
||||
name: NPM publish
|
||||
needs: [publish_npm_dry_run, publish_crates_dry_run]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Clone root README.md on tuono package
|
||||
run: cp README.md packages/tuono/README.md
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish -r --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish_crates:
|
||||
name: Crates publish
|
||||
needs: [publish_npm_dry_run, publish_crates_dry_run]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- 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')) }}
|
||||
@@ -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')) }}
|
||||
+24
-2
@@ -1,10 +1,32 @@
|
||||
## 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
|
||||
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
**/debug/
|
||||
**/target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
**/Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Files, generated by the code editors
|
||||
.vscode/
|
||||
|
||||
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
dist/
|
||||
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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
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,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
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,
|
||||
and learning from the experience
|
||||
- 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
|
||||
address, without their explicit permission
|
||||
- Other conduct that could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
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 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.
|
||||
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.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
valerioageno@yahoo.it.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
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
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,26 @@
|
||||

|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
# How to Contribute to Tuono
|
||||
|
||||
## Contributions
|
||||
|
||||
Any feature contribution or suggestion is strongly appreciated.
|
||||
|
||||
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/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?**
|
||||
|
||||
[Visit the documentation to learn how to open a pull request](https://tuono.dev/documentation/contributing/pull-requests)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/tuono",
|
||||
"crates/tuono_internal",
|
||||
"crates/tuono_lib",
|
||||
"crates/tuono_lib_macros"
|
||||
]
|
||||
exclude = [
|
||||
"examples/with-mdx",
|
||||
"examples/tuono-app",
|
||||
"examples/tuono-tutorial",
|
||||
"examples/with-tailwind",
|
||||
"e2e/fixtures/base",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2024 Valerio Ageno
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,20 +1,63 @@
|
||||
# Tuono.js
|
||||
[Documentation](https://tuono.dev) | [Tutorial](https://tuono.dev/documentation/tutorial) |
|
||||
[Discord](https://discord.com/invite/khQzPa654B) | [✨Contributing](https://tuono.dev/documentation/contributing)
|
||||
|
||||
The fullstack react/rust framework.
|
||||
# Tuono
|
||||
|
||||
> This project is under heavy development. API might drastically change!
|
||||
<img align="right" src="https://raw.githubusercontent.com/tuono-labs/tuono/main/assets/logo.png" width="160px">
|
||||
|
||||
## Features
|
||||
- Rust based server side rendering
|
||||
- FS routing
|
||||
- CSS modules
|
||||

|
||||

|
||||
|
||||
## Folder structure
|
||||
**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.
|
||||
|
||||
If you have experience with [Next.js](https://nextjs.org/) you will feel home here.
|
||||
|
||||
Some of its features are:
|
||||
|
||||
- 🟦 Native Typescript
|
||||
- 🌐 Next.js like Routing
|
||||
- 🍭 CSS/SCSS modules
|
||||
- 🧬 Server Side Rendering
|
||||
- 🔥 Hot Module Reload
|
||||
|
||||
## Documentation
|
||||
|
||||
The [documentation](https://tuono.dev/) is available on
|
||||
[tuono.dev](https://tuono.dev/).
|
||||
|
||||
## Installation
|
||||
|
||||
Available on MacOS, Linux and Windows.
|
||||
|
||||
```sh
|
||||
cargo install tuono
|
||||
```
|
||||
- .tuono
|
||||
- public/
|
||||
- src/
|
||||
| - routes/
|
||||
| | - api/
|
||||
|
||||
## Getting started
|
||||
|
||||
Create a new project with:
|
||||
|
||||
```sh
|
||||
tuono new [PROJECT_NAME]
|
||||
```
|
||||
|
||||
> 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
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Repository structure doc
|
||||
|
||||
## Monorepo tool
|
||||
- [Turborepo](https://turbo.build/repo)
|
||||
|
||||
## Packages
|
||||
> Highly inspired by [@tanstack/router](https://tanstack.com/router/latest)
|
||||
|
||||
- vite-fs-router
|
||||
- router-generator
|
||||
- react-router
|
||||
- react-router-server
|
||||
- tuono-runtime (entry point)
|
||||
|
||||
### JS Public interface
|
||||
- tuono-runtime (all in one js framework package)
|
||||
|
||||
## Crates
|
||||
- tuono (cli interface)
|
||||
- tuono-axum-builder (axum project builder)
|
||||
- tuono-watch (folder watch reload)
|
||||
|
||||
### Rust Public interface
|
||||
- tuono (cli, dev and prod environment)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
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", "templates/*", "Cargo.toml"]
|
||||
|
||||
[lib]
|
||||
name = "tuono"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.4", features = ["derive", "cargo"] }
|
||||
syn = { version = "2.0.100", features = ["full"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
|
||||
miette = "7.2.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"] }
|
||||
glob = "0.3.1"
|
||||
regex = "1.10.4"
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! ## 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;
|
||||
mod symbols;
|
||||
mod typescript;
|
||||
@@ -0,0 +1,18 @@
|
||||
use tracing::error;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tuono::cli::app;
|
||||
|
||||
fn main() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::crate_version;
|
||||
use tracing::error;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::route::AxumInfo;
|
||||
use crate::route::Route;
|
||||
use crate::typescript::TypesJar;
|
||||
|
||||
#[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");
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const MAIN_FILE_PATH: &str = "./.tuono/main.rs";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FALLBACK_HTML_PATH: &str = "./.tuono/index.html";
|
||||
|
||||
const ROUTE_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// 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(mode: Mode) -> io::Result<Self> {
|
||||
if !PathBuf::from("tuono.config.ts").exists() {
|
||||
recoverable_error("Cannot find tuono.config.ts - is this a tuono project?");
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_set_the_correct_mode() {
|
||||
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();
|
||||
|
||||
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
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]
|
||||
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"
|
||||
serde_urlencoded = "0.7.1"
|
||||
reqwest = {version = "0.12.4", features = ["json", "stream"]}
|
||||
once_cell = "1.19.0"
|
||||
regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
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"
|
||||
@@ -0,0 +1,3 @@
|
||||
# tuono_lib
|
||||
|
||||
This project exposes the interfaces to easily handle the backend build.
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::{Payload, ssr::Js};
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn catch_all(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
request: Request,
|
||||
) -> Html<String> {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
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::render_to_string(Some(&payload));
|
||||
|
||||
match result {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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, 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;
|
||||
@@ -0,0 +1,521 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use once_cell::sync::OnceCell;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 type ViteManifest = HashMap<String, BundleInfo>;
|
||||
|
||||
fn empty_vector() -> Vec<String> {
|
||||
Vec::with_capacity(0)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RouteBundle {
|
||||
pub css_files: Vec<String>,
|
||||
pub js_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Manifest {
|
||||
/// The mapping between the route and the bundle
|
||||
bundles: HashMap<String, RouteBundle>,
|
||||
}
|
||||
|
||||
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 it_correctly_cleans_the_route_path() {
|
||||
let cleaned_path = clean_route_path("../src/routes/index.tsx".to_string());
|
||||
assert_eq!(cleaned_path, "/");
|
||||
|
||||
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!(
|
||||
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!(
|
||||
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!(
|
||||
route.js_files,
|
||||
vec![
|
||||
"assets/about-C3UqHfGb.js",
|
||||
"assets/client-main-DOdr9gvl.js",
|
||||
"assets/FileWithCssOnly.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Clone, Copy)]
|
||||
pub enum Mode {
|
||||
Dev,
|
||||
Prod,
|
||||
}
|
||||
|
||||
pub static GLOBAL_MODE: OnceCell<Mode> = OnceCell::new();
|
||||
@@ -0,0 +1,173 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use crate::manifest::MANIFEST;
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use erased_serde::Serialize;
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
use tuono_internal::config::ServerConfig;
|
||||
|
||||
use crate::request::{Location, Request};
|
||||
|
||||
#[derive(SerdeSerialize)]
|
||||
/// This is the payload sent to the client for hydration
|
||||
pub struct Payload<'a> {
|
||||
location: Location,
|
||||
data: &'a dyn Serialize,
|
||||
mode: Mode,
|
||||
#[serde(rename(serialize = "jsBundles"))]
|
||||
js_bundles: Option<Vec<String>>,
|
||||
#[serde(rename(serialize = "cssBundles"))]
|
||||
css_bundles: Option<Vec<String>>,
|
||||
#[serde(rename(serialize = "devServerConfig"))]
|
||||
dev_server_config: Option<&'a ServerConfig>,
|
||||
}
|
||||
|
||||
impl<'a> 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 {
|
||||
location: req.location(),
|
||||
data,
|
||||
mode,
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
dev_server_config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client_payload(&mut self) -> Result<String, serde_json::Error> {
|
||||
if self.mode == Mode::Prod {
|
||||
self.add_bundle_sources();
|
||||
}
|
||||
serde_json::to_string(&self)
|
||||
}
|
||||
|
||||
fn add_bundle_sources(&mut self) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::manifest::ViteManifest;
|
||||
use axum::http::Uri;
|
||||
|
||||
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<'_> {
|
||||
let manifest_mock = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE)
|
||||
.expect("Failed to parse the manifest example");
|
||||
MANIFEST.get_or_init(|| manifest_mock.into());
|
||||
|
||||
let uri = uri
|
||||
.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
|
||||
let location = Location::from(uri);
|
||||
|
||||
Payload {
|
||||
location,
|
||||
data: &None::<Option<()>>,
|
||||
mode,
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
dev_server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_bundles_on_mode_prod() {
|
||||
let mut payload = prepare_payload(None, Mode::Prod);
|
||||
|
||||
let _ = payload.client_payload();
|
||||
assert_eq!(
|
||||
payload.js_bundles,
|
||||
Some(vec![
|
||||
"assets/index-D-yFyCZo.js".to_string(),
|
||||
"assets/client-main-B9g1NVV7.js".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
payload.css_bundles,
|
||||
Some(vec![
|
||||
"assets/index-CynfArjF.css".to_string(),
|
||||
"assets/client-main-BS7N-NIa.css".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_load_the_bundles_on_mode_dev() {
|
||||
let mut payload = prepare_payload(None, Mode::Dev);
|
||||
let _ = payload.client_payload();
|
||||
assert!(payload.js_bundles.is_none());
|
||||
assert!(payload.css_bundles.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
#[serde(rename(serialize = "searchStr"))]
|
||||
search_str: String,
|
||||
search: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub fn pathname(&self) -> &String {
|
||||
&self.pathname
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
search_str: query.to_string(),
|
||||
search: serde_urlencoded::from_str(query).unwrap_or(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub uri: Uri,
|
||||
pub headers: HeaderMap,
|
||||
pub params: HashMap<String, String>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn new(
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
params: HashMap<String, String>,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Request {
|
||||
Request {
|
||||
uri,
|
||||
headers,
|
||||
params,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Location {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use crate::Request;
|
||||
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)]
|
||||
struct JsonResponseInfo {
|
||||
redirect_destination: Option<String>,
|
||||
}
|
||||
|
||||
impl JsonResponseInfo {
|
||||
fn new(redirect_destination: Option<String>) -> JsonResponseInfo {
|
||||
JsonResponseInfo {
|
||||
redirect_destination,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResponse<'a> {
|
||||
data: Option<&'a dyn Serialize>,
|
||||
info: JsonResponseInfo,
|
||||
}
|
||||
|
||||
impl<'a> JsonResponse<'a> {
|
||||
fn new(props: &'a dyn Serialize) -> Self {
|
||||
JsonResponse {
|
||||
data: Some(props),
|
||||
info: JsonResponseInfo::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_redirect(destination: String) -> Self {
|
||||
JsonResponse {
|
||||
data: None,
|
||||
info: JsonResponseInfo::new(Some(destination)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub fn new(data: impl Serialize + 'static) -> Self {
|
||||
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) -> impl IntoResponse + use<> {
|
||||
match self {
|
||||
Self::Props(Props {
|
||||
data,
|
||||
http_code,
|
||||
cookies,
|
||||
}) => {
|
||||
let payload = Payload::new(&req, data.as_ref()).client_payload().unwrap();
|
||||
|
||||
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 + use<> {
|
||||
match self {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use crate::manifest::load_manifest;
|
||||
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::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 {
|
||||
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);
|
||||
|
||||
// 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 {
|
||||
if let Err(err) = load_manifest() {
|
||||
tuono_println!("Failed to load vite manifest: {}", err.to_string().red());
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
self.display_start_message();
|
||||
|
||||
if self.mode == Mode::Dev {
|
||||
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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
|
||||
/// 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 {
|
||||
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("{}"))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user