Compare commits

...

119 Commits

Author SHA1 Message Date
Nicolas Dorseuil 44ae7afc37 resolve more things at the beginning 2025-06-16 16:32:42 +02:00
Nicolas Dorseuil b2859600bc Merge remote-tracking branch 'origin/main' into gbo/prefetch-data-rnd-7317 2025-06-16 15:04:46 +02:00
Utku Ufuk d99da6a3ae Ignore case while pattern-matching to highlight search results (#3326) 2025-06-16 12:33:00 +00:00
Samy Pessé 72cd0e59e6 Replace withoutConcurrency by a smarter React.cache (#3325) 2025-06-16 13:37:45 +02:00
Utku Ufuk 42d88da73c Fix UX issue about highlighting the search term in search result sections (#3323) 2025-06-16 13:31:15 +03:00
Nicolas Dorseuil aabdd3f4d7 fix v1 2025-06-16 11:48:57 +02:00
Nicolas Dorseuil a2fc076acb fix getContentRef 2025-06-16 10:54:01 +02:00
Nicolas Dorseuil 82ed911cd8 Refactor content reference resolution to use context.getContentRef method across components 2025-06-16 10:12:40 +02:00
Nicolas Dorseuil 7b4908c8fd Refactor content reference handling to use getContentRef method across components 2025-06-16 09:47:21 +02:00
Taran Vohra 2863fe0dc1 Use resolvePublishedContentByUrl instead of the deprecated resolution endpoint (#3310) 2025-06-16 09:59:55 +05:30
Nicolas Dorseuil de19ada4fa move to prefetch 2025-06-14 12:01:02 +02:00
Nicolas Dorseuil 47c80991d3 try using prefetched data 2025-06-14 11:49:16 +02:00
Nicolas Dorseuil 9e582ed9f0 basic dirty implementation of prefetching 2025-06-14 11:49:16 +02:00
conico974 87218062be Use Promise.all whenever possible (#3319)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-14 11:10:26 +02:00
Samy Pessé 8a3910e208 Experiment with hiding sections without a matching site space (#3318) 2025-06-13 15:55:04 +02:00
conico974 6294bbb53c Fix error propagating (#3317)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-13 15:46:37 +02:00
Zeno Kapitein a28a997851 Add margin to adjacent buttons (#3316) 2025-06-13 15:42:00 +02:00
spastorelli 28a9ee701e Add paypal.gitbook.com to ADAPTIVE_CONTENT_HOSTS list (#3315) 2025-06-13 10:10:10 +00:00
Samy Pessé 50e8b2ec52 Don't use next/server after (#3314) 2025-06-13 11:30:18 +02:00
Samy Pessé 315717fa2d Disable the background fetch of getRevisionPageDocument (#3313) 2025-06-13 11:17:33 +02:00
Samy Pessé da67711c90 Fix crash on Vercel (#3311) 2025-06-12 23:37:38 +02:00
Samy Pessé fecdbe8dde Support dynamic expressions (#3295) 2025-06-12 12:25:28 +02:00
Nolann B. 9316ccd1f4 Update Models page styling (#3307) 2025-06-12 10:57:37 +02:00
Samy Pessé c3b620e975 Preserve current variant when navigating between sections (#3306) 2025-06-12 09:30:08 +02:00
Nolann B. 7a00880e5b Improve support for OAuth2 security type (#3304) 2025-06-11 15:34:52 +02:00
Zeno Kapitein b6b597564d Reverse order of feedback smileys (#3300) 2025-06-11 14:44:07 +02:00
spastorelli d410381957 Add docs.testgitbook.com to ADAPTIVE_CONTENT_HOSTS list (#3303) 2025-06-11 14:25:05 +02:00
conico974 902c3c6c1a apply customization for dynamic site context in v2 (#3302)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-11 14:05:30 +02:00
Zeno Kapitein 4fb2a4a6d7 Rework full-width layout, add support for full-width page option (#3293)
Co-authored-by: conico974 <nicodorseuil@yahoo.fr>
2025-06-11 14:03:45 +02:00
conico974 2d64c78787 return null for 404 cached page in the incremental cache (#3296)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-11 13:47:34 +02:00
Samy Pessé fbfcca5dae Fix rendering of font in ogimage (#3299) 2025-06-11 13:41:26 +02:00
Greg Bergé 664ae8bff6 Make heading from stepper visible in outline (#3301) 2025-06-11 13:40:59 +02:00
Samy Pessé fa3d2aae08 Fix inline expression crashing pages (#3297) 2025-06-11 12:27:24 +02:00
Zeno Kapitein e7a591d9f6 Fix border being added to cards (#3294) 2025-06-10 17:25:29 +00:00
conico974 2860beacdc revert to use withoutConcurrentExecution (#3292)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-10 19:05:07 +02:00
Zeno Kapitein 231167d3e1 Make icons for page groups more contrasting (#3286) 2025-06-10 17:48:06 +02:00
Brett Jephson c0ee60ec13 RND-6843: columns block (#3257)
Co-authored-by: Steven Hall <steven@gitbook.io>
2025-06-09 11:37:56 +01:00
conico974 f58b904ba6 Encode customization header (#3289)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-09 11:37:29 +02:00
conico974 d5992f1a5c Bump OpenNext to latest (#3288)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-09 10:40:44 +02:00
Samy Pessé fa12f9eeb6 Page cover dark-mode specific (#3287) 2025-06-08 14:57:50 +02:00
Samy Pessé 33726c88a0 Generate a llms-full version of the docs (#3285) 2025-06-08 12:13:14 +02:00
Zeno Kapitein 6aa3ff9c5e Fix various styling issues (#3283) 2025-06-08 00:38:41 +01:00
Zeno Kapitein 7d3fe23195 Add circular corners and depth styles (#3280)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-06-07 13:48:16 +02:00
Samy Pessé 521052d84f Remove withoutConcurrentExecution and withCacheKey (#3281) 2025-06-05 20:43:15 +01:00
Samy Pessé c730845e17 Fix missing title for button to close announcement banner (#3279) 2025-06-04 16:33:01 +01:00
Addison 7b5a3616c4 Add UTM tags to the preview site badge (#3277) 2025-06-03 16:07:55 +01:00
Zeno Kapitein c9373efdb5 Fix bold header links hover color (#3255) 2025-06-03 15:18:04 +02:00
Brett Jephson 015615de8a fix: defaultWidth/fullWidth options in images (#3274) 2025-06-03 12:18:01 +01:00
conico974 0f867e533a bump OpenNext to latest (#3276)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-02 17:40:04 +02:00
conico974 7d5a6d2255 Fix hydration error on V2 (#3272)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-02 14:23:15 +01:00
conico974 a1da4f023d Move DO to a 3rd worker (#3270)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-30 16:19:11 +02:00
conico974 666e8426e1 External middleware in cloudflare (#3254)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-30 14:56:00 +02:00
conico974 df2fa42bda Chores: create server worker (#3265)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-30 14:31:21 +02:00
conico974 b3a7ad6d2c Fix href being empty in TOC (#3269)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-28 17:07:33 +02:00
conico974 2932077bf9 Remove trailing slash from linker (#3268)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-27 21:13:55 +02:00
Nolann B. 957afd967f Add authorization header for OAuth2 (#3266) 2025-05-26 20:54:07 +02:00
Nolann B. 4c9a9d0416 Handle nested deprecated properties in generateSchemaExample (#3267) 2025-05-26 20:28:26 +02:00
Nolann B. 40df91a363 Deduplicate path parameters from OpenAPI spec (#3264) 2025-05-26 18:55:11 +02:00
conico974 af66ff71ed Add a force-revalidate api route (#3263)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-25 16:55:45 +02:00
Samy Pessé 6f8ecd6111 Use more strict context ID for images during preview (#3261) 2025-05-23 13:16:43 +02:00
conico974 fa3eb07617 Cache fonts and static image in memory for OG Image (#3258)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-23 10:20:07 +02:00
Samy Pessé dc4268db64 Compute correct urls for variants/sections when previewing unpublished site (#3259) 2025-05-23 10:01:15 +02:00
Nolann B. a0c06a72cc Indent JSON python code sample (#3260) 2025-05-23 09:13:20 +02:00
conico974 19c20c0be3 Fix OGImage icon fetch on cloudflare (#3253)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-21 15:39:52 +02:00
Viktor Renkema fae34e56ac Calculate class more explicitly & separately to fix horizontal table cell alignment issue (#3252) 2025-05-21 14:20:39 +02:00
conico974 ba0094a862 fix ISR in preview in cloudflare (#3250)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-21 09:52:05 +02:00
conico974 b259009c9c Add temporary logging for Cloudflare debugging (#3249)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-20 19:33:33 +02:00
conico974 d88dd5ed8a Update OpenNext (#3248)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-20 19:04:07 +02:00
Zeno Kapitein 57bb146075 Dynamic TOC height (#3233) 2025-05-20 14:13:57 +02:00
Zeno Kapitein 4b67fe5317 V2: Add urlObject.hash to linker.toLinkForContent (#3246) 2025-05-20 10:25:35 +02:00
Samy Pessé 938125e772 Version Packages (#3180)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-19 19:10:57 +02:00
conico974 1c8d9febb3 Custom Incremental Cache (#3239)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-19 17:00:09 +02:00
conico974 223edae17d Add env variables for the DOQueue (#3243)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-19 13:47:33 +02:00
Nolann B. 12a455dab5 Fix OpenAPI layout issues (#3242) 2025-05-18 14:50:05 +02:00
Nolann B. d00dc8ca72 Pass spec validation errors through OpenAPIParseError (#3240) 2025-05-18 13:20:17 +02:00
Samy Pessé 2c85a302fc Update opennext and leverage "use cache" on CF (#3204)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-05-16 13:58:35 +01:00
Addison 3460b78b45 Update docs.gitbook.com to gitbook.com/docs (#3187)
Co-authored-by: Tal Gluck <talagluck@gmail.com>
Co-authored-by: Taran Vohra <taranvohra@outlook.com>
2025-05-16 17:59:53 +05:30
Zeno Kapitein 95a1f652f9 Better print layouts: wrap code blocks & force table column auto-sizing (#3236) 2025-05-15 13:24:10 +02:00
spastorelli cc37e2ae15 Persist visitor params values in public visitor session cookie (#3231) 2025-05-14 14:16:13 +02:00
Claire Chabas 08382ce62f Improve tabs link support (#3211) 2025-05-14 11:10:09 +02:00
Nolann B. 80cb52a237 Handle OpenAPI alternatives from schema.items (#3235) 2025-05-14 08:05:08 +02:00
Nolann B. c6637b09e2 Use default value if string/number/boolean in generateSchemaExample (#3234) 2025-05-13 22:07:41 +02:00
Samy Pessé 778624af00 Only resize images with supported extensions (#3229) 2025-05-13 10:52:58 +02:00
Samy Pessé e15757d01f Use next@15.3.2 instead of canary (#3227) 2025-05-09 15:17:49 +02:00
Zeno Kapitein aed79fd6c3 Remove header logo rounding (#3225) 2025-05-09 14:45:38 +02:00
Greg Bergé 0c973a3292 Always link main logo to the root of the site (#3226) 2025-05-09 10:31:52 +00:00
spastorelli 56c923c0ed Adapt site resolution/lookup to pass visitor unsigned claims in addition to token (#3202)
Co-authored-by: taranvohra <taranvohra@outlook.com>
2025-05-09 15:34:20 +05:30
Samy Pessé 7d7806df30 Passthrough SVG images in image resizing (#3224) 2025-05-08 18:57:50 +02:00
Nolann B. cb5598dc19 Handle invalid OpenAPI Responses (#3223) 2025-05-08 11:54:46 +02:00
Greg Bergé 47f01edcb8 Fix E2E icons (#3222) 2025-05-08 13:04:04 +05:30
Greg Bergé e6ddc0f07f Fix sitemap URLs (#3220) 2025-05-08 08:54:28 +02:00
Zeno Kapitein 373f18f451 Prevent section group popovers from opening on click (#3213) 2025-05-07 13:57:58 +00:00
Taran Vohra 3f292065cd Update site redirect regex for validation (#3216) 2025-05-07 12:42:28 +05:30
Greg Bergé 5e975ab95b Fix code highlighting for HTTP (#3214) 2025-05-06 13:41:15 +00:00
Greg Bergé a3ec264764 Fix Python code sample "null vs None" (#3215) 2025-05-06 15:40:56 +02:00
Samy Pessé 5d504ffa4c Fix resolution of links in reusable content (#3212) 2025-05-06 10:30:55 +02:00
Samy Pessé 04999662db Use absolute URLs in sitemap.xml when pointing to sitemap-pages.xml (#3210) 2025-05-05 09:03:28 +02:00
Zeno Kapitein f7a34706c7 Allow high-contrast input colors to be used as-is (#3209) 2025-05-02 19:41:05 +02:00
Taran Vohra f328a41982 Revert getPublishedContentByUrl timeout back to 10s (#3208) 2025-05-02 11:16:31 +00:00
Nolann B. 20ebecb114 Missing top-level required OpenAPI alternatives (#3207) 2025-05-02 11:48:15 +02:00
Taran Vohra 5a69692f54 Temporarily bump getPublishedContentByUrl timeout to 30s on v1 (#3199) 2025-04-30 19:14:09 +05:30
Nolann B. 42ca7e1580 Fix openapi CR preview (#3197) 2025-04-30 11:44:39 +02:00
Nolann B. c4ebb3fecd Fix openapi-select hover in responses (#3196) 2025-04-30 11:27:03 +02:00
Zeno Kapitein 2a805cc082 Change OpenAPI schema-optional from info to tint color (#3194) 2025-04-29 19:30:11 +02:00
Zeno Kapitein 89a5816ee4 Fix OpenAPI disclosure label ("Show properties") misalignment on mobile (#3193) 2025-04-29 19:29:56 +02:00
Zeno Kapitein c3f6b8c003 Reduce chroma of text layers (#3188) 2025-04-29 19:29:42 +02:00
Zeno Kapitein 0e201d5899 Add border to filled sidebar on gradient theme (#3185) 2025-04-29 19:29:32 +02:00
Zeno Kapitein ae5f1abc58 Change Dropdowns to use Radix's DropdownMenu (#3189) 2025-04-29 18:30:47 +02:00
Nolann B. 580101d04e Schemas disclosure label causing client error (#3192) 2025-04-29 16:16:58 +02:00
Johan Preynat 81b4a4db53 Allow /~gitbook/image and /~gitbook/icon despite querystring parameters for SEO in robots.txt (#3191) 2025-04-28 12:07:34 +02:00
Steven H 8339e91e2b Fix images and other content refs in reusable content across spaces. (#3190) 2025-04-25 15:28:09 +01:00
Steven H 3119066728 Support for reusable content across spaces. (#3173) 2025-04-24 16:54:11 +01:00
Zeno Kapitein 326e28e9b0 Restyle openapi blocks (#3169) 2025-04-24 16:53:29 +02:00
Johan Preynat 90f0127ada Use react@19 on v2 and v1 (#3184) 2025-04-24 16:53:23 +02:00
Viktor Renkema 0353a8186c Have all nodes within a text-cells adhere to alignments (#3175) 2025-04-24 11:43:20 +02:00
Steven H dd043df00f Revert investigation work around URL caches. (#3183) 2025-04-23 16:53:33 +01:00
Steven H 97b7c79bc6 Increase logging around caching behaviour causing page crashes. (#3182) 2025-04-23 15:04:00 +01:00
Steven H 634e0b4302 Improve error messages around undefined site sections. (#3179) 2025-04-23 11:47:28 +00:00
Greg Bergé 2a23f1f003 Improve performances of TOC (#3176) 2025-04-23 09:31:22 +02:00
292 changed files with 7383 additions and 3435 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix UX issue about highlighting the search term in search result sections
+9
View File
@@ -0,0 +1,9 @@
---
"gitbook": patch
---
Fix three small visual issues
- Fix sidebar showing on `no-toc` pages in the gradient theme
- Fix variant selector truncating incorrectly in header when sections are present
- Fix page cover alignment on `lg` screens without TOC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Respect fullWidth and defaultWidth for images
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Add docs.testgitbook.com to ADAPTIVE_CONTENT_HOSTS list
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
fix nested a tag causing hydration error
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
fix href being empty in TOC
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix navigation between sections/variants when previewing a site in v2
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add circular corners and depth styling
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Add authorization header for OAuth2
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve support for OAuth2 security type
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Adds Columns layout block to GBO
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support dark-mode specific page cover image
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Update Models page styling
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Optimize performances by using a smarter per-request cache arround data cached functions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
add a force-revalidate api route to force bust the cache in case of errors
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Best effort at preserving current variant when navigating between sections by matching the pathname against site spaces in the new section.
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
encode customization header
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Rework full-width layout, add support for full-width page option
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
fix ISR on preview env
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Fix concurrent execution in Vercel causing pages to not be attached to the proper tags.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Generate a llms-full.txt version of the docs site
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/fonts": minor
---
Initial version of the package
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add margin to adjacent buttons
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
add a global error boundary
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Indent JSON python code sample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix missing title on button to close the announcement banner.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Make icons for page groups more contrasting
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Ignore case while highlighting search results.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Make TOC height dynamic based on visible header and footer elements
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix bold header links hover color
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
cache fonts and static image used in OGImage in memory
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix border being added to cards
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Handle nested deprecated properties in generateSchemaExample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Add `urlObject.hash` to `linker.toLinkForContent` to pass through URL fragment identifiers, used in search
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
apply customization for dynamic context
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Reverse order of feedback smileys
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Deduplicate path parameters from OpenAPI spec
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ogimage using incorrect Google Font depending on language.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
remove trailing slash from linker
+2 -2
View File
@@ -63,9 +63,9 @@ To start your local version of GitBook, run the command `bun dev`.
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`. When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server. For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/gitbook.com/docs` after running the development server.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version. You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
### Commit your update ### Commit your update
@@ -0,0 +1,83 @@
name: Gradual Deploy to Cloudflare
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
inputs:
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
middlewareVersionId:
description: 'Middleware version ID to deploy'
required: true
serverVersionId:
description: 'Server version ID to deploy'
required: true
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
@@ -28,7 +28,7 @@ inputs:
outputs: outputs:
deployment-url: deployment-url:
description: "Deployment URL" description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }} value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -63,8 +63,8 @@ runs:
env: env:
GITBOOK_RUNTIME: cloudflare GITBOOK_RUNTIME: cloudflare
shell: bash shell: bash
- id: deploy
name: Deploy to Cloudflare - name: Upload the DO worker
uses: cloudflare/wrangler-action@v3.14.0 uses: cloudflare/wrangler-action@v3.14.0
with: with:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
@@ -72,10 +72,67 @@ runs:
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc
- id: upload_server
name: Upload server to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
- name: Extract server version worker ID
shell: bash
id: extract_server_version_id
run: |
version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- name: Run updateWrangler scripts
shell: bash
run: |
bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
- id: upload_middleware
name: Upload middleware to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
run: |
version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- name: Deploy server and middleware to Cloudflare
if: ${{ inputs.deploy == 'true' }}
uses: ./.github/actions/gradual-deploy-cloudflare
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
opServiceAccount: ${{ inputs.opServiceAccount }}
opItem: ${{ inputs.opItem }}
environment: ${{ inputs.environment }}
serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }}
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
deploy: ${{ inputs.deploy }}
- name: Outputs - name: Outputs
shell: bash shell: bash
env: env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
run: | run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}" echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}"
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
+5 -7
View File
@@ -152,14 +152,14 @@ jobs:
| Site | `v1` | `2v` | `2c` | | Site | `v1` | `2v` | `2c` |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com) | | GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | | E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace edit-mode: replace
visual-testing-v1: visual-testing-v1:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing v1 name: Visual Testing v1
needs: deploy-v1-cloudflare needs: deploy-v1-cloudflare
timeout-minutes: 8 timeout-minutes: 10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -200,7 +200,6 @@ jobs:
name: Visual Testing v2 (Cloudflare) name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare needs: deploy-v2-cloudflare
timeout-minutes: 10 timeout-minutes: 10
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -221,7 +220,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing Customers v1 name: Visual Testing Customers v1
needs: deploy-v1-cloudflare needs: deploy-v1-cloudflare
timeout-minutes: 8 timeout-minutes: 10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -241,7 +240,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing Customers v2 name: Visual Testing Customers v2
needs: deploy-v2-vercel needs: deploy-v2-vercel
timeout-minutes: 8 timeout-minutes: 10
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -262,8 +261,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare) name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare needs: deploy-v2-cloudflare
timeout-minutes: 8 timeout-minutes: 10
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1
View File
@@ -9,6 +9,7 @@
], ],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"], "tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false, "prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit", "source.organizeImports.biome": "explicit",
+12 -6
View File
@@ -1,7 +1,7 @@
<h1 align="center">GitBook</h1> <h1 align="center">GitBook</h1>
<p align="center"> <p align="center">
<a href="https://docs.gitbook.com/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a> <a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
</p> </p>
<p align="center"> <p align="center">
@@ -56,17 +56,23 @@ git clone https://github.com/gitbookIO/gitbook.git
bun install bun install
``` ```
4. Start your local development server. 4. Run build.
```
bun build:v2
```
5. Start your local development server.
``` ```
bun dev:v2 bun dev:v2
``` ```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`. 6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples: examples:
- http://localhost:3000/url/docs.gitbook.com - http://localhost:3000/url/gitbook.com/docs
- http://localhost:3000/url/open-source.gitbook.io/midjourney - http://localhost:3000/url/open-source.gitbook.io/midjourney
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser. Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
@@ -150,11 +156,11 @@ See `LICENSE` for more information.
</p> </p>
```md ```md
[![GitBook](https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1)](https://gitbook.com/) [![GitBook](https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1)](https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link)
``` ```
```html ```html
<a href="https://gitbook.com"> <a href="https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link">
<img <img
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1" src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
/> />
+318 -197
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -7,12 +7,11 @@
"turbo": "^2.5.0", "turbo": "^2.5.0",
"vercel": "^39.3.0" "vercel": "^39.3.0"
}, },
"packageManager": "bun@1.2.8", "packageManager": "bun@1.2.15",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"react": "18.3.1", "react": "^19.0.0",
"react-dom": "18.3.1", "react-dom": "^19.0.0"
"@gitbook/api": "0.111.0"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
@@ -34,7 +33,12 @@
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean" "clean": "turbo run clean"
}, },
"workspaces": ["packages/*"], "workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.120.0"
}
},
"patchedDependencies": { "patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch" "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"version": "0.3.1", "version": "0.3.1",
"dependencies": { "dependencies": {
"@gitbook/api": "^0.111.0", "@gitbook/api": "catalog:",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
+8
View File
@@ -1,5 +1,13 @@
# @gitbook/colors # @gitbook/colors
## 0.3.3
### Patch Changes
- c3f6b8c: Update chroma ratio per step
- 5e975ab: Fix code highlighting for HTTP
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
## 0.3.2 ## 0.3.2
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.2", "version": "0.3.3",
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
+20 -2
View File
@@ -214,13 +214,31 @@ export function colorScale(
const targetL = const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]); foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) { if (
index === 8 &&
!mix &&
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
) {
// Original colour is close enough to target, so let's use the original colour as step 9. // Original colour is close enough to target, so let's use the original colour as step 9.
result.push(hex); result.push(hex);
continue; continue;
} }
const chromaRatio = index === 8 || index === 9 ? 1 : index * 0.05; const chromaRatio = (() => {
switch (index) {
// Step 9 and 10 have max chroma, meaning they are fully saturated.
case 8:
case 9:
return 1;
// Step 11 and 12 have a reduced chroma
case 10:
return 0.4;
case 11:
return 0.1;
default:
return index * 0.05;
}
})();
const shade = { const shade = {
L: targetL, // Blend lightness L: targetL, // Blend lightness
+2
View File
@@ -0,0 +1,2 @@
dist/
src/data/*.json
+3
View File
@@ -0,0 +1,3 @@
# `@gitbook/fonts`
Utilities to lookup default fonts supported by GitBook.
+91
View File
@@ -0,0 +1,91 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { APIv2 } from 'google-font-metadata';
import { CustomizationDefaultFont } from '@gitbook/api';
import type { FontDefinitions } from '../src/types';
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
[CustomizationDefaultFont.Inter]: 'inter',
[CustomizationDefaultFont.FiraSans]: 'fira-sans-extra-condensed',
[CustomizationDefaultFont.IBMPlexSerif]: 'ibm-plex-serif',
[CustomizationDefaultFont.Lato]: 'lato',
[CustomizationDefaultFont.Merriweather]: 'merriweather',
[CustomizationDefaultFont.NotoSans]: 'noto-sans',
[CustomizationDefaultFont.OpenSans]: 'open-sans',
[CustomizationDefaultFont.Overpass]: 'overpass',
[CustomizationDefaultFont.Poppins]: 'poppins',
[CustomizationDefaultFont.Raleway]: 'raleway',
[CustomizationDefaultFont.Roboto]: 'roboto',
[CustomizationDefaultFont.RobotoSlab]: 'roboto-slab',
[CustomizationDefaultFont.SourceSansPro]: 'source-sans-3',
[CustomizationDefaultFont.Ubuntu]: 'ubuntu',
[CustomizationDefaultFont.ABCFavorit]: 'inter',
};
/**
* Scripts to generate the list of all icons.
*/
async function main() {
// @ts-expect-error - we build the object
const output: FontDefinitions = {};
for (const font of Object.values(CustomizationDefaultFont)) {
const googleFontName = googleFontsMap[font];
const fontMetadata = APIv2[googleFontName.toLowerCase()];
if (!fontMetadata) {
throw new Error(`Font ${googleFontName} not found`);
}
output[font] = {
font: googleFontName,
unicodeRange: fontMetadata.unicodeRange,
variants: {
'400': {},
'700': {},
},
};
Object.keys(output[font].variants).forEach((weight) => {
const variants = fontMetadata.variants[weight];
const normalVariant = variants.normal;
if (!normalVariant) {
throw new Error(`Font ${googleFontName} has no normal variant`);
}
output[font].variants[weight] = {};
Object.entries(normalVariant).forEach(([script, url]) => {
output[font].variants[weight][script] = url.url.woff;
});
});
}
await writeDataFile('fonts', JSON.stringify(output, null, 2));
}
/**
* We write both in dist and src as the build process might have happen already
* and tsc doesn't copy the files.
*/
async function writeDataFile(name, content) {
const srcData = path.resolve(__dirname, '../src/data');
const distData = path.resolve(__dirname, '../dist/data');
// Ensure the directories exists
await Promise.all([
fs.mkdir(srcData, { recursive: true }),
fs.mkdir(distData, { recursive: true }),
]);
await Promise.all([
fs.writeFile(path.resolve(srcData, `${name}.json`), content),
fs.writeFile(path.resolve(distData, `${name}.json`), content),
]);
}
main().catch((error) => {
console.error(`Error generating icons list: ${error}`);
process.exit(1);
});
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@gitbook/fonts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.0.0",
"dependencies": {
"@gitbook/api": "catalog:"
},
"devDependencies": {
"google-font-metadata": "^6.0.3",
"typescript": "^5.5.3"
},
"scripts": {
"generate": "bun ./bin/generate.js",
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test"
},
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,57 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`getDefaultFont should return correct object for Latin text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should return correct object for Greek text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should handle mixed script text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should handle different font weights: regular 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different font weights: bold 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different fonts: inter 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{
"font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
}
`;
+5
View File
@@ -0,0 +1,5 @@
import type { FontDefinitions } from './types';
import rawFonts from './data/fonts.json' with { type: 'json' };
export const fonts: FontDefinitions = rawFonts;
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationDefaultFont } from '@gitbook/api';
import { getDefaultFont } from './getDefaultFont';
describe('getDefaultFont', () => {
it('should return null for invalid font', () => {
const result = getDefaultFont({
font: 'invalid-font' as CustomizationDefaultFont,
text: 'Hello',
weight: 400,
});
expect(result).toBeNull();
});
it('should return null for invalid weight', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 999 as any,
});
expect(result).toBeNull();
});
it('should return null for text not supported by any script', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: '😀', // Emoji not supported by Inter
weight: 400,
});
expect(result).toBeNull();
});
it('should return correct object for Latin text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello World',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should return correct object for Cyrillic text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Привет мир',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should return correct object for Greek text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Γεια σας',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should handle mixed script text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello Привет',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should handle different font weights', () => {
const regular = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 400,
});
const bold = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 700,
});
expect(regular).not.toBeNull();
expect(bold).not.toBeNull();
expect(regular).toMatchSnapshot('regular');
expect(bold).toMatchSnapshot('bold');
});
it('should handle empty string', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: '',
weight: 400,
});
expect(result).toBeNull();
});
it('should handle different fonts', () => {
const inter = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 400,
});
const roboto = getDefaultFont({
font: CustomizationDefaultFont.Roboto,
text: 'Hello',
weight: 400,
});
expect(inter).not.toBeNull();
expect(roboto).not.toBeNull();
expect(inter).toMatchSnapshot('inter');
expect(roboto).toMatchSnapshot('roboto');
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
import { fonts } from './fonts';
import type { FontWeight } from './types';
/**
* Get the URL to load a font for a text.
*/
export function getDefaultFont(input: {
/**
* GitBook font to use.
*/
font: CustomizationDefaultFont;
/**
* Text to display with the font.
*/
text: string;
/**
* Font weight to use.
*/
weight: FontWeight;
}): { font: string; url: string } | null {
if (!input.text.trim()) {
return null;
}
const fontDefinition = fonts[input.font];
if (!fontDefinition) {
return null;
}
const variant = fontDefinition.variants[`${input.weight}`];
if (!variant) {
return null;
}
const script = getBestUnicodeRange(input.text, fontDefinition.unicodeRange);
if (!script) {
return null;
}
return variant[script]
? {
font: input.font,
url: variant[script],
}
: null;
}
/**
* Determine which named @font-face unicode-range covers
* the greatest share of the characters in `text`.
*
* @param text The text you want to inspect.
* @param ranges An object whose keys are range names and whose
* values are CSS-style comma-separated unicode-range
* declarations (e.g. "U+0370-03FF,U+1F00-1FFF").
* @returns The key of the best-matching range, or `null`
* when nothing matches at all.
*/
function getBestUnicodeRange(text: string, ranges: Record<string, string>): string | null {
// ---------- helper: parse "U+XXXX" or "U+XXXX-YYYY" ----------
const parseOne = (token: string): [number, number] | null => {
token = token.trim().toUpperCase();
if (!token.startsWith('U+')) return null;
const body = token.slice(2); // drop "U+"
const [startHex, endHex] = body.split('-');
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;
return [start, end];
};
// ---------- helper: build lookup table ----------
const parsed: Record<string, [number, number][]> = {};
for (const [label, list] of Object.entries(ranges)) {
parsed[label] = list
.split(',')
.map(parseOne)
.filter((x): x is [number, number] => x !== null);
}
// ---------- tally code-point hits ----------
const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0]));
for (let i = 0; i < text.length; ) {
const cp = text.codePointAt(i)!;
i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates)
for (const [label, rangesArr] of Object.entries(parsed)) {
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
hits[label]++;
}
}
}
// ---------- choose the "best" ----------
let winner: string | null = null;
let maxCount = 0;
for (const [label, count] of Object.entries(hits)) {
if (count > maxCount) {
maxCount = count;
winner = label;
}
}
return maxCount > 0 ? winner : null;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './getDefaultFont';
export * from './types';
+17
View File
@@ -0,0 +1,17 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
export type FontWeight = 400 | 700;
export type FontDefinition = {
font: string;
unicodeRange: {
[script: string]: string;
};
variants: {
[weight in string]: {
[script: string]: string;
};
};
};
export type FontDefinitions = { [fontName in CustomizationDefaultFont]: FontDefinition };
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"lib": ["es2023"],
"module": "ESNext",
"target": "es2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowJs": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"inputs": ["bin/**/*", "package.json"],
"outputs": ["src/data/*.json", "dist/data/*.json"]
}
}
}
+1
View File
@@ -6,6 +6,7 @@
# cloudflare # cloudflare
.open-next .open-next
.wrangler
# Symbolic links # Symbolic links
public public
+19
View File
@@ -1,5 +1,24 @@
# gitbook-v2 # gitbook-v2
## 0.3.0
### Minor Changes
- 3119066: Add support for reusable content across spaces.
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
### Patch Changes
- 1c8d9fe: keep data cache in OpenNext between deployment
- 778624a: Only resize images with supported extensions.
- e6ddc0f: Fix URL in sitemap
- 5e975ab: Fix code highlighting for HTTP
- e15757d: Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
- 634e0b4: Improve error messages around undefined site sections.
- 97b7c79: Increase logging around caching behaviour causing page crashes.
- 3f29206: Update the regex for validating site redirect
- dd043df: Revert investigation work around URL caches.
## 0.2.5 ## 0.2.5
### Patch Changes ### Patch Changes
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+27 -16
View File
@@ -1,18 +1,29 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare'; import type { OpenNextConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
export default defineCloudflareConfig({ export default {
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }), default: {
tagCache: doShardedTagCache({ override: {
baseShardSize: 12, wrapper: 'cloudflare-node',
regionalCache: true, converter: 'edge',
shardReplication: { proxyExternalRequest: 'fetch',
numberOfSoftReplicas: 2, queue: () => import('./openNext/queue/middleware').then((m) => m.default),
numberOfHardReplicas: 1, incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}), },
queue: doQueue, middleware: {
}); external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
dangerous: {
enableCacheInterception: true,
},
edgeExternals: ['node:crypto'],
} satisfies OpenNextConfig;
@@ -0,0 +1,36 @@
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { DurableObject } from 'cloudflare:workers';
// Only needed to run locally, in prod we'll use the one from do.js
export class R2WriteBuffer extends DurableObject {
writePromise;
async write(cacheKey, value) {
// We are already writing to this key
if (this.writePromise) {
return;
}
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.ctx.waitUntil(
this.writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
export default {
async fetch(request, env, ctx) {
return runWithCloudflareRequestContext(request, env, ctx, async () => {
// We can't move the handler import to the top level, otherwise the runtime will not be properly initialized
const { handler } = await import(
'../../.open-next/server-functions/default/handler.mjs'
);
// - `Request`s are handled by the Next server
return handler(request, env, ctx);
});
},
};
@@ -0,0 +1,163 @@
{
"main": "default.js",
"name": "gitbook-open-v2-server",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": true
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"env": {
"dev": {
"vars": {
"STAGE": "dev"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-dev"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["R2WriteBuffer"]
}
]
},
"preview": {
"vars": {
"STAGE": "preview",
// Just as a test for the preview environment to check that everything works
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-preview"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-preview"
}
]
}
},
"staging": {
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-staging"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-staging"
}
]
},
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
]
},
"production": {
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-production"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-production"
}
]
},
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
]
}
}
}
@@ -0,0 +1,38 @@
// This worker only purposes it to host the different DO that we will need in the other workers.
import { DurableObject } from 'cloudflare:workers';
// `use cache` could cause multiple writes to the same key to happen concurrently, there is a limit of 1 write per key/second
// so we need to buffer writes to the R2 bucket to avoid hitting this limit.
export class R2WriteBuffer extends DurableObject {
writePromise;
async write(cacheKey, value) {
// We are already writing to this key
if (this.writePromise) {
return;
}
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.ctx.waitUntil(
this.writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default {
async fetch() {
// This worker does not handle any requests, it only provides Durable Objects
return new Response('This worker is not meant to handle requests directly', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
});
},
};
@@ -0,0 +1,145 @@
{
"main": "do.js",
"name": "gitbook-open-v2-do",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": true
},
"env": {
"preview": {
"vars": {
"STAGE": "preview",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
}
]
},
"staging": {
"vars": {
"STAGE": "staging",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
}
]
},
"production": {
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
"STAGE": "production"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
}
]
}
}
}
@@ -0,0 +1,42 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs';
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default class extends WorkerEntrypoint {
async fetch(request) {
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
// - `Request`s are handled by the Next server
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
if (reqOrResp instanceof Response) {
return reqOrResp;
}
if (this.env.STAGE !== 'preview') {
// https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity
reqOrResp.headers.set(
'Cloudflare-Workers-Version-Overrides',
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
);
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
cf: {
cacheEverything: false,
},
});
}
// 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, {
cf: {
cacheEverything: false,
},
});
});
}
}
@@ -0,0 +1,216 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"assets": {
"directory": "../../.open-next/assets",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"env": {
"dev": {
"vars": {
"STAGE": "dev",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-dev"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-dev"
}
]
},
"preview": {
"vars": {
"STAGE": "preview",
"PREVIEW_HOSTNAME": "TO_REPLACE",
"WORKER_VERSION_ID": "TO_REPLACE"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-preview"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-preview"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-preview"
}
]
}
},
"staging": {
"vars": {
"STAGE": "staging",
"WORKER_VERSION_ID": "TO_REPLACE"
},
"routes": [
{
"pattern": "open-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
},
{
"pattern": "static-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-staging"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-staging"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-staging"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
},
"production": {
"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",
// Temporary variable to find the issue once deployed
// TODO: remove this once the issue is fixed
"DEBUG_CLOUDFLARE": "true",
"WORKER_VERSION_ID": "TO_REPLACE",
"STAGE": "production"
},
"routes": [
{
"pattern": "open-2c.gitbook.com/*",
"zone_name": "gitbook.com"
},
{
"pattern": "static-2c.gitbook.com/*",
"zone_name": "gitbook.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production"
},
{
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-production"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
"script_name": "gitbook-open-v2-do-production"
},
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
"script_name": "gitbook-open-v2-do-production"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
}
}
}
@@ -0,0 +1,26 @@
// In this script, we use the args from the cli to update the PREVIEW_URL vars in the wrangler config file for the middleware
import fs from 'node:fs';
import path from 'node:path';
const wranglerConfigPath = path.join(__dirname, '../middlewareWrangler.jsonc');
const file = fs.readFileSync(wranglerConfigPath, 'utf-8');
const args = process.argv.slice(2);
// The versionId is in the format xxx-xxx-xxx-xxx, we need the first part to reconstruct the preview URL
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`;
let updatedFile = file.replace(
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
`"PREVIEW_HOSTNAME": "${previewHostname}"`
);
updatedFile = updatedFile.replaceAll(
/"WORKER_VERSION_ID": "TO_REPLACE"/g,
`"WORKER_VERSION_ID": "${versionId}"`
);
fs.writeFileSync(wranglerConfigPath, updatedFile);
@@ -0,0 +1,217 @@
import { createHash } from 'node:crypto';
import { trace } from '@/lib/tracing';
import type {
CacheEntryType,
CacheValue,
IncrementalCache,
WithLastModified,
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
export const DEFAULT_PREFIX = 'incremental-cache';
export type KeyOptions = {
cacheType?: CacheEntryType;
};
/**
*
* 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.
*/
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;
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;
}
}
);
}
//TODO: This is a workaround to handle 404 responses in the cache.
// It should be handled by OpenNext cache interception directly. This should be removed once OpenNext cache interception is fixed.
returnNullOn404<CacheType extends CacheEntryType = 'cache'>(
cacheEntry: WithLastModified<CacheValue<CacheType>> | null
): WithLastModified<CacheValue<CacheType>> | null {
if (!cacheEntry?.value) return null;
if ('meta' in cacheEntry.value && cacheEntry.value.meta?.status === 404) {
return null;
}
return cacheEntry;
}
async set<CacheType extends CacheEntryType = 'cache'>(
key: string,
value: CacheValue<CacheType>,
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);
}
}
);
}
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);
// 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);
}
}
);
}
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);
// 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;
}
// Utility function to generate keys for R2/Cache API
getR2Key(key: string, cacheType: CacheEntryType = 'cache'): string {
const hash = createHash('sha256').update(key).digest('hex');
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
/\/+/g,
'/'
);
}
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();
@@ -0,0 +1,14 @@
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';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
const { ctx } = getCloudflareContext();
ctx.waitUntil(doQueue.send(msg));
});
},
} satisfies Queue;
@@ -0,0 +1,9 @@
import type { Queue } from '@opennextjs/aws/types/overrides.js';
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
// 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);
},
} satisfies Queue;
@@ -0,0 +1,81 @@
import { trace } from '@/lib/tracing';
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';
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
// We can probably increase this value even further
regionalCacheTtlSec: 60,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
regionalReplication: {
defaultRegion: 'enam',
},
},
});
export default {
name: 'GitbookTagCache',
mode: 'nextMode',
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);
}
);
},
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;
}
);
},
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);
}
);
},
} satisfies NextModeTagCache;
+13 -10
View File
@@ -1,23 +1,24 @@
{ {
"name": "gitbook-v2", "name": "gitbook-v2",
"version": "0.2.5", "version": "0.3.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"next": "canary", "@gitbook/api": "catalog:",
"@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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@gitbook/api": "*", "rison": "^0.1.1",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"warn-once": "^0.1.1", "warn-once": "^0.1.1",
"rison": "^0.1.1", "object-identity": "^0.1.2"
"jwt-decode": "^4.0.0",
"assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"gitbook": "*", "gitbook": "*",
"@opennextjs/cloudflare": "^1.0.0-beta.3",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"postcss": "^8" "postcss": "^8"
@@ -29,7 +30,9 @@
"build:v2": "next build", "build:v2": "next build",
"start": "next start", "start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare build", "build:v2:cloudflare": "opennextjs-cloudflare build",
"dev:v2:cloudflare": "wrangler dev --port 8771", "dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:v2:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:v2:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"unit": "bun test", "unit": "bun test",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
} }
@@ -0,0 +1,18 @@
'use client';
import NextError from 'next/error';
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
console.error('Global error:', error);
return (
<html lang="en">
<body>
<NextError statusCode={undefined as any} />
</body>
</html>
);
}
@@ -3,7 +3,7 @@ import {
generateSitePageMetadata, generateSitePageMetadata,
generateSitePageViewport, generateSitePageViewport,
} from '@/components/SitePage'; } from '@/components/SitePage';
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@v2/app/utils'; import { type RouteParams, getDynamicSiteContext } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
type PageProps = { type PageProps = {
@@ -14,9 +14,8 @@ type PageProps = {
export default async function Page(props: PageProps) { export default async function Page(props: PageProps) {
const params = await props.params; const params = await props.params;
const { context } = await getDynamicSiteContext(params); const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} />; return <SitePage context={context} pageParams={params} />;
} }
export async function generateViewport(props: PageProps): Promise<Viewport> { export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -27,10 +26,9 @@ export async function generateViewport(props: PageProps): Promise<Viewport> {
export async function generateMetadata(props: PageProps): Promise<Metadata> { export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params; const params = await props.params;
const { context } = await getDynamicSiteContext(params); const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({ return generateSitePageMetadata({
context, context,
pageParams: { pathname }, pageParams: params,
}); });
} }
@@ -1,9 +1,5 @@
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { import { SiteLayout, generateSiteLayoutViewport } from '@/components/SiteLayout';
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils'; import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env'; import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware } from '@v2/lib/middleware'; import { getThemeFromMiddleware } from '@v2/lib/middleware';
@@ -38,7 +34,7 @@ export async function generateViewport({ params }: SiteDynamicLayoutProps) {
return generateSiteLayoutViewport(context); return generateSiteLayoutViewport(context);
} }
export async function generateMetadata({ params }: SiteDynamicLayoutProps) { // export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
const { context } = await getDynamicSiteContext(await params); // const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutMetadata(context); // return generateSiteLayoutMetadata(context);
} // }
@@ -3,7 +3,8 @@ import {
generateSitePageMetadata, generateSitePageMetadata,
generateSitePageViewport, generateSitePageViewport,
} from '@/components/SitePage'; } from '@/components/SitePage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils'; import type { RouteParams } from '@v2/app/utils';
import { getPrefetchedDataFromLayoutParams } from '@v2/lib/data/prefetch';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
@@ -15,24 +16,24 @@ type PageProps = {
export default async function Page(props: PageProps) { export default async function Page(props: PageProps) {
const params = await props.params; const params = await props.params;
const { context } = await getStaticSiteContext(params); const { staticSiteContext } = getPrefetchedDataFromLayoutParams(params);
const pathname = getPagePathFromParams(params); const { context } = await staticSiteContext;
return <SitePage context={context} pageParams={{ pathname }} />; return <SitePage context={context} pageParams={params} />;
} }
export async function generateViewport(props: PageProps): Promise<Viewport> { export async function generateViewport(props: PageProps): Promise<Viewport> {
const { context } = await getStaticSiteContext(await props.params); const params = await props.params;
const { context } = await getPrefetchedDataFromLayoutParams(params).staticSiteContext;
return generateSitePageViewport(context); return generateSitePageViewport(context);
} }
export async function generateMetadata(props: PageProps): Promise<Metadata> { export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params; const params = await props.params;
const { context } = await getStaticSiteContext(params); const { context } = await getPrefetchedDataFromLayoutParams(params).staticSiteContext;
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({ return generateSitePageMetadata({
context, context,
pageParams: { pathname }, pageParams: params,
}); });
} }
@@ -5,6 +5,7 @@ import {
generateSiteLayoutViewport, generateSiteLayoutViewport,
} from '@/components/SiteLayout'; } from '@/components/SiteLayout';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils'; import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
import { getPrefetchedDataFromLayoutParams } from '@v2/lib/data/prefetch';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env'; import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
interface SiteStaticLayoutProps { interface SiteStaticLayoutProps {
@@ -36,6 +37,6 @@ export async function generateViewport({ params }: SiteStaticLayoutProps) {
} }
export async function generateMetadata({ params }: SiteStaticLayoutProps) { export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const { context } = await getStaticSiteContext(await params); const prefetchedData = getPrefetchedDataFromLayoutParams(await params);
return generateSiteLayoutMetadata(context); return generateSiteLayoutMetadata(prefetchedData);
} }
@@ -0,0 +1,14 @@
import type { NextRequest } from 'next/server';
import { serveLLMsFullTxt } from '@/routes/llms-full';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsFullTxt(context);
}
+22 -1
View File
@@ -1,6 +1,13 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive'; import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import { getDynamicCustomizationSettings } from '@/lib/customization';
import type { SiteAPIToken } from '@gitbook/api'; import type { SiteAPIToken } from '@gitbook/api';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context'; import {
type GitBookSiteContext,
type SiteURLData,
fetchSiteContextByURLLookup,
getBaseContext,
} from '@v2/lib/context';
import { getResizedImageURL } from '@v2/lib/images';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation'; import { forbidden } from 'next/navigation';
import rison from 'rison'; import rison from 'rison';
@@ -67,6 +74,8 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
siteURLData siteURLData
); );
context.customization = await getDynamicCustomizationSettings(context.customization);
return { return {
context, context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData), visitorAuthClaims: getVisitorAuthClaims(siteURLData),
@@ -102,3 +111,15 @@ function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
const decoded = decodeURIComponent(params.siteData); const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded); return rison.decode(decoded);
} }
export const getIcon = async (context: GitBookSiteContext, theme: 'light' | 'dark') => {
const { linker, imageResizer, customization } = context;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
const faviconSize = 48;
return customIcon?.[theme]
? await getResizedImageURL(imageResizer, customIcon[theme], {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(linker.toPathInSpace('~gitbook/icon?size=small&theme=light'));
};
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test';
import { withStableRef } from './cache';
describe('withStableRef', () => {
it('should return primitive values as is', () => {
const toStableRef = withStableRef();
expect(toStableRef(42)).toBe(42);
expect(toStableRef('hello')).toBe('hello');
expect(toStableRef(true)).toBe(true);
expect(toStableRef(null)).toBe(null);
expect(toStableRef(undefined)).toBe(undefined);
});
it('should return the same reference for identical objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return the same reference for identical arrays', () => {
const toStableRef = withStableRef();
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const ref1 = toStableRef(arr1);
const ref2 = toStableRef(arr2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(arr1);
expect(ref1).not.toBe(arr2);
});
it('should return the same reference for identical nested objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: { b: 1 }, c: [2, 3] };
const obj2 = { a: { b: 1 }, c: [2, 3] };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return different references for different objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1 };
const obj2 = { a: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).not.toBe(ref2);
});
it('should maintain reference stability across multiple calls', () => {
const toStableRef = withStableRef();
const obj = { a: 1 };
const ref1 = toStableRef(obj);
const ref2 = toStableRef(obj);
const ref3 = toStableRef(obj);
expect(ref1).toBe(ref2);
expect(ref2).toBe(ref3);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { identify } from 'object-identity';
import * as React from 'react';
/**
* Equivalent to `React.cache` but with support for non-primitive arguments.
* As `React.cache` only uses `Object.is` to compare arguments, it will not work with non-primitive arguments.
*/
export function cache<Args extends any[], Return>(fn: (...args: Args) => Return) {
const cached = React.cache(fn);
return (...args: Args) => {
const toStableRef = getWithStableRef();
const stableArgs = args.map((value) => {
return toStableRef(value);
}) as Args;
return cached(...stableArgs);
};
}
/**
* To ensure memory is garbage collected between each request, we use a per-request cache to store the ref maps.
*/
const getWithStableRef = React.cache(withStableRef);
/**
* Create a function that converts a value to a stable reference.
*/
export function withStableRef(): <T>(value: T) => T {
const reverseIndex = new WeakMap<object, string>();
const refIndex = new Map<string, object>();
return <T>(value: T) => {
if (isPrimitive(value)) {
return value;
}
const objectValue = value as object;
const index = reverseIndex.get(objectValue);
if (index !== undefined) {
return refIndex.get(index) as T;
}
const hash = identify(objectValue);
reverseIndex.set(objectValue, hash);
const existing = refIndex.get(hash);
if (existing !== undefined) {
return existing as T;
}
// first time we've seen this shape
refIndex.set(hash, objectValue);
return value;
};
}
function isPrimitive(value: any): boolean {
return value === null || typeof value !== 'object';
}
+39 -12
View File
@@ -19,6 +19,7 @@ import {
getDataOrNull, getDataOrNull,
throwIfDataError, throwIfDataError,
} from '@v2/lib/data'; } from '@v2/lib/data';
import assertNever from 'assert-never';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials'; import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env'; import { GITBOOK_URL } from './env';
@@ -164,7 +165,7 @@ export function getBaseContext(input: {
// Create link in the same format for links to other sites/sections. // Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => { linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL); const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`; return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`;
}; };
} }
@@ -242,19 +243,45 @@ export async function fetchSiteContextByIds(
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection) ? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
: null; : null;
const siteSpace = ( // Parse the current siteSpace and siteSpaces based on the site structure type.
siteStructure.type === 'siteSpaces' && siteStructure.structure const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
? siteStructure.structure if (siteStructure.type === 'siteSpaces') {
: sections?.current.siteSpaces const siteSpaces = siteStructure.structure;
)?.find((siteSpace) => siteSpace.id === ids.siteSpace); const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) { if (!siteSpace) {
throw new Error('Site space not found'); throw new Error(
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
);
} }
const siteSpaces = return { siteSpaces, siteSpace };
siteStructure.type === 'siteSpaces' }
? siteStructure.structure
: (sections?.current.siteSpaces ?? []); if (siteStructure.type === 'sections') {
assert(
sections,
`cannot find site space "${ids.siteSpace}" because parsed sections are missing siteStructure.type="sections" siteSection="${ids.siteSection}"`
);
const currentSection = sections.current;
const siteSpaces = currentSection.siteSpaces;
const siteSpace = currentSection.siteSpaces.find(
(siteSpace) => siteSpace.id === ids.siteSpace
);
if (!siteSpace) {
throw new Error(
`Site space "${ids.siteSpace}" not found in structure type="sections" currentSection="${currentSection.id}"`
);
}
return { siteSpaces, siteSpace };
}
// @ts-expect-error
assertNever(siteStructure, `cannot handle site structure of type ${siteStructure.type}`);
})();
const customization = (() => { const customization = (() => {
if (ids.siteSpace) { if (ids.siteSpace) {
@@ -380,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) { function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false }); const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
const section = parseCurrentSection(structure, siteSectionId); const section = parseCurrentSection(structure, siteSectionId);
assert(section, 'A section must be defined when there are multiple sections'); assert(section, `couldn't find section "${siteSectionId}" in site structure`);
return { list: sectionsAndGroups, current: section } satisfies SiteSections; return { list: sectionsAndGroups, current: section } satisfies SiteSections;
} }
File diff suppressed because it is too large Load Diff
@@ -11,15 +11,3 @@ export function getCloudflareContext() {
return getCloudflareContextOpenNext(); return getCloudflareContextOpenNext();
} }
/**
* Return an object representing the current request.
*/
export function getCloudflareRequestGlobal() {
const context = getCloudflareContext();
if (!context) {
return null;
}
return context.cf;
}
+1 -2
View File
@@ -1,8 +1,7 @@
export * from './api'; export * from './api';
export * from './types'; export * from './types';
export * from './pages';
export * from './urls'; export * from './urls';
export * from './errors'; export * from './errors';
export * from './lookup'; export * from './lookup';
export * from './proxy';
export * from './visitor'; export * from './visitor';
export * from './pages';
+17 -23
View File
@@ -1,52 +1,46 @@
import { race, tryCatch } from '@/lib/async'; import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths'; import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing'; import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api'; import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api'; import { apiClient } from './api';
import { getExposableError } from './errors'; import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types'; import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls'; import { getURLLookupAlternatives, stripURLSearch } from './urls';
/** interface LookupPublishedContentByUrlInput {
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
url: string; url: string;
visitorAuthToken: string | null;
redirectOnError: boolean; redirectOnError: boolean;
apiToken: string | null; apiToken: string | null;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> { visitorPayload: SiteVisitorPayload;
}
/**
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function lookupPublishedContentByUrl(
input: LookupPublishedContentByUrlInput
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url); const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL); const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url); const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => { const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = await apiClient({ apiToken: input.apiToken }); const api = apiClient({ apiToken: input.apiToken });
const callResult = await trace( const callResult = await trace(
{ {
operation: 'getPublishedContentByURL', operation: 'resolvePublishedContentByUrl',
name: alternative.url, name: alternative.url,
}, },
() => () =>
tryCatch( tryCatch(
api.urls.getPublishedContentByUrl( api.urls.resolvePublishedContentByUrl(
{ {
url: alternative.url, url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined, ...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError, redirectOnError: input.redirectOnError,
// As this endpoint is cached by our API, we version the request
// to void getting stale data with missing properties.
// this could be improved by ensuring our API cache layer is versioned
// or invalidated when needed
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
}, },
{ { signal }
signal,
}
) )
) )
); );
@@ -1,52 +0,0 @@
import { describe, expect, it, mock } from 'bun:test';
import { AsyncLocalStorage } from 'node:async_hooks';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
describe('withoutConcurrentExecution', () => {
it('should memoize the function based on the cache key', async () => {
const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b);
const memoized = withoutConcurrentExecution(() => null, fn);
const p1 = memoized('c1', 1, 2);
const p2 = memoized('c1', 1, 2);
const p3 = memoized('c3', 2, 3);
expect(await p1).toBe(await p2);
expect(await p1).not.toBe(await p3);
expect(fn.mock.calls.length).toBe(2);
});
it('should support caching per request', async () => {
const fn = mock(async () => Math.random());
const request1 = { id: 'request1' };
const request2 = { id: 'request2' };
const requestContext = new AsyncLocalStorage<{ id: string }>();
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn);
// Both in the same request
const promise1 = requestContext.run(request1, () => memoized('c1'));
const promise2 = requestContext.run(request1, () => memoized('c1'));
// In a different request
const promise3 = requestContext.run(request2, () => memoized('c1'));
expect(await promise1).toBe(await promise2);
expect(await promise1).not.toBe(await promise3);
expect(fn.mock.calls.length).toBe(2);
});
});
describe('withCacheKey', () => {
it('should wrap the function by passing the cache key', async () => {
const fn = mock(
async (cacheKey: string, arg: { a: number; b: number }, c: number) =>
`${cacheKey}, result=${arg.a + arg.b + c}`
);
const memoized = withCacheKey(fn);
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
expect(fn.mock.calls.length).toBe(1);
});
});
@@ -1,87 +0,0 @@
/**
* Wrap a function by preventing concurrent executions of the same function.
* With a logic to work per-request in Cloudflare Workers.
*/
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
getGlobalContext: () => object | null | undefined,
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
return (key: string, ...args: ArgsType) => {
const globalContext = getGlobalContext() ?? globalThis;
/**
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
* to avoid "Cannot perform I/O on behalf of a different request" errors.
*/
const promiseCache =
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
globalPromiseCache.set(globalContext, promiseCache);
const concurrent = promiseCache.get(key);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped(key, ...args);
return result;
} finally {
promiseCache.delete(key);
}
})();
promiseCache.set(key, promise);
return promise;
};
}
/**
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args));
}
function deepSortValue(value: unknown): unknown {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (Array.isArray(value)) {
return value.map(deepSortValue);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, subValue]) => {
return [key, deepSortValue(subValue)] as const;
})
.sort((a, b) => {
return a[0].localeCompare(b[0]);
});
}
return value;
}
+47 -4
View File
@@ -1,15 +1,30 @@
import type { JSONDocument, RevisionPageDocument, Space } from '@gitbook/api'; import { waitUntil } from '@/lib/waitUntil';
import type { JSONDocument, RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext, GitBookSpaceContext } from '../context';
import { getDataOrNull } from './errors'; import { getDataOrNull } from './errors';
import type { GitBookDataFetcher } from './types';
/** /**
* Get the document for a page. * Get the document for a page.
*/ */
export async function getPageDocument( export async function getPageDocument(
dataFetcher: GitBookDataFetcher, context: GitBookSpaceContext | GitBookSiteContext,
space: Space,
page: RevisionPageDocument page: RevisionPageDocument
): Promise<JSONDocument | null> { ): Promise<JSONDocument | null> {
const { dataFetcher, space } = context;
if (
'site' in context &&
(context.site.id === 'site_JOVzv' || context.site.id === 'site_IxAYj')
) {
return getDataOrNull(
dataFetcher.getRevisionPageDocument({
spaceId: space.id,
revisionId: space.revision,
pageId: page.id,
})
);
}
if (page.documentId) { if (page.documentId) {
return getDataOrNull( return getDataOrNull(
dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId }) dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId })
@@ -26,5 +41,33 @@ export async function getPageDocument(
); );
} }
// Pre-fetch the document to start filling the cache before we migrate to this API.
if (process.env.NODE_ENV === 'development') {
// Disable for now to investigate side-effects
if (isInPercentRollout(space.id, 10) || process.env.VERCEL_ENV === 'preview') {
await waitUntil(
getDataOrNull(
dataFetcher.getRevisionPageDocument({
spaceId: space.id,
revisionId: space.revision,
pageId: page.id,
})
)
);
}
}
return null; return null;
} }
function isInPercentRollout(value: string, rollout: number) {
return getRandomPercent(value) < rollout;
}
function getRandomPercent(value: string) {
const hash = value.split('').reduce((acc, char) => {
return acc + char.charCodeAt(0);
}, 0);
return hash % 100;
}
@@ -0,0 +1,220 @@
import { type PagePathParams, fetchPageData } from '@/components/SitePage';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { AncestorRevisionPage } from '@/lib/pages';
import {
type ResolveContentRefOptions,
type ResolvedContentRef,
resolveContentRef,
} from '@/lib/references';
import type { ContentRef, JSONDocument, RevisionPageDocument } from '@gitbook/api';
import {
type RouteLayoutParams,
type RouteParams,
getIcon,
getPagePathFromParams,
getStaticSiteContext,
} from '@v2/app/utils';
import { identify } from 'object-identity';
import { cache } from '../cache';
import type { GitBookSiteContext } from '../context';
import { getPageDocument } from './pages';
export interface PrefetchedLayoutData {
staticSiteContext: Promise<{
context: GitBookSiteContext;
visitorAuthClaims: VisitorAuthClaims;
}>;
icons: Promise<
{
url: string;
type: string;
media: string;
}[]
>;
}
export interface PrefetchedPageData {
pageData: Promise<{
context: GitBookSiteContext & {
page?: RevisionPageDocument;
};
pageTarget?: {
page: RevisionPageDocument;
ancestors: AncestorRevisionPage[];
};
}>;
document: Promise<JSONDocument | null>;
getPrefetchedRef: (
ref?: ContentRef,
options?: ResolveContentRefOptions
) => Promise<ResolvedContentRef | null>;
}
const cachedInitialDate = cache(() => Date.now());
/**
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
*/
export async function getPageDataWithFallback(args: {
context: GitBookSiteContext;
pagePathParams: PagePathParams;
}) {
const { context: baseContext, pagePathParams } = args;
const { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
return {
context: {
...context,
page: pageTarget?.page,
},
pageTarget,
};
}
export async function getIcons(context: GitBookSiteContext): Promise<
{
url: string;
type: string;
media: string;
}[]
> {
return Promise.all([getIcon(context, 'light'), getIcon(context, 'dark')]).then((urls) => [
{
url: urls[0],
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url: urls[1],
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
]);
}
export const getPrefetchedDataFromLayoutParams = cache(
(params: RouteLayoutParams): PrefetchedLayoutData => {
const startingDate = cachedInitialDate();
const staticSiteContext = getStaticSiteContext(params).finally(() => {
console.log(`Finished fetching static site context in ${Date.now() - startingDate}ms`);
});
const icons = staticSiteContext
.then(({ context }) => getIcons(context))
.finally(() => {
console.log(`Finished fetching icons in ${Date.now() - startingDate}ms`);
});
return {
staticSiteContext,
icons,
};
}
);
export const prefetchedDocumentRef = (
document: JSONDocument | null,
context: GitBookSiteContext
) => {
const fetched = new Map<string, Promise<ResolvedContentRef | null>>();
if (!document) return fetched;
const traverseNodes = (nodes: any[]): void => {
for (const node of nodes) {
// We try prefetching as many references as possible.
if (node.data?.ref) {
fetched.set(identify(node.data.ref), resolveContentRef(node.data.ref, context));
}
// Handle prefetching of references for cards
if (node.data?.view && node.data.view.type === 'cards') {
const view = node.data.view;
const records = Object.entries(node.data.records || {});
records.forEach(async (record: [string, any]) => {
const coverFile = view.coverDefinition
? record[1].values[view.coverDefinition]?.[0]
: null;
const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef)
: null;
if (targetRef) {
fetched.set(identify(targetRef), resolveContentRef(targetRef, context));
}
if (coverFile) {
const fileRef = {
kind: 'file' as const,
file: coverFile,
};
fetched.set(identify(fileRef), resolveContentRef(fileRef, context));
}
});
}
if (node.nodes && Array.isArray(node.nodes)) {
traverseNodes(node.nodes);
}
if (node.fragments && Array.isArray(node.fragments)) {
traverseNodes(node.fragments);
}
}
};
if (document.nodes && Array.isArray(document.nodes)) {
traverseNodes(document.nodes);
}
return fetched;
};
export const getPrefetchedDataFromPageParams = cache((params: RouteParams): PrefetchedPageData => {
const startingDate = cachedInitialDate();
const { staticSiteContext } = getPrefetchedDataFromLayoutParams(params);
const pathname = getPagePathFromParams(params);
const pageData = staticSiteContext
.then(({ context }) =>
getPageDataWithFallback({
context,
pagePathParams: {
pathname,
},
})
)
.finally(() => {
console.log(`Finished fetching page data in ${Date.now() - startingDate}ms`);
});
const document = pageData
.then(({ context, pageTarget }) => {
if (!pageTarget?.page) {
return null;
}
return getPageDocument(context, pageTarget?.page);
})
.finally(() => {
console.log(`Finished fetching document in ${Date.now() - startingDate}ms`);
});
const prefetchedRef = Promise.all([staticSiteContext, document])
.then(([{ context }, document]) => {
// Prefetch the references in the document
return prefetchedDocumentRef(document, context);
})
.finally(() => {
console.log(`Finished prefetching references in ${Date.now() - startingDate}ms`);
});
const getContentRef = async (
ref?: ContentRef,
options?: ResolveContentRefOptions
): Promise<ResolvedContentRef | null> => {
if (!ref) {
return null;
}
if (options) {
const { context } = await staticSiteContext;
return resolveContentRef(ref, context, options);
}
return prefetchedRef.then((prefetched) => prefetched.get(identify(ref)) ?? null);
};
return {
pageData,
document,
getPrefetchedRef: getContentRef,
};
});
@@ -106,6 +106,15 @@ export interface GitBookDataFetcher {
pageId: string; pageId: string;
}): Promise<DataFetcherResponse<string>>; }): Promise<DataFetcherResponse<string>>;
/**
* Get the document of a page by its path.
*/
getRevisionPageDocument(params: {
spaceId: string;
revisionId: string;
pageId: string;
}): Promise<DataFetcherResponse<api.JSONDocument>>;
/** /**
* Get a document by its space ID and document ID. * Get a document by its space ID and document ID.
*/ */
+1 -1
View File
@@ -1,6 +1,6 @@
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths'; import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import type { PublishedSiteContent } from '@gitbook/api'; import type { PublishedSiteContent } from '@gitbook/api';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy'; import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
/** /**
* Get the appropriate base path for the visitor authentication cookie. * Get the appropriate base path for the visitor authentication cookie.

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