Compare commits

...

238 Commits

Author SHA1 Message Date
Samy Pessé 75b52eac28 Version Packages (#3651)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-10 15:11:33 +02:00
Greg Bergé ad4d07dc06 Support inline icon colors (#3717) 2025-10-10 14:51:40 +02:00
Greg Bergé 75086740f8 Fix bundling of embed package (#3716) 2025-10-10 10:57:06 +02:00
Nolann B. eea8f1e00f Enhance OpenAPI security scopes handling (#3712) 2025-10-09 10:16:12 +02:00
Greg Bergé 4f9abfb6f2 Display headings from reusable content in TOC (#3708) 2025-10-08 12:17:05 +02:00
conico974 252afceab0 Store customization in a cookie for preview route (#3707)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-10-08 10:37:52 +02:00
Viktor Renkema bef191660a Allow hiding toolbar for one session or persistent (#3698) 2025-10-08 09:48:38 +02:00
Zeno Kapitein 145625142d Unify section display condition (#3706) 2025-10-07 15:12:08 +02:00
Greg Bergé f70a2aa310 Disable flashing when navigating between sections (#3705) 2025-10-07 12:00:42 +02:00
Samy Pessé 56e46ce8ab Add Italian translation (#3704) 2025-10-06 17:07:30 +02:00
Nolann B. 754cc11e0b Fix merge logic for allOf alternatives (#3515) 2025-10-06 14:37:51 +02:00
Nolann B. 3551d147e9 Move to urls for page actions (#3703) 2025-10-06 09:42:37 +02:00
Zeno Kapitein 162cfe4163 Hide sections if only one is present (#3701) 2025-10-04 20:16:02 +02:00
Nolann B. 6815dd197e Fix markdownPageURL for PageActions (#3702) 2025-10-04 19:02:30 +02:00
Zeno Kapitein d74f8a7098 Improve wide page width (#3692) 2025-10-03 18:52:05 +02:00
Zeno Kapitein 1e53f46521 Fix button content truncate (#3693) 2025-10-03 18:51:32 +02:00
Claire Chabas 1a6216df4f Fix URL used for Ask to LLM (#3697) 2025-10-03 14:24:24 +02:00
Nolann B. a629900324 Add dev script for @gitbook/expr (#3700) 2025-10-03 13:23:15 +02:00
Nolann B. a4c33997fe Fix OpenAPI alternatives not showing (#3699) 2025-10-03 13:06:10 +02:00
Brett Jephson badf3a92f6 RND-8165: make sure we get alt text coming through on InlineImage (#3696) 2025-10-03 09:40:34 +01:00
Claire Chabas e0fb9ac3ca Support image frame (#3695) 2025-10-02 10:17:48 +02:00
spastorelli f3e40410c4 Adapt OpenAPI code samples to prefill API key using visitor data (#3666) 2025-09-30 11:09:05 +02:00
spastorelli 8e99871004 Highlight discriminator properties in oneOf, allOf, anyOf objects (#3691) 2025-09-29 17:09:21 +02:00
Zeno Kapitein 1e4e54e752 Fix rounded outline items (#3694) 2025-09-29 13:33:40 +02:00
spastorelli 50c1be3447 Move inline expression evaluation from API to GBO (#3687) 2025-09-26 10:55:09 +02:00
spastorelli 319a1e536c Ensure operation security reqs inherits from spec-level when not defined (#3690) 2025-09-26 09:11:52 +02:00
conico974 50cb0c6e15 Fix NavigationLoader on search and cmd/click (#3689)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-26 08:34:41 +02:00
Zeno Kapitein ceb32b1a2e Redo search scopes (#3688)
Co-authored-by: Claire Chabas <claire.chabas@gmail.com>
2025-09-25 16:49:53 +00:00
Zeno Kapitein f8c54b4d34 Improve assistant demo (#3686) 2025-09-25 17:07:07 +02:00
Greg Bergé c06ad70f8a Improve fonts loading (#3682) 2025-09-25 13:17:21 +02:00
Brett Jephson 0ef5dc8538 Update column width sizing (#3676) 2025-09-25 08:14:15 +01:00
Zeno Kapitein 28adc9b5f1 Add graphic to embed assistant demo page (#3685) 2025-09-24 14:37:41 +00:00
Zeno Kapitein 912d1693f5 Fix section group alignment (#3684) 2025-09-24 13:58:34 +00:00
Samy Pessé c23d4ef331 Fix crash for card cover defined without objectFit (#3683) 2025-09-24 11:01:06 +02:00
Zeno Kapitein c21693fe50 Improve screen reader accessibility for hints (#3680) 2025-09-24 10:23:34 +02:00
Zeno Kapitein c06b3dd60d Improve default site icon (#3679) 2025-09-24 10:22:49 +02:00
conico974 f173e014db Use cloudflare regional cache instead of our own implementation. (#3673)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-24 10:11:11 +02:00
conico974 1b458c436e Fix NavigationLoader not visible with bold header (#3681)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-24 09:54:57 +02:00
conico974 b42b0b3910 Add navigation loader and rename context provider (#3642)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-09-23 15:00:27 +02:00
Brett Jephson 2fc21272ff RND-7985: accessible search (#3637) 2025-09-23 11:36:50 +01:00
Zeno Kapitein 379d486488 Expose "Best match" when site has translations (#3670) 2025-09-22 22:31:17 +02:00
Zeno Kapitein cba583a31c Use siteSpace title instead of space title in search (#3677) 2025-09-22 18:03:50 +02:00
Zeno Kapitein 7a504b7cc5 Fix OpenAPI response dropdown & vertical overflow (#3678) 2025-09-22 18:03:43 +02:00
Addison 8ec5fd0986 Add blob: to style source for integrations (#3675) 2025-09-22 18:41:16 +09:00
spastorelli 529f940a45 Fix OpenAPISecurities and code sample not using operation security requirements (#3671) 2025-09-22 10:00:46 +02:00
Viktor Renkema 8a3c15911f Update tooltip labels in docs sites toolbars (#3674) 2025-09-22 09:28:42 +02:00
spastorelli d7948e34b2 Fix OpenAPI response showing as JSON instead of YAML (#3669) 2025-09-18 18:40:24 +02:00
conico974 9f42211993 Enhance tracking with geolocation headers and continent info in analytics proxy (#3667)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 18:31:21 +02:00
conico974 65e62307bf Update @opennextjs/cloudflare to version 1.8.3 (#3668)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 18:04:38 +02:00
spastorelli 469b332da2 Add support for inline expressions rendering with visitor data on GBO side (#3660) 2025-09-18 16:30:52 +02:00
Zeno Kapitein e434442f1e Support site section groups (#3664) 2025-09-18 15:28:39 +02:00
Samy Pessé 9344431bfc Fix API URL we proxy insights events to (#3665) 2025-09-18 13:14:25 +00:00
conico974 622ecbec1d Handle broken links in header and footer (#3663)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 11:22:57 +02:00
conico974 fa1382b289 Add responsive image sizes for RecordCard component (#3654)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 08:55:11 +02:00
Zeno Kapitein b8388e5a2a Add ScrollContainer component, use for section tabs (#3661) 2025-09-17 22:20:32 +02:00
Zeno Kapitein 2bce12798f Fix section group dropdown offset on big screens (#3662) 2025-09-17 19:10:52 +02:00
Greg Bergé f89e889d18 Convert relative URL to absolute in Markdown (page and copy) (#3659) 2025-09-17 18:12:01 +02:00
Zeno Kapitein 2b8a2d24b6 Add stable scroll gutter to search container (#3656) 2025-09-17 17:27:47 +02:00
Zeno Kapitein b85eccdff5 Refactor site section tabs (#3655) 2025-09-17 17:27:34 +02:00
Viktor Renkema d924e655c0 Add visibility label and smoothen magnification (#3657) 2025-09-16 16:34:21 +02:00
Zeno Kapitein b932e4e2e9 Scope search across sections and variants (#3640)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 12:07:29 +02:00
Samy Pessé ca4a3e8c9a Track utm on links from the toolbar (#3653) 2025-09-15 16:51:31 +02:00
John Cashman b498521196 Refactor robots.txt generation logic (#3621)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 14:37:02 +02:00
Samy Pessé 8a8cf2025d Skip using __session cookie if it's in the old format (#3652) 2025-09-15 12:10:51 +02:00
Samy Pessé ed7d47d9b8 Toolbar for authenticated GitBook users (#3650)
Co-authored-by: Viktor Renkema <vrenkema@gmail.com>
2025-09-15 10:32:23 +02:00
Viktor Renkema b5494cc04b Restyle the toolbar in CR and revision preview links (#3631)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 08:33:23 +02:00
Samy Pessé 229f2ba9d5 Version Packages (#3646)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 16:40:28 +02:00
spastorelli 6da36558f6 Fix exports in gitbook/expr package.json after removing src (#3648) 2025-09-12 16:20:29 +02:00
Samy Pessé ca71c1fad6 Fix MCP in production (#3647) 2025-09-12 14:59:47 +02:00
Samy Pessé 262afa357b Implement an MCP server on each docs site (#3641) 2025-09-12 13:19:16 +02:00
Samy Pessé 7375d3c597 Version Packages (#3645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 11:37:22 +02:00
spastorelli 3548fa6dff Fix eval estree expr cjs named import when gitbook/expr is imported in playwright tests (#3643) 2025-09-12 11:20:33 +02:00
conico974 cb73040e0f Bump Next.js to version 15.4.0 (#3644)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-12 11:17:43 +02:00
Samy Pessé 872d36b64f Version Packages (#3629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-11 18:06:31 +02:00
spastorelli e1ff17e655 Fix bundling of @gitbook/expr package (#3639) 2025-09-11 17:24:06 +02:00
Utku Ufuk f3010bd28a Stop using deprecated search API param (#3638) 2025-09-11 15:26:35 +03:00
spastorelli 8ff1e3b619 Port latest changes from gbx util-expr (#3636) 2025-09-09 18:55:41 +02:00
Viktor Renkema d7596bf454 Don't show toolbar when rendering within GitBook app preview (#3635) 2025-09-09 15:09:52 +02:00
Zeno Kapitein aea5eb10ae Persist language choice across sections (#3633) 2025-09-09 15:06:28 +02:00
Zeno Kapitein 1165a81cf5 Language selector fixes (#3632) 2025-09-08 17:22:42 +02:00
conico974 61d1a0192e Fix null contentRef handling in RecordColumnValue (#3566)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-08 13:54:58 +02:00
Zeno Kapitein f9a2977621 Better handling for external link "mailto:" in Hovered Card in GBO (#3630) 2025-09-08 10:31:30 +00:00
spastorelli 24f601d594 Small optim in resolveTryItPrefillForOperation and remove uneeded dep (#3628) 2025-09-08 10:29:59 +02:00
Samy Pessé 7b34537bbe Version Packages (#3599)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-06 11:40:05 +02:00
conico974 145f385fdb Add pagination for llms-full.txt file (#3619)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-05 17:20:25 +02:00
Greg Bergé 360aa1c6d6 Upgrade Scalar dependencies (#3626) 2025-09-05 16:57:21 +02:00
conico974 ccc0975240 Fix error on parameters being an object instead of an array (#3623)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-05 16:17:50 +02:00
Zeno Kapitein 791135040a Add language selector to site header (#3622) 2025-09-05 12:15:07 +00:00
spastorelli 12c9d765ff Adapt OpenAPI to eval adaptive exprs & prefill TryIt config (#3607) 2025-09-05 13:39:12 +02:00
Greg Bergé 45207288a1 Support bold in headings (#3609) 2025-09-05 11:47:39 +02:00
Viktor Renkema 177afa5828 Handle non-defined objectFit on card cover images (#3625) 2025-09-05 10:42:57 +02:00
Zeno Kapitein d51b79edbb Fix "Search bar is broken on site with sections when header is disabled" (#3624) 2025-09-04 13:48:08 +02:00
conico974 a2720ac49d Update @opennextjs/cloudflare to version 1.7.1 (#3620)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-03 16:09:27 +02:00
Viktor Renkema 5f6853d812 Support new object-fit property on individual card cover image (#3594) 2025-09-03 09:11:17 +02:00
Zeno Kapitein 17dd382dc3 Add "original" color step and apply to bg-primary-solid elements (#3618) 2025-09-02 16:08:21 +02:00
Nolann B. 98e42cfe79 Handle OpenAPI ignored responses (#3614) 2025-09-01 14:25:35 +02:00
Samy Pessé c890e01004 Fix order in robots.txt preventing indexation of images by Google (#3617) 2025-09-01 11:21:11 +00:00
Samy Pessé 659be551bb Track insight event when embedded assistant is displayed (#3616) 2025-09-01 12:59:40 +02:00
Zeno Kapitein 78a632b7fe Fix Edit on GitHub button doesn't take you to GitHub (#3615) 2025-09-01 12:01:36 +02:00
spastorelli 4927e964b7 Add support for YAML content type in request body/example (#3612) 2025-09-01 10:56:09 +02:00
Samy Pessé 262a9b198b Fix embed script caching (#3611) 2025-08-29 22:35:14 +02:00
Samy Pessé 4f3588240c Unify layout and fix tracking of ask_question (#3610) 2025-08-29 16:10:28 +02:00
Nolann B. abbae3ec4d Remove scroll-nojump utility and update layout structure (#3494)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-08-29 11:22:55 +02:00
conico974 1b8609ac60 fix next start not working (#3608)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-28 16:36:06 +02:00
Samy Pessé 193d591e9f Use space.language as source of truth (#3606) 2025-08-27 18:27:29 +02:00
Zeno Kapitein 1edc5d622a Assistant hold message (#3603) 2025-08-27 13:48:57 +00:00
Zeno Kapitein 61b8507996 OpenAPI: Make empty responses clickable (#3605) 2025-08-27 13:38:24 +00:00
Zeno Kapitein 6f368b5cf3 Fix embed assistant window width on small screens (#3604) 2025-08-27 15:33:37 +02:00
Brett Jephson 9201e2cf52 Add vertical align property to column block (#3596)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-08-27 13:58:26 +01:00
Zeno Kapitein 2e0d706d43 Fix corner radius of active section on "Line" sidebar style (#3601) 2025-08-27 13:15:07 +02:00
Nolann B. 7fefe4919c Catch OpenAPI validation errors (#3602) 2025-08-27 12:29:50 +02:00
Zeno Kapitein 729921f338 Move "Edit on Git" and "Export PDF" actions into page actions dropdown (#3574) 2025-08-27 11:32:55 +02:00
Zeno Kapitein 1839ea2678 Fix content min-height with sections (#3597) 2025-08-27 11:30:22 +02:00
Zeno Kapitein f08dd29364 Fix Search results are not clickable (#3595) 2025-08-26 20:33:37 +00:00
conico974 ca2dfa3065 Rollback to Next.js 15.3 (#3600)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-26 18:26:32 +02:00
Utku Ufuk 10ded437f0 Turn on the "noUncheckedIndexedAccess" flag in all TS config files (#3587) 2025-08-26 19:25:54 +03:00
Zeno Kapitein d1fdc13441 Remove ligatures from Lato font (#3592) 2025-08-26 15:04:22 +00:00
Zeno Kapitein a8fca0e033 Fix custom assistant keyboard shortcut (#3598) 2025-08-26 14:57:08 +00:00
Samy Pessé 9251b33959 Version Packages (#3535)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-26 14:21:26 +02:00
Samy Pessé 81a6bd756a Embeddable GitBook API (#3593) 2025-08-26 14:08:24 +02:00
spastorelli ada195d329 Add gitbook/expr package (#3586) 2025-08-25 13:10:19 +02:00
conico974 1211ebea9b Proxy analytics events (#3570)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-25 11:34:54 +02:00
Zeno Kapitein 2e6e28eb54 Fix: Long strings overflow out of message bubble in docs assistant (#3588) 2025-08-22 19:23:05 +02:00
Zeno Kapitein 854c448bad Custom assistants followup (#3584) 2025-08-22 17:32:10 +02:00
conico974 64a0b0f169 Add logging for request duration and response details in 2c (#3583)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-22 17:15:00 +02:00
Zeno Kapitein 13ff22b6a8 Fix AI Search follow-up question closing search (#3585) 2025-08-22 14:51:21 +02:00
Samy Pessé 8927e8fbbb Embeddable Docs Assistant (#3572)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-08-22 09:44:51 +02:00
Samy Pessé 25e2b40d47 Update icons to latest version (#3582) 2025-08-21 15:31:13 +02:00
Zeno Kapitein a60af8ff9c Simplify tooltip implementation, add HoverCard (#3580) 2025-08-21 11:14:08 +02:00
Zeno Kapitein 27b328cfd7 Fix pageaside height on pages with hero cover (#3581)
Co-authored-by: Claire Chabas <claire.chabas@gmail.com>
2025-08-21 10:58:13 +02:00
conico974 fa6ef0c01c Remove motion animations from ScrollSectionList (#3573)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-20 19:14:01 +02:00
Zeno Kapitein 6217a2ed3b Page outline: scroll to active item (#3578) 2025-08-20 15:55:16 +02:00
Zeno Kapitein ffa866cb99 Fixes to search modal (#3577) 2025-08-20 10:50:58 +00:00
Zeno Kapitein 011aa931cf Integration tools styling (#3575)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-08-20 09:48:55 +00:00
Taran Vohra 68308154b5 Register custom assistant (#3569)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-08-19 12:07:47 +02:00
Zeno Kapitein 36af03f4d5 Pageaside fixes (#3567) 2025-08-18 18:50:00 +02:00
Samy Pessé cbc71a56b6 API to let integrations define custom tools for Docs Assistant (#3568) 2025-08-17 10:03:54 +02:00
Zeno Kapitein 1420180220 Assistant: Prevent sending queries multiple times (#3565) 2025-08-15 08:44:31 +02:00
Zeno Kapitein e1b2cf6d81 Fix page outline scroll (#3563) 2025-08-14 17:52:56 +02:00
conico974 c0e9c49679 Optimize cache retrieval performance and reduce costs on 2C (#3542)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-14 09:14:40 +02:00
conico974 afc4bd4415 Improve image resizing logic in fetchImage function (#3553)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-13 15:35:14 +02:00
Zeno Kapitein bcfa8d8b19 Improve vertical alignment of site items and fix floating page aside (#3560) 2025-08-13 14:36:07 +02:00
Zeno Kapitein 43766d6519 Fix custom logo not rendering on the published site (#3557) 2025-08-13 14:29:49 +02:00
Greg Bergé 42c17f5c74 Improve OpenAPI parsing errors (#3555) 2025-08-13 13:38:14 +02:00
Greg Bergé d655d3eece Support "objectivec" as alias for Objective C syntax (#3556) 2025-08-13 13:37:06 +02:00
conico974 3500de95e8 Never follow redirect in the middleware (#3552)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-13 12:58:43 +02:00
Taran Vohra 7f03b94949 Fix an issue where recommended questions werent using the current siteSpace context (#3559) 2025-08-13 11:06:05 +05:30
Zeno Kapitein f0cf060191 Disable AI Search tests for now (#3561) 2025-08-12 22:12:51 +02:00
Zeno Kapitein 5a137e7b14 Always show placeholder while generating (#3554) 2025-08-12 15:43:29 +02:00
conico974 28f7fbaa8a Fix image format handling and enhance PageCoverImage component (#3527)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 18:30:00 +02:00
Nolann B. ff96bb5787 Support new coverDefinitionDark for cards & image type (#3547) 2025-08-08 18:08:11 +02:00
conico974 87841124e0 Add HashContext and HashProvider for managing URL hash state (#3541)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 17:01:21 +02:00
conico974 a650b58276 feat: add tracing to server actions (#3543)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-08 16:10:06 +02:00
Zeno Kapitein 388b20d44f Clear AI chat stream properly (#3544) 2025-08-07 19:07:25 +02:00
Claire Chabas ea7e94fc51 Fix search bar layout shit caused by the ToC (#3545) 2025-08-07 15:19:20 +02:00
Zeno Kapitein d30bcbabdb Improve Button and ButtonGroup styling (#3540) 2025-08-06 16:10:55 +02:00
Zeno Kapitein fb858a1941 Tweaks to AIChatButton and AIChatInput (#3539) 2025-08-06 16:10:05 +02:00
Samy Pessé cc2e615b9a Emit a <link rel="alternate"> for the markdown version of a page (#3528) 2025-08-06 08:55:02 +02:00
Samy Pessé a862cde6b4 Use new API to fetch the reusable content document (#3536) 2025-08-06 08:54:43 +02:00
Zeno Kapitein e2b7bec162 Add linewrap to AI actions dropdown (#3534) 2025-08-05 12:17:00 +00:00
Zeno Kapitein c1b577ecc9 Updates to RU translation (#3532) 2025-08-05 14:10:45 +02:00
Zeno Kapitein ba7ec14f76 Fix z-index of search highlight (#3533) 2025-08-05 14:08:46 +02:00
Zeno Kapitein 9f0117723c Add russian translation (#3531) 2025-08-05 12:52:30 +02:00
conico974 f72b6a20db Validate 'next-action' header format in server action requests (#3523)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:31:43 +02:00
conico974 2f798209bb Bump OpenNext to 1.6.4 (#3520)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:18:56 +02:00
conico974 eec8f16424 Increase timeout for search answer visibility check (#3530)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-08-05 11:18:12 +02:00
Samy Pessé 885264f132 Version Packages (#3506)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 11:03:50 +02:00
Greg Bergé f1a6dec951 Less strict OpenAPI parser (#3529)
Co-authored-by: Nolann Biron <biron.nolann@gmail.com>
2025-08-05 08:45:45 +00:00
Claire Chabas 44f41510fa Fix close buttons tooltips (#3525) 2025-08-04 15:22:59 +02:00
spastorelli f220229e5e Fix stepper title misalignment (#3524) 2025-08-04 09:34:22 +02:00
Greg Bergé 61b4cb8d7c Upgrade Argos (#3522) 2025-08-01 16:07:35 +02:00
Greg Bergé 185cdb4883 Fix scrolling to anchor positionning (#3521) 2025-08-01 15:44:27 +02:00
Zeno Kapitein 2cdba53451 Upgrade to Tailwind v4 (#3206) 2025-08-01 13:03:44 +02:00
Greg Bergé 950f6c36cf greg/rnd 6997 fix long openapi responses label (#3518) 2025-07-31 12:08:57 +00:00
Zeno Kapitein ea37977b1e Fix inconsistent state between q= and ask= and keyboard navigation (#3519) 2025-07-31 13:20:27 +02:00
Greg Bergé 81da82aed7 Increase tests max duration (#3517) 2025-07-30 18:33:56 +00:00
Greg Bergé 39a3518651 Show a HoverCard when an internal link is broken (#3516) 2025-07-30 18:09:57 +02:00
spastorelli d903273759 Strip visitor.* param when normalizing URL (#3512) 2025-07-30 17:22:09 +02:00
Taran Vohra d130532f69 Suggest questions in the current space context (#3514) 2025-07-30 20:36:40 +05:30
Zeno Kapitein 94fcd21f34 Improve ?ask= init handling (#3513) 2025-07-30 13:17:15 +02:00
conico974 1d2db4ae2b Fix cookie handling for AI assistant server actions (#3509)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-30 12:29:51 +02:00
Steven H d270b4af9b Don't track events if a special middleware header is set. (#3511) 2025-07-30 09:26:01 +01:00
Zeno Kapitein ccfbd3c1a8 Fix AI Search Query (#3510) 2025-07-30 07:44:37 +00:00
Claire Chabas 5c4923557f Temporarily remove highlighting for PowerShell and C++ in Safari to avoid page crash (#3508) 2025-07-29 20:39:26 +02:00
Zeno Kapitein b1f608b865 Implement basic URL scheme for Assistant & AI Search (#3503) 2025-07-29 16:13:44 +02:00
conico974 c7993756dd Fix regional caching of tag cache (#3504)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-29 13:27:56 +02:00
Nolann B. d02465823f Prevent page breaks inside blocks when printing (#3507) 2025-07-28 18:08:08 +02:00
Greg Bergé 6b9f6b5b79 Improve french translations (#3501) 2025-07-28 14:05:04 +02:00
Taran Vohra df7de8fb0c Fix an issue where links were not rendering properly in Ask AI answers (#3505) 2025-07-28 16:43:18 +05:30
Samy Pessé 72f5ce3985 Version Packages (#3480)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-24 13:20:46 +02:00
conico974 04c80262e2 Make sure to use the correct version for adaptive content (#3487)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-24 11:40:12 +02:00
Valentino Hudhra 56d1b8fe2d Fix overflowing recommended follow up questions in assistant (#3502) 2025-07-24 11:03:04 +02:00
Zeno Kapitein 054a1634ab Improve AI Actions dropdown (#3499) 2025-07-24 10:40:13 +02:00
conico974 6ff63f2db6 Don't throw on >500 API error (#3500)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-24 09:41:47 +02:00
Greg Bergé b5ad0ce1e5 Fix OpenAPI v2 parsing (#3498) 2025-07-23 17:21:05 +02:00
Claire Chabas 2ba7e54c81 Fix headings styles in hint block (#3497) 2025-07-23 15:46:44 +02:00
Zeno Kapitein 0a858e74ad Fix React warning (#3496) 2025-07-23 10:56:12 +02:00
Zeno Kapitein b0c534fc2c Tweaks to AI Chat and AI Actions Dropdown (#3495) 2025-07-22 19:18:35 +02:00
Steven H 250c194a02 Add nosnippet to announcement banner. (#3490) 2025-07-22 15:03:35 +01:00
Zeno Kapitein 5b10cde936 Fix background corners showing through Powered through GitBook badge (#3493) 2025-07-22 15:15:38 +02:00
Zeno Kapitein 4aeb81bfa5 Fix search input styling & text cutoff (#3491) 2025-07-22 14:58:20 +02:00
conico974 955cebf944 Add cache tags for fetchFileSystem (#3489)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-22 13:41:31 +02:00
Greg Bergé 7c951ef437 Support new colors in text formatting (#3492) 2025-07-22 13:40:03 +02:00
Brett Jephson 68f0dbcc35 RND-7464: confirm in content kit button (#3488) 2025-07-22 10:51:25 +01:00
Zeno Kapitein 39c4f76aea Add fullwidth page option (#3344) 2025-07-21 11:20:17 +02:00
Zeno Kapitein 2fe58e8cbb Page actions customisation (#3485)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-07-18 21:10:38 +02:00
Zeno Kapitein 765141a4d8 Make "last updated" section optional (#3486) 2025-07-18 18:46:11 +00:00
Zeno Kapitein eb1bd3aa01 AI response feedback buttons (#3482) 2025-07-18 15:40:04 +00:00
Zeno Kapitein b7284b0223 Small tweaks to assistant styling (#3481) 2025-07-17 14:31:53 +00:00
conico974 5bbd254742 Support webp and avif formats for og image (#3478)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-17 11:36:07 +02:00
Nolann B. 611e28626a Remove padding on first Heading in Columns (#3479) 2025-07-16 12:51:37 +02:00
Samy Pessé 2a3bb0eb22 Version Packages (#3453)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-15 17:40:24 +02:00
conico974 6816f0fa5a Add browserslist and improve CSS masks for compatibility (#3471)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-15 17:15:56 +02:00
Greg Bergé 17386776ec Add missing changeset to release @gitbook/openapi-parser (#3477) 2025-07-15 12:01:40 +00:00
Nolann B. 9ee9082614 Improvement for CopyMarkdown action (#3465) 2025-07-15 13:41:25 +02:00
Nolann B. 334cfdd522 Fix responsive class for SearchInput button (#3476) 2025-07-15 12:50:57 +02:00
Greg Bergé 8c961a5dbd Fix OpenAPI spec validation (#3473) 2025-07-15 08:56:08 +02:00
conico974 4708956e77 Ensure schema.security is always an array (#3472)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-14 22:01:06 +02:00
Zeno Kapitein 813dd03dbb Fix AIChatInput focus & styling (#3470)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-07-14 11:48:34 +01:00
conico974 0522dbc61e Handle markdown errors gracefully (#3468)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-11 19:11:09 +02:00
Zeno Kapitein acb9f53156 Inline search modal (#3416) 2025-07-11 16:29:03 +01:00
conico974 6016846156 Add cache life inference from API response headers (#3467)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-11 17:10:00 +02:00
Greg Bergé cf1fae5837 Fix using CMD or CTRL to open in a new tab (#3466) 2025-07-11 14:25:37 +00:00
Nolann B. 7212345466 Fix AI Actions dropdown and LLM integration (#3464) 2025-07-11 12:14:06 +02:00
conico974 e38caf78ff Implement consistent logging across the application (#3368)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-11 10:36:03 +02:00
Greg Bergé 0a8dd67541 Better aria for breadcrumbs (#3461) 2025-07-11 09:45:14 +02:00
Nolann B. ed684c19a9 Move AI Actions markdown fetching to client-side (#3459) 2025-07-10 16:05:53 +02:00
Zeno Kapitein 52ab3681e3 Reposition AI Actions dropdown (#3460) 2025-07-09 14:38:23 +00:00
Zeno Kapitein 9cc5a787e9 Generalise keyboard shortcuts, add Cmd+J to AI Chat (#3457) 2025-07-09 12:13:36 +00:00
Nolann B. 0003030bf5 Implement AI actions dropdown (#3431) 2025-07-08 23:33:36 +02:00
Zeno Kapitein 9ad8f45d38 Rebrand assistant (#3458) 2025-07-08 21:31:14 +02:00
conico974 a3855257c8 Gracefully handle OpenAPI fetch errors (#3455)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-08 17:35:23 +02:00
conico974 9169c2f053 Fix site redirect resolution with length check for source paths (#3454)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-08 17:15:57 +02:00
Greg Bergé abe028bd38 Fix Link behaviour reloading the page (#3456) 2025-07-08 14:30:12 +00:00
conico974 12c2451f4f Bump OpenNext to latest versions (#3451)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-07-08 15:06:23 +02:00
Samy Pessé 8daede5427 Ensure all content links are resolved relatively to preview (#3450) 2025-07-08 13:05:31 +02:00
Samy Pessé 4c89aa60eb Adapt cache header parsing to support s-maxage (#3452) 2025-07-08 12:55:00 +02:00
Greg Bergé d0a3f64ae4 Always open header logo link in the current tab (#3445) 2025-07-08 09:02:49 +00:00
422 changed files with 25412 additions and 7056 deletions
@@ -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@v4
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
+35 -17
View File
@@ -102,7 +102,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -110,8 +110,8 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run Playwright tests
run: bun e2e
env:
@@ -123,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -131,8 +131,8 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run Playwright tests
run: bun e2e
env:
@@ -144,7 +144,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -152,8 +152,8 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run Playwright tests
run: bun e2e-customers
env:
@@ -165,7 +165,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -173,8 +173,8 @@ jobs:
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run Playwright tests
run: bun e2e-customers
env:
@@ -182,9 +182,9 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v2:
browserless-testing-v2-vercel:
runs-on: ubuntu-latest
name: PageSpeed Testing v1
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
steps:
- name: Checkout
@@ -195,8 +195,26 @@ jobs:
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
- name: Run tests
run: cd ./packages/gitbook && bun e2e-browserless
env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
# needs: deploy-v2-cloudflare
# 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 tests
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
+3 -1
View File
@@ -18,10 +18,12 @@
"**/.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"
"packages/gitbook/worker-configuration.d.ts",
"**/*.css"
]
},
"formatter": {
+965 -510
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -3,9 +3,9 @@
"version": "0.1.0",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
"@changesets/cli": "^2.29.7",
"turbo": "^2.5.8",
"vercel": "^39.4.2"
},
"packageManager": "bun@1.2.15",
"overrides": {
@@ -16,7 +16,7 @@
},
"private": true,
"scripts": {
"dev": "turbo run dev",
"dev": "turbo run dev --concurrency 20",
"build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck",
@@ -26,15 +26,18 @@
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"changeset-version": "changeset version && bun run format",
"release": "turbo run release && changeset publish",
"changeset-version": "changeset version && bun run format && bun update",
"release": "turbo run release && bun run publish-all-packages",
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean"
},
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.128.0"
"@gitbook/api": "0.143.2",
"bidc": "^0.0.2",
"tsdown": "^0.15.6"
}
},
"patchedDependencies": {
+1
View File
@@ -0,0 +1 @@
dist/
+13
View File
@@ -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
+3
View File
@@ -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.
+24
View File
@@ -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"]
}
+84
View File
@@ -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;
}
}
+25
View File
@@ -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"]
}
+10 -18
View File
@@ -90,7 +90,7 @@ export function getCacheTag(
| {
tag: 'translation';
organization: string;
translationSettings: string;
translation: string;
}
): string {
switch (spec.tag) {
@@ -115,23 +115,12 @@ export function getCacheTag(
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
return `organization:${spec.organization}:translation:${spec.translation}`;
default:
assertNever(spec);
}
}
/**
* Get the cache tag for a given URL.
*/
export function getCacheTagForURL(url: string | URL) {
const parsedURL = url instanceof URL ? url : new URL(url);
return getCacheTag({
tag: 'url',
hostname: parsedURL.hostname,
});
}
/**
* Get the tags for a computed content source.
*/
@@ -144,6 +133,10 @@ export function getComputedContentSourceCacheTags(
) {
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 ?? {});
@@ -167,18 +160,18 @@ export function getComputedContentSourceCacheTags(
})
);
break;
case 'translation-language':
case 'translation':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
translation: dependency.ref.translation,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
// as it might mean we are lagging behind the API version
break;
}
});
@@ -194,9 +187,8 @@ export function getComputedContentSourceCacheTags(
}
// 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];
const integration = source.type.split(':')[1]!;
tags.push(
getCacheTag({
tag: 'integration',
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
+10
View File
@@ -1,5 +1,15 @@
# @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
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.3.3",
"version": "0.4.0",
"devDependencies": {
"typescript": "^5.5.3"
},
+16 -11
View File
@@ -8,7 +8,7 @@ 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]; // Reference white (D65)
const D65 = [95.047, 100.0, 108.883] as const; // Reference white (D65)
export enum ColorCategory {
backgrounds = 'backgrounds',
@@ -19,7 +19,7 @@ export enum ColorCategory {
}
type ColorSubScale = {
[key: string]: number;
[key: string]: number | string;
};
/**
@@ -56,6 +56,8 @@ export const scale: Record<ColorCategory, ColorSubScale> = {
solid: 9,
/** Hovered solid backgrounds */
'solid-hover': 10,
/** Original color */
original: 'original',
},
[ColorCategory.text]: {
/** Very low-contrast text
@@ -211,8 +213,8 @@ export function colorScale(
const result = [];
for (let index = 0; index < mapping.length; index++) {
const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
const step = mapping[index]!;
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
if (
index === 8 &&
@@ -295,7 +297,7 @@ export function rgbArrayToHex(rgb: RGBColor): string {
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index] - channel));
return Math.round(channel + percentage * (start[index]! - channel));
});
return rgbArrayToHex(rgb as RGBColor);
@@ -392,14 +394,14 @@ export function xyzToLab65(xyz: [number, number, number]): {
B: number;
} {
const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[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),
L: 116 * y! - 16,
A: 500 * (x! - y!),
B: 200 * (y! - z!),
};
}
@@ -419,7 +421,10 @@ export function dpsContrast(a: RGBColor, b: RGBColor) {
return contrast < 7.5 ? 0 : contrast;
}
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
export function colorContrast(
background: string,
foreground: string[] = [LIGHT_BASE, DARK_BASE]
): string {
const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = {
@@ -436,5 +441,5 @@ export function colorContrast(background: string, foreground: string[] = [LIGHT_
}
}
return best.color ? rgbArrayToHex(best.color) : foreground[0];
return best.color ? rgbArrayToHex(best.color) : foreground[0] || LIGHT_BASE;
}
+1
View File
@@ -5,6 +5,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
+2
View File
@@ -0,0 +1,2 @@
dist/
standalone/
+25
View File
@@ -0,0 +1,25 @@
# @gitbook/embed
## 0.1.2
### Patch Changes
- 7508674: Fix bundling of packages
## 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
+49
View File
@@ -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>
```
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@gitbook/embed",
"description": "Embeddable components for GitBook",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
}
},
"version": "0.1.2",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:"
},
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"tsdown": "catalog:",
"typescript": "^5.5.3",
"react": "^19.0.0"
},
"scripts": {
"build": "bun run build-lib && bun run build-standalone",
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"typecheck": "tsc --noEmit"
},
"files": ["dist", "README.md", "CHANGELOG.md"]
}
@@ -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)
);
};
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export * from './createGitBook';
export * from './createGitBookFrame';
export * from './protocol';
+90
View File
@@ -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';
};
+1
View File
@@ -0,0 +1 @@
export * from './client';
+51
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
'use client';
import * as React from 'react';
import type { GitBookClient } from '../client';
export const GitBookContext = React.createContext<GitBookClient | null>(null);
+2
View File
@@ -0,0 +1,2 @@
export * from './GitBookProvider';
export * from './GitBookFrame';
+178
View File
@@ -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', {});
+171
View File
@@ -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);
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.ts', 'src/react/index.ts'],
dts: true,
format: ['esm'],
},
]);
+1
View File
@@ -0,0 +1 @@
dist
+35
View File
@@ -0,0 +1,35 @@
# @gitbook/expr
## 1.2.1
### Patch Changes
- a629900: Add dev script for @gitbook/expr
## 1.2.0
### Minor Changes
- 6da3655: Fix exports in gitbook/expr package.json
## 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.
+3
View File
@@ -0,0 +1,3 @@
# `@gitbook/expr`
Safely evaluate & parse user-defined GitBook expressions.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.1",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"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": "catalog:",
"@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15",
"@types/escodegen": "^0.0.10"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"clean": "rm -rf ./dist",
"dev": "bun run build -- --watch"
},
"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',
},
});
});
});
+178
View File
@@ -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!');
});
});
+568
View File
@@ -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;
}
+24
View File
@@ -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;
}
}
+7
View File
@@ -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';
+77
View File
@@ -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}`);
}
}
+289
View File
@@ -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: [],
});
});
});
});
+3
View File
@@ -0,0 +1,3 @@
export * from './symbols';
export * from './symbols-table';
export * from './types';
+350
View File
@@ -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>;
}
+322
View File
@@ -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;
}
+193
View File
@@ -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}`);
}
}
+57
View File
@@ -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;
}
+176
View File
@@ -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;
}
+40
View File
@@ -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]>;
};
+18
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.ts'],
dts: true,
format: ['esm'],
},
]);
+57
View File
@@ -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;
}
}
+2 -2
View File
@@ -67,7 +67,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
const body = token.slice(2); // drop "U+"
const [startHex, endHex] = body.split('-');
const start = Number.parseInt(startHex, 16);
const start = Number.parseInt(startHex!, 16);
const end = endHex ? Number.parseInt(endHex, 16) : start;
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
@@ -92,7 +92,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
for (const [label, rangesArr] of Object.entries(parsed)) {
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
hits[label]++;
hits[label]!++;
}
}
}
+1
View File
@@ -4,6 +4,7 @@
"module": "ESNext",
"target": "es2022",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
+225
View File
@@ -1,5 +1,230 @@
# gitbook
## 0.19.0
### Minor Changes
- ed7d47d: Show a toolbar for authenticated users with access to the current site.
- 50c1be3: Move inline expression evaluation from API to GBO
- 56e46ce: Add Italian translation
- b932e4e: Scope search across sections and variants
### Patch Changes
- 162cfe4: Hide sections if only one is present
- 1456251: Unify section display condition
- 9344431: Fix missing geo data in site insights because of incorrect proxying of events.
- eea8f1e: Enhance OpenAPI security scopes handling
- 2b8a2d2: Add stable scroll gutter to search container
- b498521: Fix robots.txt preventing favicon from being indexed.
- ceb32b1: Redo search scope UI
- 8e99871: Highlight discriminator properties in oneOf, allOf, anyOf objects
- 2fc2127: Make search accessible
- f3e4041: Adapt OpenAPI code samples to prefill API key using visitor data
- 6815dd1: Fix markdownPageURL for PageActions
- c06b3dd: Improve default site icon
- cba583a: Use sitespace title instead of space title in search
- e434442: Support nested site section groups
- 469b332: Add support for inline expressions rendering with visitor data on GBO side
- 0ef5dc8: Update to column width sizing
- 1e4e54e: Fix rounded outline items
- 379d486: Expose "Best match" when site has translations
- c21693f: Improve screen reader accessibility for hints
- badf3a9: Add alt text to inline image
- c23d4ef: Fix crash for card cover defined without objectFit
- d74f8a7: Improve wide page width layout
- b8388e5: Add scrollcontainer component
- b85eccd: Refactor section tabs
- 1e53f46: Fix button content truncate
- 7a504b7: Fix OpenAPI response dropdown & vertical overflow
- a4c3399: Fix OpenAPI alternatives not showing
- Updated dependencies [a629900]
- Updated dependencies [eea8f1e]
- Updated dependencies [8e99871]
- Updated dependencies [f3e4041]
- Updated dependencies [319a1e5]
- Updated dependencies [529f940]
- Updated dependencies [754cc11]
- Updated dependencies [d7948e3]
- Updated dependencies [a4c3399]
- @gitbook/expr@1.2.1
- @gitbook/react-openapi@1.5.0
## 0.18.0
### Minor Changes
- 262afa3: Expose a MCP server for the docs site under /~gitbook/mcp
### Patch Changes
- Updated dependencies [262afa3]
- @gitbook/icons@0.3.1
- @gitbook/react-openapi@1.4.3
- @gitbook/react-contentkit@0.7.5
## 0.17.2
### Patch Changes
- @gitbook/react-openapi@1.4.2
## 0.17.1
### Patch Changes
- 24f601d: Small optim in resolveTryItPrefillForOperation
- aea5eb1: Persist language choice across sections if possible
- 1165a81: Language selector edge cases
- f9a2977: Better handling for external link "mailto:" in Hovered Card in GBO
- Updated dependencies [24f601d]
- @gitbook/react-openapi@1.4.1
## 0.17.0
### Minor Changes
- 12c9d76: Adapt OpenAPI blocks to eval adaptive exprs & prefill TryIt config
- 7911350: Add language selector to site header
- 659be55: Track insight event when embedded assistant is displayed.
### Patch Changes
- 2e0d706: Fix corner radius of active section on "Line" sidebar style
- 78a632b: Fix edit on GitHub button doesn't take you to GitHub
- 1edc5d6: Add "hold message" to Assistant
- 4520728: Support bold in headings
- 61b8507: OpenAPI: Make responses without objects clickable
- d1fdc13: Remove ligatures from Lato font
- a8fca0e: Fix custom assistant keyboard shortcut
- 262a9b1: Fix embed script and assets being cached for too long
- c890e01: Fix order in robots.txt preventing indexation of images by Google.
- 1839ea2: Fix content min-height with sections
- 9201e2c: Adds vertical align to column block
- 193d591: Use space language as source of truth for UI locale
- f08dd29: Fix Search results are not clickable on sites without header
- 17dd382: Add `original` background color step
- 4f35882: Fix event ask_question not being tracked
- d51b79e: Fix Search bar is broken on site with sections when header is disabled
- 6f368b5: Fix embed assistant window width on small screens
- Updated dependencies [193d591]
- Updated dependencies [12c9d76]
- Updated dependencies [4927e96]
- Updated dependencies [61b8507]
- Updated dependencies [7fefe49]
- Updated dependencies [360aa1c]
- Updated dependencies [98e42cf]
- Updated dependencies [17dd382]
- @gitbook/colors@0.4.0
- @gitbook/react-openapi@1.4.0
- @gitbook/openapi-parser@3.0.2
## 0.16.0
### Minor Changes
- 6830815: Support custom AI providers
- cbc71a5: Allow integrations to provide tools to the Docs Assistant
- cc2e615: Emit a <link rel="alternate"> for the markdown version of a page
- 81a6bd7: Support customization of buttons and tools through iframe API
- 8927e8f: Start routes for embeddable version of the assistant and docs pages.
### Patch Changes
- d30bcba: Improve `Button` and `ButtonGroup` styling
- e1b2cf6: Fix scroll of page outline
- d655d3e: Support "objectivec" as alias for Objective C syntax
- 13ff22b: Fix AI Search follow-up question closing search
- ffa866c: Small fixes to search modal
- 36af03f: Fixes to `PageAside`
- fb858a1: Tweaks to AIChatButton and AIChatInput
- bcfa8d8: Improve vertical alignment of site items and fix floating page aside
- ea7e94f: Fix search bar layout shift caused by ToC
- 2e6e28e: Fix: Long strings overflow out of message bubble in docs assistant
- ff96bb5: Support new coverDefinitionDark for cards & image type
- 388b20d: Clear AI chat properly
- ba7ec14: Fix bug in search highlight in GBO
- 6217a2e: Page outline: scroll to active item
- 42c17f5: Improve OpenAPI parsing errors
- 854c448: Custom assistants followup
- 43766d6: Fix Custom logo not rendering on the published site
- Updated dependencies [cbc71a5]
- Updated dependencies [25e2b40]
- Updated dependencies [42c17f5]
- Updated dependencies [854c448]
- @gitbook/browser-types@0.1.0
- @gitbook/icons@0.3.0
- @gitbook/openapi-parser@3.0.1
- @gitbook/react-contentkit@0.7.4
- @gitbook/react-openapi@1.3.6
## 0.15.0
### Minor Changes
- d130532: Suggest questions in the current space context
### Patch Changes
- 44f4151: Fix close buttons tooltips
- d903273: Strip visitor params from URL
- df7de8f: Fix an issue where links were not rendering properly in Ask AI answers
- d270b4a: All tracking is now disabled for dynamic routes such as the site preview.
- b1f608b: Implement basic URL scheme for assistant with ask
- d024658: Prevent page breaks inside blocks when printing
- 185cdb4: Fix scrolling to anchor positionning
- 5c49235: Remove highlighting in Safari for PowerShell and C++ to avoid page crash until next version with bug fix is released
- f1a6dec: Update OpenAPI parser
- 2cdba53: Upgrade to Tailwind v4
- Updated dependencies [2cdba53]
- Updated dependencies [f1a6dec]
- @gitbook/react-math@0.6.1
- @gitbook/icons@0.2.2
- @gitbook/openapi-parser@3.0.0
- @gitbook/react-contentkit@0.7.3
- @gitbook/react-openapi@1.3.5
## 0.14.1
### Patch Changes
- 7c951ef: 'Support new colors in text formatting
- 39c4f76: Add fullwidth page option
- eb1bd3a: AI response feedback buttons
- 2ba7e54: Fix headings styles in hint block
- 250c194: Add nosnippet to announcement banner.
- 4aeb81b: Fix search input styling & text cutoff
- 68f0dbc: Add confirmation modal to contentkit button
- 611e286: Remove padding on first Heading in Columns
- b0c534f: Tweaks to AI Chat and AI Actions Dropdown
- Updated dependencies [b5ad0ce]
- @gitbook/openapi-parser@2.2.2
- @gitbook/react-openapi@1.3.4
## 0.14.0
### Minor Changes
- 0003030: Implement AI actions dropdown
- acb9f53: New search layout
### Patch Changes
- 334cfdd: Fix responsive class for SearchInput button
- 6816f0f: add browserlists and fix old browser css-masks
- 9ee9082: Refactor icon loading state in AIAction components
- 52ab368: Reposition AI Actions dropdown
- 7212345: Fix AI Actions dropdown and LLM integration
- 8daede5: Ensure all content links are resolved relatively to preview.
- ed684c1: Move AI Actions markdown fetching to client-side
- 9cc5a78: Generalise keyboard shortcuts, add Cmd+J to AI Chat
- Updated dependencies [6816f0f]
- Updated dependencies [1738677]
- @gitbook/icons@0.2.1
- @gitbook/openapi-parser@2.2.1
- @gitbook/react-contentkit@0.7.2
- @gitbook/react-openapi@1.3.3
## 0.13.1
### Patch Changes
+5 -5
View File
@@ -62,11 +62,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.gmgn.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.spicychat.ai',
contentBaseURL: 'https://docs.spicychat.ai',
tests: [{ name: 'Home', url: '/' }],
},
// {
// name: 'docs.spicychat.ai',
// contentBaseURL: 'https://docs.spicychat.ai',
// tests: [{ name: 'Home', url: '/' }],
// },
{
name: 'docs.portainer.io',
contentBaseURL: 'https://docs.portainer.io',
+215 -56
View File
@@ -1,4 +1,5 @@
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCorners,
CustomizationDefaultMonospaceFont,
@@ -19,6 +20,7 @@ import {
import { getSiteAPIToken } from '../tests/utils';
import {
type Test,
type TestsCase,
allDeprecatedThemePresets,
allLocales,
@@ -34,6 +36,179 @@ import {
waitForNotFound,
} from './util';
const searchTestCases: Test[] = [
{
name: 'Search - AI Mode: None - Complete flow',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
// Fill search input, expecting search results
await searchInput.fill('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
const pageResults = await page.getByTestId('search-page-result').all();
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
},
},
{
name: 'Search - AI Mode: None - Keyboard shortcut',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await page.keyboard.press('ControlOrMeta+K');
await expect(page.getByTestId('search-input')).toBeFocused();
},
},
{
name: 'Search - AI Mode: None - URL query (Initial)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
})}&q=`,
run: async (page) => {
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
},
},
{
name: 'Search - AI Mode: None - URL query (Results)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
})}&q=gitbook`,
run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Search - AI Mode: Search - Complete flow
// - Search - AI Mode: Search - URL query (Initial)
{
name: 'Search - AI Mode: Search - URL query (Results)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.Search,
},
})}&q=gitbook`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Ask - AI Mode: Search - URL query (Ask initial)
// - Ask - AI Mode: Search - URL query (Ask results)
{
name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
const searchInput = page.locator('css=[data-testid="search-input"]');
// Focus search input, expecting recommended questions
await searchInput.focus();
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
// await expect(page.getByTestId('search-results')).toBeVisible();
// const recommendedQuestions = await page
// .getByTestId('search-recommended-question')
// .all();
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// Fill search input, expecting AI search option
await searchInput.fill('What is gitbook?');
const aiSearchResult = page.getByTestId('search-ask-question');
await expect(aiSearchResult).toBeVisible();
await aiSearchResult.click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
},
},
{
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
},
},
{
name: 'Ask - AI Mode: Assistant - Button',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
},
},
{
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.Assistant,
},
})}&ask=`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
},
},
{
name: 'Ask - AI Mode: Assistant - URL query (Results)',
url: `${getCustomizationURL({
ai: {
mode: CustomizationAIMode.Assistant,
},
})}&ask=What+is+GitBook%3F`,
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
'What is GitBook?'
);
},
},
];
const testCases: TestsCase[] = [
{
name: 'GitBook Site (Single Variant)',
@@ -53,34 +228,7 @@ const testCases: TestsCase[] = [
);
},
},
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
...searchTestCases,
{
name: 'Not found',
url: 'content-not-found',
@@ -279,34 +427,7 @@ const testCases: TestsCase[] = [
url: '',
run: waitForCookiesDialog,
},
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
...searchTestCases,
{
name: 'Not found',
url: 'content-not-found',
@@ -415,6 +536,28 @@ const testCases: TestsCase[] = [
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
},
},
{
name: 'With sections',
url: async () => {
const data = await getSiteAPIToken('https://gitbook.com/docs');
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
},
screenshot: false,
run: async (page) => {
const sectionTabs = page.getByLabel('Sections');
await expect(sectionTabs).toBeVisible();
const sectionTabLinks = sectionTabs.getByRole('link');
for (const link of await sectionTabLinks.all()) {
const href = await link.getAttribute('href');
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
}
},
},
],
},
{
@@ -910,6 +1053,22 @@ const testCases: TestsCase[] = [
},
]),
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Without page actions',
url: getCustomizationURL({
pageActions: {
markdown: false,
externalAI: false,
},
}),
run: waitForCookiesDialog,
},
],
},
{
name: 'Ads',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+5 -4
View File
@@ -318,10 +318,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
feedback: {
enabled: false,
},
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
aiSearch: {
enabled: true,
},
ai: {
mode: CustomizationAIMode.None,
},
@@ -337,6 +333,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
pagination: {
enabled: true,
},
pageActions: {
externalAI: true,
markdown: true,
mcp: true,
},
trademark: {
enabled: true,
},
+3
View File
@@ -14,6 +14,9 @@ const nextConfig = {
dynamic: 3600, // 1 hour
static: 3600, // 1 hour
},
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
},
env: {
@@ -2,6 +2,9 @@ import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/ini
import { DurableObject } from 'cloudflare:workers';
//Only needed to run locally, in prod we'll use the one from do.js
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
// Only needed to run locally, in prod we'll use the one from do.js
export class R2WriteBuffer extends DurableObject {
writePromise;
@@ -16,7 +16,9 @@
"env": {
"dev": {
"vars": {
"STAGE": "dev"
"STAGE": "dev",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771"
},
"r2_buckets": [
{
@@ -35,13 +37,17 @@
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["R2WriteBuffer"]
"new_sqlite_classes": ["R2WriteBuffer", "DOShardedTagCache"]
}
]
},
@@ -84,6 +90,9 @@
}
},
"staging": {
"vars": {
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
@@ -125,7 +134,8 @@
"vars": {
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
// This means that we'll have up to 100 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "100"
"MAX_REVALIDATE_CONCURRENCY": "100",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"r2_buckets": [
{
@@ -7,12 +7,39 @@ export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
//Format used by the tail logger
const formatLog = (requestDuration, responseCode, responseCache, responseRoute) =>
`%${JSON.stringify({ requestDuration, responseCode, responseCache, responseRoute })}%[main] Response Done`;
const getResolvedRoute = (reqOrResp, defaultValue) => {
try {
const resolvedRoutes = JSON.parse(
reqOrResp.headers.get('x-opennext-resolved-routes') ?? ''
)[0].route;
return resolvedRoutes ?? defaultValue;
} catch {
return defaultValue;
}
};
export default class extends WorkerEntrypoint {
async fetch(request) {
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
const startTime = Date.now();
const middlewareRequest = new Request(request.url, request);
middlewareRequest.headers.set('x-open-next-continent', request.cf?.continent || '');
// - `Request`s are handled by the Next server
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
const reqOrResp = await middlewareHandler(middlewareRequest, this.env, this.ctx);
if (reqOrResp instanceof Response) {
const duration = Date.now() - startTime;
const logMessage = formatLog(
duration,
reqOrResp.status,
reqOrResp.headers.get('x-opennext-cache') ?? 'MISS',
getResolvedRoute(reqOrResp, 'middleware')
);
// biome-ignore lint/suspicious/noConsole: <explanation>
console.log(logMessage);
return reqOrResp;
}
@@ -22,17 +49,31 @@ export default class extends WorkerEntrypoint {
'Cloudflare-Workers-Version-Overrides',
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
);
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
const response = await this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
redirect: 'manual',
cf: {
cacheEverything: false,
},
});
const formatedLog = formatLog(
Date.now() - startTime,
response.status,
`SERVER-${response.headers.get('x-nextjs-cache') ?? 'MISS'}`,
getResolvedRoute(reqOrResp, 'unresolved')
);
// biome-ignore lint/suspicious/noConsole: <explanation>
console.log(formatedLog);
return response;
}
// If we are in preview mode, we need to send the request to the preview URL
const modifiedUrl = new URL(reqOrResp.url);
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
const nextRequest = new Request(modifiedUrl, reqOrResp);
return fetch(nextRequest, {
// We never want to follow the redirects here.
// Redirects are supposed to happen from the client.
redirect: 'manual',
cf: {
cacheEverything: false,
},
@@ -21,7 +21,12 @@
"dev": {
"vars": {
"STAGE": "dev",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
"NEXT_PRIVATE_DEBUG_CACHE": "true",
// When deployed locally, we don't have access to the tag cache here,
// we should just bypass the cache to go to the server directly
"SHOULD_BYPASS_CACHE": "true",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771"
},
"r2_buckets": [
{
@@ -85,7 +90,8 @@
"staging": {
"vars": {
"STAGE": "staging",
"WORKER_VERSION_ID": "TO_REPLACE"
"WORKER_VERSION_ID": "TO_REPLACE",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"routes": [
{
@@ -153,7 +159,8 @@
// TODO: remove this once the issue is fixed
"DEBUG_CLOUDFLARE": "true",
"WORKER_VERSION_ID": "TO_REPLACE",
"STAGE": "production"
"STAGE": "production",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
},
"routes": [
{
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
const versionId = args[0];
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
const previewHostname = `${versionId?.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
let updatedFile = file.replace(
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
+66 -142
View File
@@ -1,6 +1,4 @@
import { createHash } from 'node:crypto';
import { trace } from '@/lib/tracing';
import type {
CacheEntryType,
CacheValue,
@@ -9,6 +7,8 @@ import type {
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
@@ -20,55 +20,42 @@ export type KeyOptions = {
/**
*
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
* the cache operations. It also integrates both R2 and Cache API in a single class.
* Having our own, will allow us to customize it in the future if needed.
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
*/
class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache';
protected localCache: Cache | undefined;
async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
const cacheKey = this.getR2Key(key, cacheType);
return trace(
{
operation: 'openNextIncrementalCacheGet',
name: cacheKey,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
// Check local cache first if available
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
if (localCacheEntry) {
span.setAttribute('cacheHit', 'local');
const result = (await localCacheEntry.json()) as WithLastModified<
CacheValue<CacheType>
>;
return this.returnNullOn404(result);
}
const r2Object = await r2.get(cacheKey);
if (!r2Object) return null;
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new Error('No R2 bucket');
if (process.env.SHOULD_BYPASS_CACHE === 'true') {
// We are in a local middleware environment, we should bypass the cache
// and go directly to the server.
return null;
}
try {
const r2Object = await r2.get(cacheKey);
if (!r2Object) return null;
span.setAttribute('cacheHit', 'r2');
return this.returnNullOn404({
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
});
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
}
);
const json = (await r2Object.json()) as CacheValue<CacheType>;
const lastModified = r2Object.uploaded.getTime();
if (!json) return null;
return this.returnNullOn404({
value: json,
lastModified,
});
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
}
//TODO: This is a workaround to handle 404 responses in the cache.
@@ -89,97 +76,49 @@ class GitbookIncrementalCache implements IncrementalCache {
cacheType?: CacheType
): Promise<void> {
const cacheKey = this.getR2Key(key, cacheType);
return trace(
{
operation: 'openNextIncrementalCacheSet',
name: cacheKey,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const localCache = await this.getCacheInstance();
try {
await this.writeToR2(cacheKey, JSON.stringify(value));
//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
const tags = this.getTagsFromCacheEntry(value) ?? [
`build_id/${process.env.NEXT_BUILD_ID}`,
];
// We consider R2 as the source of truth, so we update the local cache
// only after a successful R2 write
await localCache.put(
this.getCacheUrlKey(cacheKey),
new Response(
JSON.stringify({
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
}),
{
headers: {
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
// In theory we should always get the `revalidate` value
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
'cache-tag': tags.join(','),
},
}
)
);
} catch (e) {
console.error('Failed to set to cache', e);
}
}
);
try {
await this.writeToR2(cacheKey, JSON.stringify(value));
} catch (e) {
console.error('Failed to set to cache', e);
}
}
async delete(key: string): Promise<void> {
const cacheKey = this.getR2Key(key);
return trace(
{
operation: 'openNextIncrementalCacheDelete',
name: cacheKey,
},
async () => {
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
await r2.delete(cacheKey);
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new Error('No R2 bucket');
// Here again R2 is the source of truth, so we delete from local cache first
await localCache.delete(this.getCacheUrlKey(cacheKey));
} catch (e) {
console.error('Failed to delete from cache', e);
}
}
);
try {
await r2.delete(cacheKey);
} catch (e) {
console.error('Failed to delete from cache', e);
}
}
async writeToR2(key: string, value: string): Promise<void> {
const env = getCloudflareContext().env as {
WRITE_BUFFER: DurableObjectNamespace<
Rpc.DurableObjectBranded & {
write: (key: string, value: string) => Promise<void>;
}
>;
};
const id = env.WRITE_BUFFER.idFromName(key);
try {
const env = getCloudflareContext().env as {
WRITE_BUFFER: DurableObjectNamespace<
Rpc.DurableObjectBranded & {
write: (key: string, value: string) => Promise<void>;
}
>;
};
const id = env.WRITE_BUFFER.idFromName(key);
// A stub is a client used to invoke methods on the Durable Object
const stub = env.WRITE_BUFFER.get(id);
// A stub is a client used to invoke methods on the Durable Object
const stub = env.WRITE_BUFFER.get(id);
await stub.write(key, value);
}
async getCacheInstance(): Promise<Cache> {
if (this.localCache) return this.localCache;
this.localCache = await caches.open('incremental-cache');
return this.localCache;
await stub.write(key, value);
} catch {
// We fallback to writing directly to R2
// it can fail locally because the limit is 1Mb per args
// It is 32Mb in production, so we should be fine
const r2 = getCloudflareContext().env[BINDING_NAME];
r2?.put(key, value);
}
}
// Utility function to generate keys for R2/Cache API
@@ -197,28 +136,13 @@ class GitbookIncrementalCache implements IncrementalCache {
'/'
);
}
getCacheUrlKey(cacheKey: string): string {
return `http://cache.local/${cacheKey}`;
}
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
entry: CacheValue<CacheType>
): string[] | undefined {
if ('tags' in entry && entry.tags) {
return entry.tags;
}
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
const rawTags = entry.meta.headers['x-next-cache-tags'];
if (typeof rawTags === 'string') {
return rawTags.split(',');
}
}
if ('value' in entry) {
return entry.tags;
}
}
}
export default new GitbookIncrementalCache();
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,4 +1,3 @@
import { trace } from '@/lib/tracing';
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
@@ -6,9 +5,7 @@ import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
const { ctx } = getCloudflareContext();
ctx.waitUntil(doQueue.send(msg));
});
const { ctx } = getCloudflareContext();
ctx.waitUntil(doQueue.send(msg));
},
} satisfies Queue;
+3 -1
View File
@@ -1,9 +1,11 @@
import { getLogger } from '@/lib/logger';
import type { Queue } from '@opennextjs/aws/types/overrides.js';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
const logger = getLogger().subLogger('GitbookISRQueue');
// We should never reach this point in the server. If that's the case, we should log it.
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
logger.warn('send called on server side, this should not happen.', msg);
},
} satisfies Queue;
@@ -1,4 +1,4 @@
import { trace } from '@/lib/tracing';
import { createLogger, getLogger } from '@/lib/logger';
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
@@ -6,8 +6,9 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
// We can probably increase this value even further
regionalCacheTtlSec: 60,
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
// Because we invalidate the Cache API on update, we can safely set this to true
regionalCacheDangerouslyPersistMissingTags: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
@@ -20,62 +21,39 @@ const originalTagCache = doShardedTagCache({
export default {
name: 'GitbookTagCache',
mode: 'nextMode',
// We don't really use this one, as of now it is only used for soft tags
getLastRevalidated: async (tags: string[]) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
tags
);
return 0; // If no tags to check, return 0
}
return trace(
{
operation: 'gitbookTagCacheGetLastRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
return await originalTagCache.getLastRevalidated(tagsToCheck);
}
);
return await originalTagCache.getLastRevalidated(tagsToCheck);
},
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
// If we reach here, it probably means that there is an issue that we'll need to address.
console.warn(
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
tags
);
return false; // If no tags to check, return false
}
return trace(
{
operation: 'gitbookTagCacheHasBeenRevalidated',
name: tagsToCheck.join(', '),
},
async () => {
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
return result;
try {
const tagsToCheck = tags.filter(softTagFilter);
if (tagsToCheck.length === 0) {
return false; // If no tags to check, return false
}
);
return await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
} catch (e) {
createLogger('gitbookTagCache', {}).error(
`hasBeenRevalidated - Error checking tags ${tags.join(', ')}`,
e
);
return false; // In case of error, return false
}
},
writeTags: async (tags: string[]) => {
return trace(
{
operation: 'gitbookTagCacheWriteTags',
name: tags.join(', '),
},
async () => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
console.warn('writeTags - No valid tags to write');
return; // If no tags to write, exit early
}
// Write only the filtered tags
await originalTagCache.writeTags(tagsToWrite);
}
);
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
const logger = getLogger().subLogger('gitbookTagCache');
logger.warn('writeTags - No valid tags to write');
return; // If no tags to write, exit early
}
await originalTagCache.writeTags(tagsToWrite);
},
} satisfies NextModeTagCache;
+45 -36
View File
@@ -1,79 +1,87 @@
{
"name": "gitbook",
"version": "0.13.1",
"version": "0.19.0",
"private": true,
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/expr": "workspace:*",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@opennextjs/cloudflare": "1.2.1",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"object-identity": "^0.1.2",
"@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "^1.17.5",
"@opennextjs/cloudflare": "^1.8.3",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bidc": "catalog:",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-frontmatter": "^2.0.0",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"next-themes": "^0.2.1",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.12",
"next": "15.4.0",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"parse-cache-control": "^1.0.1",
"quick-lru": "^7.0.1",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0",
"zustand": "^5.0.3",
"image-size": "^2.0.2",
"direction": "^2.0.1"
"warn-once": "^0.1.1",
"zod": "^3",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.5",
"@playwright/test": "^1.51.1",
"@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2",
"@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
@@ -81,36 +89,37 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/psi": "^4.1.6",
"@types/react": "18.3.13",
"@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9",
"autoprefixer": "^10",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2",
"postcss": "^8",
"psi": "^4.1.0",
"stylelint": "^16.16.0",
"tailwindcss": "^3.4.0",
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "^5.5.3",
"vercel": "^39.3.0"
},
"scripts": {
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math && wrangler types",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
"generate": "./scripts/generate.sh",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next --turbopack",
"dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"start": "next start",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
"typecheck": "tsc --noEmit"
}
},
"browserslist": [
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
]
}
+7 -1
View File
@@ -10,9 +10,15 @@ export default defineConfig({
['@argos-ci/playwright/reporter', { uploadToArgos: !!process.env.CI }],
],
projects: [
// To speed up CI, we only test on Chrome.
// https://github.com/microsoft/playwright/issues/14434
// https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
channel: 'chrome',
},
},
],
use: {
+1 -2
View File
@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
},
};
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit
set -o pipefail
rm -rf ./.next
rm -rf ./public/~gitbook/static/icons
rm -rf ./public/~gitbook/static/math
rm -rf ./public/~gitbook/static/embed
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -o errexit
set -o pipefail
# Copy the assets
gitbook-icons ./public/~gitbook/static/icons custom-icons
gitbook-math ./public/~gitbook/static/math
cp -r ../embed/standalone/ ./public/~gitbook/static/embed
# Generate the types
wrangler types
@@ -5,8 +5,9 @@ import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -18,13 +19,14 @@ export default async function SiteDynamicLayout({
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
const withTracking = shouldTrackEvents(await headers());
return (
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
@@ -0,0 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
return <EmbeddableAssistantPage context={context} />;
}
@@ -0,0 +1,41 @@
import type { RouteLayoutParams } from '@/app/utils';
import {
EmbeddableRootLayout,
generateEmbeddableMetadata,
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function RootLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</EmbeddableRootLayout>
);
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
const { context } = await getEmbeddableStaticContext(await params);
return generateEmbeddableViewport({ context });
}
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const { context } = await getEmbeddableStaticContext(await params);
return generateEmbeddableMetadata({ context });
}
@@ -0,0 +1,23 @@
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
import type { Metadata } from 'next';
type PageProps = {
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
const pathname = getPagePathFromParams(params);
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
const pathname = getPagePathFromParams(params);
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
}
@@ -5,7 +5,7 @@ import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env';
import { shouldTrackEvents } from '@/lib/tracking';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -16,12 +16,13 @@ export default async function SiteStaticLayout({
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout customization={context.customization}>
<CustomizationRootLayout context={context}>
<SiteLayout
context={context}
withTracking={!GITBOOK_DISABLE_TRACKING}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
@@ -0,0 +1,20 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveLLMsFullTxt } from '@/routes/llms-full';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { page: string }> }
) {
const awaitedParams = await params;
const page = Number(awaitedParams.page);
// If page is not a number, not an integer, or less than 0, return an error
if (Number.isNaN(page) || !Number.isInteger(page) || page < 0) {
return new Response('Invalid page', { status: 400 });
}
const { context } = await getStaticSiteContext(awaitedParams);
return serveLLMsFullTxt(context, page);
}
@@ -0,0 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
return <EmbeddableAssistantPage context={context} />;
}
@@ -0,0 +1,129 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
/**
* This route serves a quick demo to test the script.js script.
* It is not used in production.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return new Response(
`
<html>
<head>
<meta name="color-scheme" content="light dark">
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.1" x="0.566895" y="208" width="529.567" height="228" rx="16" fill="#A4A7B0"/>
<g opacity="0.2">
<line x1="141.567" y1="472" x2="4.56689" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="416.067" y1="472" x2="165.567" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="472" x2="440.067" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="194.567" y1="496" x2="4.56689" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="324.067" y1="496" x2="218.567" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="473.567" y1="496" x2="348.067" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="231.901" y1="520" x2="4.56689" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="291.186" y1="520" x2="255.901" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="442.058" y1="520" x2="315.186" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="520" x2="466.058" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="141.567" y1="544" x2="4.56689" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="348.191" y1="544" x2="165.567" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="184.017" y1="592" x2="4.56689" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="312.848" y1="592" x2="208.017" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="386.574" y1="592" x2="336.848" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="448.401" y1="592" x2="410.574" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="126.633" y1="616" x2="4.56689" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="401.133" y1="616" x2="150.633" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="616" x2="425.133" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="179.457" y1="640" x2="4.56689" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="329.569" y1="640" x2="203.457" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="141.567" y1="4" x2="4.56689" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="416.067" y1="4" x2="165.567" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="4" x2="440.067" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="194.567" y1="28" x2="4.56689" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="324.067" y1="28" x2="218.567" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="473.567" y1="28" x2="348.067" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="231.901" y1="52" x2="4.56689" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="291.186" y1="52" x2="255.901" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="442.058" y1="52" x2="315.186" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="52" x2="466.058" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="141.567" y1="76" x2="4.56689" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="348.191" y1="76" x2="165.567" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="184.017" y1="124" x2="4.56689" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="312.848" y1="124" x2="208.017" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="386.574" y1="124" x2="336.848" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="448.401" y1="124" x2="410.574" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="126.633" y1="148" x2="4.56689" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="401.133" y1="148" x2="150.633" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="148" x2="425.133" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="179.457" y1="172" x2="4.56689" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="329.569" y1="172" x2="203.457" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<rect x="563" y="208" width="156" height="256" rx="16" fill="#A4A7B0" opacity="0.05"/>
<g opacity="0.2">
<line x1="682.214" y1="228" x2="583" y2="228" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="638.472" y1="252" x2="583" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="681.396" y1="252" x2="654.472" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="617.989" y1="276" x2="583" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="276" x2="633.989" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="603.168" y1="300" x2="583" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="657.873" y1="300" x2="619.168" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="637" y1="324" x2="583" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="688.836" y1="324" x2="653" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="657.474" y1="372" x2="583" y2="372" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="633" y1="396" x2="583" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="396" x2="649" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="585.416" y1="420" x2="583" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="621.731" y1="420" x2="601.416" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="420" x2="637.731" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="619.471" y1="444" x2="583" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script>
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
});
window.GitBook('open');
</script>
</html>
`,
{
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
}
);
}
@@ -0,0 +1,40 @@
import type { RouteLayoutParams } from '@/app/utils';
import {
EmbeddableRootLayout,
generateEmbeddableMetadata,
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { shouldTrackEvents } from '@/lib/tracking';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function RootLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents();
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</EmbeddableRootLayout>
);
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
const { context } = await getEmbeddableStaticContext(await params);
return generateEmbeddableViewport({ context });
}
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const { context } = await getEmbeddableStaticContext(await params);
return generateEmbeddableMetadata({ context });
}
@@ -0,0 +1,25 @@
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import type { Metadata } from 'next';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
const pathname = getPagePathFromParams(params);
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
const pathname = getPagePathFromParams(params);
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
}
@@ -0,0 +1,77 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getAssetURL } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import type { CreateGitBookOptions } from '@gitbook/embed';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
/**
* This route is used to serve the assistant.js script.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getEmbeddableStaticContext(await params);
const initOptions: CreateGitBookOptions = {
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
};
return new Response(
`
(function () {
const w = window;
const gb = w.GitBook;
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
if (typeof gb === "function") {
gb('init', initOptions);
} else {
var d = document;
var g = function () {
g.c(arguments);
};
g.q = [];
g.c = function (args) {
g.q.push(args);
};
w.GitBook = g;
g('init', initOptions);
const load = function () {
const style = document.createElement('link');
style.rel = 'stylesheet';
style.href = ${JSON.stringify(getAssetURL(`embed/index.css?v=${buildVersion()}`))};
document.head.appendChild(style);
const script = d.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.src = ${JSON.stringify(getAssetURL(`embed/index.js?v=${buildVersion()}`))};
var latestScript = d.getElementsByTagName('script')[0];
latestScript.parentNode.insertBefore(script, latestScript);
};
if (document.readyState === 'complete') {
load();
} else if (w.attachEvent) {
w.attachEvent('onload', load);
} else {
w.addEventListener('load', load, false);
}
}
})();
`,
{
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
}
);
}
@@ -0,0 +1,92 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
async function handler(
nextRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, site } = context;
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
return {
content: results.flatMap((spaceResult) => {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceResult.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return spaceResult.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
},
{},
{
basePath: context.linker.toPathInSite('~gitbook/'),
streamableHttpEndpoint: '/mcp',
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = nextRequest.nextUrl.search;
const request = new Request(requestURL, nextRequest);
return mcpHandler(request);
}
export { handler as GET, handler as POST };
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getLogger } from '@/lib/logger';
import { withVerifySignature } from '@/lib/routes';
import { revalidateTag } from 'next/cache';
@@ -12,6 +13,7 @@ interface JsonBody {
* The body should be a JSON with { tags: string[] }
*/
export async function POST(req: NextRequest) {
const logger = getLogger().subLogger('revalidate');
return withVerifySignature<JsonBody>(req, async (body) => {
if (!body.tags || !Array.isArray(body.tags)) {
return NextResponse.json(
@@ -23,8 +25,7 @@ export async function POST(req: NextRequest) {
}
body.tags.forEach((tag) => {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating tag: ${tag}`);
logger.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
});
@@ -1 +1,3 @@
export * from './useAI';
export * from './useAIChat';
export type { RenderAIMessageOptions } from './server-actions';
@@ -1,4 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import type { AIMessage } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary';
@@ -13,24 +14,31 @@ export function AIMessageView(
context: GitBookSiteContext;
}
) {
const { message, context, renderToolCalls = true } = props;
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return (
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
return (
<div key={index} className="flex animate-[fadeIn_500ms_both] flex-col gap-2">
<div
key={index}
className={tcls(
'flex animate-fade-in-slow flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
shouldRenderLinkPreviews: true,
withLinkPreviews,
}}
style={['space-y-4']}
style="mt-2 space-y-4 empty:hidden"
/>
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null}
</div>
@@ -4,11 +4,13 @@ import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import type {
AIToolCall,
AIToolCallGetPageContent,
AIToolCallGetPages,
AIToolCallMCP,
AIToolCallOther,
AIToolCallSearch,
ContentRef,
} from '@gitbook/api';
@@ -37,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props;
return (
<div className="flex items-start gap-2 text-sm text-tint-subtle">
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon
icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -57,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
case 'mcp':
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
case 'other':
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
default:
return <>{toolCall.tool}</>;
}
@@ -68,7 +72,7 @@ function DescriptionForPageContentToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<p>
@@ -98,7 +102,7 @@ function DescriptionForMCPToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<p>
@@ -111,13 +115,22 @@ function DescriptionForMCPToolCall(props: {
);
}
function DescriptionForOtherToolCall(props: {
toolCall: AIToolCallOther;
context: GitBookSiteContext;
}) {
const { toolCall } = props;
return <p>{toolCall.summary.text}</p>;
}
async function DescriptionForSearchToolCall(props: {
toolCall: AIToolCallSearch;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
@@ -144,69 +157,80 @@ async function DescriptionForSearchToolCall(props: {
})
);
const hasResults = toolCall.results.length > 0;
return (
<details className="-ml-5 group flex w-full flex-col gap-2">
<summary className="-mx-2 -mt-2 flex cursor-pointer list-none items-center gap-2 rounded-corners:rounded-md py-2 pr-4 pl-7 transition-colors marker:hidden hover:bg-primary-hover">
<div className="flex flex-col">
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<summary
className={tcls(
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
)}
>
<div className="flex min-w-0 flex-col break-words leading-snug">
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
<p className="text-tint-subtle text-xs">
{toolCall.results.length
<p className="mt-0.5 text-tint-subtle text-xs">
{hasResults
? t(language, 'search_results_count', toolCall.results.length)
: t(language, 'search_no_results')}
</p>
</div>
<div className="ml-auto flex items-center gap-1">
<span className="block group-open:hidden">{t(language, 'view')}</span>
<span className="hidden group-open:block">{t(language, 'close')}</span>
<Icon
icon="chevron-right"
className="size-3 shrink-0 transition-transform group-open:rotate-90"
/>
</div>
{hasResults ? (
<div className="ml-auto flex items-center gap-1">
<span className="block group-open:hidden">{t(language, 'view')}</span>
<span className="hidden group-open:block">{t(language, 'close')}</span>
<Icon
icon="chevron-right"
className="size-3 shrink-0 transition-transform group-open:rotate-90"
/>
</div>
) : null}
</summary>
<div className="max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fadeIn"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
{hasResults ? (
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Icon
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.title}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
>
<Icon
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.description}
text={result.title}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.description}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
) : null}
</details>
);
}
@@ -217,7 +241,7 @@ function DescriptionForGetPagesToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context.customization);
const language = getSpaceLanguage(context);
return (
<p>
@@ -235,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
return 'magnifying-glass';
case 'getPages':
return 'files';
case 'other':
return (toolCall.summary.icon as IconName) ?? 'hammer';
default:
return 'hammer';
}
@@ -1,6 +1,7 @@
'use server';
import type { GitBookBaseContext } from '@/lib/context';
import { fetchServerActionSiteContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import {
type AIMessage,
AIMessageRole,
@@ -19,97 +20,103 @@ export async function streamRenderAIMessage(
rawStream: AsyncIterable<AIStreamResponse>,
options?: RenderAIMessageOptions
) {
const message: AIMessage = {
id: '',
role: AIMessageRole.Assistant,
steps: [],
};
const updateProcessingMessageStep = (
stepIndex: number,
callback: (step: AIMessageStep) => void
) => {
if (stepIndex > message.steps.length) {
throw new Error(
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
);
}
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
message.steps[stepIndex] = { ...message.steps[stepIndex] };
callback(message.steps[stepIndex]);
} else {
message.steps = [
...message.steps,
{
content: {
object: 'document',
data: {},
nodes: [],
},
},
];
callback(message.steps[stepIndex]);
}
};
// Fetch the full-context in the background to avoid blocking the stream.
const promiseContext = fetchServerActionSiteContext(baseContext);
return parseResponse<{
content: React.ReactNode;
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* The agent is processing a tool call in a new message.
*/
case 'response_tool_call': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.toolCalls ??= [];
step.toolCalls.push(event.toolCall);
});
break;
}
/**
* The agent is writing the content of a new message.
*/
case 'response_reasoning':
case 'response_document': {
updateProcessingMessageStep(event.stepIndex, (step) => {
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
step[container] ??= {
object: 'document',
data: {},
nodes: [],
};
step[container] = {
...step[container],
nodes: [...step[container].nodes],
};
if (event.operation === 'insert') {
step[container].nodes.push(...event.blocks);
} else {
step[container].nodes.splice(
-event.blocks.length,
event.blocks.length,
...event.blocks
);
}
});
break;
}
}
return {
event,
content: (
<AIMessageView message={message} context={await promiseContext} {...options} />
),
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
const message: AIMessage = {
id: '',
role: AIMessageRole.Assistant,
steps: [],
};
const updateProcessingMessageStep = (
stepIndex: number,
callback: (step: AIMessageStep) => void
) => {
if (stepIndex > message.steps.length) {
throw new Error(
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
);
}
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
// @ts-expect-error
message.steps[stepIndex] = { ...message.steps[stepIndex] };
// @ts-expect-error
callback(message.steps[stepIndex]);
} else {
message.steps = [
...message.steps,
{
content: {
object: 'document',
data: {},
nodes: [],
},
},
];
// @ts-expect-error
callback(message.steps[stepIndex]);
}
};
// Fetch the full-context in the background to avoid blocking the stream.
const promiseContext = fetchServerActionSiteContext(baseContext);
return parseResponse<{
content: React.ReactNode;
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* The agent is processing a tool call in a new message.
*/
case 'response_tool_call': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.toolCalls ??= [];
step.toolCalls.push(event.toolCall);
});
break;
}
/**
* The agent is writing the content of a new message.
*/
case 'response_reasoning':
case 'response_document': {
updateProcessingMessageStep(event.stepIndex, (step) => {
const container =
event.type === 'response_reasoning' ? 'reasoning' : 'content';
step[container] ??= {
object: 'document',
data: {},
nodes: [],
};
step[container] = {
...step[container],
nodes: [...step[container].nodes],
};
if (event.operation === 'insert') {
step[container].nodes.push(...event.blocks);
} else {
step[container].nodes.splice(
-event.blocks.length,
event.blocks.length,
...event.blocks
);
}
});
break;
}
}
return {
event,
content: (
<AIMessageView message={message} context={await promiseContext} {...options} />
),
};
});
});
}
@@ -1,7 +1,15 @@
'use server';
import { getEmbeddableLinker } from '@/lib/embeddable';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api';
import { traceErrorOnly } from '@/lib/tracing';
import {
type AIMessageContext,
AIMessageRole,
AIModel,
type AIToolCallResult,
type AIToolDefinition,
} from '@gitbook/api';
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
@@ -12,32 +20,48 @@ export async function* streamAIChatResponse({
message,
messageContext,
previousResponseId,
toolCall,
tools,
options,
}: {
message: string;
message?: string;
messageContext: AIMessageContext;
previousResponseId?: string;
toolCall?: AIToolCallResult;
tools?: AIToolDefinition[];
options?: RenderAIMessageOptions;
}) {
const context = await getServerActionBaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
let context = await getServerActionBaseContext();
if (options?.asEmbeddable) {
context = { ...context, linker: getEmbeddableLinker(context.linker) };
}
const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
mode: 'assistant',
input: [
const siteURLData = await getSiteURLDataFromMiddleware();
const api = await context.dataFetcher.api();
const rawStream = api.orgs.streamAiResponseInSite(
siteURLData.organization,
siteURLData.site,
{
role: AIMessageRole.User,
content: message,
context: messageContext,
},
],
output: { type: 'document' },
model: AIModel.ReasoningLow,
previousResponseId,
});
input: message
? [
{
role: AIMessageRole.User,
content: message,
context: messageContext,
},
]
: [],
model: AIModel.ReasoningLow,
previousResponseId,
toolCall,
tools,
}
);
const { stream } = await streamRenderAIMessage(context, rawStream, options);
return await streamRenderAIMessage(context, rawStream, options);
});
for await (const output of stream) {
yield output;
@@ -1,3 +1,2 @@
export * from './types';
export * from './responses';
export * from './chat';

Some files were not shown because too many files have changed in this diff Show More