mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 14:00:28 +00:00
Compare commits
907 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7375d3c597 | |||
| 3548fa6dff | |||
| cb73040e0f | |||
| 872d36b64f | |||
| e1ff17e655 | |||
| f3010bd28a | |||
| 8ff1e3b619 | |||
| d7596bf454 | |||
| aea5eb10ae | |||
| 1165a81cf5 | |||
| 61d1a0192e | |||
| f9a2977621 | |||
| 24f601d594 | |||
| 7b34537bbe | |||
| 145f385fdb | |||
| 360aa1c6d6 | |||
| ccc0975240 | |||
| 791135040a | |||
| 12c9d765ff | |||
| 45207288a1 | |||
| 177afa5828 | |||
| d51b79edbb | |||
| a2720ac49d | |||
| 5f6853d812 | |||
| 17dd382dc3 | |||
| 98e42cfe79 | |||
| c890e01004 | |||
| 659be551bb | |||
| 78a632b7fe | |||
| 4927e964b7 | |||
| 262a9b198b | |||
| 4f3588240c | |||
| abbae3ec4d | |||
| 1b8609ac60 | |||
| 193d591e9f | |||
| 1edc5d622a | |||
| 61b8507996 | |||
| 6f368b5cf3 | |||
| 9201e2cf52 | |||
| 2e0d706d43 | |||
| 7fefe4919c | |||
| 729921f338 | |||
| 1839ea2678 | |||
| f08dd29364 | |||
| ca2dfa3065 | |||
| 10ded437f0 | |||
| d1fdc13441 | |||
| a8fca0e033 | |||
| 9251b33959 | |||
| 81a6bd756a | |||
| ada195d329 | |||
| 1211ebea9b | |||
| 2e6e28eb54 | |||
| 854c448bad | |||
| 64a0b0f169 | |||
| 13ff22b6a8 | |||
| 8927e8fbbb | |||
| 25e2b40d47 | |||
| a60af8ff9c | |||
| 27b328cfd7 | |||
| fa6ef0c01c | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 | |||
| 885264f132 | |||
| f1a6dec951 | |||
| 44f41510fa | |||
| f220229e5e | |||
| 61b4cb8d7c | |||
| 185cdb4883 | |||
| 2cdba53451 | |||
| 950f6c36cf | |||
| ea37977b1e | |||
| 81da82aed7 | |||
| 39a3518651 | |||
| d903273759 | |||
| d130532f69 | |||
| 94fcd21f34 | |||
| 1d2db4ae2b | |||
| d270b4af9b | |||
| ccfbd3c1a8 | |||
| 5c4923557f | |||
| b1f608b865 | |||
| c7993756dd | |||
| d02465823f | |||
| 6b9f6b5b79 | |||
| df7de8fb0c | |||
| 72f5ce3985 | |||
| 04c80262e2 | |||
| 56d1b8fe2d | |||
| 054a1634ab | |||
| 6ff63f2db6 | |||
| b5ad0ce1e5 | |||
| 2ba7e54c81 | |||
| 0a858e74ad | |||
| b0c534fc2c | |||
| 250c194a02 | |||
| 5b10cde936 | |||
| 4aeb81bfa5 | |||
| 955cebf944 | |||
| 7c951ef437 | |||
| 68f0dbcc35 | |||
| 39c4f76aea | |||
| 2fe58e8cbb | |||
| 765141a4d8 | |||
| eb1bd3aa01 | |||
| b7284b0223 | |||
| 5bbd254742 | |||
| 611e28626a | |||
| 2a3bb0eb22 | |||
| 6816f0fa5a | |||
| 17386776ec | |||
| 9ee9082614 | |||
| 334cfdd522 | |||
| 8c961a5dbd | |||
| 4708956e77 | |||
| 813dd03dbb | |||
| 0522dbc61e | |||
| acb9f53156 | |||
| 6016846156 | |||
| cf1fae5837 | |||
| 7212345466 | |||
| e38caf78ff | |||
| 0a8dd67541 | |||
| ed684c19a9 | |||
| 52ab3681e3 | |||
| 9cc5a787e9 | |||
| 0003030bf5 | |||
| 9ad8f45d38 | |||
| a3855257c8 | |||
| 9169c2f053 | |||
| abe028bd38 | |||
| 12c2451f4f | |||
| 8daede5427 | |||
| 4c89aa60eb | |||
| d0a3f64ae4 | |||
| 433d66482d | |||
| bd553bcf9e | |||
| f5894bcf74 | |||
| 1b59e7ce36 | |||
| 32d6f5845d | |||
| 4f5cbfe732 | |||
| afba8fcb34 | |||
| 377a4899e4 | |||
| 938bdeb34b | |||
| d216eaa3f6 | |||
| 5ca5da0f21 | |||
| ebe6eb3b39 | |||
| 3bfe347096 | |||
| 7b38f89078 | |||
| 09b689b0ea | |||
| 2350baa75f | |||
| bc1eca815e | |||
| 78c10340e7 | |||
| c16890a3a6 | |||
| 3b4fe2827a | |||
| 7e807aabb5 | |||
| 973c74ee69 | |||
| efed0b0617 | |||
| ca3b9aca5d | |||
| e2afc07ab2 | |||
| e8fb84d362 | |||
| 57f951a7d9 | |||
| 28008667ed | |||
| f3affc3034 | |||
| 59da30f3c6 | |||
| 2db721112a | |||
| b60039b7d5 | |||
| 8fe9c9afea | |||
| 216ba7a556 | |||
| a2ff57081b | |||
| 40dbd9a14b | |||
| e5bac69c5c | |||
| 8d6598393f | |||
| 59b86eb9dc | |||
| 52c9f6da2f | |||
| dd65987c94 | |||
| ace61901a4 | |||
| e6c3c7635d | |||
| 5134d9e7f8 | |||
| 6821fb2937 | |||
| cfe4812045 | |||
| 14843ac343 | |||
| 8fb6d7465f | |||
| 0ef647586f | |||
| b4039627d9 | |||
| 4a295e6db4 | |||
| 5726999a5a | |||
| 58d7f3c140 | |||
| 07eab986d5 | |||
| 711cf38f9b | |||
| 4f7c0eea9f | |||
| f3448de2da | |||
| ec92075ef8 | |||
| 1e013cd87c | |||
| f033734308 | |||
| 50c05164f7 | |||
| 7fb9004b43 | |||
| 4721403986 | |||
| df848ef64e | |||
| 0b40ccd286 | |||
| caaa692f0b | |||
| 42d43e09c4 | |||
| 7ccbf55ba7 | |||
| 81f5bfc2f9 | |||
| a7a713bca2 | |||
| d6613c787f | |||
| 427f748e1c | |||
| 4f5fec7213 | |||
| 8c0a53ab40 | |||
| 2dfd5aab20 | |||
| 8d3c6562a2 | |||
| dae019c115 | |||
| 73e0cbb2d6 | |||
| 392f59450c | |||
| b4918f60ce | |||
| 8f7c304d58 | |||
| a3a944d7dd | |||
| 88a35ed057 | |||
| 67998b6f15 | |||
| ff6d1150a5 | |||
| 500c8cb649 | |||
| 6859f7d239 | |||
| dfa8a37be1 | |||
| 11a6511b7a | |||
| af98402655 | |||
| b7a0db3339 | |||
| 382a19885b | |||
| d99da6a3ae | |||
| 72cd0e59e6 | |||
| 42d88da73c | |||
| 2863fe0dc1 | |||
| 87218062be | |||
| 8a3910e208 | |||
| 6294bbb53c | |||
| a28a997851 | |||
| 28a9ee701e | |||
| 50e8b2ec52 | |||
| 315717fa2d | |||
| da67711c90 | |||
| fecdbe8dde | |||
| 9316ccd1f4 | |||
| c3b620e975 | |||
| 7a00880e5b | |||
| b6b597564d | |||
| d410381957 | |||
| 902c3c6c1a | |||
| 4fb2a4a6d7 | |||
| 2d64c78787 | |||
| fbfcca5dae | |||
| 664ae8bff6 | |||
| fa3d2aae08 | |||
| e7a591d9f6 | |||
| 2860beacdc | |||
| 231167d3e1 | |||
| c0ee60ec13 | |||
| f58b904ba6 | |||
| d5992f1a5c | |||
| fa12f9eeb6 | |||
| 33726c88a0 | |||
| 6aa3ff9c5e | |||
| 7d3fe23195 | |||
| 521052d84f | |||
| c730845e17 | |||
| 7b5a3616c4 | |||
| c9373efdb5 | |||
| 015615de8a | |||
| 0f867e533a | |||
| 7d5a6d2255 | |||
| a1da4f023d | |||
| 666e8426e1 | |||
| df2fa42bda | |||
| b3a7ad6d2c | |||
| 2932077bf9 | |||
| 957afd967f | |||
| 4c9a9d0416 | |||
| 40df91a363 | |||
| af66ff71ed | |||
| 6f8ecd6111 | |||
| fa3eb07617 | |||
| dc4268db64 | |||
| a0c06a72cc | |||
| 19c20c0be3 | |||
| fae34e56ac | |||
| ba0094a862 | |||
| b259009c9c | |||
| d88dd5ed8a | |||
| 57bb146075 | |||
| 4b67fe5317 | |||
| 938125e772 | |||
| 1c8d9febb3 | |||
| 223edae17d | |||
| 12a455dab5 | |||
| d00dc8ca72 | |||
| 2c85a302fc | |||
| 3460b78b45 | |||
| 95a1f652f9 | |||
| cc37e2ae15 | |||
| 08382ce62f | |||
| 80cb52a237 | |||
| c6637b09e2 | |||
| 778624af00 | |||
| e15757d01f | |||
| aed79fd6c3 | |||
| 0c973a3292 | |||
| 56c923c0ed | |||
| 7d7806df30 | |||
| cb5598dc19 | |||
| 47f01edcb8 | |||
| e6ddc0f07f | |||
| 373f18f451 | |||
| 3f292065cd | |||
| 5e975ab95b | |||
| a3ec264764 | |||
| 5d504ffa4c | |||
| 04999662db | |||
| f7a34706c7 | |||
| f328a41982 | |||
| 20ebecb114 | |||
| 5a69692f54 | |||
| 42ca7e1580 | |||
| c4ebb3fecd | |||
| 2a805cc082 | |||
| 89a5816ee4 | |||
| c3f6b8c003 | |||
| 0e201d5899 | |||
| ae5f1abc58 | |||
| 580101d04e | |||
| 81b4a4db53 | |||
| 8339e91e2b | |||
| 3119066728 | |||
| 326e28e9b0 | |||
| 90f0127ada | |||
| 0353a8186c | |||
| dd043df00f | |||
| 97b7c79bc6 | |||
| 634e0b4302 | |||
| 2a23f1f003 | |||
| 88ffdcc303 | |||
| b6b09d42af | |||
| ebc39e9db9 | |||
| 0df3849ea2 | |||
| 8ed1bda2f2 | |||
| d67699a1f1 | |||
| 3363a18856 | |||
| ad1dc0b914 | |||
| e4c7307c99 | |||
| f9c942c8ff | |||
| eeb977fc03 | |||
| 4b8a62122a | |||
| e44e947fc1 | |||
| 7588cfe1c5 | |||
| a9a643f222 | |||
| 77d6cb75be | |||
| 1942d221df | |||
| 041601ed65 | |||
| 5567281a85 | |||
| 4c3753046b | |||
| 89f8e16184 | |||
| a3e1125ced | |||
| 4dae8acfed | |||
| bc081c4344 | |||
| e4275b235d | |||
| 77397cac82 | |||
| b6a08bc679 | |||
| 116575c811 | |||
| 4ba7bd5ce1 | |||
| 223ab5377f | |||
| ae43ca146c | |||
| 42342893d6 | |||
| eedefdd5c9 | |||
| f5e152dd74 | |||
| 95ea22d9c5 | |||
| f23023ddfa | |||
| da74077954 | |||
| 29aaba5949 | |||
| daf41fc728 | |||
| b08eb1af59 | |||
| 8322cd3e35 | |||
| 444039c142 | |||
| 3b0eff7f71 | |||
| c765463e38 | |||
| e8ca25b67e | |||
| 528eee3fc6 | |||
| 168a4fa998 | |||
| 3ea75adba4 | |||
| cbd768a095 | |||
| 2d01653a78 | |||
| aa3357a57d | |||
| de53946896 | |||
| d51204148e | |||
| 319761a39e | |||
| 7fbf01a348 | |||
| c22490bd80 | |||
| 860fd6d02e | |||
| c43c524804 | |||
| 66bb938d85 | |||
| 0e6d928496 | |||
| f07982d9f8 | |||
| b92ecfad58 | |||
| 416bde70bc | |||
| 580f7ade15 | |||
| cdffd7c47b | |||
| 5a959cf041 | |||
| 31466546a2 | |||
| 90ead98eaa | |||
| 65e9cb9d5c | |||
| e59076a074 | |||
| 653920dec9 | |||
| 23cedd2d8b | |||
| 70c4182fd8 | |||
| b62b101065 | |||
| b9c929beeb | |||
| 2b6c593289 | |||
| c0b339e406 | |||
| da485f5144 | |||
| 139a8050a5 | |||
| da7b369ffd | |||
| 432da64919 | |||
| 7d0b422200 | |||
| fb90eb0acc | |||
| b647538e41 | |||
| e50797a5c1 | |||
| 5b1e01c786 | |||
| 813b2af06b | |||
| 6f71da88d8 | |||
| fa91eb7eaf | |||
| c756761591 | |||
| 434af90366 | |||
| 0f41e194cb | |||
| bd353489e9 | |||
| 77fd393f15 | |||
| 8701b5e078 | |||
| 61db166f92 | |||
| 0d9b9cea0f | |||
| 7bb37c7f0a | |||
| 5b2bf828e3 | |||
| c85d3f9752 | |||
| 4d7088bbf0 | |||
| dd6ab6044c | |||
| d236bf029c | |||
| d70d566abe | |||
| a25fdedea9 | |||
| 373183a2cd | |||
| e9fa50dfb5 | |||
| 57ca4e0e88 | |||
| bc90adb73a | |||
| 1505ddbd5a | |||
| cd99ed57fb | |||
| 27de1cd01d | |||
| ae78fc5394 | |||
| 40e8e69c6d | |||
| e84a46a558 | |||
| 18a72d4466 | |||
| d7aa5fa003 | |||
| 27532a5f41 | |||
| 48c18c03b9 | |||
| bba2e52e24 | |||
| 54ee0149e1 | |||
| 8fda9d2fab | |||
| 77a72d79ce | |||
| 73e2b472ef | |||
| 2f28cc1323 | |||
| f5516d4e21 | |||
| d2facb2db1 | |||
| 6eae764b7c | |||
| 970ef8f886 | |||
| ed0720699e | |||
| ad94d00d5f | |||
| c7843f7f52 | |||
| 1fe3286bdb | |||
| ce030fdf76 | |||
| 9ba8783952 | |||
| 2f3966cc53 | |||
| 8f65a22e13 | |||
| 2787d884cd | |||
| fc00b51683 | |||
| f80c3a7b37 | |||
| 4e5d6c7487 | |||
| 96f84b5917 | |||
| 6b8a5fdd1b | |||
| 89083ac790 | |||
| 70be2c6b2b | |||
| bcf877841b | |||
| a84b06bb19 | |||
| 24b7808d9a | |||
| 8364ee988e | |||
| 07f6e2d233 | |||
| 7212973f34 | |||
| af44e0436b | |||
| 3f9c35d505 | |||
| 2c13f8b95c | |||
| 4dab1c56e5 | |||
| 4723f03ef1 | |||
| 278689e0a0 | |||
| 886e204e68 | |||
| c59947ab39 | |||
| 4f0a772a21 | |||
| 903f962673 | |||
| 31d800e48b | |||
| c1c58eb919 | |||
| eec3eed100 | |||
| 99da8df3d1 | |||
| c9ea23919e | |||
| 16292def79 | |||
| 9108c56ca1 | |||
| b011ea0af8 | |||
| d33171b779 | |||
| 6aaeae269e | |||
| e5f0cc8663 | |||
| 9b5f971aa1 | |||
| ff3b708ad3 | |||
| e996904013 | |||
| f32bf1f15d | |||
| 6cda2dd57b | |||
| c60e9ba27f | |||
| 9bc3d504c8 | |||
| b5e4fd75e9 | |||
| a92cb49445 | |||
| 88f64b0d63 | |||
| 844059f9a5 | |||
| eaf2d68308 | |||
| 12bdbf988d | |||
| 29bab79005 | |||
| 8d5de97ddf | |||
| d9b5a898ab | |||
| f127d28675 | |||
| 65c1bdc455 | |||
| 0711a171d4 | |||
| c58c551f9e | |||
| f574858b9b | |||
| 3e1592e16b | |||
| 90604c4c3b | |||
| 9ffc3b606d | |||
| e00c87d3e6 | |||
| bb3ca9c165 | |||
| 8ee9757132 | |||
| 946a463979 | |||
| 7aad9f7ff8 | |||
| 3fe8f7c219 | |||
| 0278a14e8e | |||
| e5366d3a49 | |||
| 38e84ffe1a | |||
| 052e07a936 | |||
| 7e67ebc11d | |||
| c9dea94717 | |||
| 1344754ca9 | |||
| 0f4759fb89 | |||
| 6d77b647a4 | |||
| 5907bd9af4 | |||
| b66d04c9c5 | |||
| 3173d8ec2d | |||
| 31e381035e | |||
| 259074b81f | |||
| 9a2f5b9295 | |||
| 58cd70b0c1 | |||
| 821899a767 | |||
| bfcc119ff3 | |||
| 76c797498e | |||
| 13514bf6fa | |||
| 05ffd0e321 | |||
| 701eaad92a | |||
| c66ef5dd10 | |||
| f774f9b3a0 | |||
| 29dc7ccb1a | |||
| 80237c34f9 | |||
| 979233c5c0 | |||
| 2f03e6acee | |||
| c492f09a19 | |||
| 2c3af5e73c | |||
| 6597f8508d | |||
| 8beb5d6bcf | |||
| 53f5dbea26 | |||
| 725952a106 | |||
| 3319375e9a | |||
| c5a4619020 | |||
| 989fc69abe | |||
| 0924259217 | |||
| 05affac34f | |||
| 453a459566 | |||
| 722f02ea09 | |||
| 5bcea2fe3b | |||
| 73afd1f5d0 | |||
| 9b8a0d3e54 | |||
| 75bd98d66c | |||
| 9b914d10bd | |||
| 3e11678d8d | |||
| 6e03638130 | |||
| 23f884efb7 | |||
| 2ae76f999b | |||
| 685325cdc7 | |||
| ab132eadb7 | |||
| 14504e43b5 | |||
| 4ed79574e8 | |||
| 31bcd1a386 | |||
| 270e5ef7ad | |||
| 1f4733355e | |||
| 027a859ef4 | |||
| 59f50eaee7 | |||
| 80d2733c25 | |||
| feb6735a4d | |||
| f1d1d2fd81 | |||
| a749c67803 | |||
| c808bb1483 | |||
| e24206ebf0 | |||
| 3410785a3a | |||
| b4a12d606e | |||
| a0545545d7 | |||
| 1f11650ca1 | |||
| da55facf2c | |||
| dc2dbc5710 | |||
| bdd6303bcc | |||
| 66d0fc0683 | |||
| 05e1d8cd96 | |||
| 9f0de74caa | |||
| a820739bd2 | |||
| 304042017c | |||
| 82cd9f2979 | |||
| a3f1fea27b | |||
| bb5c6a42e7 | |||
| 445baaaa61 | |||
| 7419ee7dba | |||
| aa6be381a4 | |||
| 61575838f3 | |||
| 359bb979f8 | |||
| b29a885bae | |||
| 919da7f2c9 | |||
| d222c116e0 | |||
| df840dac18 | |||
| f8d4c7697c | |||
| dddb4ecbb8 | |||
| 470d10cb47 | |||
| dae11e9607 | |||
| 3d1eb53708 | |||
| a76c9a56a7 | |||
| eaad0d4e09 | |||
| 46edde9da6 | |||
| dda0cc635e | |||
| 727bde2d16 | |||
| 9f30af1b33 | |||
| ff05e20537 | |||
| 53de5b13c1 | |||
| a025118f69 | |||
| 71688a817b | |||
| 00c476ea7f | |||
| 5576906914 | |||
| d5aaccd2c4 | |||
| 7059c2ba25 | |||
| aaab157d49 | |||
| 9943276c0c | |||
| c31cf5243a | |||
| 0c03676efb | |||
| 883d10acf8 | |||
| 3e5e458118 | |||
| 0510b6f29e | |||
| 92b7668bf3 | |||
| 3973b1cbf3 | |||
| 160fca1ea1 | |||
| e90c96f7e9 | |||
| d935fb1b2e | |||
| a6529584a5 | |||
| 18953b2d6b | |||
| 21cbd9e3b0 | |||
| 38061bdf65 | |||
| d9029c7e1e | |||
| fe8acc986e | |||
| e721f17f7e | |||
| 2f73db785e | |||
| 9e18ae6587 | |||
| 9eca010f94 | |||
| 6e54a06d9c | |||
| 1f8e416e41 | |||
| cfccc44406 | |||
| 45b30c9c27 | |||
| 67f78368fb | |||
| d9c8d57e8e | |||
| 0d615e3888 | |||
| 68287d3df4 | |||
| dff08ae3e3 | |||
| 8cfa67c1dd | |||
| b41d425993 | |||
| 12c7862250 | |||
| 6f54826296 | |||
| ccf2cffc4b | |||
| f7b801b4c8 | |||
| 95f2aa45eb | |||
| 9524ff5f5a | |||
| 1823101b03 | |||
| 1a8cfd2a8b | |||
| 5b4e710770 | |||
| 1762f85ef9 | |||
| d370a3f0e3 | |||
| d3e573c2e9 | |||
| 94876e3ad1 | |||
| ad190602c7 | |||
| 162b4b78b6 | |||
| e5dc05e994 | |||
| b46cff70c0 | |||
| b6c3870eda | |||
| 1c1f504601 | |||
| 1b8a45678c | |||
| 38de9eb9d7 | |||
| eac1314a7d | |||
| 29d59793dc | |||
| 128ad20280 | |||
| 994ebff8c1 | |||
| 142938401c | |||
| 65cc4afb23 | |||
| e000f23c44 | |||
| 1de9d1aa33 | |||
| 0e601e277b | |||
| e582a47f37 | |||
| 1b251a78b0 | |||
| 24f5249ae9 | |||
| 17c1631013 | |||
| cb100d5e00 | |||
| 1fcc807513 | |||
| 5664e5a30f | |||
| 26e64019d8 | |||
| 56331d2e74 | |||
| 55b095e503 | |||
| 6a073e1dd5 | |||
| 960e25a3f6 | |||
| 1de338c312 | |||
| dbba50cea4 | |||
| 8f046a9cd6 | |||
| 2906e6038a | |||
| 87b8ea8c05 | |||
| 47971dce31 | |||
| 528a05398c | |||
| f584148ab9 | |||
| 0de22f7a96 | |||
| 09c7c30779 | |||
| fde32e26bf | |||
| a6f65916f1 | |||
| 6059efe094 | |||
| 16194c50ba | |||
| af3c6a971c | |||
| ecfdb976a3 | |||
| 08acea651a | |||
| 6b503608cd | |||
| d8763997a5 | |||
| 32aa1f99c1 | |||
| 718a8a54b1 | |||
| 741dd490d4 | |||
| 02d876e6d8 | |||
| 56c52e0bb0 | |||
| 6036becf93 | |||
| 3a7210d514 | |||
| 6088fa582d | |||
| 6691492ef7 | |||
| 7ee9158cd3 | |||
| 5ae1b883ab | |||
| 88397bf419 | |||
| 1138d5935b | |||
| 8276ba080e | |||
| 37d13d80f5 | |||
| 1c97536ab1 | |||
| ca8f028cc0 | |||
| 300f7bfe4b | |||
| 82dc9c40f7 | |||
| 44a20fe5ee | |||
| b950a64406 | |||
| c5f374f66f | |||
| ae99f87db0 | |||
| 5112e3e79b | |||
| c1e27ccacc | |||
| b0bd871997 | |||
| c30bc24fd6 | |||
| 648f0e9e84 | |||
| d2bc5672e0 | |||
| 5c87ec7c2f | |||
| d66c184ed2 | |||
| 12f25d8bfc | |||
| cbe61397a3 | |||
| deb8c54da3 | |||
| 5dab70fab4 | |||
| 665b6bed66 | |||
| f4a90defc3 | |||
| 46f63cbd55 | |||
| c77142a16f | |||
| eb7c22f52a | |||
| e86e51f06f | |||
| db74ea3001 | |||
| 99579ac29c | |||
| 2f6540e008 | |||
| 72c3b80529 | |||
| 8126a83c50 | |||
| 210c432b37 | |||
| f92e90603f | |||
| 14172791e9 | |||
| e4e2f524d4 | |||
| fc7b16f6a7 | |||
| 6ba3ae74d5 | |||
| c71d1598d8 | |||
| 5950657b0e | |||
| 0e1a48cd06 | |||
| 98245e5f20 | |||
| 0b6ddca981 | |||
| ea1468c892 | |||
| e8e64bf510 | |||
| aaf8daf0d7 | |||
| 8af1abc4e1 | |||
| 48ab59feb3 | |||
| f64d5073d4 | |||
| 53b9f10ac8 | |||
| 75606e4df7 | |||
| 0643b7f351 | |||
| 5b3b4e0614 | |||
| 8d039d1348 | |||
| 87eea73081 | |||
| 67a6fb4c87 | |||
| 23584c9862 | |||
| 2f767125c4 | |||
| c73e07d42d | |||
| 6b5d7e6057 | |||
| 076dc48e89 | |||
| bb208ab36a | |||
| 60e35001b6 | |||
| 29a0d7ea02 | |||
| 1f2b7f7acf | |||
| 39e5cb63ef | |||
| 5b5928fb8d | |||
| 08068743a5 | |||
| 35eae1adcf | |||
| e85d357abd | |||
| 31396d8109 | |||
| 664debc0bc | |||
| 56c31c76b3 | |||
| 2d1a71f3d1 | |||
| c67cd73058 | |||
| 2572cb4352 | |||
| c9f6c7ccac | |||
| b6520b0dbc | |||
| 9fe8142117 | |||
| 57cdd25b5f | |||
| 1005ee5d52 | |||
| d9bb9f9a07 | |||
| 75a6fabe61 | |||
| 75e29b69cc | |||
| ffd3937538 | |||
| 575587e4c5 | |||
| 2ce59d7c92 | |||
| 3b3d6e2a73 | |||
| f2a467fdbf | |||
| 867481c66d | |||
| fc8065b7a8 | |||
| 28f1ed9205 | |||
| 568266bc9a | |||
| 7739f341b8 | |||
| 2b501f191c | |||
| aa2ed0fda1 | |||
| 51cba074d7 | |||
| 2c945c663c | |||
| d9a18ecabb | |||
| eab7931afb | |||
| 99b6c92377 | |||
| 4d56f1163b | |||
| 07cf835551 | |||
| 061c0c1a90 | |||
| 1ed18c05ef | |||
| ca134c8a60 | |||
| 7ba67fd852 | |||
| 7675c2c411 | |||
| 7c71363af8 | |||
| d48926eb2a | |||
| d843e5e06f | |||
| a2e564738e | |||
| 54a797a0b1 | |||
| a78c1ecb4d | |||
| 4771c78001 | |||
| b7a5106f36 | |||
| 4bcbdc50ff | |||
| 5d72b35c7f | |||
| ff50ac215c |
@@ -0,0 +1,3 @@
|
|||||||
|
# Changes to the API data cache functions can invalidate all existing data cache
|
||||||
|
# causing a massive amount of revalidation, impacting our API.
|
||||||
|
packages/gitbook/src/lib/data/api.ts @SamyPesse
|
||||||
+29
-6
@@ -53,19 +53,42 @@ After forking this repository, you'll want to [create a branch](https://docs.git
|
|||||||
|
|
||||||
#### 3. Install dependencies and run the project locally
|
#### 3. Install dependencies and run the project locally
|
||||||
|
|
||||||
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
|
##### Prerequisites:
|
||||||
|
- Node.js (Version: >=20.6)
|
||||||
|
- Use `nvm` for easy Node management
|
||||||
|
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||||
|
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||||
|
|
||||||
You can easily do this by running the command `nvm use`.
|
##### Setup steps:
|
||||||
|
|
||||||
To start your local version of GitBook, run the command `bun dev`.
|
1. Ensure you are using the project's version of Node:
|
||||||
|
```bash
|
||||||
|
nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies using Bun:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional development commands:
|
||||||
|
- `bun format`: Format the code using Biome
|
||||||
|
- `bun typecheck`: Run TypeScript type checking
|
||||||
|
- `bun unit`: Run unit tests
|
||||||
|
- `bun e2e`: Run end-to-end tests
|
||||||
|
|
||||||
#### 4. Preview your changes
|
#### 4. Preview your changes
|
||||||
|
|
||||||
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`.
|
||||||
|
|
||||||
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/url/gitbook.com/docs` after running the development server.
|
||||||
|
|
||||||
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
||||||
|
|
||||||
### Commit your update
|
### Commit your update
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Gradual Deploy to Cloudflare
|
||||||
|
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
|
||||||
|
inputs:
|
||||||
|
apiToken:
|
||||||
|
description: 'Cloudflare API token'
|
||||||
|
required: true
|
||||||
|
accountId:
|
||||||
|
description: 'Cloudflare account ID'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Cloudflare environment to deploy to (staging, production, preview)'
|
||||||
|
required: true
|
||||||
|
middlewareVersionId:
|
||||||
|
description: 'Middleware version ID to deploy'
|
||||||
|
required: true
|
||||||
|
serverVersionId:
|
||||||
|
description: 'Server version ID to deploy'
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
deployment-url:
|
||||||
|
description: "Deployment URL"
|
||||||
|
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- id: wrangler_status
|
||||||
|
name: Check wrangler deployment status
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
# This step is used to get the version ID that is currently deployed to Cloudflare.
|
||||||
|
- id: extract_current_version
|
||||||
|
name: Extract current version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- id: deploy_server
|
||||||
|
name: Deploy server to Cloudflare at 0%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
|
||||||
|
- id: deploy_middleware
|
||||||
|
name: Deploy middleware to Cloudflare at 100%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Deploy server to Cloudflare at 100%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Outputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: 'Setup Playwright'
|
|
||||||
description: 'Install Playwright and dependencies'
|
|
||||||
runs:
|
|
||||||
using: 'composite'
|
|
||||||
steps:
|
|
||||||
# Run npm ci and get Playwright version
|
|
||||||
- name: 🏗 Prepare Playwright env
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
|
||||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
|
||||||
- name: 🧰 Cache Playwright browser binaries
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: playwright-cache
|
|
||||||
with:
|
|
||||||
path: '~/.cache/ms-playwright'
|
|
||||||
key: '${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}'
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-
|
|
||||||
|
|
||||||
# Install browser binaries & OS dependencies if cache missed
|
|
||||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
shell: bash
|
|
||||||
working-directory: packages/gitbook
|
|
||||||
run: |
|
|
||||||
bun x playwright install --with-deps chromium
|
|
||||||
|
|
||||||
# Install only the OS dependencies if cache hit
|
|
||||||
- name: 🏗 Install Playwright OS dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
shell: bash
|
|
||||||
working-directory: packages/gitbook
|
|
||||||
run: |
|
|
||||||
bun x playwright install-deps
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: 'Deploy cloudflare'
|
||||||
|
description: 'Deploy GitBook to Cloudflare'
|
||||||
|
inputs:
|
||||||
|
opItem:
|
||||||
|
description: '1Password item to load secrets from'
|
||||||
|
required: true
|
||||||
|
opServiceAccount:
|
||||||
|
description: '1Password service account token'
|
||||||
|
required: true
|
||||||
|
apiToken:
|
||||||
|
description: 'Cloudflare API token'
|
||||||
|
required: true
|
||||||
|
accountId:
|
||||||
|
description: 'Cloudflare account ID'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Cloudflare environment to deploy to (staging, production, preview)'
|
||||||
|
required: true
|
||||||
|
deploy:
|
||||||
|
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||||
|
required: true
|
||||||
|
commitTag:
|
||||||
|
description: 'Commit branch to associate with the deployment'
|
||||||
|
required: true
|
||||||
|
commitMessage:
|
||||||
|
description: 'Commit message to associate with the deployment'
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
deployment-url:
|
||||||
|
description: "Deployment URL"
|
||||||
|
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Load secret
|
||||||
|
uses: 1password/load-secrets-action@v2
|
||||||
|
env:
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||||
|
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||||
|
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||||
|
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
||||||
|
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||||
|
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||||
|
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||||
|
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||||
|
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||||
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||||
|
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||||
|
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
|
- name: Build worker
|
||||||
|
run: bun run turbo build:cloudflare
|
||||||
|
env:
|
||||||
|
GITBOOK_RUNTIME: cloudflare
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload the DO worker
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
|
||||||
|
|
||||||
|
- id: upload_server
|
||||||
|
name: Upload server to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Extract server version worker ID
|
||||||
|
shell: bash
|
||||||
|
id: extract_server_version_id
|
||||||
|
run: |
|
||||||
|
version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Run updateWrangler scripts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bun run ./packages/gitbook/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
|
||||||
|
|
||||||
|
- id: upload_middleware
|
||||||
|
name: Upload middleware to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Extract middleware version worker ID
|
||||||
|
shell: bash
|
||||||
|
id: extract_middleware_version_id
|
||||||
|
run: |
|
||||||
|
version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Deploy server and middleware to Cloudflare
|
||||||
|
if: ${{ inputs.deploy == 'true' }}
|
||||||
|
uses: ./.github/actions/gradual-deploy-cloudflare
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
opServiceAccount: ${{ inputs.opServiceAccount }}
|
||||||
|
opItem: ${{ inputs.opItem }}
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }}
|
||||||
|
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
|
||||||
|
deploy: ${{ inputs.deploy }}
|
||||||
|
|
||||||
|
|
||||||
|
- name: Outputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}"
|
||||||
|
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: 'Deploy vercel'
|
||||||
|
description: 'Deploy GitBook to Vercel'
|
||||||
|
inputs:
|
||||||
|
vercelOrg:
|
||||||
|
description: 'Vercel organization'
|
||||||
|
required: true
|
||||||
|
vercelProject:
|
||||||
|
description: 'Vercel project'
|
||||||
|
required: true
|
||||||
|
vercelToken:
|
||||||
|
description: 'Vercel token'
|
||||||
|
required: true
|
||||||
|
opItem:
|
||||||
|
description: '1Password item to load secrets from'
|
||||||
|
required: true
|
||||||
|
opServiceAccount:
|
||||||
|
description: '1Password service account token'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Environment to deploy to'
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
deployment-url:
|
||||||
|
description: "Deployment URL"
|
||||||
|
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: bun run vercel pull --yes --environment=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||||
|
- name: Load secret
|
||||||
|
uses: 1password/load-secrets-action@v2
|
||||||
|
env:
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||||
|
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||||
|
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||||
|
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||||
|
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||||
|
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||||
|
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||||
|
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||||
|
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||||
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||||
|
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||||
|
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||||
|
GITBOOK_RUNTIME: vercel
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
id: deploy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }})
|
||||||
|
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||||
|
- name: Outputs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
name: 'Setup Bun'
|
||||||
|
description: 'Install Bun and cache dependencies'
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Setup bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version-file: 'package.json'
|
||||||
+15
-178
@@ -5,192 +5,31 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
env:
|
env:
|
||||||
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
|
||||||
name: Deploy to Cloudflare Pages
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
deployments: write
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
checks: write
|
|
||||||
statuses: write
|
|
||||||
outputs:
|
|
||||||
deployment_url: ${{ steps.deploy.outputs.url }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
env:
|
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
|
||||||
- name: Cache Next.js build
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/.next/cache
|
|
||||||
# Generate a new cache whenever packages or source files change.
|
|
||||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
|
||||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
|
|
||||||
- name: Sets env vars for production
|
|
||||||
run: |
|
|
||||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
|
||||||
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
|
|
||||||
if: startsWith(github.ref, 'refs/heads/main')
|
|
||||||
- name: Sets env vars for preview
|
|
||||||
run: |
|
|
||||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
|
||||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
|
||||||
- name: Build Next.js with next-on-pages
|
|
||||||
run: bun run build:cloudflare
|
|
||||||
env:
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
|
||||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
|
||||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
|
||||||
- id: deploy
|
|
||||||
name: Deploy to Cloudflare
|
|
||||||
uses: cloudflare/wrangler-action@v3
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
workingDirectory: ./
|
|
||||||
wranglerVersion: '3.82.0'
|
|
||||||
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
|
||||||
- name: Outputs
|
|
||||||
run: |
|
|
||||||
echo "URL: ${{ steps.deploy.outputs.url }}"
|
|
||||||
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
|
|
||||||
- name: Archive build output
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build-output
|
|
||||||
path: .vercel/
|
|
||||||
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
|
|
||||||
- name: Update Deployment Status to Success
|
|
||||||
env:
|
|
||||||
DEPLOYMENT_URL: ${{ steps.deploy.outputs.url }}
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
|
|
||||||
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Find GitHub Comment
|
|
||||||
uses: peter-evans/find-comment@v3
|
|
||||||
id: fc
|
|
||||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
|
||||||
comment-author: 'github-actions[bot]'
|
|
||||||
body-includes: GitBook Preview
|
|
||||||
|
|
||||||
- name: Create or update GitHub comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v4
|
|
||||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
|
||||||
with:
|
|
||||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
|
||||||
issue-number: ${{ github.event.pull_request.number }}
|
|
||||||
body: |
|
|
||||||
**GitBook Preview**
|
|
||||||
Latest commit: [${{ steps.deploy.outputs.url }}](${{ steps.deploy.outputs.url }})
|
|
||||||
PR: [${{ steps.deploy.outputs.alias }}](${{ steps.deploy.outputs.alias }})
|
|
||||||
edit-mode: replace
|
|
||||||
visual-testing:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Visual Testing
|
|
||||||
needs: deploy
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
- name: Setup Playwright
|
|
||||||
uses: ./.github/actions/setup-playwright
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: bun e2e
|
|
||||||
env:
|
|
||||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
with:
|
|
||||||
name: playwright-test-results
|
|
||||||
path: packages/gitbook/test-results/
|
|
||||||
retention-days: 3
|
|
||||||
pagespeed-testing:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: PageSpeed Testing
|
|
||||||
needs: deploy
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
env:
|
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
|
||||||
- name: Run pagespeed tests
|
|
||||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
|
||||||
env:
|
|
||||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
|
|
||||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Format
|
name: Format
|
||||||
|
timeout-minutes: 6
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- run: bun format:check
|
- run: bun format:check
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Lint
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
env:
|
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
|
||||||
- run: bun lint --no-cache
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Test
|
name: Test
|
||||||
|
timeout-minutes: 6
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -200,30 +39,28 @@ jobs:
|
|||||||
# CI to check that the repository builds correctly on a machine without the credentials
|
# CI to check that the repository builds correctly on a machine without the credentials
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Build (Open Source)
|
name: Build (Open Source)
|
||||||
|
timeout-minutes: 6
|
||||||
env:
|
env:
|
||||||
NPMRC_FONT_AWESOME_TOKEN: ''
|
NPM_TOKEN_READONLY: ''
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- run: bun run build
|
- run: bun run build
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Typecheck
|
name: Typecheck
|
||||||
|
timeout-minutes: 6
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
name: Preview
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
|
jobs:
|
||||||
|
deploy-v2-vercel:
|
||||||
|
name: Deploy v2 to Vercel (preview)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: 2v-preview
|
||||||
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy to Vercel
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-vercel
|
||||||
|
with:
|
||||||
|
environment: preview
|
||||||
|
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
opItem: op://gitbook-open/2v-preview
|
||||||
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
deploy-v2-cloudflare:
|
||||||
|
name: Deploy v2 to Cloudflare Worker (preview)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: 2c-preview
|
||||||
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy to Cloudflare
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-cloudflare
|
||||||
|
with:
|
||||||
|
environment: preview
|
||||||
|
deploy: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
opItem: op://gitbook-open/2c-preview
|
||||||
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
|
- name: Extract Worker ID
|
||||||
|
id: extract-worker-id
|
||||||
|
if: ${{ !steps.deploy.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
|
||||||
|
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
|
||||||
|
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
- name: Outputs
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
|
||||||
|
comment-deployments:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Comment Deployments (preview)
|
||||||
|
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
||||||
|
needs:
|
||||||
|
- deploy-v2-vercel
|
||||||
|
- deploy-v2-cloudflare
|
||||||
|
steps:
|
||||||
|
- name: Find GitHub Comment
|
||||||
|
uses: peter-evans/find-comment@v3
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-author: 'github-actions[bot]'
|
||||||
|
body-includes: 'Summary of the deployments'
|
||||||
|
|
||||||
|
- name: Create or update GitHub comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v4
|
||||||
|
with:
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
body: |
|
||||||
|
Summary of the deployments:
|
||||||
|
|
||||||
|
| Version | URL | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||||
|
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||||
|
|
||||||
|
### Test content
|
||||||
|
|
||||||
|
| Site | `2v` | `2c` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GitBook | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
|
||||||
|
| E2E | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||||
|
edit-mode: replace
|
||||||
|
visual-testing-v2-vercel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing v2
|
||||||
|
needs: deploy-v2-vercel
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||||
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'v2-vercel'
|
||||||
|
visual-testing-v2-cloudflare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing v2 (Cloudflare)
|
||||||
|
needs: deploy-v2-cloudflare
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||||
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
||||||
|
visual-testing-customers-v2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing Customers v2
|
||||||
|
needs: deploy-v2-vercel
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e-customers
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||||
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
|
visual-testing-customers-v2-cloudflare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing Customers v2 (Cloudflare)
|
||||||
|
needs: deploy-v2-cloudflare
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e-customers
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||||
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
|
pagespeed-testing-v2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: PageSpeed Testing v1
|
||||||
|
needs: deploy-v2-vercel
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
|
- name: Run pagespeed tests
|
||||||
|
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||||
|
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Production
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
|
jobs:
|
||||||
|
deploy-v2-vercel:
|
||||||
|
name: Deploy v2 to Vercel (production)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: 2v-production
|
||||||
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-vercel
|
||||||
|
with:
|
||||||
|
environment: production
|
||||||
|
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
opItem: op://gitbook-open/2v-production
|
||||||
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
deploy-v2-cloudflare:
|
||||||
|
name: Deploy v2 to Cloudflare Worker (production)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: 2c-production
|
||||||
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-cloudflare
|
||||||
|
with:
|
||||||
|
environment: production
|
||||||
|
deploy: true
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
opItem: op://gitbook-open/2c-production
|
||||||
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: main
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
|
- name: Outputs
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Staging
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
|
jobs:
|
||||||
|
deploy-v2-vercel:
|
||||||
|
name: Deploy v2 to Vercel (staging)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: 2v-staging
|
||||||
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-vercel
|
||||||
|
with:
|
||||||
|
environment: staging
|
||||||
|
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
opItem: op://gitbook-open/2v-staging
|
||||||
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
deploy-v2-cloudflare:
|
||||||
|
name: Deploy v2 to Cloudflare Worker (staging)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: 2c-staging
|
||||||
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Deploy
|
||||||
|
id: deploy
|
||||||
|
uses: ./.github/composite/deploy-cloudflare
|
||||||
|
with:
|
||||||
|
environment: staging
|
||||||
|
deploy: true
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
opItem: op://gitbook-open/2c-staging
|
||||||
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: main
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
|
- name: Outputs
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
@@ -5,6 +5,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,10 +20,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -30,6 +31,7 @@ jobs:
|
|||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
publish: npm run release
|
publish: npm run release
|
||||||
|
version: npm run changeset-version
|
||||||
env:
|
env:
|
||||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||||
@@ -37,24 +39,4 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
release-preview:
|
|
||||||
# For now it releases the cache-do to both preview and production
|
|
||||||
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
|
|
||||||
name: Release Preview
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repo
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: Setup bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
env:
|
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
|
||||||
- name: Release preview packages
|
|
||||||
run: bun run release:preview
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Env files
|
# Env files
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
.next
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# Generated
|
|
||||||
packages/emoji-codepoints/index.ts
|
|
||||||
packages/gitbook/public/~gitbook/static/
|
|
||||||
packages/icons/src/data/*.json
|
|
||||||
|
|
||||||
# Build files
|
|
||||||
dist/
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 4
|
|
||||||
}
|
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["biomejs.biome"]
|
||||||
|
}
|
||||||
Vendored
+11
-1
@@ -7,5 +7,15 @@
|
|||||||
["style \\=([^;]*);", "\"([^\"]*)\""],
|
["style \\=([^;]*);", "\"([^\"]*)\""],
|
||||||
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
|
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
|
||||||
],
|
],
|
||||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"]
|
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||||
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports.biome": "explicit",
|
||||||
|
"source.fixAll.biome": "explicit"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<h1 align="center">GitBook</h1>
|
<h1 align="center">GitBook</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://docs.gitbook.com/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
|
<a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a> - <a href="https://github.com/orgs/GitbookIO/discussions/categories/feature-requests">Feature requests</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (Version: >=18.x)
|
- Node.js (Version: >=20.6)
|
||||||
- Bun
|
- Use nvm for easy Node management
|
||||||
|
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||||
|
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||||
|
|
||||||
### Set up
|
### Set up
|
||||||
|
|
||||||
@@ -60,24 +62,39 @@ bun install
|
|||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/url`.
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
|
|
||||||
- http://localhost:3000/docs.gitbook.com
|
- http://localhost:3000/url/gitbook.com/docs
|
||||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
- http://localhost:3000/url/open-source.gitbook.io/midjourney
|
||||||
|
|
||||||
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
||||||
|
|
||||||
### Other development commands
|
|
||||||
|
|
||||||
- `bun format`: format the code
|
|
||||||
- `bun lint`: lint the code
|
|
||||||
|
|
||||||
### CI and testing
|
### CI and testing
|
||||||
|
|
||||||
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
All pull-requests will be tested against both visual and performances testing to prevent regressions.
|
||||||
|
|
||||||
|
## Fonts and Icons
|
||||||
|
|
||||||
|
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
|
||||||
|
|
||||||
|
```
|
||||||
|
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
|
||||||
|
```
|
||||||
|
|
||||||
|
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
|
||||||
|
|
||||||
|
If you are GitBook staff, you'll need our NPM token in your local environment.
|
||||||
|
|
||||||
|
```
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
NPM_TOKEN_READONLY=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
and then reinstall dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||||
@@ -128,11 +145,11 @@ See `LICENSE` for more information.
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
```md
|
```md
|
||||||
[](https://gitbook.com/)
|
[](https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link)
|
||||||
```
|
```
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a href="https://gitbook.com">
|
<a href="https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link">
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"ignore": [
|
||||||
|
"**/node_modules/**/*",
|
||||||
|
"**/dist/**/*",
|
||||||
|
"**/build/**/*",
|
||||||
|
"**/public/**/*",
|
||||||
|
"**/.next/**/*",
|
||||||
|
"**/.open-next/**/*",
|
||||||
|
"**/.turbo/**/*",
|
||||||
|
"**/.vercel/**/*",
|
||||||
|
"**/.cache/**/*",
|
||||||
|
"**/.wrangler/**/*",
|
||||||
|
"packages/embed/standalone/**/*",
|
||||||
|
"packages/openapi-parser/src/fixtures/**/*",
|
||||||
|
"packages/emoji-codepoints/index.ts",
|
||||||
|
"packages/icons/src/data/*.json",
|
||||||
|
"packages/gitbook/worker-configuration.d.ts",
|
||||||
|
"**/*.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"useEditorconfig": true,
|
||||||
|
"formatWithErrors": false,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 100,
|
||||||
|
"attributePosition": "auto",
|
||||||
|
"bracketSpacing": true
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"performance": {
|
||||||
|
"noDelete": "warn"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off",
|
||||||
|
"noUselessFragments": "warn",
|
||||||
|
"noBannedTypes": "warn"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUndeclaredVariables": "error",
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"useArrayLiterals": "error",
|
||||||
|
"useHookAtTopLevel": "error",
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"noVoidElementsWithChildren": "warn",
|
||||||
|
"useJsxKeyInIterable": "warn",
|
||||||
|
"useExhaustiveDependencies": "warn",
|
||||||
|
"noUnknownFunction": "warn"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "warn",
|
||||||
|
"noParameterAssign": "off",
|
||||||
|
"useThrowOnlyError": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": {
|
||||||
|
"level": "warn",
|
||||||
|
"options": {
|
||||||
|
"allow": ["assert", "error", "warn"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noExplicitAny": "warn",
|
||||||
|
"noImplicitAnyLet": "warn",
|
||||||
|
"noConfusingVoidType": "warn",
|
||||||
|
"noControlCharactersInRegex": "warn",
|
||||||
|
"noPrototypeBuiltins": "warn",
|
||||||
|
"noAssignInExpressions": "warn",
|
||||||
|
"noArrayIndexKey": "warn"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useSemanticElements": "warn",
|
||||||
|
"useKeyWithClickEvents": "warn",
|
||||||
|
"noSvgWithoutTitle": "warn",
|
||||||
|
"useButtonType": "warn",
|
||||||
|
"useIframeTitle": "warn",
|
||||||
|
"useAltText": "warn",
|
||||||
|
"noPositiveTabindex": "warn",
|
||||||
|
"useFocusableInteractive": "warn",
|
||||||
|
"useAriaPropsForRole": "warn",
|
||||||
|
"useValidAnchor": "warn",
|
||||||
|
"noLabelWithoutControl": "warn",
|
||||||
|
"noNoninteractiveTabindex": "warn"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"useSortedClasses": {
|
||||||
|
"level": "error",
|
||||||
|
"fix": "safe",
|
||||||
|
"options": {
|
||||||
|
"attributes": ["class", "className", "style"],
|
||||||
|
"functions": ["clsx", "tw"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"trailingCommas": "es5",
|
||||||
|
"semicolons": "always",
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"attributePosition": "auto",
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"packages/gitbook/**/*",
|
||||||
|
"packages/react-openapi/**/*",
|
||||||
|
"packages/react-math/**/*",
|
||||||
|
"packages/react-contentkit/**/*",
|
||||||
|
"packages/icons/**/*"
|
||||||
|
],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["React"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["packages/gitbook/**/*"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["React", "GitBookIntegrationEvent"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["*.css"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["theme"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
|
||||||
|
"javascript": {
|
||||||
|
"globals": ["Bun"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"packages/cache-do/**/*",
|
||||||
|
"packages/gitbook/cf-env.d.ts",
|
||||||
|
"packages/gitbook/src/cloudflare-entrypoint.ts"
|
||||||
|
],
|
||||||
|
"javascript": {
|
||||||
|
"globals": [
|
||||||
|
"DurableObjectLocationHint",
|
||||||
|
"DurableObjectNamespace",
|
||||||
|
"DurableObjectStub",
|
||||||
|
"ContinentCode",
|
||||||
|
"Fetcher",
|
||||||
|
"ExportedHandler"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
[install.scopes]
|
[install.scopes]
|
||||||
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
|
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# Caching
|
|
||||||
|
|
||||||
## Revalidating the cache
|
|
||||||
|
|
||||||
Invalidate cache can be done at two levels using tags:
|
|
||||||
|
|
||||||
- Data fetching cache
|
|
||||||
- Rendering cache
|
|
||||||
|
|
||||||
To invalidate and refetch the data cache, you can execute a POST request to `/~/gitbook/revalidate`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --location --request POST 'https://gitbook/mycompany.com/~gitbook/revalidate' \
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data-raw '{"tags": ["space.id"]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
To invalidate the rendering cache, the implementation mainly depends on the infrastructure serving the content, GitBook outputs a `Cache-Tag` header on every requests. The value of the header is a comma separated list of tags.
|
|
||||||
|
|
||||||
## Purging the cache
|
|
||||||
|
|
||||||
Purging the cache, without revalidating, is done by passing `"purge": true` in the request body.
|
|
||||||
+26
-15
@@ -2,33 +2,44 @@
|
|||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.27.7",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"prettier": "^3.0.3",
|
"@changesets/cli": "^2.27.12",
|
||||||
"turbo": "^2.1.2"
|
"turbo": "^2.5.0",
|
||||||
|
"vercel": "^39.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.1.18",
|
"packageManager": "bun@1.2.15",
|
||||||
"patchedDependencies": {
|
"overrides": {
|
||||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
|
"@codemirror/state": "6.4.1",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"esbuild": "0.24.2"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:cloudflare": "turbo run build:cloudflare",
|
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||||
"lint": "turbo run lint",
|
|
||||||
"lint:fix": "turbo run lint -- --fix",
|
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"format": "prettier ./ --ignore-unknown --write",
|
"format": "biome check --write ./",
|
||||||
"format:check": "prettier ./ --ignore-unknown --list-different",
|
"format:check": "biome check --diagnostic-level=error ./",
|
||||||
"unit": "turbo run unit",
|
"unit": "turbo run unit",
|
||||||
"e2e": "turbo run e2e",
|
"e2e": "turbo run e2e",
|
||||||
|
"e2e-customers": "turbo run e2e-customers",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
|
"changeset-version": "changeset version && bun run format",
|
||||||
"release": "turbo run release && changeset publish",
|
"release": "turbo run release && changeset publish",
|
||||||
"release:preview": "turbo run release:preview",
|
|
||||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||||
"clean": "turbo run clean"
|
"clean": "turbo run clean"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": {
|
||||||
"packages/*"
|
"packages": ["packages/*"],
|
||||||
]
|
"catalog": {
|
||||||
|
"@gitbook/api": "^0.140.0",
|
||||||
|
"bidc": "^0.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||||
|
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# @gitbook/browser-types
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- cbc71a5: First version of the public package for typing script integrations.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 854c448: Custom assistants followup
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/browser-types`
|
||||||
|
|
||||||
|
Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/browser-types",
|
||||||
|
"description": "Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/icons": "workspace:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||||
|
|
||||||
|
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||||
|
|
||||||
|
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||||
|
/**
|
||||||
|
* Confirmation action to be displayed to the user before executing the tool.
|
||||||
|
*/
|
||||||
|
confirmation?: {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the tool is executed.
|
||||||
|
* The input is provided by the AI assistant following the input schema of the tool.
|
||||||
|
*/
|
||||||
|
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookAssistant = {
|
||||||
|
/**
|
||||||
|
* Name of the assistant displayed in the UI.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon of the assistant displayed in the UI.
|
||||||
|
* Any FontAwesome icon name is supported.
|
||||||
|
* @example 'sparkle'
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the assistant is opened.
|
||||||
|
*/
|
||||||
|
open: (query?: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to display the triggers for this assistant in the UI.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
ui?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookGlobal = {
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*/
|
||||||
|
addEventListener: (
|
||||||
|
type: GitBookIntegrationEvent,
|
||||||
|
func: GitBookIntegrationEventCallback
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an event listener.
|
||||||
|
*/
|
||||||
|
removeEventListener: (
|
||||||
|
type: GitBookIntegrationEvent,
|
||||||
|
func: GitBookIntegrationEventCallback
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom tool to be exposed to the AI assistant.
|
||||||
|
*/
|
||||||
|
registerTool: (tool: GitBookIntegrationTool) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom assistant to be available on the site.
|
||||||
|
*/
|
||||||
|
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* Global `window.GitBook` object accessible by integrations.
|
||||||
|
*/
|
||||||
|
GitBook?: GitBookGlobal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.wrangler
|
|
||||||
worker-configuration.d.ts
|
|
||||||
dist/
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# @gitbook/cache-do
|
|
||||||
|
|
||||||
## 0.1.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
|
|
||||||
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 56f5fa1: Enable Workers observability with a sampling of 0.1
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# `@gitbook/cache-do`
|
|
||||||
|
|
||||||
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
|
|
||||||
|
|
||||||
### Performances
|
|
||||||
|
|
||||||
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
|
|
||||||
|
|
||||||
### Geo-distribution
|
|
||||||
|
|
||||||
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
|
|
||||||
|
|
||||||
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
|
|
||||||
|
|
||||||
### Concepts
|
|
||||||
|
|
||||||
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
|
|
||||||
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
|
|
||||||
|
|
||||||
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
|
|
||||||
|
|
||||||
**Location**: cache is distributed over 7 unique locations, one for each continent.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@gitbook/cache-do",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"development": "./src/index.ts",
|
|
||||||
"default": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./api": {
|
|
||||||
"types": "./dist/api.d.ts",
|
|
||||||
"development": "./src/api.ts",
|
|
||||||
"default": "./dist/api.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": "0.1.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
|
||||||
"lru_map": "^0.4.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"wrangler": "3.82.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"generate": "wrangler types --experimental-include-runtime",
|
|
||||||
"build": "tsc",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"dev": "tsc -w",
|
|
||||||
"release": "wrangler deploy",
|
|
||||||
"release:preview": "wrangler deploy && wrangler deploy --env preview"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"src",
|
|
||||||
"bin",
|
|
||||||
"data",
|
|
||||||
"README.md",
|
|
||||||
"CHANGELOG.md"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import { encode, decode } from '@msgpack/msgpack';
|
|
||||||
import { DurableObject } from 'cloudflare:workers';
|
|
||||||
import { LRUMap } from 'lru_map';
|
|
||||||
|
|
||||||
export interface CacheObjectDescriptor {
|
|
||||||
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
|
|
||||||
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Value stored in a chunked binary msgpack format.
|
|
||||||
* Stored under the key `prop.${key}.${index}`.
|
|
||||||
*/
|
|
||||||
interface CacheObjectProp<Value = unknown> {
|
|
||||||
value: Value;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
|
|
||||||
*/
|
|
||||||
interface CacheObjectExp {
|
|
||||||
/** Key of the property */
|
|
||||||
k: string;
|
|
||||||
/** Number of chunks */
|
|
||||||
c: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Durable Object class being deployed as a distributed cache.
|
|
||||||
*/
|
|
||||||
export class CacheObject extends DurableObject {
|
|
||||||
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a descriptor to access the cache object.
|
|
||||||
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
|
|
||||||
* One session is opened per request on the client side and used to perform multiple operations.
|
|
||||||
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
|
|
||||||
*/
|
|
||||||
public open(): CacheObjectDescriptor {
|
|
||||||
return {
|
|
||||||
get: async <Value = unknown>(key: string) => {
|
|
||||||
return this.get<Value>(key);
|
|
||||||
},
|
|
||||||
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
|
|
||||||
await this.set(key, value, expiresAt);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the value of a property.
|
|
||||||
*/
|
|
||||||
public async get<Value = unknown>(key: string) {
|
|
||||||
return this.logOperation({ operation: 'get', key }, async (setLog) => {
|
|
||||||
// Try the memory state first.
|
|
||||||
const memoryEntry = this.lru.get(key);
|
|
||||||
if (memoryEntry) {
|
|
||||||
setLog({ memory: true });
|
|
||||||
setLog({ memoryMatch: !!memoryEntry.match });
|
|
||||||
if (!memoryEntry.match) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpired = memoryEntry.match.expiresAt < Date.now();
|
|
||||||
setLog({ memoryExpired: isExpired });
|
|
||||||
|
|
||||||
if (!isExpired) {
|
|
||||||
return memoryEntry.match.value as Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.getFromStorage<Value>(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the value of a property from the DO storage.
|
|
||||||
*/
|
|
||||||
public async getFromStorage<Value = unknown>(key: string) {
|
|
||||||
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
|
|
||||||
const entries = await this.ctx.storage.list<Uint8Array>({
|
|
||||||
prefix: getStoragePropKey(key),
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
if (entries.size) {
|
|
||||||
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
|
|
||||||
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
|
|
||||||
if (entry && entry.value.expiresAt > Date.now()) {
|
|
||||||
// Found
|
|
||||||
this.lru.set(key, { match: entry.value });
|
|
||||||
return entry.value.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found
|
|
||||||
this.lru.set(key, { match: undefined });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value in the cache object.
|
|
||||||
*/
|
|
||||||
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
|
||||||
return this.logOperation({ operation: 'set', key }, async (setLog) => {
|
|
||||||
const prop: CacheObjectProp<Value> = {
|
|
||||||
value,
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.lru.set(key, { match: prop });
|
|
||||||
await this.ctx.storage.transaction(async (tx) => {
|
|
||||||
const entries = encodeChunks(key, prop);
|
|
||||||
const chunks = Object.keys(entries).length;
|
|
||||||
setLog({ chunks });
|
|
||||||
|
|
||||||
const clockValue: CacheObjectExp = {
|
|
||||||
k: key,
|
|
||||||
c: chunks,
|
|
||||||
};
|
|
||||||
|
|
||||||
await tx.put(getGCClockKey(key, expiresAt), clockValue);
|
|
||||||
await tx.put(entries);
|
|
||||||
|
|
||||||
const currentAlarm = await tx.getAlarm();
|
|
||||||
if (!currentAlarm) {
|
|
||||||
// Set an alarm to garbage collect all entries that have expired in 12h.
|
|
||||||
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purge all keys in the cache object.
|
|
||||||
*/
|
|
||||||
public async purge() {
|
|
||||||
return this.logOperation({ operation: 'purge' }, async (setLog) => {
|
|
||||||
let result = new Set<string>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// List all the keys in the cache object.
|
|
||||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
|
||||||
prefix: 'exp.',
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
setLog({ entries: entries.size });
|
|
||||||
entries.forEach((exp) => {
|
|
||||||
result.add(exp.k);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// If an error occurs, reset the cache object.
|
|
||||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
|
||||||
console.error('Error during purge, resetting the cache object', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.reset();
|
|
||||||
return Array.from(result);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alarm to garbage collect all entries that have expired.
|
|
||||||
*/
|
|
||||||
async alarm() {
|
|
||||||
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
|
|
||||||
try {
|
|
||||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
|
||||||
prefix: 'exp.',
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
setLog({ entries: entries.size });
|
|
||||||
const toDeleteSet = new Set<string>();
|
|
||||||
|
|
||||||
for (const [key, exp] of entries) {
|
|
||||||
const timestamp = parseInt(key.split('.')[1]);
|
|
||||||
if (timestamp < Date.now()) {
|
|
||||||
toDeleteSet.add(key);
|
|
||||||
for (let i = 0; i < exp.c; i++) {
|
|
||||||
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the keys by batch of 128.
|
|
||||||
const toDelete = Array.from(toDeleteSet);
|
|
||||||
setLog({ toDelete: toDelete.length });
|
|
||||||
for (let i = 0; i < toDelete.length; i += 128) {
|
|
||||||
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
|
|
||||||
if (toDelete.length) {
|
|
||||||
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If an error occurs, reset the cache object.
|
|
||||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
|
||||||
console.error('Error during alarm, reset the cache object', error);
|
|
||||||
await this.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the cache object.
|
|
||||||
*/
|
|
||||||
async reset() {
|
|
||||||
return this.logOperation({ operation: 'reset' }, async () => {
|
|
||||||
this.lru.clear();
|
|
||||||
await this.ctx.storage.deleteAll();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Time and log an operation.
|
|
||||||
*/
|
|
||||||
async logOperation<T>(
|
|
||||||
log: Record<string, unknown>,
|
|
||||||
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
|
|
||||||
let update: Record<string, unknown> = {};
|
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
return await fn((arg) => {
|
|
||||||
Object.assign(update, arg);
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
console.log({ ...log, ...update, objectId, duration });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoragePropKey(key: string): string {
|
|
||||||
return `prop.${key}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoragePropChunkKey(key: string, index: number): string {
|
|
||||||
return `${getStoragePropKey(key)}${index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGCClockRootKey(timestamp: number): string {
|
|
||||||
return `exp.${timestamp}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGCClockKey(key: string, expiresAt: number): string {
|
|
||||||
return `${getGCClockRootKey(expiresAt)}${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
|
|
||||||
const buf = encode(value);
|
|
||||||
const entries: Record<string, Uint8Array> = {};
|
|
||||||
const chunks = chunkUint8Array(buf, 128 * 1024);
|
|
||||||
|
|
||||||
for (let index = 0; index < chunks.length; index++) {
|
|
||||||
entries[getStoragePropChunkKey(key, index)] = chunks[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
|
|
||||||
const chunks = Array.from(entries.entries())
|
|
||||||
.map(([key, value]) => {
|
|
||||||
const index = parseInt(key.split('.').pop()!);
|
|
||||||
return [index, value] as const;
|
|
||||||
})
|
|
||||||
.sort(([a], [b]) => a - b)
|
|
||||||
.map(([, value]) => value);
|
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buf = mergeUint8Array(chunks);
|
|
||||||
return { value: decode(buf) as T, size: buf.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
for (let i = 0; i < input.length; i += chunkSize) {
|
|
||||||
chunks.push(input.slice(i, i + chunkSize));
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
|
|
||||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
||||||
const result = new Uint8Array(totalLength);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
result.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { CacheObject, CacheObjectDescriptor } from './CacheObject';
|
|
||||||
|
|
||||||
export type CacheLocationId = ContinentCode;
|
|
||||||
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location hint for the CacheObject durable object.
|
|
||||||
*/
|
|
||||||
const doLocationHints: {
|
|
||||||
[key in CacheLocationId]: DurableObjectLocationHint;
|
|
||||||
} = {
|
|
||||||
AF: 'afr',
|
|
||||||
AS: 'apac',
|
|
||||||
NA: 'wnam',
|
|
||||||
SA: 'sam',
|
|
||||||
AN: 'oc',
|
|
||||||
EU: 'weur',
|
|
||||||
OC: 'oc',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client to access a cache tag.
|
|
||||||
*/
|
|
||||||
export class CacheObjectStub {
|
|
||||||
private stub: DurableObjectStub<CacheObject>;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
/** Binding to the CacheObject durable object */
|
|
||||||
private doNamespace: DurableObjectNamespace<CacheObject>,
|
|
||||||
/** ID of the location to target */
|
|
||||||
private locationId: CacheLocationId,
|
|
||||||
/** Name of the tag */
|
|
||||||
private tag: string,
|
|
||||||
) {
|
|
||||||
const groupId = getCacheObjectIdName(this.locationId, this.tag);
|
|
||||||
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
|
||||||
// Initialize the object with a locaiton hint,
|
|
||||||
// as we might want to purge all locations before the object is created.
|
|
||||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
|
||||||
locationHint: doLocationHints[this.locationId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a descriptor to the cache object.
|
|
||||||
* It can be used to perform multiple operations in a single RPC session.
|
|
||||||
* Ex:
|
|
||||||
* ```ts
|
|
||||||
* using desc = cache.open();
|
|
||||||
* await desc.set('key', 'value', Date.now() + 1000);
|
|
||||||
* await desc.get('key');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async open() {
|
|
||||||
return await this.stub.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from the cache.
|
|
||||||
*/
|
|
||||||
async get<Value = unknown>(key: string) {
|
|
||||||
return (await this.stub.get(key)) as Value | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value in the cache.
|
|
||||||
*/
|
|
||||||
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
|
||||||
return await this.stub.set(key, value, expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purge all keys in the cache tag.
|
|
||||||
*/
|
|
||||||
async purge() {
|
|
||||||
const keys = new Set<string>();
|
|
||||||
await Promise.all(
|
|
||||||
allLocations.map(async (locationId) => {
|
|
||||||
const groupId = getCacheObjectIdName(locationId, this.tag);
|
|
||||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
|
||||||
// Initialize the object with a locaiton hint,
|
|
||||||
// as we might want to purge all locations before the object is created.
|
|
||||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
|
||||||
locationHint: doLocationHints[this.locationId],
|
|
||||||
});
|
|
||||||
const locationkeys = await cacheGroup.purge();
|
|
||||||
locationkeys.forEach((key) => keys.add(key));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
|
|
||||||
return `${locationId}:${tag}`;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './CacheObjectStub';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
||||||
|
|
||||||
export * from './CacheObject';
|
|
||||||
|
|
||||||
export default class Worker extends WorkerEntrypoint {
|
|
||||||
fetch() {
|
|
||||||
return new Response('Hello, world!');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
main = "./src/index.ts"
|
|
||||||
name = "gitbook-open-cache"
|
|
||||||
compatibility_date = "2024-09-02"
|
|
||||||
|
|
||||||
durable_objects.bindings = [
|
|
||||||
{name = "CACHE", class_name = "CacheObject"}
|
|
||||||
]
|
|
||||||
|
|
||||||
migrations = [
|
|
||||||
{tag = "v1", new_classes = ["CacheObject"]}
|
|
||||||
]
|
|
||||||
|
|
||||||
[observability]
|
|
||||||
enabled = true
|
|
||||||
head_sampling_rate = 0.01
|
|
||||||
|
|
||||||
[env.preview]
|
|
||||||
name = "gitbook-open-cache-preview"
|
|
||||||
durable_objects.bindings = [
|
|
||||||
{name = "CACHE", class_name = "CacheObject"}
|
|
||||||
]
|
|
||||||
migrations = [
|
|
||||||
{tag = "v1", new_classes = ["CacheObject"]}
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.preview.observability]
|
|
||||||
enabled = true
|
|
||||||
head_sampling_rate = 1
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# @gitbook/cache-tags
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 77397ca: Fix version of @gitbook/api referenced in package.json
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- f32bf1f: Export function `getCacheTagForURL` to easily get the cache tag for a URL.
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 05ffd0e: Initial version of the package
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/cache-tags`
|
||||||
|
|
||||||
|
Utility to generate cache tags for GitBook Open.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/cache-tags",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.3.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"assert-never": "^1.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { ComputedContentSource } from '@gitbook/api';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a stringified cache tag for a given object.
|
||||||
|
*/
|
||||||
|
export function getCacheTag(
|
||||||
|
spec: /**
|
||||||
|
* All data related to a user
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'user';
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a space
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'space';
|
||||||
|
space: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to an integration.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'integration';
|
||||||
|
integration: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a change request
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'change-request';
|
||||||
|
space: string;
|
||||||
|
changeRequest: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Immutable data related to a revision
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'revision';
|
||||||
|
space: string;
|
||||||
|
revision: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Immutable data related to a document
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'document';
|
||||||
|
space: string;
|
||||||
|
document: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Immutable data related to a computed document
|
||||||
|
* @deprecated - in v2, no tag as this is an immutable data
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'computed-document';
|
||||||
|
space: string;
|
||||||
|
sourceType: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to the URL of a content
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'url';
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a site
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'site';
|
||||||
|
site: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to an OpenAPI spec
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'openapi';
|
||||||
|
organization: string;
|
||||||
|
openAPISpec: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a translation
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'translation';
|
||||||
|
organization: string;
|
||||||
|
translation: string;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
switch (spec.tag) {
|
||||||
|
case 'user':
|
||||||
|
return `user:${spec.user}`;
|
||||||
|
case 'url':
|
||||||
|
return `url:${spec.hostname}`;
|
||||||
|
case 'space':
|
||||||
|
return `space:${spec.space}`;
|
||||||
|
case 'change-request':
|
||||||
|
return `space:${spec.space}:change-request:${spec.changeRequest}`;
|
||||||
|
case 'revision':
|
||||||
|
return `space:${spec.space}:revision:${spec.revision}`;
|
||||||
|
case 'document':
|
||||||
|
return `space:${spec.space}:document:${spec.document}`;
|
||||||
|
case 'computed-document':
|
||||||
|
return `space:${spec.space}:computed-document:${spec.sourceType}`;
|
||||||
|
case 'site':
|
||||||
|
return `site:${spec.site}`;
|
||||||
|
case 'integration':
|
||||||
|
return `integration:${spec.integration}`;
|
||||||
|
case 'openapi':
|
||||||
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
|
case 'translation':
|
||||||
|
return `organization:${spec.organization}:translation:${spec.translation}`;
|
||||||
|
default:
|
||||||
|
assertNever(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags for a computed content source.
|
||||||
|
*/
|
||||||
|
export function getComputedContentSourceCacheTags(
|
||||||
|
inContext: {
|
||||||
|
spaceId: string;
|
||||||
|
organizationId: string;
|
||||||
|
},
|
||||||
|
source: ComputedContentSource
|
||||||
|
) {
|
||||||
|
const tags: string[] = [];
|
||||||
|
|
||||||
|
if (!('dependencies' in source)) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
||||||
|
// when the dependencies are updated.
|
||||||
|
const dependencies = Object.values(source.dependencies ?? {});
|
||||||
|
if (dependencies.length > 0) {
|
||||||
|
dependencies.forEach((dependency) => {
|
||||||
|
switch (dependency.ref.kind) {
|
||||||
|
case 'space':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'space',
|
||||||
|
space: dependency.ref.space,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'openapi':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'openapi',
|
||||||
|
organization: inContext.organizationId,
|
||||||
|
openAPISpec: dependency.ref.spec,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'translation':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'translation',
|
||||||
|
organization: inContext.organizationId,
|
||||||
|
translation: dependency.ref.translation,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Do not throw for unknown dependency types
|
||||||
|
// as it might mean we are lagging behind the API version
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Push a dummy tag, as the v1 is only using the first tag
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'computed-document',
|
||||||
|
space: inContext.spaceId,
|
||||||
|
sourceType: source.type,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We invalidate the computed content when a new version of the integration is deployed.
|
||||||
|
if (source.type.startsWith('integration:')) {
|
||||||
|
const integration = source.type.split(':')[1]!;
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'integration',
|
||||||
|
integration,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 17dd382: Add `original` background color step
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 193d591: Fix return type for `colorContrast`
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- c3f6b8c: Update chroma ratio per step
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cdffd7c: Desaturate text colors by decreasing chroma for the last steps of the color scale
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fb90eb0: Reduce chroma of first color scale step
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 4f0a772: Override tint lightness if supplied color is out of bounds
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 445baaa: Initial release
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/colors`
|
||||||
|
|
||||||
|
A set of default colors and transformation functions used throughout the GitBook Open and app.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/colors",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.4.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Default primary color throughout the GitBook ecosystem.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PRIMARY_COLOR = '#346DDB';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The darkest color that exists in GitBook, used as the relative minimum of every generated color scale.
|
||||||
|
*/
|
||||||
|
export const DARK_BASE = '#1D1D1D';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lightest color that exists in GitBook, used as the relative maximum of every generated color scale.
|
||||||
|
*/
|
||||||
|
export const LIGHT_BASE = '#FFFFFF';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used as the basis of all UI elements that are not colored by the primary color. Neutral gray by default, overridden by site customization.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_TINT_COLOR = '#787878';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for informational messages and neutral alerts.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_INFO_COLOR = '#787878';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for showing important information or non-critical warnings.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for destructive actions or raising attention to critical information.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for showing positive actions or achievements.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './colors';
|
||||||
|
export * from './transformations';
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { DARK_BASE, DEFAULT_TINT_COLOR, LIGHT_BASE } from './colors';
|
||||||
|
|
||||||
|
type ColorShades = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RGBColor = [number, number, number];
|
||||||
|
type OKLABColor = { L: number; A: number; B: number };
|
||||||
|
type OKLCHColor = { L: number; C: number; H: number };
|
||||||
|
|
||||||
|
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
|
||||||
|
|
||||||
|
export enum ColorCategory {
|
||||||
|
backgrounds = 'backgrounds',
|
||||||
|
components = 'components',
|
||||||
|
borders = 'borders',
|
||||||
|
accents = 'accents',
|
||||||
|
text = 'text',
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorSubScale = {
|
||||||
|
[key: string]: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main color scale object.
|
||||||
|
*
|
||||||
|
* Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation.
|
||||||
|
* Each subitem maps a semantic name within that category to a step in the scale.
|
||||||
|
*/
|
||||||
|
export const scale: Record<ColorCategory, ColorSubScale> = {
|
||||||
|
[ColorCategory.backgrounds]: {
|
||||||
|
/** Base background */
|
||||||
|
base: 1,
|
||||||
|
/** Accent background */
|
||||||
|
subtle: 2,
|
||||||
|
},
|
||||||
|
[ColorCategory.components]: {
|
||||||
|
/** Component background */
|
||||||
|
DEFAULT: 3,
|
||||||
|
/** Component hover background */
|
||||||
|
hover: 4,
|
||||||
|
/** Component active background */
|
||||||
|
active: 5,
|
||||||
|
},
|
||||||
|
[ColorCategory.borders]: {
|
||||||
|
/** Subtle borders, separators */
|
||||||
|
subtle: 6,
|
||||||
|
/** Element border, focus rings */
|
||||||
|
DEFAULT: 7,
|
||||||
|
/** Element hover border */
|
||||||
|
hover: 8,
|
||||||
|
},
|
||||||
|
[ColorCategory.accents]: {
|
||||||
|
/** Solid backgrounds */
|
||||||
|
solid: 9,
|
||||||
|
/** Hovered solid backgrounds */
|
||||||
|
'solid-hover': 10,
|
||||||
|
/** Original color */
|
||||||
|
original: 'original',
|
||||||
|
},
|
||||||
|
[ColorCategory.text]: {
|
||||||
|
/** Very low-contrast text
|
||||||
|
* Caution: this contrast does not meet accessiblity guidelines.
|
||||||
|
* Always check if you need to include a mitigating contrast-more style for users who need it. */
|
||||||
|
subtle: 9,
|
||||||
|
/** Low-contrast text */
|
||||||
|
DEFAULT: 11,
|
||||||
|
/** High-contrast text */
|
||||||
|
strong: 12,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mix of foreground and background for every step in a colour scale.
|
||||||
|
* 0: 100% of the background color's luminosity, white in light mode
|
||||||
|
* 1: 100% of the foreground color's luminosity, black in light mode
|
||||||
|
*/
|
||||||
|
export const colorMixMapping = {
|
||||||
|
// bgs |components |borders |solid |text
|
||||||
|
light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1],
|
||||||
|
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex color to an RGB color.
|
||||||
|
*/
|
||||||
|
export function hexToRgb(hex: string): string {
|
||||||
|
const [r, g, b] = hexToRgbArray(hex);
|
||||||
|
// Return the RGB values separated by spaces
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex color to a RGBA color.
|
||||||
|
*/
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const [r, g, b] = hexToRgbArray(hex);
|
||||||
|
// Return the RGBA values separated by spaces
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Tailwind-compatible shades from a single color
|
||||||
|
* @param {string} hex The hex code to generate shades from
|
||||||
|
* @param {boolean} halfShades Generate additional shades, e.g. at 150
|
||||||
|
* @returns {{[key: number]: string}}
|
||||||
|
*/
|
||||||
|
export function shadesOfColor(hex: string, halfShades = false) {
|
||||||
|
const baseColor = hex;
|
||||||
|
|
||||||
|
const shades = [
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
200,
|
||||||
|
300,
|
||||||
|
400,
|
||||||
|
500,
|
||||||
|
600,
|
||||||
|
700,
|
||||||
|
800,
|
||||||
|
900,
|
||||||
|
...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []),
|
||||||
|
].sort();
|
||||||
|
|
||||||
|
const result: ColorShades = {};
|
||||||
|
|
||||||
|
for (const shade of shades) {
|
||||||
|
const key = shade.toString();
|
||||||
|
|
||||||
|
if (shade === 500) {
|
||||||
|
result[key] = hex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shadeIndex = shade;
|
||||||
|
const isDarkShade = shadeIndex > 500;
|
||||||
|
if (isDarkShade) {
|
||||||
|
shadeIndex -= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = shadeIndex / 500;
|
||||||
|
const startColor = isDarkShade ? DARK_BASE : baseColor;
|
||||||
|
const endColor = isDarkShade ? baseColor : LIGHT_BASE;
|
||||||
|
|
||||||
|
result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorScaleOptions = {
|
||||||
|
/** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */
|
||||||
|
darkMode?: boolean;
|
||||||
|
|
||||||
|
/** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
|
||||||
|
background?: string;
|
||||||
|
|
||||||
|
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
|
||||||
|
foreground?: string;
|
||||||
|
|
||||||
|
mix?: {
|
||||||
|
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
/** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */
|
||||||
|
ratio: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour.
|
||||||
|
* @param {string} hex The hex code to generate shades from
|
||||||
|
* @param {object} options
|
||||||
|
*/
|
||||||
|
export function colorScale(
|
||||||
|
hex: string,
|
||||||
|
{
|
||||||
|
darkMode = false,
|
||||||
|
background = darkMode ? DARK_BASE : LIGHT_BASE,
|
||||||
|
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
|
||||||
|
mix,
|
||||||
|
}: ColorScaleOptions = {}
|
||||||
|
) {
|
||||||
|
const baseColor = rgbToOklch(hexToRgbArray(hex));
|
||||||
|
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
|
||||||
|
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
|
||||||
|
const backgroundColor = rgbToOklch(hexToRgbArray(background));
|
||||||
|
let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
|
||||||
|
|
||||||
|
if (mixColor && mix?.ratio && mix.ratio > 0) {
|
||||||
|
// If defined, we mix in a (tiny) bit of the mix color with the base color.
|
||||||
|
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
|
||||||
|
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
|
||||||
|
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(darkMode && baseColor.L < backgroundColor.L) ||
|
||||||
|
(!darkMode && baseColor.L > backgroundColor.L)
|
||||||
|
) {
|
||||||
|
// If the supplied color is outside of our lightness bounds, use the supplied color's lightness.
|
||||||
|
// This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look.
|
||||||
|
const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L;
|
||||||
|
backgroundColor.L = baseColor.L;
|
||||||
|
// At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out.
|
||||||
|
const amplifier = 1;
|
||||||
|
mapping = mapping.map((step, index) =>
|
||||||
|
index < 9 ? step + step * amplifier * difference : step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < mapping.length; index++) {
|
||||||
|
const step = mapping[index]!;
|
||||||
|
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||||
|
|
||||||
|
if (
|
||||||
|
index === 8 &&
|
||||||
|
!mix &&
|
||||||
|
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
|
||||||
|
) {
|
||||||
|
// Original colour is close enough to target, so let's use the original colour as step 9.
|
||||||
|
result.push(hex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chromaRatio = (() => {
|
||||||
|
switch (index) {
|
||||||
|
// Step 9 and 10 have max chroma, meaning they are fully saturated.
|
||||||
|
case 8:
|
||||||
|
case 9:
|
||||||
|
return 1;
|
||||||
|
// Step 11 and 12 have a reduced chroma
|
||||||
|
case 10:
|
||||||
|
return 0.4;
|
||||||
|
case 11:
|
||||||
|
return 0.1;
|
||||||
|
default:
|
||||||
|
return index * 0.05;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const shade = {
|
||||||
|
L: targetL, // Blend lightness
|
||||||
|
C: baseColor.C * chromaRatio,
|
||||||
|
H: baseColor.H, // Maintain the hue from the base color
|
||||||
|
};
|
||||||
|
|
||||||
|
const newHex = rgbArrayToHex(oklchToRgb(shade));
|
||||||
|
|
||||||
|
result.push(newHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex color to an RGB color set.
|
||||||
|
*/
|
||||||
|
export function hexToRgbArray(hex: string): RGBColor {
|
||||||
|
const originalHex = hex;
|
||||||
|
|
||||||
|
let value = hex.replace('#', '');
|
||||||
|
if (hex.length === 3) value = value + value;
|
||||||
|
|
||||||
|
const r = value.substring(0, 2);
|
||||||
|
const g = value.substring(2, 4);
|
||||||
|
const b = value.substring(4, 6);
|
||||||
|
|
||||||
|
const rgb = [r, g, b].map((channel) => {
|
||||||
|
try {
|
||||||
|
const channelInt = Number.parseInt(channel, 16);
|
||||||
|
if (channelInt < 0 || channelInt > 255) throw new Error();
|
||||||
|
return channelInt;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid hex color provided: ${originalHex}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rgb as RGBColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a RGB color set to a hex color.
|
||||||
|
*/
|
||||||
|
export function rgbArrayToHex(rgb: RGBColor): string {
|
||||||
|
return `#${rgb
|
||||||
|
.map((channel) => {
|
||||||
|
const component = channel.toString(16);
|
||||||
|
if (component.length === 1) return `0${component}`;
|
||||||
|
return component;
|
||||||
|
})
|
||||||
|
.join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
|
||||||
|
const rgb = end.map((channel, index) => {
|
||||||
|
return Math.round(channel + percentage * (start[index]! - channel));
|
||||||
|
});
|
||||||
|
|
||||||
|
return rgbArrayToHex(rgb as RGBColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility constants and helper functions
|
||||||
|
export function rgbToLinear(rgb: RGBColor): [number, number, number] {
|
||||||
|
return rgb.map((v) => {
|
||||||
|
const scaled = v / 255;
|
||||||
|
return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
|
||||||
|
}) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linearToRgb(linear: [number, number, number]): RGBColor {
|
||||||
|
return linear.map((v) => {
|
||||||
|
const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
||||||
|
return Math.round(Math.max(0, Math.min(1, scaled)) * 255);
|
||||||
|
}) as RGBColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToOklab(rgb: RGBColor): OKLABColor {
|
||||||
|
const [r, g, b] = rgbToLinear(rgb);
|
||||||
|
|
||||||
|
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
||||||
|
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
||||||
|
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
||||||
|
|
||||||
|
const lRoot = Math.cbrt(l);
|
||||||
|
const mRoot = Math.cbrt(m);
|
||||||
|
const sRoot = Math.cbrt(s);
|
||||||
|
|
||||||
|
return {
|
||||||
|
L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
|
||||||
|
A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
|
||||||
|
B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklabToRgb(oklab: OKLABColor): RGBColor {
|
||||||
|
const { L, A, B } = oklab;
|
||||||
|
|
||||||
|
const lRoot = L + 0.3963377774 * A + 0.2158037573 * B;
|
||||||
|
const mRoot = L - 0.1055613458 * A - 0.0638541728 * B;
|
||||||
|
const sRoot = L - 0.0894841775 * A - 1.291485548 * B;
|
||||||
|
|
||||||
|
const l = lRoot ** 3;
|
||||||
|
const m = mRoot ** 3;
|
||||||
|
const s = sRoot ** 3;
|
||||||
|
|
||||||
|
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||||
|
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||||
|
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
||||||
|
|
||||||
|
return linearToRgb([r, g, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklabToOklch(oklab: OKLABColor): OKLCHColor {
|
||||||
|
const { L, A, B } = oklab;
|
||||||
|
const C = Math.sqrt(A ** 2 + B ** 2);
|
||||||
|
const H = (Math.atan2(B, A) * 180) / Math.PI;
|
||||||
|
return { L, C, H: H < 0 ? H + 360 : H };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklchToOklab(oklch: OKLCHColor): OKLABColor {
|
||||||
|
const { L, C, H } = oklch;
|
||||||
|
const rad = (H * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
L,
|
||||||
|
A: C * Math.cos(rad),
|
||||||
|
B: C * Math.sin(rad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToOklch(rgb: RGBColor): OKLCHColor {
|
||||||
|
return oklabToOklch(rgbToOklab(rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oklchToRgb(oklch: OKLCHColor): RGBColor {
|
||||||
|
return oklabToRgb(oklchToOklab(oklch));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbToXyz(rgb: RGBColor): [number, number, number] {
|
||||||
|
const [r, g, b] = rgbToLinear(rgb);
|
||||||
|
return [
|
||||||
|
(r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
|
||||||
|
(r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100,
|
||||||
|
(r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xyzToLab65(xyz: [number, number, number]): {
|
||||||
|
L: number;
|
||||||
|
A: number;
|
||||||
|
B: number;
|
||||||
|
} {
|
||||||
|
const [x, y, z] = xyz.map((v, i) => {
|
||||||
|
const scaled = v / D65[i]!;
|
||||||
|
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
L: 116 * y! - 16,
|
||||||
|
A: 500 * (x! - y!),
|
||||||
|
B: 200 * (y! - z!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } {
|
||||||
|
return xyzToLab65(rgbToXyz(rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Delta Phi Star perceptual lightness contrast by Andrew Somers:
|
||||||
|
https://github.com/Myndex/deltaphistar
|
||||||
|
*/
|
||||||
|
export const PHI = 0.5 + Math.sqrt(1.25);
|
||||||
|
|
||||||
|
export function dpsContrast(a: RGBColor, b: RGBColor) {
|
||||||
|
const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI);
|
||||||
|
const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40;
|
||||||
|
return contrast < 7.5 ? 0 : contrast;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorContrast(
|
||||||
|
background: string,
|
||||||
|
foreground: string[] = [LIGHT_BASE, DARK_BASE]
|
||||||
|
): string {
|
||||||
|
const bg = hexToRgbArray(background);
|
||||||
|
|
||||||
|
const best: { color?: RGBColor; contrast: number } = {
|
||||||
|
color: undefined,
|
||||||
|
contrast: 0,
|
||||||
|
};
|
||||||
|
for (const color of foreground) {
|
||||||
|
const c = hexToRgbArray(color);
|
||||||
|
|
||||||
|
const contrast = dpsContrast(c, bg);
|
||||||
|
if (contrast > best.contrast) {
|
||||||
|
best.color = c;
|
||||||
|
best.contrast = contrast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
standalone/
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# @gitbook/embed
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6f368b5: Fix embed assistant window width on small screens
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 81a6bd7: Improve API to control the GitBook embed
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8927e8f: Initial version of the embed SDK.
|
||||||
|
- Updated dependencies [25e2b40]
|
||||||
|
- @gitbook/icons@0.3.0
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# `@gitbook/embed`
|
||||||
|
|
||||||
|
Embed the GitBook Docs Assistant in your product or website.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
## As a script from your docs site
|
||||||
|
|
||||||
|
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
||||||
|
|
||||||
|
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||||
|
|
||||||
|
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||||
|
<script>
|
||||||
|
window.GitBook('show');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## As a package from NPM
|
||||||
|
|
||||||
|
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createGitBook } from '@gitbook/embed';
|
||||||
|
|
||||||
|
const gitbook = createGitBook({
|
||||||
|
siteURL: 'https://docs.company.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = gitbook.getFrameURL();
|
||||||
|
|
||||||
|
const frame = gitbook.createFrame(iframe);
|
||||||
|
```
|
||||||
|
|
||||||
|
## As React components
|
||||||
|
|
||||||
|
After installing the NPM package, you can import prebuilt React components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||||
|
|
||||||
|
<GitBookProvider siteURL="https://docs.company.com">
|
||||||
|
<GitBookAssistantFrame />
|
||||||
|
</GitBookProvider>
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/embed",
|
||||||
|
"description": "Embeddable components for GitBook",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js",
|
||||||
|
"standalone": "./dist/standalone/index.js",
|
||||||
|
"react": "./dist/react/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/icons": "workspace:",
|
||||||
|
"bidc": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"react": "^19.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { type GitBookFrameClient, createGitBookFrame } from './createGitBookFrame';
|
||||||
|
|
||||||
|
export type CreateGitBookOptions = {
|
||||||
|
/**
|
||||||
|
* URL of the GitBook site to embed.
|
||||||
|
*/
|
||||||
|
siteURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetFrameURLOptions = {
|
||||||
|
/**
|
||||||
|
* Authentication to use for the frame.
|
||||||
|
*/
|
||||||
|
visitor?: {
|
||||||
|
/**
|
||||||
|
* Signed JWT token for Adaptive Content or Visitor Authentication to use.
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsigned claims to pass to the frame.
|
||||||
|
* You can use these claims in dynamic expressions using `visitor.claims.unsigned.<claim-name>`.
|
||||||
|
*/
|
||||||
|
unsignedClaims?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitBookClient = {
|
||||||
|
/**
|
||||||
|
* Get the URL for a GitBook frame.
|
||||||
|
*/
|
||||||
|
getFrameURL: (options: GetFrameURLOptions) => string;
|
||||||
|
/**
|
||||||
|
* Create a new GitBook frame.
|
||||||
|
*/
|
||||||
|
createFrame: (iframe: HTMLIFrameElement) => GitBookFrameClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createGitBook(options: CreateGitBookOptions) {
|
||||||
|
const client: GitBookClient = {
|
||||||
|
getFrameURL: (frameOptions) => {
|
||||||
|
const url = new URL(options.siteURL);
|
||||||
|
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||||
|
|
||||||
|
if (frameOptions.visitor?.token) {
|
||||||
|
url.searchParams.set('token', frameOptions.visitor.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameOptions.visitor?.unsignedClaims) {
|
||||||
|
Object.entries(frameOptions.visitor.unsignedClaims).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(`visitor.${key}`, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
createFrame: (iframe) => createGitBookFrame(iframe),
|
||||||
|
};
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createChannel } from 'bidc';
|
||||||
|
import type {
|
||||||
|
FrameToParentMessage,
|
||||||
|
GitBookEmbeddableConfiguration,
|
||||||
|
ParentToFrameMessage,
|
||||||
|
} from './protocol';
|
||||||
|
|
||||||
|
export type GitBookFrameClient = {
|
||||||
|
/**
|
||||||
|
* Navigate to a page by its path.
|
||||||
|
*/
|
||||||
|
navigateToPage: (path: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the assistant.
|
||||||
|
*/
|
||||||
|
navigateToAssistant: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a message to the chat.
|
||||||
|
*/
|
||||||
|
postUserMessage: (message: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the chat.
|
||||||
|
*/
|
||||||
|
clearChat: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the placeholder settings.
|
||||||
|
*/
|
||||||
|
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*/
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a client to communicate with the GitBook Assistant frame.
|
||||||
|
*/
|
||||||
|
export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClient {
|
||||||
|
if (!iframe.contentWindow) {
|
||||||
|
throw new Error('Iframe must have a content window');
|
||||||
|
}
|
||||||
|
const channel = createChannel(iframe.contentWindow);
|
||||||
|
|
||||||
|
channel.receive((message: FrameToParentMessage) => {
|
||||||
|
console.log('[gitbook:embed] received message', message);
|
||||||
|
if (message.type === 'close') {
|
||||||
|
const listeners = events.get('close') || [];
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendToFrame = (message: ParentToFrameMessage) => {
|
||||||
|
console.log('[gitbook:embed] send message', message);
|
||||||
|
channel.send(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
|
const configuration: GitBookEmbeddableConfiguration = {
|
||||||
|
buttons: [],
|
||||||
|
welcomeMessage: '',
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToPage: (pagePath) => {
|
||||||
|
sendToFrame({ type: 'navigateToPage', pagePath });
|
||||||
|
},
|
||||||
|
navigateToAssistant: () => {
|
||||||
|
sendToFrame({ type: 'navigateToAssistant' });
|
||||||
|
},
|
||||||
|
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
|
||||||
|
configure: (settings) => {
|
||||||
|
Object.assign(configuration, settings);
|
||||||
|
sendToFrame({ type: 'configure', settings: configuration });
|
||||||
|
},
|
||||||
|
clearChat: () => sendToFrame({ type: 'clearChat' }),
|
||||||
|
on: (event, listener) => {
|
||||||
|
const listeners = events.get(event) || [];
|
||||||
|
listeners.push(listener);
|
||||||
|
events.set(event, listeners);
|
||||||
|
return () => {
|
||||||
|
events.set(
|
||||||
|
event,
|
||||||
|
listeners.filter((l) => l !== listener)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './createGitBook';
|
||||||
|
export * from './createGitBookFrame';
|
||||||
|
export * from './protocol';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tool definition to be passed to the AI assistant.
|
||||||
|
*/
|
||||||
|
export type GitBookToolDefinition = AIToolDefinition & {
|
||||||
|
/**
|
||||||
|
* Confirmation action to be displayed to the user before executing the tool.
|
||||||
|
*/
|
||||||
|
confirmation?: {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the tool is executed.
|
||||||
|
* The input is provided by the AI assistant following the input schema of the tool.
|
||||||
|
*/
|
||||||
|
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom button definition to be passed to the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
export type GitBookEmbeddableButtonDefinition = {
|
||||||
|
/**
|
||||||
|
* Icon to be displayed in the button.
|
||||||
|
*/
|
||||||
|
icon: IconName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label to be displayed in the button.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the button is clicked.
|
||||||
|
*/
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall configuration for the layout of the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
export type GitBookEmbeddableConfiguration = {
|
||||||
|
/**
|
||||||
|
* Buttons to be displayed in the header of the embeddable GitBook.
|
||||||
|
*/
|
||||||
|
buttons: GitBookEmbeddableButtonDefinition[];
|
||||||
|
|
||||||
|
/** Message to be displayed in the welcome page. */
|
||||||
|
welcomeMessage: string;
|
||||||
|
|
||||||
|
/** Suggestions of questions to be displayed in the welcome page. */
|
||||||
|
suggestions: string[];
|
||||||
|
|
||||||
|
/** Tools to be provided to the assistant. */
|
||||||
|
tools: GitBookToolDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages sent from the parent to the frame.
|
||||||
|
*/
|
||||||
|
export type ParentToFrameMessage =
|
||||||
|
| {
|
||||||
|
type: 'postUserMessage';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'clearChat';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'configure';
|
||||||
|
settings: GitBookEmbeddableConfiguration;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigateToPage';
|
||||||
|
pagePath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigateToAssistant';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages sent from the frame to the parent.
|
||||||
|
*/
|
||||||
|
export type FrameToParentMessage = {
|
||||||
|
type: 'close';
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './client';
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type {
|
||||||
|
GetFrameURLOptions,
|
||||||
|
GitBookEmbeddableConfiguration,
|
||||||
|
GitBookFrameClient,
|
||||||
|
} from '../client';
|
||||||
|
import { useGitBook } from './GitBookProvider';
|
||||||
|
|
||||||
|
export type GitBookFrameProps = {
|
||||||
|
className?: string;
|
||||||
|
} & GetFrameURLOptions &
|
||||||
|
GitBookEmbeddableConfiguration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a frame with the GitBook Assistant in it.
|
||||||
|
*/
|
||||||
|
export function GitBookFrame(props: GitBookFrameProps) {
|
||||||
|
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||||
|
|
||||||
|
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
|
const gitbook = useGitBook();
|
||||||
|
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
|
||||||
|
|
||||||
|
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (frameRef.current) {
|
||||||
|
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
||||||
|
}
|
||||||
|
}, [gitbook]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
gitbookFrame?.configure({
|
||||||
|
buttons,
|
||||||
|
welcomeMessage,
|
||||||
|
suggestions,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
title="GitBook"
|
||||||
|
ref={frameRef}
|
||||||
|
src={frameURL}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { type CreateGitBookOptions, createGitBook } from '../client';
|
||||||
|
import { GitBookContext } from './context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for the GitBook client.
|
||||||
|
*/
|
||||||
|
export function GitBookProvider(props: React.PropsWithChildren<CreateGitBookOptions>) {
|
||||||
|
const { siteURL, children } = props;
|
||||||
|
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
siteURL,
|
||||||
|
}),
|
||||||
|
[siteURL]
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = React.useMemo(() => createGitBook(options), [options]);
|
||||||
|
|
||||||
|
return <GitBookContext.Provider value={client}>{children}</GitBookContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the GitBook client.
|
||||||
|
*/
|
||||||
|
export function useGitBook() {
|
||||||
|
const context = React.useContext(GitBookContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('This component must be used within a <GitBookProvider />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { GitBookClient } from '../client';
|
||||||
|
|
||||||
|
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './GitBookProvider';
|
||||||
|
export * from './GitBookFrame';
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import './style.css';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CreateGitBookOptions,
|
||||||
|
type GetFrameURLOptions,
|
||||||
|
type GitBookClient,
|
||||||
|
type GitBookEmbeddableConfiguration,
|
||||||
|
type GitBookFrameClient,
|
||||||
|
createGitBook,
|
||||||
|
} from '../client';
|
||||||
|
|
||||||
|
export type GitBook = () => void;
|
||||||
|
|
||||||
|
type StandaloneCalls =
|
||||||
|
// Initialize the widget
|
||||||
|
| ['init', CreateGitBookOptions, GetFrameURLOptions]
|
||||||
|
// Unload the widget
|
||||||
|
| ['unload']
|
||||||
|
// Show the widget
|
||||||
|
| ['show']
|
||||||
|
// Hide the widget
|
||||||
|
| ['hide']
|
||||||
|
// Open the window
|
||||||
|
| ['open']
|
||||||
|
// Close the window
|
||||||
|
| ['close']
|
||||||
|
// Toggle the window
|
||||||
|
| ['toggle']
|
||||||
|
// Post a user message
|
||||||
|
| ['postUserMessage', string]
|
||||||
|
// Clear the chat
|
||||||
|
| ['clearChat']
|
||||||
|
// Configure the embed
|
||||||
|
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||||
|
// Navigate to a page
|
||||||
|
| ['navigateToPage', string]
|
||||||
|
// Navigate to the assistant
|
||||||
|
| ['navigateToAssistant'];
|
||||||
|
|
||||||
|
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||||
|
q?: StandaloneCalls[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgetButton = document.createElement('button');
|
||||||
|
widgetButton.id = 'gitbook-widget-button';
|
||||||
|
widgetButton.addEventListener('click', () => {
|
||||||
|
GitBook('toggle');
|
||||||
|
});
|
||||||
|
widgetButton.innerHTML = `
|
||||||
|
<span id="gitbook-widget-button-icon"></span>
|
||||||
|
<span id="gitbook-widget-button-label">Ask</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const widgetWindow = document.createElement('div');
|
||||||
|
widgetWindow.id = 'gitbook-widget-window';
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
|
||||||
|
document.body.appendChild(widgetButton);
|
||||||
|
document.body.appendChild(widgetWindow);
|
||||||
|
|
||||||
|
let widgetIframe: HTMLIFrameElement | undefined;
|
||||||
|
let _client: GitBookClient | undefined;
|
||||||
|
let _frame: GitBookFrameClient | undefined;
|
||||||
|
let frameOptions: GetFrameURLOptions | undefined;
|
||||||
|
let frameConfiguration: GitBookEmbeddableConfiguration = {
|
||||||
|
buttons: [],
|
||||||
|
welcomeMessage: '',
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
if (!_client) {
|
||||||
|
throw new Error(
|
||||||
|
'GitBook client not initialized. Call GitBook("init", { siteURL: "..." }) first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIframe() {
|
||||||
|
if (!widgetIframe || !_frame) {
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
widgetIframe?.remove();
|
||||||
|
widgetIframe = document.createElement('iframe');
|
||||||
|
widgetIframe.id = 'gitbook-widget-iframe';
|
||||||
|
widgetIframe.src = client.getFrameURL({
|
||||||
|
...frameOptions,
|
||||||
|
});
|
||||||
|
widgetWindow.appendChild(widgetIframe);
|
||||||
|
|
||||||
|
_frame = client.createFrame(widgetIframe);
|
||||||
|
}
|
||||||
|
return { iframe: widgetIframe, frame: _frame };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GitBook = (...args: StandaloneCalls) => {
|
||||||
|
switch (args[0]) {
|
||||||
|
case 'init':
|
||||||
|
if (_client) {
|
||||||
|
throw new Error(
|
||||||
|
'GitBook client already initialized. Call GitBook("unload") first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_client = createGitBook(args[1]);
|
||||||
|
frameOptions = args[2];
|
||||||
|
break;
|
||||||
|
case 'unload':
|
||||||
|
_client = undefined;
|
||||||
|
_frame = undefined;
|
||||||
|
widgetIframe?.remove();
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
case 'show':
|
||||||
|
widgetButton.classList.remove('hidden');
|
||||||
|
break;
|
||||||
|
case 'hide':
|
||||||
|
widgetButton.classList.add('hidden');
|
||||||
|
break;
|
||||||
|
case 'open':
|
||||||
|
widgetWindow.classList.remove('hidden');
|
||||||
|
widgetButton.classList.add('open');
|
||||||
|
getIframe();
|
||||||
|
break;
|
||||||
|
case 'toggle':
|
||||||
|
widgetWindow.classList.toggle('hidden');
|
||||||
|
widgetButton.classList.toggle('open');
|
||||||
|
getIframe();
|
||||||
|
break;
|
||||||
|
case 'close':
|
||||||
|
widgetWindow.classList.add('hidden');
|
||||||
|
widgetButton.classList.remove('open');
|
||||||
|
break;
|
||||||
|
case 'postUserMessage':
|
||||||
|
getIframe().frame.postUserMessage(args[1]);
|
||||||
|
break;
|
||||||
|
case 'configure':
|
||||||
|
frameConfiguration = {
|
||||||
|
...frameConfiguration,
|
||||||
|
...args[1],
|
||||||
|
};
|
||||||
|
getIframe().frame.configure({
|
||||||
|
...frameConfiguration,
|
||||||
|
buttons: [
|
||||||
|
...frameConfiguration.buttons,
|
||||||
|
|
||||||
|
// Always include a close button
|
||||||
|
{
|
||||||
|
icon: 'close',
|
||||||
|
label: 'Close',
|
||||||
|
onClick: () => {
|
||||||
|
GitBook('close');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'clearChat':
|
||||||
|
getIframe().frame.clearChat();
|
||||||
|
break;
|
||||||
|
case 'navigateToPage':
|
||||||
|
getIframe().frame.navigateToPage(args[1]);
|
||||||
|
break;
|
||||||
|
case 'navigateToAssistant':
|
||||||
|
getIframe().frame.navigateToAssistant();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error - GitBook is not defined in the global scope
|
||||||
|
const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||||
|
|
||||||
|
// @ts-expect-error - GitBook is not defined in the global scope
|
||||||
|
window.GitBook = GitBook;
|
||||||
|
precalls.forEach((call) => GitBook(...call));
|
||||||
|
|
||||||
|
GitBook('configure', {});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
:root {
|
||||||
|
--gitbook-widget-top: 1rem;
|
||||||
|
--gitbook-widget-bottom: 1rem;
|
||||||
|
--gitbook-widget-right: 1rem;
|
||||||
|
--gitbook-widget-left: 1rem;
|
||||||
|
|
||||||
|
--gitbook-widget-button-height: 46px;
|
||||||
|
|
||||||
|
--gitbook-widget-radius: .5rem;
|
||||||
|
--gitbook-widget-text-size: 1rem;
|
||||||
|
--gitbook-widget-text-color: #656973;
|
||||||
|
--gitbook-widget-border-color: #e5e5e5;
|
||||||
|
|
||||||
|
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
|
||||||
|
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
|
||||||
|
--gitbook-widget-background-solid: #FFFFFF;
|
||||||
|
--gitbook-widget-background-solid-hover: #FBFBFB;
|
||||||
|
|
||||||
|
--gitbook-widget-icon-size: 1.25rem;
|
||||||
|
|
||||||
|
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||||
|
--gitbook-widget-window-height: 40rem; /* 640px */
|
||||||
|
--gitbook-widget-window-spacing: .5rem; /* Spacing between the button and the window */
|
||||||
|
--gitbook-widget-window-bottom: calc(var(--gitbook-widget-bottom) + var(--gitbook-widget-button-height) + var(--gitbook-widget-window-spacing));
|
||||||
|
|
||||||
|
--gitbook-widget-transition-duration-fast: 0.2s;
|
||||||
|
--gitbook-widget-transition-duration-slow: 0.5s;
|
||||||
|
--gitbook-widget-easing: cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
#gitbook-widget-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--gitbook-widget-bottom);
|
||||||
|
right: var(--gitbook-widget-right);
|
||||||
|
height: var(--gitbook-widget-button-height);
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
background-color: var(--gitbook-widget-background-translucent);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
|
||||||
|
font-size: var(--gitbook-widget-text-size);
|
||||||
|
color: var(--gitbook-widget-text-color);
|
||||||
|
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||||
|
transition: all var(--gitbook-widget-transition-duration-fast) var(--gitbook-widget-easing);
|
||||||
|
|
||||||
|
cursor:pointer;
|
||||||
|
animation: gitbook-widget-present var(--gitbook-widget-transition-duration-slow) var(--gitbook-widget-easing-bounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
background-color: var(--gitbook-widget-background-translucent-hover);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
#gitbook-widget-button {
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
}
|
||||||
|
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||||
|
background-color: var(--gitbook-widget-background-solid-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.open {
|
||||||
|
padding: .75rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button: Icon */
|
||||||
|
#gitbook-widget-button-icon {
|
||||||
|
width: var(--gitbook-widget-icon-size);
|
||||||
|
height: var(--gitbook-widget-icon-size);
|
||||||
|
mask-image: url("https://static-2v.gitbook.com/~gitbook/static/icons/svgs/custom-icons/gitbook-assistant.svg?v=2");
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||||
|
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button: Label */
|
||||||
|
#gitbook-widget-button.open #gitbook-widget-button-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Window */
|
||||||
|
#gitbook-widget-window {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--gitbook-widget-window-bottom);
|
||||||
|
right: var(--gitbook-widget-right);
|
||||||
|
z-index: 9998;
|
||||||
|
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
|
||||||
|
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||||
|
background-color: var(--gitbook-widget-background-solid);
|
||||||
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
|
border-radius: var(--gitbook-widget-radius);
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition-property: transform, opacity, display;
|
||||||
|
transition-duration: var(--gitbook-widget-transition-duration-slow);
|
||||||
|
transition-timing-function: var(--gitbook-widget-easing-bounce);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
#gitbook-widget-window {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#gitbook-widget-button.hidden) #gitbook-widget-window {
|
||||||
|
bottom: var(--gitbook-widget-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-window.hidden {
|
||||||
|
transition-property: transform, opacity, display;
|
||||||
|
transition-duration: var(--gitbook-widget-transition-duration-fast);
|
||||||
|
transition-timing-function: var(--gitbook-widget-easing);
|
||||||
|
transition-behavior: allow-discrete;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gitbook-widget-present {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,11 @@ Object.entries(emojis).forEach(([key, value]) => {
|
|||||||
if (emoji && key !== emoji) {
|
if (emoji && key !== emoji) {
|
||||||
output[key] = emoji;
|
output[key] = emoji;
|
||||||
} else if (!emoji) {
|
} else if (!emoji) {
|
||||||
console.log('No emoji for', key);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
|
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.resolve(__dirname, 'dist/index.ts'),
|
path.resolve(__dirname, 'dist/index.ts'),
|
||||||
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
|
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# @gitbook/expr
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3548fa6: Fix eval-estree-expr named import
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e1ff17e: Fix bundling of gitbook/expr package
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8ff1e3b: Add support for every/some array methods
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Major Changes
|
||||||
|
|
||||||
|
- ada195d: Publish gitbook/expr package to help evaluate user defined expressions.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/expr`
|
||||||
|
|
||||||
|
Safely evaluate & parse user-defined GitBook expressions.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/expr",
|
||||||
|
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"dependencies": {
|
||||||
|
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"acorn-loose": "8.4.0",
|
||||||
|
"acorn-walk": "^8.3.4",
|
||||||
|
"escodegen": "^2.1.0",
|
||||||
|
"assert-never": "^1.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "^1.1.20",
|
||||||
|
"tsdown": "^0.15.0",
|
||||||
|
"@types/estree": "^1.0.6",
|
||||||
|
"@babel/types": "^7.26.0",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
|
"@types/escodegen": "^0.0.10"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsdown --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"unit": "bun test",
|
||||||
|
"clean": "rm -rf ./dist"
|
||||||
|
},
|
||||||
|
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolType,
|
||||||
|
SymbolsTable,
|
||||||
|
} from '../symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
describe('autocomplete', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const visitorClaimsHelloArraySymbol = SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
});
|
||||||
|
const symbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: visitorClaimsHelloArraySymbol,
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const context = new SymbolsTable(symbols);
|
||||||
|
const SCENARIOS: Array<{
|
||||||
|
expressionWithCursor: string;
|
||||||
|
expectedSuggestions: AutocompleteSuggestions;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
'visitor.claims.role',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.role',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.role.length',
|
||||||
|
'visitor.claims.role.at',
|
||||||
|
'visitor.claims.role.endsWith',
|
||||||
|
'visitor.claims.role.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.ke<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolString({ name: 'key' }),
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.h<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.hello.length',
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...visitorClaimsHelloArraySymbol.methods.map<AutocompleteSymbolSuggestion>(
|
||||||
|
(method) => ({
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: method,
|
||||||
|
ref: `visitor.claims.hello.${method.name}`,
|
||||||
|
parentRef: 'visitor.claims.hello',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FL<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG3',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
ref: 'visitor.claims.flags.FLAG4',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.key <cur>',
|
||||||
|
expectedSuggestions: [...SUPPORTED_BINARY_OPERATORS].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS].filter((op) =>
|
||||||
|
['==', '!=', '===', '!==', '&&', '||'].includes(op.operator)
|
||||||
|
),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 =<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['==', '==='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ==<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '==='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter((op) =>
|
||||||
|
['!=', '!=='].includes(op.operator)
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !=<cur>',
|
||||||
|
expectedSuggestions: SUPPORTED_BINARY_OPERATORS.filter(
|
||||||
|
(op) => op.operator === '!=='
|
||||||
|
).map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == t<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == tr<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == f<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == fa<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == non<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == false<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'reader' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "ad<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'admin' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "admin"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "edit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == editor<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.String, data: 'editor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.role == "editor"<cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello == <cur>',
|
||||||
|
expectedSuggestions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.hello[1] == "test<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: visitorClaimsHelloArraySymbol,
|
||||||
|
matchedLiteralString: '"test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 == true <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS.filter((op) => ['&&', '||'].includes(op.operator)),
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
].map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 !== true && v<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? <cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visit<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
role: SymbolString({
|
||||||
|
name: 'role',
|
||||||
|
enum: ['admin', 'editor', 'reader'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor',
|
||||||
|
parentRef: undefined,
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expressionWithCursor: 'visitor.claims.flags.FLAG1 ? visitor.claims.fl<cur>',
|
||||||
|
expectedSuggestions: [
|
||||||
|
{
|
||||||
|
type: 'symbol',
|
||||||
|
symbol: {
|
||||||
|
definition: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolBoolean({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolBoolean({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolBoolean({ name: 'FLAG3' }),
|
||||||
|
FLAG4: SymbolBoolean({ name: 'FLAG4' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
'visitor.claims.flags.FLAG4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
it.each(SCENARIOS)(
|
||||||
|
'should provide matching suggestion for expression with cursor: $expressionWithCursor',
|
||||||
|
({ expressionWithCursor, expectedSuggestions }) => {
|
||||||
|
const { expression, cursorOffset } = extractCursorPosition(
|
||||||
|
expressionWithCursor,
|
||||||
|
'<cur>'
|
||||||
|
);
|
||||||
|
const { suggestions } = runtime.autocomplete(expression, cursorOffset, context);
|
||||||
|
expect(suggestions).toStrictEqual(expectedSuggestions);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractCursorPosition(
|
||||||
|
expressionWithCursor: string,
|
||||||
|
cursorPlaceholder: string
|
||||||
|
): { expression: string; cursorOffset: number } {
|
||||||
|
const cursorOffset = expressionWithCursor.indexOf(cursorPlaceholder);
|
||||||
|
if (cursorOffset === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Cursor position (${cursorPlaceholder}) not found in the expression string.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = expressionWithCursor.replace(cursorPlaceholder, '');
|
||||||
|
return { expression, cursorOffset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { inferDefaultInputValuesFromObjectJSONSchema } from '../input-values';
|
||||||
|
|
||||||
|
describe('inferDefaultInputValuesFromObjectJSONSchema', () => {
|
||||||
|
it('should infer properly the default input value based on the JSON schema of an object', () => {
|
||||||
|
const defaultInputValues = inferDefaultInputValuesFromObjectJSONSchema({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isAlphaUser: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['enumValue1', 'enumValue2', 'enumValue3'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(defaultInputValues).toMatchObject({
|
||||||
|
claims: {
|
||||||
|
key: 'default',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'default',
|
||||||
|
FLAG2: 'default',
|
||||||
|
FLAG3: 'default',
|
||||||
|
},
|
||||||
|
isAlphaUser: true,
|
||||||
|
hello: 'enumValue1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionError } from '../errors';
|
||||||
|
import { ExpressionRuntime } from '../runtime';
|
||||||
|
import type { Logger } from '../types';
|
||||||
|
|
||||||
|
const SILENT_LOGGER: Logger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const runtime = new ExpressionRuntime(SILENT_LOGGER);
|
||||||
|
|
||||||
|
describe('evaluate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
inputs: { isBetaUser: false },
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
inputs: {
|
||||||
|
useProductA: true,
|
||||||
|
isBetaUser: false,
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
inputs: {
|
||||||
|
products: ['productA', 'productB'],
|
||||||
|
userSegments: {
|
||||||
|
alpha: true,
|
||||||
|
beta: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'array method',
|
||||||
|
condition: 'reviews.every(review => !!review.status)',
|
||||||
|
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'array every',
|
||||||
|
condition: 'reviews.every(review => review.status === "approved")',
|
||||||
|
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'array map',
|
||||||
|
condition: '[1, 2, 3].map(n => n * x)',
|
||||||
|
inputs: { x: 2 },
|
||||||
|
expectedResult: [2, 4, 6],
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||||
|
({ condition, inputs, expectedResult }) => {
|
||||||
|
expect(runtime.evaluate(condition, inputs)).toEqual(expectedResult);
|
||||||
|
expect(runtime.safeEvaluate(condition, inputs).value).toEqual(expectedResult);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const INVALID_EXPRESSSIONS = [
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: 'while (1) {}',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'unsafe expression',
|
||||||
|
condition: '[1, 2, 3].map(() => { while (1) {}})',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should return an object with the error for non conditional expression or syntax errors when using safeEvaluate is on (default): $scenario',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
const result = runtime.safeEvaluate(condition, inputs);
|
||||||
|
expect(result.value).toBeUndefined();
|
||||||
|
expect(result.error instanceof ExpressionError).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(INVALID_EXPRESSSIONS)(
|
||||||
|
'should throw an error when using evaluate with invalid expressions',
|
||||||
|
({ condition, inputs }) => {
|
||||||
|
expect(() => runtime.evaluate(condition, inputs)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse', () => {
|
||||||
|
it('should produce a valid ESTree compatible AST node for conditional expressions', () => {
|
||||||
|
const ast = runtime.parse('isBetaUser === true');
|
||||||
|
|
||||||
|
expect(ast.result).toEqual({
|
||||||
|
type: 'BinaryExpression',
|
||||||
|
start: 0,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 19 } },
|
||||||
|
left: {
|
||||||
|
type: 'Identifier',
|
||||||
|
start: 0,
|
||||||
|
end: 10,
|
||||||
|
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
|
||||||
|
name: 'isBetaUser',
|
||||||
|
},
|
||||||
|
operator: '===',
|
||||||
|
right: {
|
||||||
|
type: 'Literal',
|
||||||
|
start: 15,
|
||||||
|
end: 19,
|
||||||
|
loc: { start: { line: 1, column: 15 }, end: { line: 1, column: 19 } },
|
||||||
|
value: true,
|
||||||
|
raw: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'invalid syntax',
|
||||||
|
condition: 't}=d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'non conditional expression',
|
||||||
|
condition: 'const a = 1;',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should throw an error for non conditional expressions or syntax errors: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
expect(() => runtime.parse(condition)).toThrowError(ExpressionError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip('generate', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'simple condition',
|
||||||
|
condition: 'isBetaUser === true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'simple condition with multiple inputs variables',
|
||||||
|
condition: 'useProductA && !isBetaUser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'condition with objects in inputs variables',
|
||||||
|
condition: 'products.includes("productA") && userSegments.alpha',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should produce the original expression using an AST node produced by parse: $scenario',
|
||||||
|
({ condition }) => {
|
||||||
|
const { result } = runtime.parse(condition);
|
||||||
|
expect(runtime.generate(result)).toStrictEqual(condition);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { ExpressionRuntime, parseTemplate } from '../';
|
||||||
|
|
||||||
|
describe('template expressions', () => {
|
||||||
|
it('should parse template into parts', () => {
|
||||||
|
const parts = parseTemplate('Hello {{ user.name }}!');
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ type: 'text', value: 'Hello ', start: 0, end: 6 },
|
||||||
|
{ type: 'expression', value: 'user.name', start: 8, end: 19 },
|
||||||
|
{ type: 'text', value: '!', start: 21, end: 22 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse template starting with an expression', () => {
|
||||||
|
const parts = parseTemplate('{{ user.name }} is cool');
|
||||||
|
expect(parts).toEqual([
|
||||||
|
{ type: 'expression', value: 'user.name', start: 2, end: 13 },
|
||||||
|
{ type: 'text', value: ' is cool', start: 15, end: 23 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse template without expressions', () => {
|
||||||
|
const parts = parseTemplate('Hello world');
|
||||||
|
expect(parts).toEqual([{ type: 'text', value: 'Hello world', start: 0, end: 11 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate template', () => {
|
||||||
|
const runtime = new ExpressionRuntime();
|
||||||
|
const result = runtime.evaluateTemplate('Hello {{ user.name }}!', {
|
||||||
|
user: { name: 'John' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('Hello John!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import type {
|
||||||
|
Node as AcornNode,
|
||||||
|
AnyNode,
|
||||||
|
BinaryExpression,
|
||||||
|
Expression,
|
||||||
|
Identifier,
|
||||||
|
Literal,
|
||||||
|
MemberExpression,
|
||||||
|
PrivateIdentifier,
|
||||||
|
Super,
|
||||||
|
} from 'acorn';
|
||||||
|
import { isDummy } from 'acorn-loose';
|
||||||
|
import * as walk from 'acorn-walk';
|
||||||
|
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
|
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
|
||||||
|
import {
|
||||||
|
type AutocompleteLiteralValueSuggestion,
|
||||||
|
type AutocompleteOperatorSuggestion,
|
||||||
|
type AutocompleteSuggestions,
|
||||||
|
type AutocompleteSymbolSuggestion,
|
||||||
|
type DirectLiteralValueSuggestion,
|
||||||
|
type ExpressionParserResult,
|
||||||
|
type Logger,
|
||||||
|
SUPPORTED_BINARY_OPERATORS,
|
||||||
|
SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface ExpressionParser {
|
||||||
|
parse(expr: string, options: { loose?: boolean }): ExpressionParserResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutoComplete {
|
||||||
|
#parser: ExpressionParser;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(parser: ExpressionParser, logger: Logger = console) {
|
||||||
|
this.#parser = parser;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates autocomplete suggestions based on the input expression and cursor offset position.
|
||||||
|
*/
|
||||||
|
public getSuggestions(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): AutocompleteSuggestions {
|
||||||
|
if (!expr.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result, invalidNodes } = this.#parser.parse(expr, { loose: true });
|
||||||
|
|
||||||
|
// Locate the node at the cursor position.
|
||||||
|
const nodeAtCursorFound = walk.findNodeAround(result, cursorOffset, (_type, node) =>
|
||||||
|
isNodeAtCursor(node, cursorOffset)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nodeAtCursorFound) {
|
||||||
|
// When we can't find one we might be in the boundary of the program/expression.
|
||||||
|
// We could possibly be in a whitespace at the end of the expression or in a situation where the parsed
|
||||||
|
// tree may contain 2 top level ExpressionStatement (second one returned as invalid node by the parser).
|
||||||
|
//
|
||||||
|
// In this case we want to provide operators as suggestions and refine the search of the "cursor" node to either:
|
||||||
|
// - using the end position of the whole expression AST (e.g white space at the very end of the expression string)
|
||||||
|
// - or using the second top level ExpressionStatement node found by the parser as the cursor is at the end of that node.
|
||||||
|
if (cursorOffset > result.end) {
|
||||||
|
const ast = invalidNodes.length > 0 ? invalidNodes[0]?.expression : result;
|
||||||
|
|
||||||
|
if (!ast) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastNodeFound = walk.findNodeAround(ast, ast.end, (_type, node) =>
|
||||||
|
isNodeAtCursor(node, ast.end)
|
||||||
|
);
|
||||||
|
if (!lastNodeFound) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { node } = lastNodeFound;
|
||||||
|
|
||||||
|
if (!isAnyNode(node)) {
|
||||||
|
throw Error(`Unexpected node type ${node.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, node, result.end, context);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node } = nodeAtCursorFound;
|
||||||
|
|
||||||
|
if (!isAnyNode(node)) {
|
||||||
|
throw Error(`Unexpected node type ${node.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSuggestionsForNode(result, node, expr, cursorOffset, context);
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error('Error while computing autocomplete suggestions', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getSuggestionsForNode(
|
||||||
|
ast: Expression,
|
||||||
|
node: AnyNode,
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): AutocompleteSuggestions {
|
||||||
|
// When the node is an identifier look up the parent to get more context for the suggestions.
|
||||||
|
let inferNode: AnyNode = node;
|
||||||
|
if (node.type === 'Identifier' || node.type === 'Literal') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
inferNode =
|
||||||
|
parent &&
|
||||||
|
!['ExpressionStatement', 'LogicalExpression', 'ConditionalExpression'].includes(
|
||||||
|
parent.type
|
||||||
|
)
|
||||||
|
? parent
|
||||||
|
: node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (inferNode.type) {
|
||||||
|
case 'Identifier':
|
||||||
|
case 'MemberExpression': {
|
||||||
|
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
inferNode,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch suggestions from the symbol table
|
||||||
|
const candidatesKeys = context.getMatchingSymbolsKeys(pathParts);
|
||||||
|
|
||||||
|
const suggestions: AutocompleteSymbolSuggestion[] = [];
|
||||||
|
for (const candidate of candidatesKeys) {
|
||||||
|
const symbolInfo = context.getSymbolInfo(candidate);
|
||||||
|
if (symbolInfo) {
|
||||||
|
suggestions.push({ type: 'symbol', symbol: symbolInfo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 1) {
|
||||||
|
const lastPathSegment = pathParts.at(-1)?.replace(/\*$/, '');
|
||||||
|
|
||||||
|
// Return no suggestion when the only match is an exact match of the
|
||||||
|
// typed token.
|
||||||
|
return lastPathSegment !== suggestions[0]?.symbol.definition.name
|
||||||
|
? suggestions
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
case 'AssignmentExpression':
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
// Provide suggestions for binary or logical operators based on the parsed operator
|
||||||
|
// of the partial expression (e.g suggest "==" when typing "=" (parsed as AssignmentExpression)).
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
case 'BinaryExpression': {
|
||||||
|
const { left, right } = inferNode;
|
||||||
|
|
||||||
|
const isOperatorBinaryOp = SUPPORTED_BINARY_OPERATORS.some(
|
||||||
|
(op) => op.operator === inferNode.operator
|
||||||
|
);
|
||||||
|
|
||||||
|
const operatorIndex = expr.indexOf(inferNode.operator, left.end);
|
||||||
|
const operatorOffset = operatorIndex + inferNode.operator.length;
|
||||||
|
const isCursorAfterOperator = cursorOffset > operatorOffset;
|
||||||
|
|
||||||
|
const shouldSuggestValues =
|
||||||
|
isCursorAfterOperator &&
|
||||||
|
isOperatorBinaryOp &&
|
||||||
|
isNodeAtCursor(right, cursorOffset);
|
||||||
|
|
||||||
|
if (shouldSuggestValues) {
|
||||||
|
return this.getLiteralValueSuggestionsForNode(inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide suggestions for binary operators based on the parsed operator
|
||||||
|
return this.getOperatorSuggestionsForNode(ast, inferNode, cursorOffset, context);
|
||||||
|
}
|
||||||
|
case 'ConditionalExpression': {
|
||||||
|
const { consequent, alternate } = inferNode;
|
||||||
|
|
||||||
|
if (isNodeAtCursor(consequent, cursorOffset)) {
|
||||||
|
return this.getSuggestionsForNode(ast, consequent, expr, cursorOffset, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeAtCursor(alternate, cursorOffset)) {
|
||||||
|
return this.getSuggestionsForNode(ast, alternate, expr, cursorOffset, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a path corresponding to the MemberExpression node that can be used to lookup matching symbols in the symbol table.
|
||||||
|
*/
|
||||||
|
private getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
node: MemberExpression | Identifier,
|
||||||
|
cursorOffset: number,
|
||||||
|
options?: { withWildcardMatches: boolean }
|
||||||
|
): string[] {
|
||||||
|
const withWildcardMatches = options?.withWildcardMatches ?? true;
|
||||||
|
const pathParts: string[] = [];
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'MemberExpression':
|
||||||
|
{
|
||||||
|
const memberProperty = node.property;
|
||||||
|
|
||||||
|
// Only support identifier or literal expressions as member properties.
|
||||||
|
if (!isSupportedMemberProperty(memberProperty)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the path part corresponding to the member property (e.g b in a.b or a['b'])
|
||||||
|
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
memberProperty,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
if (propertyPathPart) {
|
||||||
|
pathParts.push(
|
||||||
|
withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go through the parent(s) in the chain and add their path parts as well
|
||||||
|
let parent: Expression | Super | undefined = node.object;
|
||||||
|
while (parent) {
|
||||||
|
const parentProperty = 'property' in parent ? parent.property : parent;
|
||||||
|
|
||||||
|
// Only support identifier or literal expressions as parent property.
|
||||||
|
const parentPropertyPathPart = isSupportedMemberProperty(parentProperty)
|
||||||
|
? this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
parentProperty,
|
||||||
|
cursorOffset
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (parentPropertyPathPart) {
|
||||||
|
pathParts.unshift(parentPropertyPathPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = 'object' in parent ? parent.object : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Identifier': {
|
||||||
|
const propertyPathPart = this.getSymbolsPathPartFromMemberProperty(
|
||||||
|
node,
|
||||||
|
cursorOffset
|
||||||
|
);
|
||||||
|
if (propertyPathPart) {
|
||||||
|
pathParts.push(withWildcardMatches ? `${propertyPathPart}*` : propertyPathPart);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a part of a symbol path corresponding to a MemberExpression node property.
|
||||||
|
*/
|
||||||
|
private getSymbolsPathPartFromMemberProperty(
|
||||||
|
node: Identifier | Literal,
|
||||||
|
cursorOffset: number
|
||||||
|
): string {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Identifier': {
|
||||||
|
if (isDummy(node)) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNodeAtCursor(node, cursorOffset)
|
||||||
|
? node.name.slice(0, cursorOffset)
|
||||||
|
: node.name;
|
||||||
|
}
|
||||||
|
case 'Literal': {
|
||||||
|
if (isDummy(node) || !node.value) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
const value = String(node.value);
|
||||||
|
return isNodeAtCursor(node, cursorOffset) ? value.slice(0, cursorOffset) : value;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete literal value suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getLiteralValueSuggestionsForNode(
|
||||||
|
node: BinaryExpression,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): Array<AutocompleteLiteralValueSuggestion> {
|
||||||
|
const { left, right } = node;
|
||||||
|
|
||||||
|
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right.type !== 'Identifier' && right.type !== 'Literal') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftSymbolPath = this.getSymbolsPathPartsForMemberExpressionNode(left, cursorOffset, {
|
||||||
|
withWildcardMatches: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLeftComputedMember = left.type === 'MemberExpression' && left.computed;
|
||||||
|
const leftSymbolInfo = context.getSymbolInfo(
|
||||||
|
isLeftComputedMember ? leftSymbolPath.slice(0, -1) : leftSymbolPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leftSymbolInfo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (leftSymbolInfo.definition.type) {
|
||||||
|
case SymbolType.Boolean: {
|
||||||
|
const suggestions: DirectLiteralValueSuggestion[] = [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: { kind: 'direct', type: SymbolType.Boolean, data: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return isDummy(right)
|
||||||
|
? suggestions
|
||||||
|
: suggestions.filter((literalValue) => {
|
||||||
|
const literalValueString = String(literalValue.value.data);
|
||||||
|
const rightNodeValue =
|
||||||
|
right.type === 'Identifier' ? right.name : (right.raw ?? '');
|
||||||
|
return (
|
||||||
|
literalValueString !== rightNodeValue &&
|
||||||
|
literalValueString.startsWith(rightNodeValue)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case SymbolType.String: {
|
||||||
|
if (!leftSymbolInfo.definition.enum) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightNodeValue = (() => {
|
||||||
|
if (right.type === 'Identifier') {
|
||||||
|
return right.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeValue = right.raw ?? '';
|
||||||
|
if (/^(['"])(.*)\1$/.test(nodeValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeValue.replaceAll(/["']/g, '');
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (rightNodeValue === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = isDummy(right)
|
||||||
|
? leftSymbolInfo.definition.enum
|
||||||
|
: leftSymbolInfo.definition.enum.filter((enumValue) =>
|
||||||
|
enumValue.startsWith(rightNodeValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
return suggestions.map((value) => ({
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'direct',
|
||||||
|
type: SymbolType.String,
|
||||||
|
data: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case SymbolType.Array: {
|
||||||
|
// Only return a literal in array value suggestion when the left hand side of the binary expression
|
||||||
|
// is computed, e.g myArray[1]
|
||||||
|
return isLeftComputedMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'literal-value',
|
||||||
|
value: {
|
||||||
|
kind: 'in-array',
|
||||||
|
srcSymbol: leftSymbolInfo.definition,
|
||||||
|
matchedLiteralString: !isDummy(right)
|
||||||
|
? right.type === 'Identifier'
|
||||||
|
? right.name
|
||||||
|
: (right.raw ?? '')
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete operator suggestions for a specific node in the AST.
|
||||||
|
*/
|
||||||
|
private getOperatorSuggestionsForNode(
|
||||||
|
ast: Expression,
|
||||||
|
node: AnyNode,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): Array<AutocompleteOperatorSuggestion> {
|
||||||
|
if (node.type === 'Literal') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
|
||||||
|
if (parent?.type === 'BinaryExpression') {
|
||||||
|
return [...SUPPORTED_LOGICAL_OPERATORS, ...SUPPORTED_CONDITIONAL_OPERATORS].map(
|
||||||
|
(op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const literalSymbol = SymbolsTable.inferSymbolFromValue(node.raw);
|
||||||
|
return this.getOperatorSuggestionsForSymbol(literalSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the node is an identifier look the parent to get more context for the suggestions
|
||||||
|
let inferNode: AnyNode = node;
|
||||||
|
if (node.type === 'Identifier') {
|
||||||
|
const parent = findParentNode(node, ast);
|
||||||
|
inferNode = parent && parent.type !== 'ExpressionStatement' ? parent : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (inferNode.type) {
|
||||||
|
case 'MemberExpression': {
|
||||||
|
const pathParts = this.getSymbolsPathPartsForMemberExpressionNode(
|
||||||
|
inferNode,
|
||||||
|
cursorOffset,
|
||||||
|
{
|
||||||
|
withWildcardMatches: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const symbolInfo = context.getSymbolInfo(pathParts);
|
||||||
|
return symbolInfo
|
||||||
|
? this.getOperatorSuggestionsForSymbol(symbolInfo.definition)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
case 'AssignmentExpression':
|
||||||
|
case 'BinaryExpression':
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
// Starting to write a binary/logical operator so suggest operator matching the already
|
||||||
|
// typed character as operator.
|
||||||
|
const operator = inferNode.operator;
|
||||||
|
return (
|
||||||
|
[...SUPPORTED_BINARY_OPERATORS, ...SUPPORTED_LOGICAL_OPERATORS]
|
||||||
|
.filter((op) => op.operator.startsWith(operator))
|
||||||
|
// No need to include the operator that match exactly
|
||||||
|
.filter((op) => op.operator !== operator)
|
||||||
|
.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete operator suggestions based on the type of a symbol.
|
||||||
|
*/
|
||||||
|
private getOperatorSuggestionsForSymbol(
|
||||||
|
symbol: ExtractSymbolDef<SymbolType>
|
||||||
|
): Array<AutocompleteOperatorSuggestion> {
|
||||||
|
switch (symbol.type) {
|
||||||
|
case SymbolType.Number:
|
||||||
|
case SymbolType.Boolean:
|
||||||
|
case SymbolType.Null:
|
||||||
|
case SymbolType.Undefined:
|
||||||
|
case SymbolType.Object:
|
||||||
|
case SymbolType.Array: {
|
||||||
|
const equalityOps = SUPPORTED_BINARY_OPERATORS.slice(0, 4);
|
||||||
|
const finalSuggestions =
|
||||||
|
symbol.type === SymbolType.Boolean
|
||||||
|
? [
|
||||||
|
...equalityOps,
|
||||||
|
...SUPPORTED_LOGICAL_OPERATORS,
|
||||||
|
...SUPPORTED_CONDITIONAL_OPERATORS,
|
||||||
|
]
|
||||||
|
: equalityOps;
|
||||||
|
return finalSuggestions.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case SymbolType.String:
|
||||||
|
return SUPPORTED_BINARY_OPERATORS.map((op) => ({
|
||||||
|
type: 'operator',
|
||||||
|
...op,
|
||||||
|
}));
|
||||||
|
case SymbolType.Function:
|
||||||
|
return this.getOperatorSuggestionsForSymbol(symbol.returns);
|
||||||
|
case SymbolType.Union: {
|
||||||
|
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
|
||||||
|
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
|
||||||
|
return prev;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
assertNever(symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the parent of child node in the provided AST.
|
||||||
|
*/
|
||||||
|
function findParentNode(child: AnyNode, ast: Expression): AnyNode | undefined {
|
||||||
|
let foundParent: AnyNode | undefined;
|
||||||
|
|
||||||
|
walk.ancestor(ast, {
|
||||||
|
[child.type]: (node: AcornNode, _state: undefined, ancestors: AnyNode[]) => {
|
||||||
|
if (node.start === child.start && node.end === child.end) {
|
||||||
|
// The parent is the second last ancestor in the stack (last one is the actual node)
|
||||||
|
foundParent = ancestors[ancestors.length - 2];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedMemberProperty(node: Expression | PrivateIdentifier | Super) {
|
||||||
|
return node.type === 'Identifier' || node.type === 'Literal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeAtCursor(node: AcornNode, cursorOffset: number) {
|
||||||
|
return cursorOffset >= node.start && cursorOffset <= node.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnyNode(node: AcornNode): node is AnyNode {
|
||||||
|
return 'type' in node;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Position, Token } from 'acorn';
|
||||||
|
|
||||||
|
export class ExpressionError extends Error {
|
||||||
|
/**
|
||||||
|
* The location of the error in the parsed expression.
|
||||||
|
*/
|
||||||
|
public location?: Position | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expression token with the error
|
||||||
|
*/
|
||||||
|
public token?: Token;
|
||||||
|
|
||||||
|
constructor(message: string, loc?: Position | null, token?: Token) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, ExpressionError);
|
||||||
|
}
|
||||||
|
this.name = 'ExpressionError';
|
||||||
|
this.location = loc;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './errors';
|
||||||
|
export * from './input-values';
|
||||||
|
export * from './runtime';
|
||||||
|
export * from './symbols';
|
||||||
|
export * from './template';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
import { filterOutNullable } from './utils';
|
||||||
|
|
||||||
|
type InputValuesType =
|
||||||
|
| null
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| { [key: string]: InputValuesType }
|
||||||
|
| InputValuesType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers a default inputValues object based on the JSON schema of an object.
|
||||||
|
*/
|
||||||
|
export function inferDefaultInputValuesFromObjectJSONSchema(
|
||||||
|
schema: JSONSchema7
|
||||||
|
): Record<string, InputValuesType> {
|
||||||
|
if (schema.type !== 'object' || !schema.properties) {
|
||||||
|
throw new Error(`Expected schema of object to be provided: ${schema.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, InputValuesType> = {};
|
||||||
|
|
||||||
|
for (const [key, propertySchema] of Object.entries(schema.properties)) {
|
||||||
|
if (typeof propertySchema === 'boolean') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = inferDefaultInputValueFromJSONSchema(propertySchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefaultInputValueFromJSONSchema(schema: JSONSchema7): InputValuesType {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'object':
|
||||||
|
return inferDefaultInputValuesFromObjectJSONSchema(schema);
|
||||||
|
case 'array': {
|
||||||
|
if (schema.items && Array.isArray(schema.items)) {
|
||||||
|
return schema.items.map((itemSchema) => {
|
||||||
|
if (typeof itemSchema === 'boolean') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return inferDefaultInputValueFromJSONSchema(itemSchema);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
case 'string':
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
case 'boolean':
|
||||||
|
case 'null':
|
||||||
|
return inferDefaultInputValueFromPrimitive(schema);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDefaultInputValueFromPrimitive(schema: JSONSchema7): InputValuesType {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return true;
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return 1234;
|
||||||
|
case 'string': {
|
||||||
|
const enumValues = schema.enum?.filter(filterOutNullable);
|
||||||
|
return enumValues?.[0] ?? 'default';
|
||||||
|
}
|
||||||
|
case 'null':
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import {
|
||||||
|
type Options as AcornOptions,
|
||||||
|
type Expression,
|
||||||
|
type ExpressionStatement,
|
||||||
|
type Position,
|
||||||
|
type Program,
|
||||||
|
type Token,
|
||||||
|
parse,
|
||||||
|
tokenizer,
|
||||||
|
} from 'acorn';
|
||||||
|
import { parse as parseLoose } from 'acorn-loose';
|
||||||
|
import escodegen from 'escodegen';
|
||||||
|
import evalESTreeExpr from 'eval-estree-expression';
|
||||||
|
const { evaluate } = evalESTreeExpr;
|
||||||
|
|
||||||
|
import { AutoComplete } from './autocomplete';
|
||||||
|
import { ExpressionError } from './errors';
|
||||||
|
import type { SymbolsTable } from './symbols';
|
||||||
|
import type { TemplatePart } from './template';
|
||||||
|
import { parseTemplate as parseTemplateParts } from './template';
|
||||||
|
import type { ExpressionAutocompleteResults, ExpressionParserResult, Logger } from './types';
|
||||||
|
import { formatExpressionResult } from './utils';
|
||||||
|
|
||||||
|
export class ExpressionRuntime {
|
||||||
|
#parserOptions: AcornOptions;
|
||||||
|
#autocompleter: AutoComplete;
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(logger: Logger = console) {
|
||||||
|
this.#parserOptions = {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'script',
|
||||||
|
allowHashBang: false,
|
||||||
|
locations: true,
|
||||||
|
};
|
||||||
|
this.#autocompleter = new AutoComplete(this, logger);
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression based on the given inputs/context.
|
||||||
|
*/
|
||||||
|
public evaluate(expr: string, inputs: object): unknown {
|
||||||
|
try {
|
||||||
|
const parsed = this.parse(expr);
|
||||||
|
|
||||||
|
if (parsed.invalidNodes.length > 0) {
|
||||||
|
throw new ExpressionError('Invalid nodes found when parsing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluate.sync<Expression>(parsed.result, inputs, {
|
||||||
|
functions: true,
|
||||||
|
withMembers: true,
|
||||||
|
generate: escodegen.generate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression safely by returning the error instead of throwing when invalid.
|
||||||
|
*/
|
||||||
|
public safeEvaluate(
|
||||||
|
expr: string,
|
||||||
|
inputs: object
|
||||||
|
): { value: unknown; error?: undefined } | { value?: undefined; error: ExpressionError } {
|
||||||
|
try {
|
||||||
|
const value = this.evaluate(expr, inputs);
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.error(`Error while evaluating expression ${expr}`, error);
|
||||||
|
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: undefined,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? new ExpressionError(error.message)
|
||||||
|
: new ExpressionError('Unexpected error'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a condition safely to a boolean.
|
||||||
|
*/
|
||||||
|
public evaluateBoolean(expr: string, inputs: object): boolean {
|
||||||
|
if (expr.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = this.safeEvaluate(expr, inputs);
|
||||||
|
|
||||||
|
if (typeof evalResult.error !== 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(evalResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an array of conditions as a single logical expression.
|
||||||
|
* The function treats the conditions as if they were joined by an AND operator,
|
||||||
|
* meaning the evaluation returns `true` only if all conditions are truthy.
|
||||||
|
*/
|
||||||
|
public evaluateBooleanAll(expressions: string[], inputs: object): boolean {
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expressions.every((expression) => this.evaluateBoolean(expression, inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template and validate all embedded expressions.
|
||||||
|
*/
|
||||||
|
public parseTemplate(template: string): { parts: TemplatePart[]; errors: ExpressionError[] } {
|
||||||
|
const parts = parseTemplateParts(template);
|
||||||
|
const errors: ExpressionError[] = [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === 'expression') {
|
||||||
|
try {
|
||||||
|
const { invalidNodes } = this.parse(part.value);
|
||||||
|
if (invalidNodes.length > 0) {
|
||||||
|
errors.push(new ExpressionError('Invalid expression'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error as ExpressionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parts, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
public evaluateTemplate(template: string, inputs: object): string {
|
||||||
|
const { parts } = this.parseTemplate(template);
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return part.value;
|
||||||
|
}
|
||||||
|
const result = this.evaluate(part.value, inputs);
|
||||||
|
return formatExpressionResult(result, '');
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||||
|
*/
|
||||||
|
public parse(
|
||||||
|
expr: string,
|
||||||
|
options: { loose?: boolean } = {
|
||||||
|
loose: false,
|
||||||
|
}
|
||||||
|
): ExpressionParserResult {
|
||||||
|
try {
|
||||||
|
const ast = options.loose
|
||||||
|
? parseLoose(expr, { ...this.#parserOptions })
|
||||||
|
: parse(expr, { ...this.#parserOptions });
|
||||||
|
|
||||||
|
if (!ast.body || ast.body.length === 0) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the first expression statement that we find
|
||||||
|
const firstExprIndex = ast.body.findIndex((node) => isParsedExpressionStatement(node));
|
||||||
|
const [statement] = ast.body.splice(firstExprIndex, 1);
|
||||||
|
|
||||||
|
if (!statement || !isParsedExpressionStatement(statement)) {
|
||||||
|
throw new ExpressionError('Empty or invalid expression');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return information on the other nodes as invalid nodes
|
||||||
|
const invalidNodes = ast.body.filter(filterOutModuleDeclarationStatement);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: statement.expression,
|
||||||
|
invalidNodes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw createExpressionErrorFromSyntaxError(expr, error);
|
||||||
|
}
|
||||||
|
if (error instanceof ExpressionError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ExpressionError('Unexpected error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides autocomplete suggestions for the given expression at the provided cursor offset.
|
||||||
|
*/
|
||||||
|
public autocomplete(
|
||||||
|
expr: string,
|
||||||
|
cursorOffset: number,
|
||||||
|
context: SymbolsTable
|
||||||
|
): ExpressionAutocompleteResults {
|
||||||
|
const suggestions = this.#autocompleter.getSuggestions(expr, cursorOffset, context);
|
||||||
|
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
public generate(_node: Expression): string {
|
||||||
|
throw new Error('Not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExpressionErrorFromSyntaxError(
|
||||||
|
code: string,
|
||||||
|
error: SyntaxError & { loc?: Position }
|
||||||
|
): ExpressionError {
|
||||||
|
const loc = error.loc;
|
||||||
|
|
||||||
|
if (!loc) {
|
||||||
|
return new ExpressionError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = `${error.message.replace(/\s*\(\d+:\d+\)$/, '')} at ${code.split('\n').length > 1 ? `line ${loc.line}, ` : ''}char ${loc.column}`;
|
||||||
|
const token = getTokenAtLoc(code, loc);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new ExpressionError(errorMessage, loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExpressionError(errorMessage, loc, token);
|
||||||
|
}
|
||||||
|
function getTokenAtLoc(code: string, errorLoc: Position): Token | undefined {
|
||||||
|
const tokens = tokenizer(code, {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
locations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.loc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = token.loc;
|
||||||
|
|
||||||
|
const onSameLine = errorLoc.line === start.line;
|
||||||
|
const inColumnRange = errorLoc.column >= start.column && errorLoc.column < end.column;
|
||||||
|
|
||||||
|
if (onSameLine && inColumnRange) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParsedExpressionStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return statement.type === 'ExpressionStatement';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutModuleDeclarationStatement(
|
||||||
|
statement: Program['body'][number]
|
||||||
|
): statement is ExpressionStatement {
|
||||||
|
return ![
|
||||||
|
'ImportDeclaration',
|
||||||
|
'ExportNamedDeclaration',
|
||||||
|
'ExportDefaultDeclaration',
|
||||||
|
'ExportAllDeclaration',
|
||||||
|
].includes(statement.type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { SymbolArray, SymbolObject, SymbolString } from '../symbols';
|
||||||
|
import { SymbolsTable } from '../symbols-table';
|
||||||
|
import type { SymbolType } from '../types';
|
||||||
|
|
||||||
|
describe('ExpressionRuntime', () => {
|
||||||
|
const initialSymbols = {
|
||||||
|
visitor: SymbolObject({
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: SymbolObject({
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: SymbolString({ name: 'key' }),
|
||||||
|
flags: SymbolObject({
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: SymbolString({ name: 'FLAG1' }),
|
||||||
|
FLAG2: SymbolString({ name: 'FLAG2' }),
|
||||||
|
FLAG3: SymbolString({ name: 'FLAG3' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
hello: SymbolArray({
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: SymbolString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
describe('addSymbols', () => {
|
||||||
|
it('should the symbols matching the provided object to the table', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
symbolsTable.addSymbols({
|
||||||
|
space: SymbolObject({
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['space'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'space',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'id',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'space',
|
||||||
|
childrenRefs: ['space.id', 'space.title'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Symbols standard library', () => {
|
||||||
|
it('should allow to access methods & properties defined as part of the standard library', () => {
|
||||||
|
const symbolsTable = new SymbolsTable({
|
||||||
|
id: SymbolString({ name: 'id' }),
|
||||||
|
title: SymbolString({ name: 'title' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo<SymbolType.String>('id')?.definition.properties.length
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSymbolInfo', () => {
|
||||||
|
it('should add the symbols matching the initial symbol definition passed to the constructor', () => {
|
||||||
|
const symbolsTable = new SymbolsTable(initialSymbols);
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor',
|
||||||
|
childrenRefs: ['visitor.claims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
description: 'The claims contained in the visitor JWT token',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims',
|
||||||
|
parentRef: 'visitor',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key',
|
||||||
|
'visitor.claims.flags',
|
||||||
|
'visitor.claims.hello',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'key'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.key',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.key.length',
|
||||||
|
'visitor.claims.key.at',
|
||||||
|
'visitor.claims.key.endsWith',
|
||||||
|
'visitor.claims.key.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1',
|
||||||
|
'visitor.claims.flags.FLAG2',
|
||||||
|
'visitor.claims.flags.FLAG3',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG1'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG1',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG1.length',
|
||||||
|
'visitor.claims.flags.FLAG1.at',
|
||||||
|
'visitor.claims.flags.FLAG1.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG1.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
symbolsTable.getSymbolInfo(['visitor', 'claims', 'flags', 'FLAG2'])
|
||||||
|
).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.flags.FLAG2',
|
||||||
|
parentRef: 'visitor.claims.flags',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.flags.FLAG2.length',
|
||||||
|
'visitor.claims.flags.FLAG2.at',
|
||||||
|
'visitor.claims.flags.FLAG2.endsWith',
|
||||||
|
'visitor.claims.flags.FLAG2.includes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(symbolsTable.getSymbolInfo(['visitor', 'claims', 'hello'])).toMatchObject({
|
||||||
|
definition: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
description: 'An array of string',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ref: 'visitor.claims.hello',
|
||||||
|
parentRef: 'visitor.claims',
|
||||||
|
childrenRefs: [
|
||||||
|
'visitor.claims.hello.length',
|
||||||
|
'visitor.claims.hello.at',
|
||||||
|
'visitor.claims.hello.includes',
|
||||||
|
'visitor.claims.hello.some',
|
||||||
|
'visitor.claims.hello.every',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromValue', () => {
|
||||||
|
it('should infer properly a symbol based on a value', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromValue(
|
||||||
|
{
|
||||||
|
visitor: {
|
||||||
|
claims: {
|
||||||
|
key: 'test',
|
||||||
|
flags: {
|
||||||
|
FLAG1: 'testflag1',
|
||||||
|
FLAG2: 'testflag2',
|
||||||
|
FLAG3: 'testflag3',
|
||||||
|
},
|
||||||
|
hello: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'context'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'context',
|
||||||
|
properties: {
|
||||||
|
visitor: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'array',
|
||||||
|
name: 'hello',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSymbolFromJSONSchema', () => {
|
||||||
|
it('should infer properly a symbol table based on a JSON schema', () => {
|
||||||
|
const symbolDef = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: { type: 'string' },
|
||||||
|
FLAG2: { type: 'string' },
|
||||||
|
FLAG3: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'visitor'
|
||||||
|
);
|
||||||
|
expect(symbolDef).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
name: 'visitor',
|
||||||
|
description: `The attributes tied to a site's visitor.`,
|
||||||
|
properties: {
|
||||||
|
claims: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'claims',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'key',
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
type: 'object',
|
||||||
|
name: 'flags',
|
||||||
|
description: 'The user feature flags',
|
||||||
|
properties: {
|
||||||
|
FLAG1: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG1',
|
||||||
|
},
|
||||||
|
FLAG2: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG2',
|
||||||
|
},
|
||||||
|
FLAG3: {
|
||||||
|
type: 'string',
|
||||||
|
name: 'FLAG3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
hello: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['test', 'test1', 'test2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './symbols';
|
||||||
|
export * from './symbols-table';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
|
||||||
|
import { filterOutNullable } from '../utils';
|
||||||
|
import {
|
||||||
|
SymbolArray,
|
||||||
|
SymbolBoolean,
|
||||||
|
SymbolNull,
|
||||||
|
SymbolNumber,
|
||||||
|
SymbolObject,
|
||||||
|
SymbolString,
|
||||||
|
SymbolUndefined,
|
||||||
|
} from './symbols';
|
||||||
|
import {
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolWithMethods,
|
||||||
|
type SymbolWithProperties,
|
||||||
|
resolveSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export interface SymbolInfo<T extends SymbolType = SymbolType> {
|
||||||
|
/**
|
||||||
|
* Definition of the symbol.
|
||||||
|
*/
|
||||||
|
definition: ExtractSymbolDef<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference of the symbol in the table of symbols.
|
||||||
|
*/
|
||||||
|
ref: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the reference to the parent symbol.
|
||||||
|
*/
|
||||||
|
parentRef?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the reference to the children symbols.
|
||||||
|
*/
|
||||||
|
childrenRefs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SymbolError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, SymbolError);
|
||||||
|
}
|
||||||
|
this.name = 'SymbolError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SymbolsTable {
|
||||||
|
/**
|
||||||
|
* Internal table that keeps track of all symbols reference.
|
||||||
|
*/
|
||||||
|
#table: Record<string, SymbolInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal table that keep track of the raw symbols definitions.
|
||||||
|
*/
|
||||||
|
#rawSymbols: Record<string, GenericSymbolDef>;
|
||||||
|
|
||||||
|
constructor(initialContext: Record<string, GenericSymbolDef> = {}) {
|
||||||
|
this.#table = {};
|
||||||
|
this.#rawSymbols = {};
|
||||||
|
this.addSymbols(initialContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new symbols table by merging the current one with the provided one.
|
||||||
|
*/
|
||||||
|
merge(other: SymbolsTable): SymbolsTable {
|
||||||
|
return new SymbolsTable({
|
||||||
|
...this.#rawSymbols,
|
||||||
|
...other.#rawSymbols,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return JSON.stringify(this.#table, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the symbol of a value and generate the appropriate symbol definition.
|
||||||
|
*/
|
||||||
|
static inferSymbolFromValue(value: unknown, name?: string): ExtractSymbolDef<SymbolType> {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return SymbolArray({ items: SymbolUndefined() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(0));
|
||||||
|
// Check that the array is not a mixin of different items types.
|
||||||
|
if (value.length > 1) {
|
||||||
|
const secondItemSymbol = SymbolsTable.inferSymbolFromValue(value.at(1));
|
||||||
|
if (firstItemSymbol.type !== secondItemSymbol.type) {
|
||||||
|
throw new SymbolError('Array with mixin items types are not supported');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SymbolArray({ name, items: firstItemSymbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
return SymbolUndefined({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return SymbolNull({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueType = typeof value;
|
||||||
|
switch (valueType) {
|
||||||
|
case 'string':
|
||||||
|
return SymbolString({ name });
|
||||||
|
case 'number':
|
||||||
|
return SymbolNumber({ name });
|
||||||
|
case 'boolean':
|
||||||
|
return SymbolBoolean({ name });
|
||||||
|
case 'object': {
|
||||||
|
const properties = Object.entries(value).reduce<Record<string, GenericSymbolDef>>(
|
||||||
|
(prev, [name, val]) => {
|
||||||
|
prev[name] = SymbolsTable.inferSymbolFromValue(val, name);
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
return SymbolObject({ name, properties, methods: [] });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new SymbolError(`Unsupported symbol type ${valueType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a table of symbol based on a JSON schema object describing it.
|
||||||
|
*/
|
||||||
|
static inferSymbolFromJSONSchema(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ExtractSymbolDef<SymbolType> {
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'string':
|
||||||
|
return SymbolString({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
...(schema.enum
|
||||||
|
? {
|
||||||
|
enum: schema.enum
|
||||||
|
.filter(filterOutNullable)
|
||||||
|
.map((enumValue) => enumValue.toString()),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return SymbolNumber({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'boolean':
|
||||||
|
return SymbolBoolean({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'null':
|
||||||
|
return SymbolNull({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
});
|
||||||
|
case 'object':
|
||||||
|
return SymbolsTable.#buildObjectSymbolFromJSONSchemaObject(schema, name);
|
||||||
|
case 'array':
|
||||||
|
return SymbolsTable.#buildArraySymbolFromJSONSchemaArray(schema, name);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema type: ${schema.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildObjectSymbolFromJSONSchemaObject(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ObjectSymbolDef {
|
||||||
|
const properties: Record<string, GenericSymbolDef> = {};
|
||||||
|
|
||||||
|
if (schema.properties) {
|
||||||
|
Object.entries(schema.properties).forEach(([propertyName, propertySchema]) => {
|
||||||
|
properties[propertyName] = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
propertySchema as JSONSchema7,
|
||||||
|
propertyName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymbolObject({
|
||||||
|
name,
|
||||||
|
properties,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
methods: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildArraySymbolFromJSONSchemaArray(
|
||||||
|
schema: JSONSchema7,
|
||||||
|
name?: string
|
||||||
|
): ExtractSymbolDef<SymbolType.Array> {
|
||||||
|
if (schema.items) {
|
||||||
|
const itemSymbol = SymbolsTable.inferSymbolFromJSONSchema(
|
||||||
|
schema.items as JSONSchema7,
|
||||||
|
`${name || ''}_item`
|
||||||
|
);
|
||||||
|
return SymbolArray({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
items: itemSymbol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymbolArray({
|
||||||
|
name,
|
||||||
|
...(schema.description ? { description: schema.description } : {}),
|
||||||
|
items: SymbolUndefined(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSymbolRefPath(path: string[]): string {
|
||||||
|
return path.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single symbol to the table at the provided path.
|
||||||
|
*/
|
||||||
|
private addSymbol(path: string[], definition: GenericSymbolDef, raw = false): void {
|
||||||
|
const fullPath = this.generateSymbolRefPath(path);
|
||||||
|
const parentPath = path.slice(0, -1).join('.');
|
||||||
|
|
||||||
|
if (this.#table[fullPath]) {
|
||||||
|
throw new SymbolError(`Symbol "${fullPath}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
this.#rawSymbols[fullPath] = definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new symbol linking it to its parent
|
||||||
|
this.#table[fullPath] = {
|
||||||
|
definition: resolveSymbolDef(definition),
|
||||||
|
ref: fullPath,
|
||||||
|
parentRef: parentPath || undefined,
|
||||||
|
childrenRefs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parentPath && this.#table[parentPath]) {
|
||||||
|
if (this.#table[parentPath].childrenRefs) {
|
||||||
|
this.#table[parentPath].childrenRefs.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any nested symbols if the value is a symbol with properties...
|
||||||
|
if (isSymbolWithProperties(definition)) {
|
||||||
|
Object.entries(definition.properties).forEach(([propKey, propSymbol]) => {
|
||||||
|
if (isObjectSymbol(propSymbol)) {
|
||||||
|
this.addSymbols({ [propKey]: propSymbol }, path, false);
|
||||||
|
} else {
|
||||||
|
this.addSymbol([...path, propKey], propSymbol, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...or a symbol with methods
|
||||||
|
if (isSymbolWithMethods(definition)) {
|
||||||
|
definition.methods.forEach((methodSymbol) => {
|
||||||
|
this.addSymbol([...path, methodSymbol.name], methodSymbol, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the provided object of symbols definitions to the symbol table.
|
||||||
|
*/
|
||||||
|
public addSymbols(
|
||||||
|
symbols: Record<string, GenericSymbolDef>,
|
||||||
|
prefix: string[] = [],
|
||||||
|
raw = true
|
||||||
|
): void {
|
||||||
|
for (const [key, symbolDef] of Object.entries(symbols)) {
|
||||||
|
const path = [...prefix, key];
|
||||||
|
|
||||||
|
// Add the current symbol to the table.
|
||||||
|
this.addSymbol(path, symbolDef, raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a symbol's information using its path in the table.
|
||||||
|
*/
|
||||||
|
public getSymbolInfo<T extends SymbolType>(path: string | string[]): SymbolInfo<T> | undefined {
|
||||||
|
const key = Array.isArray(path) ? path.join('.') : path;
|
||||||
|
const info = this.#table[key];
|
||||||
|
return info ? typedSymbolInfo(info) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all symbol keys matching the pattern defined by the provided path.
|
||||||
|
*/
|
||||||
|
public getMatchingSymbolsKeys(path: string[]): string[] {
|
||||||
|
const wildcardRegex = new RegExp(
|
||||||
|
`^${path
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.includes('*')) {
|
||||||
|
return `${segment.split('*')[0]}([^.]+)?`;
|
||||||
|
}
|
||||||
|
return segment;
|
||||||
|
})
|
||||||
|
.join('\\.')}$`
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(this.#table)
|
||||||
|
.filter((key) => wildcardRegex.test(key))
|
||||||
|
.filter(filterOutNullable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectSymbol(symbol: GenericSymbolDef): symbol is ObjectSymbolDef {
|
||||||
|
return symbol.type === SymbolType.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymbolWithProperties(symbol: GenericSymbolDef): symbol is SymbolWithProperties {
|
||||||
|
return (
|
||||||
|
symbol.type === SymbolType.Object ||
|
||||||
|
symbol.type === SymbolType.Array ||
|
||||||
|
symbol.type === SymbolType.String
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymbolWithMethods(symbol: GenericSymbolDef): symbol is SymbolWithMethods {
|
||||||
|
return (
|
||||||
|
symbol.type === SymbolType.Object ||
|
||||||
|
symbol.type === SymbolType.Array ||
|
||||||
|
symbol.type === SymbolType.String
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typedSymbolInfo<T extends SymbolType>(info: SymbolInfo): SymbolInfo<T> {
|
||||||
|
const definition = resolveSymbolDef(info.definition);
|
||||||
|
return { ...info, definition } as SymbolInfo<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
type ArraySymbolDef,
|
||||||
|
type BooleanSymbolDef,
|
||||||
|
type ExtractSymbolDef,
|
||||||
|
type FunctionSymbolDef,
|
||||||
|
type GenericSymbolDef,
|
||||||
|
type NullSymbolDef,
|
||||||
|
type NumberSymbolDef,
|
||||||
|
type ObjectSymbolDef,
|
||||||
|
type StringSymbolDef,
|
||||||
|
SymbolType,
|
||||||
|
type SymbolsWithPropertiesAndMethods,
|
||||||
|
type UndefinedSymbolDef,
|
||||||
|
type UnionSymbolDef,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function SymbolBoolean(args: Omit<BooleanSymbolDef, 'type'> = {}): BooleanSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Boolean,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNumber(args: Omit<NumberSymbolDef, 'type'> = {}): NumberSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Number,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolString(
|
||||||
|
args: Omit<StringSymbolDef, 'type' | 'methods' | 'properties'> = {}
|
||||||
|
): StringSymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods<StringSymbolDef>(SymbolType.String, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolObject(args: Omit<ObjectSymbolDef, 'type'>): ObjectSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Object,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolArray(
|
||||||
|
args: Omit<ArraySymbolDef, 'type' | 'methods' | 'properties'>
|
||||||
|
): ArraySymbolDef {
|
||||||
|
return createSymbolWithPropertiesAndMethods(SymbolType.Array, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolFunction(args: Omit<FunctionSymbolDef, 'type'>): FunctionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Function,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionalFunctionArg(
|
||||||
|
optionalArg: ExtractSymbolDef<SymbolType>
|
||||||
|
): ExtractSymbolDef<SymbolType> & { optional: true } {
|
||||||
|
return {
|
||||||
|
...optionalArg,
|
||||||
|
optional: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUnion(args: Omit<UnionSymbolDef, 'type'>): UnionSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Union,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolUndefined(args: Omit<UndefinedSymbolDef, 'type'> = {}): UndefinedSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Undefined,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SymbolNull(args: Omit<NullSymbolDef, 'type'> = {}): NullSymbolDef {
|
||||||
|
return {
|
||||||
|
type: SymbolType.Null,
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSymbolWithPropertiesAndMethods<
|
||||||
|
T extends SymbolsWithPropertiesAndMethods & { type: SymbolType },
|
||||||
|
>(type: T['type'], args: Omit<T, 'type' | 'methods' | 'properties'>): T {
|
||||||
|
const symbol = { type, ...args } as T;
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'properties', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).properties
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.properties || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(symbol, 'methods', {
|
||||||
|
get() {
|
||||||
|
if (symbol.type === SymbolType.Array) {
|
||||||
|
return isArraySymbol(symbol)
|
||||||
|
? StandardLibrary[SymbolType.Array]?.(symbol).methods
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
return StandardLibrary[symbol.type]?.methods || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO-ADAPTIVE-CONTENT: extend the definition of the supported standard library methods and properties.
|
||||||
|
|
||||||
|
const StandardLibrary: Partial<
|
||||||
|
{
|
||||||
|
[key in Exclude<SymbolType, SymbolType.Array>]: {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
} & {
|
||||||
|
[SymbolType.Array]: (symbol: ArraySymbolDef) => {
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
[SymbolType.String]: {
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description:
|
||||||
|
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the string.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: 'The index (position) of the string character to be returned',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `A String consisting of the single UTF-16 code unit located at the specified position.
|
||||||
|
Returns undefined if the given index can not be found.`,
|
||||||
|
members: [SymbolString(), SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'endsWith',
|
||||||
|
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
|
||||||
|
elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `The characters to be searched for at the end of str. Cannot be a regex.
|
||||||
|
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes endsWith() to search for
|
||||||
|
the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'endPosition',
|
||||||
|
description: `The end position at which searchString is expected to be found
|
||||||
|
(the index of searchString's last character plus 1). Defaults to str.length.`,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the given characters are found at the end of the string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description: `Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions
|
||||||
|
that are greater than or equal to position; otherwise, returns false.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
|
||||||
|
args: [
|
||||||
|
SymbolString({
|
||||||
|
name: 'searchString',
|
||||||
|
description: `A string to be searched for within str. Cannot be a regex. All values that are not regexes are coerced to strings, so omitting it
|
||||||
|
or passing undefined causes includes() to search for the string "undefined", which is rarely what you want.`,
|
||||||
|
}),
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'position',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description: `true if the search string is found anywhere within the given string, including when searchString is an empty string;
|
||||||
|
otherwise, false.`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
|
||||||
|
properties: {
|
||||||
|
length: SymbolNumber({
|
||||||
|
name: 'length',
|
||||||
|
description: `The length data property of an Array instance represents the number of elements in that array.
|
||||||
|
The value is an unsigned, 32-bit integer that is always numerically greater than the highest index in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'at',
|
||||||
|
description: `Takes an integer value and returns the item at that index, allowing for positive and negative integers.
|
||||||
|
Negative integers count back from the last item in the array.`,
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at',
|
||||||
|
args: [
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'index',
|
||||||
|
description: `Zero-based index of the array element to be returned, converted to an integer.
|
||||||
|
Negative index counts back from the end of the array — if index < 0, index + array.length is accessed.`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolUnion({
|
||||||
|
description: `The element in the array matching the given index. Always returns undefined if index < -array.length or index >= array.length
|
||||||
|
without attempting to access the corresponding property.`,
|
||||||
|
members: [arraySymbolDef.items, SymbolUndefined()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'includes',
|
||||||
|
description:
|
||||||
|
'Determines whether an array includes a certain value among its entries, returning true or false as appropriate.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'searchElement',
|
||||||
|
description: 'The value to be searched for within the array.',
|
||||||
|
},
|
||||||
|
OptionalFunctionArg(
|
||||||
|
SymbolNumber({
|
||||||
|
name: 'fromIndex',
|
||||||
|
description:
|
||||||
|
'The position within the string at which to begin searching for searchString. (Defaults to 0.)',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the value searchElement is found within the array (or the part of the array indicated by the index fromIndex, if specified).',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'some',
|
||||||
|
description:
|
||||||
|
'Tests whether at least one element in the array passes the test implemented by the provided function.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some',
|
||||||
|
args: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'callback',
|
||||||
|
description: 'A function that tests each element of the array.',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'element',
|
||||||
|
description: 'The current element being processed in the array.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for at least one element in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for at least one element in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'every',
|
||||||
|
description:
|
||||||
|
'Tests whether all elements in the array pass the test implemented by the provided function.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/every',
|
||||||
|
args: [
|
||||||
|
SymbolFunction({
|
||||||
|
name: 'callback',
|
||||||
|
description: 'A function that tests each element of the array.',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
...arraySymbolDef.items,
|
||||||
|
name: 'element',
|
||||||
|
description: 'The current element being processed in the array.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for all elements in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
returns: SymbolBoolean({
|
||||||
|
description:
|
||||||
|
'true if the callback function returns a truthy value for all elements in the array.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isArraySymbol(symbol: GenericSymbolDef): symbol is ArraySymbolDef {
|
||||||
|
return symbol.type === SymbolType.Array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { MandateProps } from '../utils';
|
||||||
|
|
||||||
|
export enum SymbolType {
|
||||||
|
Boolean = 'boolean',
|
||||||
|
Number = 'number',
|
||||||
|
String = 'string',
|
||||||
|
Object = 'object',
|
||||||
|
Array = 'array',
|
||||||
|
Function = 'function',
|
||||||
|
Union = 'union',
|
||||||
|
Undefined = 'undefined',
|
||||||
|
Null = 'null',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolMetadata {
|
||||||
|
/**
|
||||||
|
* Long description of the symbol.
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to a documentation/manual page.
|
||||||
|
*/
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericSymbolDef extends SymbolMetadata {
|
||||||
|
/**
|
||||||
|
* Type of the symbol.
|
||||||
|
*/
|
||||||
|
type: SymbolType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the symbol.
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolWithProperties extends GenericSymbolDef {
|
||||||
|
/**
|
||||||
|
* Properties on the symbol type.
|
||||||
|
*/
|
||||||
|
properties: Record<string, GenericSymbolDef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolWithMethods extends GenericSymbolDef {
|
||||||
|
/**
|
||||||
|
* Methods that can be called on the symbol type.
|
||||||
|
*/
|
||||||
|
methods: FunctionSymbolDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringSymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.String;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of enumerated values that the string symbol is retristred to.
|
||||||
|
*/
|
||||||
|
enum?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties on strings.
|
||||||
|
*/
|
||||||
|
properties: {
|
||||||
|
length: NumberSymbolDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectSymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArraySymbolDef extends SymbolWithProperties, SymbolWithMethods {
|
||||||
|
type: SymbolType.Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol representing the type of the items of the array
|
||||||
|
*/
|
||||||
|
items: ExtractSymbolDef<SymbolType>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties on arrays.
|
||||||
|
*/
|
||||||
|
properties: {
|
||||||
|
length: NumberSymbolDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionSymbolDef extends MandateProps<GenericSymbolDef, 'name'> {
|
||||||
|
type: SymbolType.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols describing the arguments of the function.
|
||||||
|
*/
|
||||||
|
args: (ExtractSymbolDef<SymbolType> & { optional?: boolean })[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol describing the returned value of the function.
|
||||||
|
*/
|
||||||
|
returns: ExtractSymbolDef<SymbolType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnionSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Union;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols composing the union.
|
||||||
|
*/
|
||||||
|
members: ExtractSymbolDef<SymbolType>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndefinedSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NullSymbolDef extends GenericSymbolDef {
|
||||||
|
type: SymbolType.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SymbolsWithPropertiesAndMethods = ArraySymbolDef | ObjectSymbolDef | StringSymbolDef;
|
||||||
|
|
||||||
|
export type ExtractSymbolDef<T extends SymbolType> = T extends SymbolType.String
|
||||||
|
? StringSymbolDef
|
||||||
|
: T extends SymbolType.Number
|
||||||
|
? NumberSymbolDef
|
||||||
|
: T extends SymbolType.Boolean
|
||||||
|
? BooleanSymbolDef
|
||||||
|
: T extends SymbolType.Array
|
||||||
|
? ArraySymbolDef
|
||||||
|
: T extends SymbolType.Object
|
||||||
|
? ObjectSymbolDef
|
||||||
|
: T extends SymbolType.Function
|
||||||
|
? FunctionSymbolDef
|
||||||
|
: T extends SymbolType.Union
|
||||||
|
? UnionSymbolDef
|
||||||
|
: T extends SymbolType.Undefined
|
||||||
|
? UndefinedSymbolDef
|
||||||
|
: T extends SymbolType.Null
|
||||||
|
? NullSymbolDef
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export function resolveSymbolDef(
|
||||||
|
symbol: GenericSymbolDef
|
||||||
|
):
|
||||||
|
| StringSymbolDef
|
||||||
|
| NumberSymbolDef
|
||||||
|
| BooleanSymbolDef
|
||||||
|
| ArraySymbolDef
|
||||||
|
| ObjectSymbolDef
|
||||||
|
| FunctionSymbolDef
|
||||||
|
| UnionSymbolDef
|
||||||
|
| UndefinedSymbolDef
|
||||||
|
| NullSymbolDef {
|
||||||
|
switch (symbol.type) {
|
||||||
|
case SymbolType.String:
|
||||||
|
return symbol as StringSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Number:
|
||||||
|
return symbol as NumberSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Boolean:
|
||||||
|
return symbol as BooleanSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Array:
|
||||||
|
return symbol as ArraySymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Object:
|
||||||
|
return symbol as ObjectSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Function:
|
||||||
|
return symbol as FunctionSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Union:
|
||||||
|
return symbol as UnionSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Undefined:
|
||||||
|
return symbol as UndefinedSymbolDef;
|
||||||
|
|
||||||
|
case SymbolType.Null:
|
||||||
|
return symbol as NullSymbolDef;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown symbol type: ${symbol.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type TemplateText = {
|
||||||
|
type: 'text';
|
||||||
|
value: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateExpression = {
|
||||||
|
type: 'expression';
|
||||||
|
value: string;
|
||||||
|
start: number; // Start index of the expression content (after `{{`)
|
||||||
|
end: number; // End index of the expression content (before `}}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplatePart = TemplateText | TemplateExpression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a template string containing `{{ expression }}` placeholders.
|
||||||
|
*/
|
||||||
|
export function parseTemplate(template: string): TemplatePart[] {
|
||||||
|
const parts: TemplatePart[] = [];
|
||||||
|
const regex = /\{\{(.*?)\}\}/gs;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of template.matchAll(regex)) {
|
||||||
|
const matchStart = match.index ?? 0;
|
||||||
|
const matchEnd = matchStart + match[0].length;
|
||||||
|
|
||||||
|
if (matchStart > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(lastIndex, matchStart),
|
||||||
|
start: lastIndex,
|
||||||
|
end: matchStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
type: 'expression',
|
||||||
|
value: (match[1] ?? '').trim(),
|
||||||
|
start: matchStart + 2,
|
||||||
|
end: matchEnd - 2,
|
||||||
|
});
|
||||||
|
lastIndex = matchEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < template.length) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(lastIndex),
|
||||||
|
start: lastIndex,
|
||||||
|
end: template.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { BinaryOperator, Expression, ExpressionStatement, LogicalOperator } from 'acorn';
|
||||||
|
|
||||||
|
import type { ArraySymbolDef, SymbolInfo, SymbolType } from './symbols';
|
||||||
|
|
||||||
|
export interface ExpressionGenerator {
|
||||||
|
/**
|
||||||
|
* Converts an ESTree compatible AST node into a string representing the corresponding expression.
|
||||||
|
*/
|
||||||
|
generate(node: Expression): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionParserResult {
|
||||||
|
/**
|
||||||
|
* The expression statement from the valid portion of the parsed expression.
|
||||||
|
*
|
||||||
|
* It is undefined when no valid expression statements could be found.
|
||||||
|
*/
|
||||||
|
result: Expression;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
|
||||||
|
*/
|
||||||
|
invalidNodes: Array<ExpressionStatement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionAutocompleteResults {
|
||||||
|
suggestions: AutocompleteSuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionalOperator = '?';
|
||||||
|
|
||||||
|
export const SUPPORTED_BINARY_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '==',
|
||||||
|
description: 'Checks whether two values are equal.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '!=',
|
||||||
|
description: 'Checks whether two values are unequal.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '===',
|
||||||
|
description: 'Checks whether two values are equal (strict comparison).',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '!==',
|
||||||
|
description: 'Checks whether two values are unequal (strict comparison).',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_inequality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '<',
|
||||||
|
description: 'Checks if the left value is less than the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '<=',
|
||||||
|
description: 'Checks if the left value is less than or equal to the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Less_than_or_equal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '>',
|
||||||
|
description: 'Checks if the left value is greater than the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '>=',
|
||||||
|
description: 'Checks if the left value is greater than or equal to the right value.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Greater_than_or_equal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: 'in',
|
||||||
|
description: 'Checks if a property exists in an object or if a value is in an array.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_LOGICAL_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '&&',
|
||||||
|
description: 'Logical AND operator; returns true if both operands are true.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operator: '||',
|
||||||
|
description: 'Logical OR operator; returns true if at least one operand is true.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SUPPORTED_CONDITIONAL_OPERATORS = [
|
||||||
|
{
|
||||||
|
operator: '?',
|
||||||
|
description:
|
||||||
|
'Conditional (ternary) operator; returns one of two values based on a condition.',
|
||||||
|
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type DirectLiteralValue = {
|
||||||
|
kind: 'direct';
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: SymbolType.Boolean;
|
||||||
|
data: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.Number;
|
||||||
|
data: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.String;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: SymbolType.Null;
|
||||||
|
data: null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type InArrayLiteralValue = {
|
||||||
|
kind: 'in-array';
|
||||||
|
srcSymbol: ArraySymbolDef;
|
||||||
|
matchedLiteralString: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DirectLiteralValueSuggestion = {
|
||||||
|
type: 'literal-value';
|
||||||
|
value: DirectLiteralValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InArrayLiteralValueSuggestion = {
|
||||||
|
type: 'literal-value';
|
||||||
|
value: InArrayLiteralValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutocompleteLiteralValueSuggestion =
|
||||||
|
| DirectLiteralValueSuggestion
|
||||||
|
| InArrayLiteralValueSuggestion;
|
||||||
|
|
||||||
|
export interface AutocompleteOperatorSuggestion {
|
||||||
|
type: 'operator';
|
||||||
|
operator:
|
||||||
|
| Extract<BinaryOperator, (typeof SUPPORTED_BINARY_OPERATORS)[number]['operator']>
|
||||||
|
| Extract<LogicalOperator, (typeof SUPPORTED_LOGICAL_OPERATORS)[number]['operator']>
|
||||||
|
| Extract<
|
||||||
|
ConditionalOperator,
|
||||||
|
(typeof SUPPORTED_CONDITIONAL_OPERATORS)[number]['operator']
|
||||||
|
>;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteSymbolSuggestion {
|
||||||
|
type: 'symbol';
|
||||||
|
symbol: SymbolInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteSuggestions = Array<
|
||||||
|
| AutocompleteSymbolSuggestion
|
||||||
|
| AutocompleteLiteralValueSuggestion
|
||||||
|
| AutocompleteOperatorSuggestion
|
||||||
|
>;
|
||||||
|
|
||||||
|
type LoggerFn = (message: string, ...args: any[]) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A logger that can be passed to the runtime.
|
||||||
|
*/
|
||||||
|
export interface Logger {
|
||||||
|
debug: LoggerFn;
|
||||||
|
info: LoggerFn;
|
||||||
|
error: LoggerFn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Format the result value of an expression for display as a string.
|
||||||
|
*/
|
||||||
|
export function formatExpressionResult(value: any, defaultValue = ''): string {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter function to exclude `null` values
|
||||||
|
*/
|
||||||
|
export function filterOutNullable<T>(value: T): value is NonNullable<T> {
|
||||||
|
return !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type to make optional properties on a object mandatory.
|
||||||
|
*
|
||||||
|
* interface SomeObject {
|
||||||
|
* uid: string;
|
||||||
|
* price: number | null;
|
||||||
|
* location?: string;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* type ValuableObject = MandateProps<SomeObject, 'price' | 'location'>;
|
||||||
|
*/
|
||||||
|
export type MandateProps<T extends {}, K extends keyof T> = T & {
|
||||||
|
[MK in K]-?: NonNullable<T[MK]>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": ["./tsconfig.json"],
|
||||||
|
"exclude": ["**/*.test.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node20/tsconfig.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"incremental": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["types/**/*.d.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
dts: true,
|
||||||
|
format: ['esm'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
declare module 'eval-estree-expression' {
|
||||||
|
/**
|
||||||
|
* Options for evaluation and compilation.
|
||||||
|
*/
|
||||||
|
export interface EvalESTreeExpressionOptions {
|
||||||
|
/**
|
||||||
|
* Force logical operators to return a boolean result. Default: undefined
|
||||||
|
*/
|
||||||
|
booleanLogicalOperators?: boolean;
|
||||||
|
/**
|
||||||
|
* Allow function calls to be evaluated. This is unsafe, please enable this option at your own risk. Default: false
|
||||||
|
*/
|
||||||
|
functions?: boolean;
|
||||||
|
/**
|
||||||
|
* Enable support for function statements and expressions by enabling the functions option AND by passing the .generate() function from the escodegen library. Default: undefined
|
||||||
|
*/
|
||||||
|
generate?: boolean | ((node: any) => string);
|
||||||
|
/**
|
||||||
|
* Enable the =~ regex operator to support testing values without using functions (example name =~ /^a.*c$/). Default: true
|
||||||
|
*/
|
||||||
|
regexOperator?: boolean;
|
||||||
|
/**
|
||||||
|
* Throw an error when variables are undefined. Default: false
|
||||||
|
*/
|
||||||
|
strict?: boolean;
|
||||||
|
/**
|
||||||
|
* Used with the variables method to return nested variables (e.g., variables with dot notation, like foo.bar.baz). Default: undefined
|
||||||
|
*/
|
||||||
|
withMembers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an ESTree expression asynchronously against a given context.
|
||||||
|
* @param expression - An object representing an ESTree-compliant AST node.
|
||||||
|
* @param context - An object containing variables and values to be used during evaluation.
|
||||||
|
* @returns A promise resolving to the result of the evaluation.
|
||||||
|
*/
|
||||||
|
export function evaluate<ASTNode>(
|
||||||
|
ast: ASTNode,
|
||||||
|
context: object,
|
||||||
|
options?: EvalESTreeExpressionOptions
|
||||||
|
): Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an ESTree expression synchronously against a given context.
|
||||||
|
* @param expression - An object representing an ESTree-compliant AST node.
|
||||||
|
* @param context - An object containing variables and values to be used during evaluation.
|
||||||
|
* @returns The result of the evaluation.
|
||||||
|
*/
|
||||||
|
export namespace evaluate {
|
||||||
|
function sync<ASTNode>(
|
||||||
|
expression: ASTNode,
|
||||||
|
context: object,
|
||||||
|
options?: EvalESTreeExpressionOptions
|
||||||
|
): any;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
src/data/*.json
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# @gitbook/fonts
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- fbfcca5: Initial version of the package
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/fonts`
|
||||||
|
|
||||||
|
Utilities to lookup default fonts supported by GitBook.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { APIv2 } from 'google-font-metadata';
|
||||||
|
|
||||||
|
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
|
||||||
|
import type { FontDefinitions } from '../src/types';
|
||||||
|
|
||||||
|
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
|
||||||
|
[CustomizationDefaultFont.Inter]: 'inter',
|
||||||
|
[CustomizationDefaultFont.FiraSans]: 'fira-sans-extra-condensed',
|
||||||
|
[CustomizationDefaultFont.IBMPlexSerif]: 'ibm-plex-serif',
|
||||||
|
[CustomizationDefaultFont.Lato]: 'lato',
|
||||||
|
[CustomizationDefaultFont.Merriweather]: 'merriweather',
|
||||||
|
[CustomizationDefaultFont.NotoSans]: 'noto-sans',
|
||||||
|
[CustomizationDefaultFont.OpenSans]: 'open-sans',
|
||||||
|
[CustomizationDefaultFont.Overpass]: 'overpass',
|
||||||
|
[CustomizationDefaultFont.Poppins]: 'poppins',
|
||||||
|
[CustomizationDefaultFont.Raleway]: 'raleway',
|
||||||
|
[CustomizationDefaultFont.Roboto]: 'roboto',
|
||||||
|
[CustomizationDefaultFont.RobotoSlab]: 'roboto-slab',
|
||||||
|
[CustomizationDefaultFont.SourceSansPro]: 'source-sans-3',
|
||||||
|
[CustomizationDefaultFont.Ubuntu]: 'ubuntu',
|
||||||
|
[CustomizationDefaultFont.ABCFavorit]: 'inter',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scripts to generate the list of all icons.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
// @ts-expect-error - we build the object
|
||||||
|
const output: FontDefinitions = {};
|
||||||
|
|
||||||
|
for (const font of Object.values(CustomizationDefaultFont)) {
|
||||||
|
const googleFontName = googleFontsMap[font];
|
||||||
|
const fontMetadata = APIv2[googleFontName.toLowerCase()];
|
||||||
|
if (!fontMetadata) {
|
||||||
|
throw new Error(`Font ${googleFontName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[font] = {
|
||||||
|
font: googleFontName,
|
||||||
|
unicodeRange: fontMetadata.unicodeRange,
|
||||||
|
variants: {
|
||||||
|
'400': {},
|
||||||
|
'700': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(output[font].variants).forEach((weight) => {
|
||||||
|
const variants = fontMetadata.variants[weight];
|
||||||
|
const normalVariant = variants.normal;
|
||||||
|
if (!normalVariant) {
|
||||||
|
throw new Error(`Font ${googleFontName} has no normal variant`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[font].variants[weight] = {};
|
||||||
|
Object.entries(normalVariant).forEach(([script, url]) => {
|
||||||
|
output[font].variants[weight][script] = url.url.woff;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeDataFile('fonts', JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We write both in dist and src as the build process might have happen already
|
||||||
|
* and tsc doesn't copy the files.
|
||||||
|
*/
|
||||||
|
async function writeDataFile(name, content) {
|
||||||
|
const srcData = path.resolve(__dirname, '../src/data');
|
||||||
|
const distData = path.resolve(__dirname, '../dist/data');
|
||||||
|
|
||||||
|
// Ensure the directories exists
|
||||||
|
await Promise.all([
|
||||||
|
fs.mkdir(srcData, { recursive: true }),
|
||||||
|
fs.mkdir(distData, { recursive: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fs.writeFile(path.resolve(srcData, `${name}.json`), content),
|
||||||
|
fs.writeFile(path.resolve(distData, `${name}.json`), content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`Error generating icons list: ${error}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/fonts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"google-font-metadata": "^6.0.3",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "bun ./bin/generate.js",
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||||
|
"unit": "bun test"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user