Compare commits

..

144 Commits

Author SHA1 Message Date
Steven Hall d74c78af6f Add revalidatePaths 2025-05-24 11:19:36 +01: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
Samy Pessé 88ffdcc303 Version Packages (#3172)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-22 11:20:21 +02:00
Nolann B. b6b09d42af Fix OpenAPI responses select placement and icon (#3174) 2025-04-22 10:59:42 +02:00
Nolann B. ebc39e9db9 Missing select icon (#3171) 2025-04-18 16:26:59 +02:00
Samy Pessé 0df3849ea2 Version Packages (#3157)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-18 13:40:34 +02:00
Greg Bergé 8ed1bda2f2 Translate OpenAPI blocks (#3166) 2025-04-18 13:29:55 +02:00
Nolann B. d67699a1f1 Add OpenAPI Webhook block (#3160) 2025-04-18 08:35:44 +02:00
Greg Bergé 3363a18856 Merge simple alternatives (#3165) 2025-04-16 09:14:12 +00:00
Amrit Kahlon ad1dc0b914 Bump scalar version (#3161)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-04-15 18:00:07 -07:00
Samy Pessé e4c7307c99 Fix CORS error when loading fonts in 2c (#3163) 2025-04-14 17:05:31 -07:00
Samy Pessé f9c942c8ff Fix CORS error when loading icons in 2c (#3162) 2025-04-14 16:52:39 -07:00
Greg Bergé eeb977fc03 Fix Python JSON code example (#3159) 2025-04-14 19:50:40 +00:00
Greg Bergé 4b8a62122a Show sections tabs only if there is at least two sections (#3158) 2025-04-14 14:09:34 +02:00
Addison e44e947fc1 Update cookie banner to use American English (#3154) 2025-04-14 18:43:01 +09:00
Nolann B. 7588cfe1c5 Improve OpenAPIResponses examples and schemas (#3123) 2025-04-14 11:16:35 +02:00
Greg Bergé a9a643f222 Improve icon stabilization (#3147) 2025-04-13 05:41:47 +00:00
Samy Pessé 77d6cb75be Improve error message when image resizing failed (#3148) 2025-04-12 17:44:23 -07:00
Samy Pessé 1942d221df Bump the cache of getPublishedContentByUrl in v1 (#3146) 2025-04-12 07:18:26 -07:00
Greg Bergé 041601ed65 Upgrade Argos (#3145) 2025-04-12 15:48:51 +02:00
Steven H 5567281a85 Bump getPublishedContentByUrl cache. (#3144) 2025-04-11 17:13:01 +01:00
Samy Pessé 4c3753046b Cloudflare: fix image resizing (#3143) 2025-04-11 16:48:43 +02:00
Samy Pessé 89f8e16184 Cloudflare: resize images in v2 using direct fetch (#3141) 2025-04-11 15:18:44 +02:00
Samy Pessé a3e1125ced Hotfix for v1 crashes (#3139) 2025-04-10 22:00:41 +02:00
Samy Pessé 4dae8acfed Cloudflare: fix image resizing (#3138) 2025-04-10 19:30:15 +02:00
Samy Pessé bc081c4344 Fix cache revalidation for computed revisions (#3134) 2025-04-10 16:36:02 +02:00
Samy Pessé e4275b235d Version Packages (#3137)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-10 13:31:27 +02:00
Samy Pessé 77397cac82 Fix version of @gitbook/api referenced in cache-tags (#3136) 2025-04-10 13:30:28 +02:00
Samy Pessé b6a08bc679 Version Packages (#3084)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-10 13:18:22 +02:00
Samy Pessé 116575c811 Changeset for cache-tags (#3135) 2025-04-10 13:16:01 +02:00
Samy Pessé 4ba7bd5ce1 CF Preview URL: Extract worker URL and run visual tests (#3132) 2025-04-09 18:25:37 +02:00
Samy Pessé 223ab5377f Test cloudflare preview URL (#3130) 2025-04-09 17:24:38 +02:00
Samy Pessé ae43ca146c Fix preview URLs on Cloudflare env (#3129) 2025-04-09 16:00:21 +02:00
Taran Vohra 42342893d6 Fix incoming URL for requests that were proxied (#3127) 2025-04-09 19:10:14 +05:30
Nolann B. eedefdd5c9 Handle optional security headers (#3128) 2025-04-09 15:07:33 +02:00
Samy Pessé f5e152dd74 Fix hovering links in docs.gitbook.com (#3125) 2025-04-09 11:43:12 +02:00
Zeno Kapitein 95ea22d9c5 Cache AI Page Link summary (#3106)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-04-09 09:59:35 +02:00
Taran Vohra f23023ddfa Use the proxy request identifier for storing the VA cookie in proxy sites (#3091) 2025-04-09 12:49:22 +05:30
Samy Pessé da74077954 Remove proxy package (#3124) 2025-04-08 23:58:19 +02:00
Nolann B. 29aaba5949 Override Scalar's overscroll-behavior (#3120) 2025-04-08 19:16:46 +02:00
Zeno Kapitein daf41fc728 Footer design tweaks (#3109) 2025-04-08 18:44:57 +02:00
Zeno Kapitein b08eb1af59 Fix list of variants overflow (#3114) 2025-04-08 18:44:44 +02:00
Zeno Kapitein 8322cd3e35 Fix sections being displayed when there is only 1 (#3113) 2025-04-08 18:44:08 +02:00
Samy Pessé 444039c142 Fix opening of links when v1 is embedded in an iframe (#3122) 2025-04-08 18:43:06 +02:00
Samy Pessé 3b0eff7f71 Fix getRevisionPageByPath in v2 not encoding the path correctly (#3121) 2025-04-08 16:29:08 +00:00
Samy Pessé c765463e38 Fix ogimage generation crashing when site is using a custom WOFF2 font (#3119) 2025-04-08 16:44:28 +02:00
Samy Pessé e8ca25b67e Fix caching in Cloudflare version using R2 as a backend (#3118) 2025-04-08 13:37:07 +02:00
Brett Jephson 528eee3fc6 RND-548: add subscript and superscript support (#3110) 2025-04-08 11:32:12 +01:00
Steven H 168a4fa998 Add support for buttons to GitBook. (#3117) 2025-04-08 09:18:29 +01:00
Samy Pessé 3ea75adba4 Update turbo to 2.5.0 and fix cache config (#3116) 2025-04-08 09:56:05 +02:00
Nolann B. cbd768a095 Improve OpenAPI codesample (#3090) 2025-04-08 09:35:22 +02:00
Samy Pessé 2d01653a78 Fix ogimage on Cloudflare and resizing of ads image (#3107) 2025-04-08 01:29:34 +02:00
Nolann B. aa3357a57d Fix OpenAPISchemas description padding (#3115) 2025-04-07 21:54:33 +02:00
Samy Pessé de53946896 Sanitize the back URL used when rendering PDF (#3111) 2025-04-07 17:03:09 +02:00
Samy Pessé d51204148e Revert debugging of ogimage errors (#3105) 2025-04-06 13:33:48 +01:00
Samy Pessé 319761a39e Fix revalidation not working because of memoization (#3104) 2025-04-06 01:27:22 +02:00
Samy Pessé 7fbf01a348 Update opennext to 1.0.0-beta.0 (#3102) 2025-04-05 23:51:12 +02:00
Steven Hall c22490bd80 Prefer @vercel/og for icon generation. 2025-04-05 09:22:13 +01:00
Steven Hall 860fd6d02e Use system-ui font in icons. 2025-04-05 09:12:20 +01:00
Steven Hall c43c524804 Fix a typing issue using ArrayBuffer.detached 2025-04-05 09:06:17 +01:00
Steven H 66bb938d85 Use Inter font when generating icons to prevent ArrayBuffer re-use. (#3101) 2025-04-05 09:01:41 +01:00
Steven H 0e6d928496 Use Inter font when generating icons to prevent ArrayBuffer re-use. (#3100) 2025-04-05 08:47:56 +01:00
Steven H f07982d9f8 Investigate issue where icons and ogimage would occasionally fail. (#3099) 2025-04-05 08:15:02 +01:00
Steven H b92ecfad58 Implement retry logic for the DO cache to prevent when revalidating content. (#3098) 2025-04-04 23:44:32 +01:00
Zeno Kapitein 416bde70bc Link tooltips + AI link summaries (#3088)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-04-04 20:56:42 +02:00
Steven H 580f7ade15 Better debugging of GitBook v1s revalidate endpoint. (#3097) 2025-04-04 17:36:24 +01:00
Zeno Kapitein cdffd7c47b Desaturate text colours by decreasing chroma for the last steps of the color scale (#3096) 2025-04-04 18:00:01 +02:00
Greg Bergé 5a959cf041 Fix a regression on OpenAPI example bg (#3095) 2025-04-04 15:05:53 +00:00
Zeno Kapitein 31466546a2 Change overlay close icon to close (#3089) 2025-04-04 16:42:47 +02:00
Steven H 90ead98eaa Better error handling in revalidate v1 (#3094) 2025-04-04 14:59:14 +01:00
Samy Pessé 65e9cb9d5c Update next to "canary" for v2 (#3093) 2025-04-04 14:39:43 +02:00
Greg Bergé e59076a074 Improve OpenAPI schemas block ungrouped style (#3092) 2025-04-04 14:28:44 +02:00
Nolann B. 653920dec9 Add tests for generateSchemaExample (#3086) 2025-04-03 19:42:40 +02:00
Greg Bergé 23cedd2d8b Hide deprecated properties in examples (#3085) 2025-04-03 19:40:52 +02:00
Nolann B. 70c4182fd8 Improve OpenAPI schema style (#3079) 2025-04-03 16:10:20 +00:00
Samy Pessé b62b101065 Do not use insights cookie if disabled (#3087) 2025-04-03 17:30:43 +02:00
Zeno Kapitein b9c929beeb Update page breadcrumb color (for links-accent crumbs) (#3082) 2025-04-03 16:34:14 +02:00
Nolann B. 2b6c593289 Remove stable from x-stability (#3083) 2025-04-03 11:33:39 +02:00
Samy Pessé c0b339e406 Version Packages (#3075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-02 13:36:04 +02:00
Nolann B. da485f5144 Fix read-only in generateSchemaExample (#3069) 2025-04-02 13:03:51 +02:00
Nolann B. 139a8050a5 Fix OpenAPI enum display (#3077) 2025-04-02 10:15:19 +02:00
Nolann B. da7b369ffd Fix missing headers in OpenAPIResponses (#3074) 2025-04-01 21:57:36 +02:00
Samy Pessé 432da64919 Version Packages (#3073)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 18:13:52 +02:00
Nolann B. 7d0b422200 Handle grouped OpenAPISchemas (#3071) 2025-04-01 17:24:43 +02:00
Zeno Kapitein fb90eb0acc Limit tinted background on bold theme to sites with filled sidebar (#3072) 2025-04-01 16:43:08 +02:00
291 changed files with 10956 additions and 4044 deletions
+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-v2": patch
---
fix ISR on preview env
+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
---
Make TOC height dynamic based on visible header and footer elements
+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-v2": patch
---
Add `urlObject.hash` to `linker.toLinkForContent` to pass through URL fragment identifiers, used in search
+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/`.
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
@@ -19,6 +19,12 @@ inputs:
deploy:
description: 'Deploy as main version for all traffic instead of uploading versions'
required: true
commitTag:
description: 'Commit branch to associate with the deployment'
required: true
commitMessage:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
description: "Deployment URL"
@@ -49,10 +55,13 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
run: bun run turbo build:v2:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
shell: bash
- id: deploy
name: Deploy to Cloudflare
@@ -61,9 +70,9 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '3.112.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
- name: Outputs
shell: bash
env:
@@ -54,6 +54,7 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build Project Artifacts
@@ -62,6 +63,7 @@ runs:
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
+66 -13
View File
@@ -10,7 +10,7 @@ jobs:
deploy-v1-cloudflare:
name: Deploy v1 to Cloudflare Pages
runs-on: ubuntu-latest
environment:
environment:
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions:
@@ -40,6 +40,7 @@ jobs:
run: bun run turbo gitbook#build:cloudflare
env:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
GITBOOK_RUNTIME: cloudflare
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
@@ -56,7 +57,7 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
environment:
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -77,11 +78,11 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
environment:
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -95,9 +96,19 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
commitMessage: ${{ github.sha }}
- name: Extract Worker ID
id: extract-worker-id
if: ${{ !steps.deploy.outputs.deployment-url }}
run: |
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
@@ -123,14 +134,14 @@ jobs:
body: |
Summary of the deployments:
### Version 1 (production)
### Version 1
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
### Version 2 (experimental)
### Version 2
| Version | URL | Status |
| --- | --- | --- |
@@ -139,10 +150,10 @@ jobs:
### Test content
| Site | v1 | v2 |
| --- | --- | --- |
| 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) |
| 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) |
| Site | `v1` | `2v` | `2c` |
| --- | --- | --- | --- |
| 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) |
edit-mode: replace
visual-testing-v1:
runs-on: ubuntu-latest
@@ -184,11 +195,32 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-vercel'
visual-testing-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v1:
runs-on: ubuntu-latest
name: Visual Testing Customers v1
needs: deploy-v1-cloudflare
timeout-minutes: 6
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -208,7 +240,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 6
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -225,6 +257,27 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v1:
runs-on: ubuntu-latest
name: PageSpeed Testing v1
+3 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy staging
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-vercel
with:
@@ -48,6 +48,8 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+3 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy staging
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-vercel
with:
@@ -48,6 +48,8 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+1
View File
@@ -9,6 +9,7 @@
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
+4 -4
View File
@@ -1,7 +1,7 @@
<h1 align="center">GitBook</h1>
<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 align="center">
@@ -59,15 +59,15 @@ bun install
4. Start your local development server.
```
bun dev
bun dev:v2
```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
- http://localhost:3000/docs.gitbook.com
- http://localhost:3000/open-source.gitbook.io/midjourney
- http://localhost:3000/url/gitbook.com/docs
- 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.
+2 -1
View File
@@ -20,7 +20,8 @@
"**/.wrangler/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json"
"packages/icons/src/data/*.json",
"packages/cache-do/worker-configuration.d.ts"
]
},
"formatter": {
+394 -238
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -4,15 +4,15 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.4.4",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.2.5",
"packageManager": "bun@1.2.11",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.106.0"
"@gitbook/api": "^0.115.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"private": true,
"scripts": {
+2 -2
View File
@@ -21,10 +21,10 @@
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^3.112.0"
"wrangler": "^4.10.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
"generate": "wrangler types",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
+1 -1
View File
@@ -14,7 +14,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"types": ["./.wrangler/types/runtime.d.ts"]
"types": ["./worker-configuration.d.ts"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["worker-configuration.d.ts"]
}
}
}
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/cache-tags
## 0.3.1
### Patch Changes
- 77397ca: Fix version of @gitbook/api referenced in package.json
## 0.3.0
### Minor Changes
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
## 0.2.0
### Minor Changes
+2 -2
View File
@@ -8,9 +8,9 @@
"default": "./dist/index.js"
}
},
"version": "0.2.0",
"version": "0.3.1",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"assert-never": "^1.2.1"
},
"devDependencies": {
+20
View File
@@ -1,5 +1,25 @@
# @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
### Patch Changes
- cdffd7c: Desaturate text colors by decreasing chroma for the last steps of the color scale
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0
### Minor Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.3.0",
"version": "0.3.3",
"devDependencies": {
"typescript": "^5.5.3"
},
+20 -2
View File
@@ -214,13 +214,31 @@ export function colorScale(
const targetL =
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.
result.push(hex);
continue;
}
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
const chromaRatio = (() => {
switch (index) {
// Step 9 and 10 have max chroma, meaning they are fully saturated.
case 8:
case 9:
return 1;
// Step 11 and 12 have a reduced chroma
case 10:
return 0.4;
case 11:
return 0.1;
default:
return index * 0.05;
}
})();
const shade = {
L: targetL, // Blend lightness
+1
View File
@@ -6,6 +6,7 @@
# cloudflare
.open-next
.wrangler
# Symbolic links
public
+34
View File
@@ -1,5 +1,39 @@
# 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
### Patch Changes
- Updated dependencies [77397ca]
- @gitbook/cache-tags@0.3.1
## 0.2.4
### Patch Changes
- 4234289: Fix incoming URL for requests that were proxied
- Updated dependencies [116575c]
- @gitbook/cache-tags@0.3.0
## 0.2.3
### Patch Changes
+20 -2
View File
@@ -7,8 +7,6 @@ const nextConfig = {
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
// This is needed to use 'use cache'
useCache: true,
// Content is fully static, we can cache it in the session memory cache for a long time
@@ -33,7 +31,9 @@ const nextConfig = {
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
@@ -53,6 +53,24 @@ const nextConfig = {
},
],
},
async headers() {
return [
{
source: '/~gitbook/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
],
},
];
},
};
export default nextConfig;
+22 -6
View File
@@ -1,10 +1,26 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import d1TagCache from '@opennextjs/cloudflare/d1-tag-cache';
import kvIncrementalCache from '@opennextjs/cloudflare/kv-cache';
import memoryQueue from '@opennextjs/cloudflare/memory-queue';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import {
softTagFilter,
withFilter,
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
queue: memoryQueue,
tagCache: d1TagCache,
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: withFilter({
tagCache: doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
},
}),
// We don't use `revalidatePath`, so we filter out soft tags
filterFn: softTagFilter,
}),
queue: () => import('./openNext/queue').then((m) => m.default),
// Performance improvements as we don't use PPR
enableCacheInterception: true,
});
@@ -0,0 +1,186 @@
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';
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');
return localCacheEntry.json();
}
const r2Object = await r2.get(cacheKey);
if (!r2Object) return null;
span.setAttribute('cacheHit', 'r2');
return {
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
};
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
}
);
}
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 r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
await r2.put(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 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();
+17
View File
@@ -0,0 +1,17 @@
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import memoryQueue from '@opennextjs/cloudflare/overrides/queue/memory-queue';
interface Env {
IS_PREVIEW?: string;
}
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
const { ctx, env } = getCloudflareContext();
const isPreview = (env as Env).IS_PREVIEW === 'true';
ctx.waitUntil(isPreview ? memoryQueue.send(msg) : doQueue.send(msg));
},
} satisfies Queue;
+12 -12
View File
@@ -1,23 +1,23 @@
{
"name": "gitbook-v2",
"version": "0.2.3",
"version": "0.3.0",
"private": true,
"dependencies": {
"next": "^15.2.3",
"@gitbook/api": "^0.115.0",
"@gitbook/cache-tags": "workspace:*",
"@opennextjs/cloudflare": "1.0.4",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0",
"p-memoize": "^7.1.1"
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.10",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
@@ -28,8 +28,8 @@
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"build:v2:cloudflare": "opennextjs-cloudflare build",
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
"unit": "bun test",
"typecheck": "tsc --noEmit"
}
@@ -3,10 +3,9 @@ import {
generateSitePageMetadata,
generateSitePageViewport,
} from '@/components/SitePage';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next';
import { unstable_cacheTag as cacheTag } from 'next/cache';
export const dynamic = 'force-static';
@@ -15,19 +14,10 @@ type PageProps = {
};
export default async function Page(props: PageProps) {
'use cache';
const params = await props.params;
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return <SitePage context={context} pageParams={{ pathname }} />;
}
@@ -4,10 +4,8 @@ import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { unstable_cacheTag as cacheTag } from 'next/cache';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -17,17 +15,8 @@ export default async function SiteStaticLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
'use cache';
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout
@@ -1,15 +1,16 @@
import { type NextRequest, NextResponse } from 'next/server';
import { withVerifySignature } from '@v2/lib/routes';
import { revalidateTag } from 'next/cache';
import { revalidatePath, revalidateTag } from 'next/cache';
interface JsonBody {
tags: string[];
paths?: string[];
}
/**
* Revalidate cached data based on tags.
* The body should be a JSON with { tags: string[] }
* The body should be a JSON with { tags: string[], paths?: string[] }
*/
export async function POST(req: NextRequest) {
return withVerifySignature<JsonBody>(req, async (body) => {
@@ -22,12 +23,27 @@ export async function POST(req: NextRequest) {
);
}
if (body.paths && !Array.isArray(body.paths)) {
return NextResponse.json(
{
error: 'paths must be an array',
},
{ status: 400 }
);
}
body.tags.forEach((tag) => {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
});
body.paths?.forEach((path) => {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating path: ${path}`);
revalidatePath(path);
});
return NextResponse.json({
success: true,
});
+41 -14
View File
@@ -19,6 +19,7 @@ import {
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import assertNever from 'assert-never';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
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.
linker.toLinkForContent = (rawURL: string) => {
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)
: null;
const siteSpace = (
siteStructure.type === 'siteSpaces' && siteStructure.structure
? siteStructure.structure
: sections?.current.siteSpaces
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) {
throw new Error('Site space not found');
}
// Parse the current siteSpace and siteSpaces based on the site structure type.
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
if (siteStructure.type === 'siteSpaces') {
const siteSpaces = siteStructure.structure;
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
const siteSpaces =
siteStructure.type === 'siteSpaces'
? siteStructure.structure
: (sections?.current.siteSpaces ?? []);
if (!siteSpace) {
throw new Error(
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
);
}
return { siteSpaces, siteSpace };
}
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 = (() => {
if (ids.siteSpace) {
@@ -380,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
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;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import { getCloudflareContext as getCloudflareContextOpenNext } from '@opennextjs/cloudflare';
import { GITBOOK_RUNTIME } from '../env';
/**
* Return the Cloudflare context or null when not running in Cloudflare.
*/
export function getCloudflareContext() {
if (GITBOOK_RUNTIME !== 'cloudflare') {
return null;
}
return getCloudflareContextOpenNext();
}
/**
* Return an object representing the current request.
*/
export function getCloudflareRequestGlobal() {
const context = getCloudflareContext();
if (!context) {
return null;
}
return context.cf;
}
@@ -98,6 +98,10 @@ export async function wrapDataFetcherError<T>(
*/
export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
if (error.code >= 500) {
throw error;
}
return {
code: error.code,
message: error.errorMessage,
@@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
}
if (error instanceof DataFetcherError) {
if (error.code >= 500) {
throw error;
}
return {
code: error.code,
message: error.message,
@@ -4,3 +4,4 @@ export * from './pages';
export * from './urls';
export * from './errors';
export * from './lookup';
export * from './visitor';
+82 -35
View File
@@ -1,55 +1,102 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api';
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls';
/**
* 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: {
interface LookupPublishedContentByUrlInput {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
apiToken: string | null;
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 resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'resolvePublishedContentByUrl',
name: url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
},
});
}
/**
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*
* @deprecated use resolvePublishedContentByUrl.
*
*/
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'getPublishedContentByURL',
name: url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url,
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
redirectOnError: input.redirectOnError,
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{ signal }
)
)
);
},
});
}
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
async function lookupPublishedContentByUrl(input: {
url: string;
fetchLookupAPIResult: (args: {
url: string;
signal: AbortSignal;
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = await apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'getPublishedContentByURL',
name: alternative.url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
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,
}
)
)
);
const callResult = await input.fetchLookupAPIResult({
url: alternative.url,
signal,
});
if (callResult.error) {
if (alternative.primary) {
@@ -1,39 +1,52 @@
import { describe, expect, it, mock } from 'bun:test';
import { memoize } from './memoize';
import { AsyncLocalStorage } from 'node:async_hooks';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
describe('memoize', () => {
it('should memoize the function', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
expect(await memoized()).toBe(await memoized());
});
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);
it('should memoize the function with different arguments', async () => {
const fn = mock(async (a: number, b: number) => a + b);
const memoized = memoize(fn);
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
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 memoize a function complex object', async () => {
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
const memoized = memoize(fn);
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
await memoized({ foo: 'foo', bar: 2 })
);
expect(fn.mock.calls.length).toBe(2);
});
it('should wrap concurrent async calls', async () => {
it('should support caching per request', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
const promise1 = memoized();
const promise2 = memoized();
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);
});
});
+55 -11
View File
@@ -1,17 +1,61 @@
import pMemoize from 'p-memoize';
/**
* 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;
};
}
/**
* We wrap 'use cache' calls in a p-memoize function to avoid
* executing the function multiple times when doing concurrent calls.
*
* Hopefully one day this can be done directly by 'use cache'.
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function memoize<F extends (...args: any[]) => any>(f: F): F {
return pMemoize(f, {
cacheKey: (args) => {
return JSON.stringify(deepSortValue(args));
},
});
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 {
+11
View File
@@ -179,4 +179,15 @@ export interface GitBookDataFetcher {
integrationName: string;
request: api.RenderIntegrationUI;
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
/**
* Stream an AI response.
*/
streamAIResponse(params: {
organizationId: string;
siteId: string;
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'bun:test';
import { getVisitorAuthBasePath } from './visitor';
describe('getVisitorAuthBasePath', () => {
it('should return the correct base path for proxy requests', () => {
expect(
getVisitorAuthBasePath(
new URL('https://proxy.gitbook.site/sites/site_foo/hello/world'),
{
site: 'site_foo',
siteSpace: 'sitesp_foo',
basePath: '/foo',
siteBasePath: '/foo',
organization: 'org_foo',
space: 'space_foo',
pathname: '/hello/world',
complete: false,
apiToken: 'api_token_foo',
canonicalUrl: 'https://example.com/docs/foo/hello/world',
}
)
).toBe('/sites/site_foo/');
});
it('should return the correct base path for non-proxy requests', () => {
expect(
getVisitorAuthBasePath(new URL('https://example.com/docs/foo/hello/world'), {
site: 'site_foo',
siteSpace: 'sitesp_foo',
basePath: '/foo/',
siteBasePath: '/foo/',
organization: 'org_foo',
space: 'space_foo',
pathname: '/hello/world',
complete: false,
apiToken: 'api_token_foo',
canonicalUrl: 'https://example.com/docs/foo/hello/world',
})
).toBe('/foo/');
});
});
@@ -0,0 +1,19 @@
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import type { PublishedSiteContent } from '@gitbook/api';
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
/**
* Get the appropriate base path for the visitor authentication cookie.
*/
export function getVisitorAuthBasePath(
siteRequestURL: URL,
siteURLData: PublishedSiteContent
): string {
// The siteRequestURL for proxy requests is of the form `https://proxy.gitbook.com/site/siteId/...`
// In such cases, we should not use the resolved siteBasePath for the cookie because for subsequent requests
// we will not have the siteBasePath in the request URL in order to retrieve the cookie. So we use the
// proxy identifier instead.
return isProxyRequest(siteRequestURL)
? withLeadingSlash(withTrailingSlash(getProxyRequestIdentifier(siteRequestURL)))
: siteURLData.siteBasePath;
}
+26
View File
@@ -6,6 +6,14 @@ import 'server-only';
* and not from the `process.env` object.
*/
/**
* Runtime environment.
*/
export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as
| 'vercel'
| 'cloudflare'
| 'unknown';
/**
* Main host on which GitBook is running.
*/
@@ -77,6 +85,15 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
/**
* Mode used for resizing images.
*/
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
'GITBOOK_IMAGE_RESIZE_MODE',
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
['cdn-cgi', 'cf-fetch']
);
/**
* Endpoint where icons are served.
*/
@@ -92,3 +109,12 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
* Secret used to validate requests from the GitBook app.
*/
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
if (!enumValues.includes(value as T)) {
throw new Error(
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
);
}
return value as T;
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'bun:test';
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
describe('checkIsSizableImageURL', () => {
it('should return Skip for non-parsable URLs', () => {
expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip);
});
it('should return Skip for non-http(s) URLs', () => {
expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip);
expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip);
});
it('should return Skip for localhost URLs', () => {
expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe(
SizableImageAction.Skip
);
expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip);
});
it('should return Skip for GitBook image URLs', () => {
expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe(
SizableImageAction.Skip
);
});
it('should return Resize for supported image extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.png')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe(
SizableImageAction.Resize
);
});
it('should return Resize for URLs without extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize);
});
it('should return Passthrough for unsupported image extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe(
SizableImageAction.Passthrough
);
});
it('should handle URLs with query parameters correctly', () => {
expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe(
SizableImageAction.Passthrough
);
});
it('should be case-insensitive for extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe(
SizableImageAction.Resize
);
});
});
@@ -0,0 +1,43 @@
import { getExtension } from '@/lib/paths';
export enum SizableImageAction {
Resize = 'resize',
Skip = 'skip',
Passthrough = 'passthrough',
}
/**
* https://developers.cloudflare.com/images/transform-images/#supported-input-formats
*/
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): SizableImageAction {
if (!URL.canParse(input)) {
return SizableImageAction.Skip;
}
const parsed = new URL(input);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return SizableImageAction.Skip;
}
if (parsed.hostname === 'localhost') {
return SizableImageAction.Skip;
}
if (parsed.pathname.includes('/~gitbook/image')) {
return SizableImageAction.Skip;
}
const extension = getExtension(parsed.pathname).toLowerCase();
if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
// If no extension, we consider it resizable.
return SizableImageAction.Resize;
}
return SizableImageAction.Passthrough;
}
@@ -1,34 +1,11 @@
import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookLinker } from '../links';
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
import { getImageSize } from './resizer';
import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types';
interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
/**
* Create an image resizer for a rendering context.
*/
@@ -47,7 +24,7 @@ export function createImageResizer({
return {
getResizedImageURL: (urlInput) => {
if (!checkIsSizableImageURL(urlInput)) {
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
return null;
}
@@ -87,7 +64,7 @@ export function createImageResizer({
},
getImageSize: async (input, options) => {
if (!checkIsSizableImageURL(input)) {
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null;
}
@@ -106,124 +83,6 @@ export function createNoopImageResizer(): ImageResizer {
};
}
/**
* Check if a URL is an HTTP URL.
*/
export function checkIsHttpURL(input: string | URL): boolean {
if (!URL.canParse(input)) {
return false;
}
const parsed = new URL(input);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}
if (input.includes('/~gitbook/image')) {
return false;
}
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}
return true;
}
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (_error) {
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
}
if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
}
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
}
return await fetch(
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`,
{
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
}
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'bun:test';
import { getImageResizingContextId } from './getImageResizingContextId';
describe('getImageResizingContextId', () => {
it('should return proxy identifier for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(getImageResizingContextId(proxyRequestURL)).toBe('sites/site_foo');
});
it('should return preview identifier for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo');
});
it('should return host for regular requests', () => {
const regularRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(getImageResizingContextId(regularRequestURL)).toBe('example.com');
});
});
@@ -1,13 +1,16 @@
import { getPreviewRequestIdentifier, isPreviewRequest } from '@v2/lib/preview';
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
/**
* Get the site identifier to use for image resizing for an incoming request.
* This identifier can be obtained before resolving the request URL.
*/
export function getImageResizingContextId(url: URL): string {
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = url.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
if (isProxyRequest(url)) {
return getProxyRequestIdentifier(url);
}
if (isPreviewRequest(url)) {
return getPreviewRequestIdentifier(url);
}
return url.host;
@@ -3,3 +3,5 @@ export * from './createImageResizer';
export * from './signatures';
export * from './utils';
export * from './getImageResizingContextId';
export * from './resizer';
export * from './checkIsSizableImageURL';
@@ -0,0 +1,48 @@
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
/**
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
*/
export async function resizeImageWithCDNCgi(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
}
const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cdn-cgi: ${resizeURL}`);
return copyImageResponse(
await fetch(resizeURL, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
})
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
@@ -0,0 +1,34 @@
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
/**
* Resize an image by doing a request to the image itself using the Cloudflare fetch.
* https://developers.cloudflare.com/images/transform-images/transform-via-workers/
*
* This method doesn't work in Cloudflare Pages and is only supported in workers.
*/
export async function resizeImageWithCFFetch(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cf-fetch: ${input}`);
return copyImageResponse(
await fetch(input, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
cf: { image: resizeOptions },
})
);
}
@@ -0,0 +1,2 @@
export * from './types';
export * from './resizeImage';
@@ -0,0 +1,72 @@
import 'server-only';
import assertNever from 'assert-never';
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
import { resizeImageWithCDNCgi } from './cdn-cgi';
import { resizeImageWithCFFetch } from './cf-fetch';
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (error) {
console.warn(`Error getting image size for ${input}:`, error);
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip) {
throw new Error(
'Cannot resize this image, this function should have never been called on this url'
);
}
if (action === SizableImageAction.Passthrough) {
return fetch(input, {
signal: options.signal,
});
}
switch (GITBOOK_IMAGE_RESIZE_MODE) {
case 'cdn-cgi':
return resizeImageWithCDNCgi(input, options);
case 'cf-fetch':
return resizeImageWithCFFetch(input, options);
default:
assertNever(GITBOOK_IMAGE_RESIZE_MODE);
}
}
@@ -0,0 +1,23 @@
export interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
@@ -0,0 +1,7 @@
/**
* Copy a response to make sure it can be mutated by the rest of the middleware.
* To avoid errors "Can't modify immutable headers".
*/
export function copyImageResponse(response: Response) {
return new Response(response.body, response);
}
@@ -32,6 +32,11 @@ export async function verifyImageSignature(
): Promise<boolean> {
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input);
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
console.log(
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
);
return generated === signature;
}
@@ -65,7 +70,9 @@ const generateSignatureV2: SignFn = async (input) => {
]
.filter(Boolean)
.join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
const signature = fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
return signature;
};
// Reused buffer for FNV-1a hashing in the v1 algorithm
+5
View File
@@ -93,6 +93,11 @@ export function createLinker(
},
toAbsoluteURL(absolutePath: string): string {
// If the path is already a full URL, we return it as is.
if (URL.canParse(absolutePath)) {
return absolutePath;
}
if (!servedOn.host) {
return absolutePath;
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
describe('isPreviewRequest', () => {
it('should return true for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
expect(isPreviewRequest(previewRequestURL)).toBe(true);
});
it('should return false for non-preview requests', () => {
const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false);
});
});
describe('getPreviewRequestIdentifier', () => {
it('should return the correct identifier for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Check if the request to the site is a preview request.
*/
export function isPreviewRequest(requestURL: URL): boolean {
return requestURL.host === 'preview';
}
export function getPreviewRequestIdentifier(requestURL: URL): string {
// For preview requests, we extract the site ID from the pathname
// e.g. https://preview/site_id/...
const pathname = requestURL.pathname.slice(1).split('/');
return pathname[0];
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
describe('isProxyRequest', () => {
it('should return true for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(isProxyRequest(proxyRequestURL)).toBe(true);
});
it('should return false for non-proxy requests', () => {
const nonProxyRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(isProxyRequest(nonProxyRequestURL)).toBe(false);
});
});
describe('getProxyRequestIdentifier', () => {
it('should return the correct identifier for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(getProxyRequestIdentifier(proxyRequestURL)).toBe('sites/site_foo');
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Check if the request to the site was through a proxy.
*/
export function isProxyRequest(requestURL: URL): boolean {
return (
requestURL.host === 'proxy.gitbook.site' || requestURL.host === 'proxy.gitbook-staging.site'
);
}
export function getProxyRequestIdentifier(requestURL: URL): string {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = requestURL.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
}
+63 -34
View File
@@ -10,21 +10,22 @@ import {
type ResponseCookies,
getPathScopedCookieName,
getResponseCookiesForVisitorAuth,
getVisitorToken,
getVisitorData,
normalizeVisitorAuthURL,
} from '@/lib/visitor-token';
} from '@/lib/visitors';
import { serveResizedImage } from '@/routes/image';
import {
DataFetcherError,
getPublishedContentByURL,
getVisitorAuthBasePath,
normalizeURL,
resolvePublishedContentByUrl,
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { getImageResizingContextId } from '@v2/lib/images';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import type { SiteURLData } from './lib/context';
export const config = {
matcher: [
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
@@ -33,6 +34,15 @@ export const config = {
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
/**
* Temporary list of hosts to test adaptive content using the new resolution API.
*/
const ADAPTIVE_CONTENT_HOSTS = [
'docs.gitbook.com',
'adaptive-docs.gitbook-staging.com',
'enriched-content-playground.gitbook-staging.io',
];
export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
@@ -85,17 +95,22 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
//
// Detect and extract the visitor authentication token from the request
//
// @ts-ignore - request typing
const visitorToken = getVisitorToken({
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({
cookies: request.cookies.getAll(),
url: siteRequestURL,
});
const withAPIToken = async (apiToken: string | null) => {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError(
getPublishedContentByURL({
resolve({
url: siteRequestURL.toString(),
visitorAuthToken: visitorToken?.token ?? null,
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
},
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
@@ -106,7 +121,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
apiToken,
})
);
const cookies: ResponseCookies = [];
const cookies: ResponseCookies = visitorParamsCookie
? [
// If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation.
visitorParamsCookie,
]
: [];
//
// Handle redirects
@@ -137,24 +158,32 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return NextResponse.redirect(siteURLData.redirect);
}
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
cookies.push(
...getResponseCookiesForVisitorAuth(
getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorToken
)
);
// We use the host/origin from the canonical URL to ensure the links are
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
let incomingURL = requestURL;
// For cases where the site is proxied, we use the canonical URL
// as the incoming URL along with all the search params from the request.
if (mode !== 'url') {
incomingURL = siteCanonicalURL;
incomingURL.search = requestURL.search;
}
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (
requestURLWithoutToken !== incomingURL &&
requestURLWithoutToken.toString() !== incomingURL.toString()
) {
const incomingURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (incomingURLWithoutToken.toString() !== incomingURL.toString()) {
return writeResponseCookies(
NextResponse.redirect(requestURLWithoutToken.toString()),
NextResponse.redirect(incomingURLWithoutToken.toString()),
cookies
);
}
@@ -167,6 +196,23 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
@@ -174,7 +220,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
MiddlewareHeaders.SiteURL,
`${siteCanonicalURL.origin}${siteURLData.basePath}`
);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData));
// Preview of customization/theme
const customization = siteRequestURL.searchParams.get('customization');
@@ -204,23 +250,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
);
routeType = routeTypeFromPathname ?? routeType;
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const route = [
'sites',
routeType,
+156
View File
@@ -0,0 +1,156 @@
{
"main": ".open-next/worker.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",
"IS_PREVIEW": "false"
},
"env": {
"preview": {
"vars": {
"IS_PREVIEW": "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"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-prod-api-cache"
}
]
// No durable objects on preview, as they block the generation of preview URLs
// and we don't need tags invalidation on preview
},
"staging": {
"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": "GITBOOK_API",
"service": "gitbook-x-staging-api-cache"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"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"
},
"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": "GITBOOK_API",
"service": "gitbook-x-prod-api-cache"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
}
}
}
-50
View File
@@ -1,50 +0,0 @@
main = ".open-next/worker.js"
name = "gitbook-open-v2"
compatibility_date = "2025-03-11"
compatibility_flags = ["nodejs_compat", "allow_importable_env"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
observability = { enabled = true }
[env.preview]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "b7dd9cf58bf2458f84812a2d83b3760c" } # gitbook-open-v2-cache-preview
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "f59ddb40-ad72-4312-9395-0ac6a129af8e", database_name = "gitbook-open-v2-tags-preview" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-preview" },
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
]
[env.staging]
routes = [
{ pattern = "open-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" },
{ pattern = "static-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" }
]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "a446e25f12b741afb185f1e5b4474f0a" } # gitbook-open-v2-cache-staging
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "9df62e39-1f35-4066-83aa-e9b8ed3ac8d5", database_name = "gitbook-open-v2-tags-staging" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-staging" },
{ binding = "GITBOOK_API", service = "gitbook-x-staging-api-cache" }
]
[env.production]
routes = [
{ pattern = "open-2c.gitbook.com/*", zone_name = "gitbook.com" },
{ pattern = "static-2c.gitbook.com/*", zone_name = "gitbook.com" }
]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "72379746280d4e79acf24440eea950dc" } # gitbook-open-v2-cache-production
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "a6f16fce-5f45-43a9-89a4-7b83ddf25b77", database_name = "gitbook-open-v2-tags-production" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-production" },
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
]
+139 -1
View File
@@ -1,5 +1,143 @@
# gitbook
## 0.12.0
### Minor Changes
- 8339e91: Fix images in reusable content across spaces.
- 326e28e: Design tweaks to code blocks and OpenAPI pages
- 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
- c4ebb3f: Fix openapi-select hover in responses
- aed79fd: Decrease rounding of header logo
- 42ca7e1: Fix openapi CR preview
- e6ddc0f: Fix URL in sitemap
- 5e975ab: Fix code highlighting for HTTP
- 5d504ff: Fix resolution of links in reusable contents
- 95a1f65: Better print layouts: wrap code blocks & force table column auto-sizing
- 0499966: Fix invalid sitemap.xml generated with relative URLs instead of absolute ones
- 2a805cc: Change OpenAPI schema-optional from `info` to `tint` color
- 580101d: Fix schemas disclosure label causing client error
- 12a455d: Fix OpenAPI layout issues
- 97b7c79: Increase logging around caching behaviour causing page crashes.
- 373f18f: Prevent section group popovers from opening on click
- 3f29206: Update the regex for validating site redirect
- 0c973a3: Always link main logo to the root of the site
- ae5f1ab: Change `Dropdown`s to use Radix's `DropdownMenu`
- 0e201d5: Add border to filled sidebar on gradient theme
- dd043df: Revert investigation work around URL caches.
- 89a5816: Fix OpenAPI disclosure label ("Show properties") misalignment on mobile
- Updated dependencies [c3f6b8c]
- Updated dependencies [d00dc8c]
- Updated dependencies [42ca7e1]
- Updated dependencies [326e28e]
- Updated dependencies [5e975ab]
- Updated dependencies [f7a3470]
- Updated dependencies [580101d]
- Updated dependencies [20ebecb]
- Updated dependencies [80cb52a]
- Updated dependencies [cb5598d]
- Updated dependencies [c6637b0]
- Updated dependencies [a3ec264]
- @gitbook/colors@0.3.3
- @gitbook/openapi-parser@2.1.4
- @gitbook/react-openapi@1.3.0
## 0.11.1
### Patch Changes
- Updated dependencies [ebc39e9]
- Updated dependencies [b6b09d4]
- @gitbook/react-openapi@1.2.1
## 0.11.0
### Minor Changes
- d67699a: Add OpenAPI Webhook block
### Patch Changes
- 4b8a621: Show sections tabs only if there is at least two sections
- 8ed1bda: Translate OpenAPI blocks
- 7588cfe: Improve OpenAPIResponses examples and schemas
- Updated dependencies [eeb977f]
- Updated dependencies [3363a18]
- Updated dependencies [d67699a]
- Updated dependencies [8ed1bda]
- Updated dependencies [7588cfe]
- Updated dependencies [ad1dc0b]
- @gitbook/react-openapi@1.2.0
## 0.10.1
### Patch Changes
- Updated dependencies [77397ca]
- @gitbook/cache-tags@0.3.1
## 0.10.0
### Minor Changes
- b62b101: Do not set cookie to identify visitor for insights when disabled.
### Patch Changes
- 95ea22d: Cache AI Page Link summary
- daf41fc: Tweak footer design (and refactor)
- de53946: Fix security issue with injection of "javacript:` url in the back button of PDFs
- b92ecfa: Implement retry logic for the DO cache to prevent when revalidating content.
- 528eee3: Add superscript and subscript text rendering
- aa3357a: Fix OpenAPISchemas description padding
- 168a4fa: Add support for buttons to GitBook.
- 70c4182: Improve OpenAPI schema style
- 2b6c593: Remove stable from x-stability
- 580f7ad: Improve the error message returned by the revalidate endpoint.
- cbd768a: Improve OpenAPI codesample (add OpenAPISelect component)
- c765463: Fix ogimage generation crashing when site is using a custom WOFF2 font
- e59076a: Improve OpenAPI schemas block ungrouped style. Classnames have changed, please refer to this PR to update GBX.
- 29aaba5: Override Scalar's overscroll-behavior
- 90ead98: Better error handling in cache revalidation.
- Updated dependencies [116575c]
- Updated dependencies [cdffd7c]
- Updated dependencies [70c4182]
- Updated dependencies [2b6c593]
- Updated dependencies [cbd768a]
- Updated dependencies [e59076a]
- Updated dependencies [eedefdd]
- Updated dependencies [23cedd2]
- @gitbook/cache-tags@0.3.0
- @gitbook/colors@0.3.2
- @gitbook/react-openapi@1.1.10
- @gitbook/openapi-parser@2.1.3
## 0.9.2
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- 139a805: Fix OpenAPI enum display
- Updated dependencies [da7b369]
- Updated dependencies [da485f5]
- Updated dependencies [139a805]
- @gitbook/react-openapi@1.1.9
## 0.9.1
### Patch Changes
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
- 7d0b422: Handle grouped OpenAPISchemas
- Updated dependencies [7d0b422]
- Updated dependencies [fb90eb0]
- @gitbook/react-openapi@1.1.8
- @gitbook/colors@0.3.1
## 0.9.0
### Minor Changes
@@ -472,7 +610,7 @@
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
- 3996110: Optimize images rendered in community ads
- 133c3e7: Update design of Checkbox to be more consistent and readable
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
- 5096f7f: Disable KV cache for gitbook.com/docs as a test, also disable it for change-request to improve consistency
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
+26 -22
View File
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
],
},
{
name: 'Nexthink',
contentBaseURL: 'https://docs.nexthink.com',
tests: [
{
name: 'Home',
url: '/',
screenshot: { waitForTOCScrolling: false },
run: waitForCookiesDialog,
},
],
},
// {
// name: 'Nexthink',
// contentBaseURL: 'https://docs.nexthink.com',
// tests: [
// {
// name: 'Home',
// url: '/',
// screenshot: { waitForTOCScrolling: false },
// run: waitForCookiesDialog,
// },
// ],
// },
{
name: 'asiksupport-stg.dto.kemkes.go.id',
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
@@ -92,11 +92,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.tradeonnova.io',
contentBaseURL: 'https://docs.tradeonnova.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'azcoiner.gitbook.io',
contentBaseURL: 'https://azcoiner.gitbook.io',
@@ -162,11 +157,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://wiki.redmodding.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cherry-ai.com',
contentBaseURL: 'https://docs.cherry-ai.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
// {
// name: 'docs.cherry-ai.com',
// contentBaseURL: 'https://docs.cherry-ai.com',
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
// },
{
name: 'docs.snyk.io',
contentBaseURL: 'https://docs.snyk.io',
@@ -244,6 +239,15 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
];
runTestCases(testCases);
+81 -19
View File
@@ -12,7 +12,7 @@ import {
VISITOR_TOKEN_COOKIE,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
} from '@/lib/visitor-token';
} from '@/lib/visitors';
import { getSiteAPIToken } from '../tests/utils';
import {
@@ -111,24 +111,24 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
const spaceDrowpdown = page
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="space-dropdown-button"] + div'
'css=[data-testid="dropdown-menu"]'
);
// the customized space title
await expect(
variantSelectionDropdown.getByRole('link', {
variantSelectionDropdown.getByRole('menuitem', {
name: 'Multi-Variants',
})
).toBeVisible();
// the NON-customized space title
await expect(
variantSelectionDropdown.getByRole('link', {
variantSelectionDropdown.getByRole('menuitem', {
name: 'RFCs',
})
).toBeVisible();
@@ -145,14 +145,17 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown
await page
.getByRole('link', {
await variantSelectionDropdown
.getByRole('menuitem', {
name: '2.0',
})
.click();
@@ -168,14 +171,18 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown
await page
.getByRole('link', {
await variantSelectionDropdown
.getByRole('menuitem', {
name: '2.0',
})
.click();
@@ -205,14 +212,18 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
},
run: async (page) => {
const spaceDrowpdown = await page
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown
await page
.getByRole('link', {
await variantSelectionDropdown
.getByRole('menuitem', {
name: '2.0',
})
.click();
@@ -258,7 +269,7 @@ const testCases: TestsCase[] = [
},
{
name: 'GitBook',
contentBaseURL: 'https://docs.gitbook.com',
contentBaseURL: 'https://gitbook.com/docs/',
tests: [
{
name: 'Home',
@@ -419,6 +430,43 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Site subdirectory (proxy)',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://nextjs-gbo-proxy.vercel.app/documentation/',
tests: [
{
name: 'Main',
url: '',
fullPage: true,
},
],
},
{
name: 'Site subdirectory (proxy) with VA',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://nextjs-gbo-proxy-va.vercel.app/va/docs/',
tests: [
{
name: 'Main',
url: () => {
const privateKey =
'rqSfA6x7eAKx1qDRCDq9aCXwivpUvQ8YkXeDdFvCCUa9QchIcM7pF1iJ4o7AGOU49spmOWjKoIPtX0pVUVQ81w==';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
}
);
return `?jwt_token=${token}`;
},
fullPage: true,
},
],
},
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -837,6 +885,20 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Content Redirects',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Redirect to new location',
url: '/content-editor/editing-content/inline/redirect-test',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('Redirect test');
},
screenshot: false,
},
],
},
{
name: 'Site Redirects with sections',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
+81 -46
View File
@@ -34,6 +34,10 @@ export interface Test {
* Test to run
*/
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Mode for the test.
*/
mode?: 'page' | 'image';
/**
* Whether the test should be fullscreened during testing.
*/
@@ -154,6 +158,7 @@ export function runTestCases(testCases: TestsCase[]) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const { mode = 'page' } = testEntry;
const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, context }) => {
const testEntryPathname =
@@ -163,6 +168,7 @@ export function runTestCases(testCases: TestsCase[]) {
new URL(testEntryPathname, testCase.contentBaseURL).toString()
)
: getTestURL(testEntryPathname);
if (testEntry.cookies) {
await context.addCookies(
testEntry.cookies.map((cookie) => ({
@@ -194,24 +200,33 @@ export function runTestCases(testCases: TestsCase[]) {
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
const screenshotName = `${testCase.name} - ${testEntry.name}`;
if (mode === 'image') {
await argosScreenshot(page, screenshotName, {
viewports: ['macbook-13'],
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: true,
});
} else {
await argosScreenshot(page, screenshotName, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
await waitForIcons(page);
},
});
}
}
});
}
@@ -276,6 +291,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
internationalization: {
locale: CustomizationLocale.En,
},
insights: {
trackingCookie: true,
},
favicon: {},
header: {
preset: CustomizationHeaderPreset.Default,
@@ -328,61 +346,78 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urls = new Set<string>();
const urlStates: Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch SVG: ${response.status}`);
}
const svgText = await response.text();
const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22');
return `data:image/svg+xml;charset=utf-8,${encoded}`;
};
const loadUrl = (url: string) => {
// Mark the URL as pending.
urlStates[url] = { state: 'pending', uri: null };
fetchSvgAsDataUri(url).then((uri) => {
urlStates[url] = { state: 'loaded', uri };
});
};
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
const results = icons.map((icon) => {
if (!(icon instanceof SVGElement)) {
throw new Error('Icon is not an SVGElement');
}
// If loaded, good it passes the test.
if (icon.dataset.loadingState === 'loaded') {
return true;
}
// If not loaded yet, we need to load it.
if (icon.dataset.loadingState === 'pending') {
return false;
}
// Ignore icons that are not visible.
if (!icon.checkVisibility()) {
return true;
}
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
return false;
}
if (state === 'loaded') {
return true;
}
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
const url = urlMatch?.[1];
// If URL is invalid we throw an error.
if (!url) {
throw new Error('No mask-image');
}
// If the URL is already loaded, we just mark it as loaded.
if (urls.has(url)) {
icon.dataset.loadingState = 'loaded';
return true;
// If the URL is already queued for loading, we return the state.
if (urlStates[url]) {
if (urlStates[url].state === 'loaded') {
icon.setAttribute('data-argos-state', 'pending');
icon.style.maskImage = `url("${urlStates[url].uri}")`;
requestAnimationFrame(() => {
icon.setAttribute('data-argos-state', 'loaded');
});
return false;
}
return false;
}
// Mark the icon as pending and load the image.
icon.dataset.loadingState = 'pending';
// Mark the URL as seen.
urls.add(url);
const img = new Image();
img.src = url;
img.decode().then(() => {
// Wait two frames to let the time to the icon to repaint.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
icon.dataset.loadingState = 'loaded';
});
});
});
loadUrl(url);
return false;
});
+1
View File
@@ -5,6 +5,7 @@ module.exports = {
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
},
webpack(config) {
+16 -11
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.9.0",
"version": "0.12.0",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
},
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.115.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*",
@@ -27,17 +27,18 @@
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1",
"ai": "^4.1.46",
"ajv": "^8.12.0",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
@@ -46,15 +47,16 @@
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"next": "14.2.25",
"next": "14.2.26",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"partial-json": "^0.1.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
@@ -68,11 +70,14 @@
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^4.3.0",
"@cloudflare/next-on-pages": "1.13.7",
"@argos-ci/playwright": "^5.0.3",
"@cloudflare/next-on-pages": "1.13.12",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.51.1",
"@types/js-cookie": "^3.0.6",
+4
View File
@@ -1,3 +1,7 @@
# GitBook immutable static assets
# Duplicated from next.config.mjs until OpenNext supports generating static headers
/~gitbook/static/*
cache-control: public,max-age=31536000,immutable
Access-Control-Allow-Origin: *
/_next/static/*
Access-Control-Allow-Origin: *
@@ -14,7 +14,15 @@ interface JsonBody {
* The body should be a JSON with { tags: string[] }
*/
export async function POST(req: NextRequest) {
const json = (await req.json()) as JsonBody;
let json: JsonBody;
try {
json = await req.json();
} catch (err) {
return NextResponse.json({
error: `invalid json body: ${err}`,
});
}
if (!json.tags || !Array.isArray(json.tags)) {
return NextResponse.json(
@@ -25,10 +33,18 @@ export async function POST(req: NextRequest) {
);
}
const result = await revalidateTags(json.tags);
return NextResponse.json({
success: true,
stats: result.stats,
});
try {
const result = await revalidateTags(json.tags);
return NextResponse.json({
success: true,
stats: result.stats,
});
} catch (err: unknown) {
return NextResponse.json(
{
error: `${err}`,
},
{ status: 500 }
);
}
}
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
export function PageClientLayout(props: { withSections?: boolean }) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
useStripFallbackQueryParam();
return null;
@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/primitives/Button';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
export default function ErrorPage(props: {
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
}}
variant="secondary"
size="small"
>
{t(language, 'unexpected_error_retry')}
</Button>
label={tString(language, 'unexpected_error_retry')}
/>
</div>
</div>
</div>
@@ -0,0 +1,190 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useEffect } from 'react';
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Loading } from '../primitives';
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
/**
* Get a unique cache key for a page summary
*/
function getCacheKey(targetSpaceId: string, targetPageId: string): string {
return `${targetSpaceId}:${targetPageId}`;
}
/**
* Global state for the summaries.
*/
const useSummaries = create<{
/**
* Cache of all summaries generated so far.
*/
cache: Map<string, string>;
/**
* Get a summary for a page.
*/
getSummary: (params: { targetSpaceId: string; targetPageId: string }) => string;
/**
* Stream the generation of a summary for a page.
*/
streamSummary: (params: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
visitedPages: { spaceId: string; pageId: string }[];
}) => Promise<void>;
}>((set, get) => ({
cache: new Map(),
getSummary: ({
targetSpaceId,
targetPageId,
}: {
targetSpaceId: string;
targetPageId: string;
}) => {
return get().cache.get(getCacheKey(targetSpaceId, targetPageId)) ?? '';
},
streamSummary: async ({
currentSpaceId,
currentPageId,
currentPageTitle,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
}) => {
const cacheKey = getCacheKey(targetSpaceId, targetPageId);
if (get().cache.has(cacheKey)) {
// Already generated or generating
return;
}
const update = (summary: string) => {
set((prev) => {
const newCache = new Map(prev.cache);
newCache.set(cacheKey, summary);
return { cache: newCache };
});
};
update('');
const stream = await streamLinkPageSummary({
currentSpaceId,
currentPageId,
currentPageTitle,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
let generatedSummary = '';
for await (const highlight of stream) {
generatedSummary = highlight ?? '';
update(generatedSummary);
}
},
}));
/**
* Summarise a page's content for use in a link preview
*/
export function AIPageLinkSummary(props: {
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
showTrademark: boolean;
}) {
const { targetSpaceId, targetPageId, linkPreview, linkTitle, showTrademark = true } = props;
const currentPage = usePageContext();
const language = useLanguage();
const visitedPages = useVisitedPages((state) => state.pages);
const { summary, streamSummary } = useSummaries(
useShallow((state) => {
return {
summary: state.getSummary({ targetSpaceId, targetPageId }),
streamSummary: state.streamSummary,
};
})
);
useEffect(() => {
streamSummary({
currentSpaceId: currentPage.spaceId,
currentPageId: currentPage.pageId,
currentPageTitle: currentPage.title,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
}, [
currentPage.pageId,
currentPage.spaceId,
currentPage.title,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
streamSummary,
]);
const shimmerBlocks = [
'w-[20%] [animation-delay:-1s]',
'w-[35%] [animation-delay:-0.8s]',
'w-[25%] [animation-delay:-0.6s]',
'w-[10%] [animation-delay:-0.4s]',
'w-[40%] [animation-delay:-0.2s]',
'w-[30%] [animation-delay:0s]',
];
return (
<div className="flex flex-col gap-1">
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{showTrademark ? (
<Loading className="size-4" busy={!summary || summary.length === 0} />
) : (
<Icon icon="sparkle" className="size-3" />
)}
<h6 className="text-tint">{t(language, 'link_tooltip_ai_summary')}</h6>
</div>
{summary.length > 0 ? (
<p className="animate-fadeIn">{summary}</p>
) : (
<div className="mt-2 flex flex-wrap gap-2">
{shimmerBlocks.map((block, index) => (
<div
key={`${index}-${block}`}
className={`${block} h-4 animate-pulse rounded straight-corners:rounded-none bg-tint-active`}
/>
))}
</div>
)}
{summary.length > 0 ? (
<div className="animate-fadeIn text-tint-subtle text-xs">
{t(language, 'link_tooltip_ai_summary_description')}
</div>
) : null}
</div>
);
}
@@ -0,0 +1 @@
export * from './AIPageLinkSummary';
@@ -0,0 +1,124 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
import * as partialJson from 'partial-json';
import type { DeepPartial } from 'ts-essentials';
import type { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
/**
* Get the latest value from a stream and the response id.
*/
export async function generate<T>(
promise: MaybePromise<{
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
}>
) {
const input = await promise;
let value: T | undefined;
for await (const event of input.stream) {
value = event;
}
const { responseId } = await input.response;
return {
responseId,
value,
};
}
/**
* Stream the generation of an object using the AI.
*/
export async function streamGenerateObject<T>(
context: GitBookBaseContext,
{
organizationId,
siteId,
}: {
organizationId: string;
siteId: string;
},
{
schema,
messages,
model = AIModel.Fast,
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
}
) {
const rawStream = context.dataFetcher.streamAIResponse({
organizationId,
siteId,
input: messages,
output: {
type: 'object',
schema: zodToJsonSchema(schema),
},
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
return parsed;
}
});
}
/**
* Parse a stream from the API to extract the responseId.
*/
function parseResponse<T>(
responseStream: EventIterator<AIStreamResponse>,
parse: (response: AIStreamResponse) => T | undefined
): {
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
} {
let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string }>((resolve) => {
resolveResponse = resolve;
});
const stream = new EventIterator<T>((queue) => {
(async () => {
let foundResponse = false;
for await (const event of responseStream) {
if (event.type === 'response_finish') {
foundResponse = true;
resolveResponse({ responseId: event.responseId });
} else {
const parsed = parse(event);
if (parsed !== undefined) {
queue.push(parsed);
}
}
}
if (!foundResponse) {
throw new Error('No response found');
}
})().then(
() => {
queue.stop();
},
(error) => {
queue.fail(error);
}
);
});
return { stream, response };
}
@@ -0,0 +1 @@
export * from './streamLinkPageSummary';
@@ -0,0 +1,166 @@
'use server';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
}: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
visitedPages?: Array<{ spaceId: string; pageId: string }>;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream } = await streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
highlight: z
.string()
.describe('The reason why the user should read the target page.'),
// questions: z.array(z.string().describe('The questions to sea')).max(3),
}),
messages: [
{
role: AIMessageRole.Developer,
content: `# 1. Role
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
# 2. Task
Extract a contextually-relevant fact that:
- Directly answers the specific need or question implied by the link's placement
- States a capability, limitation, or specification from the target page
- Connects precisely to the user's current paragraph or sentence
- Completes the user's understanding based on what they're currently reading
# 3. Instructions
1. First, identify the exact need, question, or gap in the current paragraph where the link appears
2. Find the specific fact in the target page that addresses this exact contextual need
3. Ensure the fact relates directly to the context of the paragraph containing the link
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
},
{
role: AIMessageRole.Developer,
content: `# 4. Current page
The content of the current page is:`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpaceId,
pageId: currentPageId,
},
],
},
...(visitedPages
? [
{
role: AIMessageRole.Developer,
content: '# 5. Previous pages',
},
...visitedPages.map(({ spaceId, pageId }) => ({
role: AIMessageRole.Developer,
content: `## Page ${pageId}`,
attachments: [
{
type: 'page' as const,
spaceId,
pageId,
},
],
})),
]
: []),
{
role: AIMessageRole.Developer,
content: `# 6. Target page
The content of the target page is:`,
attachments: [
{
type: 'page' as const,
spaceId: targetSpaceId,
pageId: targetPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `# 7. Link preview
The content of the link preview is:
> ${linkPreview}
> Page ID: ${targetPageId}`,
},
{
role: AIMessageRole.Developer,
content: `# 8. Guidelines & Examples
ALWAYS:
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
- ALWAYS connect target page information specifically to the current paragraph context
- ALWAYS focus on the gap in knowledge that the link is meant to fill
- ALWAYS consider user's navigation history to ensure contextual continuity
- ALWAYS use action verbs like "click", "select", "use", "create", "enable"
NEVER:
- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly.
- NEVER select general facts unrelated to the specific link context
- NEVER ignore the specific context where the link appears
- NEVER repeat the same fact in different words
## Examples
Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once."
Preview: "Multiple Select: Select multiple content blocks at once."
✓ "Shift selects content between two points, useful for reorganizing your current heading structure."
✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks."
Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]."
Preview: "Change Requests: Collaborative content editing workflow."
✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions."
✗ "Each reviewer receives an email notification and can approve or request changes."
Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]."
Preview: "Live Edit: Real-time collaborative editing."
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
},
{
role: AIMessageRole.User,
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
},
].filter(filterOutNullable),
}
);
for await (const value of stream) {
const highlight = value.highlight;
if (!highlight) {
continue;
}
yield highlight;
}
}
@@ -25,7 +25,7 @@ export function AnnouncementBanner(props: {
const style = BANNER_STYLES[announcement.style];
return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => {
onUpdateState(true);
}}
>
{t(language, 'cookies_accept')}
</Button>
label={tString(language, 'cookies_accept')}
/>
<Button
variant="secondary"
size="small"
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => {
onUpdateState(false);
}}
>
{t(language, 'cookies_reject')}
</Button>
label={tString(language, 'cookies_reject')}
/>
</div>
</div>
);
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
import { List } from './List';
import { ListItem } from './ListItem';
import { BlockMath } from './Math';
import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
import { Paragraph } from './Paragraph';
import { Quote } from './Quote';
import { ReusableContent } from './ReusableContent';
@@ -85,6 +85,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <OpenAPIOperation {...props} block={block} />;
case 'openapi-schemas':
return <OpenAPISchemas {...props} block={block} />;
case 'openapi-webhook':
return <OpenAPIWebhook {...props} block={block} />;
case 'embed':
return <Embed {...props} block={block} />;
case 'blockquote':
@@ -159,6 +161,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'swagger':
case 'openapi-operation':
case 'openapi-schemas':
case 'openapi-webhook':
case 'math':
case 'divider':
case 'content-ref':
@@ -26,6 +26,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
const [isInViewport, setIsInViewport] = useState(false);
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted.
useEffect(() => {
@@ -77,6 +78,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
let cancelled = false;
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines).then((lines) => {
if (cancelled) {
@@ -84,6 +86,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}
setLines(lines);
setHighlighting(false);
});
});
}
@@ -98,6 +101,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}, [isInViewport, block, inlines]);
return (
<CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines ?? plainLines} />
<CodeBlockRenderer
ref={blockRef}
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
/>
);
}
@@ -24,7 +24,7 @@
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
@apply before:text-tint before:content-[counter(line)];
.highlight-line.highlighted > & {
@@ -14,6 +14,7 @@ import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
'aria-busy'?: boolean;
};
/**
@@ -23,7 +24,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, lines } = props;
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
@@ -31,10 +32,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const title = block.data.title;
return (
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
<div
ref={ref}
aria-busy={ariaBusy}
className={tcls('group/codeblock grid grid-flow-col', style)}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? (
<div className="inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s bg-tint px-3 py-2 text-tint text-xs leading-none tracking-wide">
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
{title}
</div>
) : null}
@@ -45,15 +50,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/>
<pre
className={tcls(
'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]',
'rounded-md straight-corners:rounded-sm',
'hide-scroll relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
'rounded-md straight-corners:rounded-sm shadow-sm',
title && 'rounded-ss-none'
)}
>
<code
id={id}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line]',
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
)}
>
@@ -1,31 +1,67 @@
:root {
--shiki-color-text: theme("colors.tint.11");
--shiki-token-constant: #0a6355;
--shiki-token-string: #8b6d32;
--shiki-token-comment: theme("colors.teal.700/.64");
--shiki-token-keyword: theme("colors.pomegranate.600");
--shiki-token-parameter: #0a3069;
--shiki-token-function: #8250df;
--shiki-token-string-expression: #6a4906;
--shiki-token-punctuation: theme("colors.pomegranate.700/.92");
--shiki-token-link: theme("colors.tint.12");
--shiki-token-inserted: #22863a;
--shiki-token-deleted: #b31d28;
--shiki-token-changed: #8250df;
--shiki-token-punctuation: theme("colors.tint.11");
--shiki-token-comment: theme("colors.neutral.9/.7");
--shiki-token-link: theme("colors.primary.10");
--shiki-token-constant: theme("colors.warning.10");
--shiki-token-string: theme("colors.warning.10");
--shiki-token-string-expression: theme("colors.success.10");
--shiki-token-keyword: theme("colors.danger.10");
--shiki-token-parameter: theme("colors.warning.10");
--shiki-token-function: theme("colors.primary.10");
--shiki-token-inserted: theme("colors.success.10");
--shiki-token-deleted: theme("colors.danger.10");
--shiki-token-changed: theme("colors.tint.12");
}
@media (prefers-contrast: more) {
:root {
--shiki-color-text: theme("colors.tint.12");
--shiki-token-punctuation: theme("colors.tint.12");
--shiki-token-comment: theme("colors.neutral.11");
--shiki-token-link: theme("colors.primary.11");
--shiki-token-constant: theme("colors.warning.11");
--shiki-token-string: theme("colors.warning.11");
--shiki-token-string-expression: theme("colors.success.11");
--shiki-token-keyword: theme("colors.danger.11");
--shiki-token-parameter: theme("colors.warning.11");
--shiki-token-function: theme("colors.primary.11");
--shiki-token-inserted: theme("colors.success.11");
--shiki-token-deleted: theme("colors.danger.11");
--shiki-token-changed: theme("colors.tint.12");
}
}
html.dark {
--shiki-color-text: theme("colors.tint.11");
--shiki-token-constant: #d19a66;
--shiki-token-string: theme("colors.pomegranate.300");
--shiki-token-comment: theme("colors.teal.300/.64");
--shiki-token-keyword: theme("colors.pomegranate.400");
--shiki-token-parameter: theme("colors.yellow.500");
--shiki-token-function: #56b6c2;
--shiki-token-string-expression: theme("colors.tint.11");
--shiki-token-punctuation: #acc6ee;
--shiki-token-link: theme("colors.pomegranate.400");
--shiki-token-inserted: #85e89d;
--shiki-token-deleted: #fdaeb7;
--shiki-token-changed: #56b6c2;
/* Override select colors to have more contrast */
--shiki-token-comment: theme("colors.neutral.9");
--shiki-token-constant: theme("colors.warning.11");
--shiki-token-string: theme("colors.warning.11");
--shiki-token-string-expression: theme("colors.success.11");
--shiki-token-keyword: theme("colors.danger.11");
--shiki-token-parameter: theme("colors.warning.11");
--shiki-token-function: theme("colors.primary.11");
}
.code-monochrome {
--shiki-token-constant: theme("colors.tint.11");
--shiki-token-string: theme("colors.tint.12");
--shiki-token-string-expression: theme("colors.tint.12");
--shiki-token-keyword: theme("colors.primary.10");
--shiki-token-parameter: theme("colors.tint.9");
--shiki-token-function: theme("colors.primary.9");
}
html.dark.code-monochrome {
--shiki-token-constant: theme("colors.tint.11");
--shiki-token-string: theme("colors.tint.12");
--shiki-token-string-expression: theme("colors.tint.12");
--shiki-token-keyword: theme("colors.primary.11");
--shiki-token-parameter: theme("colors.tint.10");
--shiki-token-function: theme("colors.primary.10");
}
@@ -6,6 +6,7 @@ import { Card } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import { getDataOrNull } from '@v2/lib/data';
import { Image } from '../utils';
import type { BlockProps } from './Block';
import { Caption } from './Caption';
import { IntegrationBlock } from './Integration';
@@ -52,7 +53,14 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<Card
leadingIcon={
embed.icon ? (
<img src={embed.icon} className={tcls('w-5', 'h-5')} alt="Logo" />
<Image
src={embed.icon}
className={tcls('w-5', 'h-5')}
alt="Logo"
sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer}
/>
) : null
}
href={block.data.url}
@@ -0,0 +1,58 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getBlockTextStyle } from './spacing';
/**
* A hash icon which adds the block or active block item's ID in the URL hash.
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
*/
export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
export function HashLinkButton(props: {
id: string;
block: DocumentBlockTabs | DocumentBlockHeading;
label?: string;
className?: ClassValue;
iconClassName?: ClassValue;
}) {
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
className={tcls(
'relative',
'hash',
'grid',
'grid-area-1-1',
'h-[1em]',
'border-0',
'opacity-0',
'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]',
'md:group-hover/hash:md:opacity-[1]',
'md:group-focus/hash:md:opacity-[1]',
className
)}
>
<a
href={`#${id}`}
aria-label={label}
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'size-3',
'self-center',
'transition-colors',
'text-transparent',
'group-hover/hash:text-tint-subtle',
'contrast-more:group-hover/hash:text-tint-strong',
iconClassName
)}
/>
</a>
</div>
);
}
@@ -1,9 +1,9 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
@@ -20,45 +20,23 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
return (
<Tag
id={id}
className={tcls(textStyle.textSize, 'heading', 'group', 'relative', 'grid', style)}
className={tcls(
textStyle.textSize,
'heading',
'grid',
'scroll-m-12',
hashLinkButtonWrapperStyles,
style
)}
>
<div
className={tcls(
'hash',
'grid',
'grid-area-1-1',
'relative',
'-ml-6',
'w-7',
'border-0',
'opacity-0',
'group-hover:opacity-[0]',
'group-focus:opacity-[0]',
'md:group-hover:md:opacity-[1]',
'md:group-focus:md:opacity-[1]',
textStyle.marginTop
)}
>
<a
href={`#${id}`}
aria-label="Direct link to heading"
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
>
<Icon
icon="hashtag"
className={tcls(
'w-3.5',
'h-[1em]',
'mt-0.5',
'transition-colors',
'text-transparent',
'group-hover:text-tint-subtle',
'contrast-more:group-hover:text-tint-strong',
'lg:w-4'
)}
/>
</a>
</div>
<HashLinkButton
id={id}
block={block}
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
iconClassName={tcls('size-4')}
label="Direct link to heading"
/>
<div
className={tcls(
'grid-area-1-1',
@@ -104,7 +104,7 @@ const HINT_STYLES: {
'[&_.can-override-text]:text-neutral-strong',
],
container:
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2',
'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
containerWithHeader: 'border-info-solid bg-info-subtle',
},
warning: {
@@ -1,6 +1,7 @@
import type {
DocumentInline,
DocumentInlineAnnotation,
DocumentInlineButton,
DocumentInlineEmoji,
DocumentInlineImage,
DocumentInlineLink,
@@ -13,6 +14,7 @@ import assertNever from 'assert-never';
import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton';
import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink';
import { InlineMath } from './Math';
@@ -44,7 +46,8 @@ export function Inline<
| DocumentInlineEmoji
| DocumentInlineLink
| DocumentInlineMath
| DocumentInlineMention,
| DocumentInlineMention
| DocumentInlineButton,
>(props: InlineProps<T>) {
const { inline, ...contextProps } = props;
@@ -61,6 +64,8 @@ export function Inline<
return <Mention {...contextProps} inline={inline} />;
case 'inline-image':
return <InlineImage {...contextProps} inline={inline} />;
case 'button':
return <InlineButton {...contextProps} inline={inline} />;
default:
assertNever(inline);
}
@@ -0,0 +1,34 @@
import { resolveContentRef } from '@/lib/references';
import * as api from '@gitbook/api';
import { Button } from '../primitives';
import type { InlineProps } from './Inline';
export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline, context } = props;
if (!context.contentContext) {
throw new Error('InlineButton requires a contentContext');
}
const resolved = await resolveContentRef(inline.data.ref, context.contentContext);
if (!resolved) {
return null;
}
return (
<Button
href={resolved.href}
label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
/>
);
}
@@ -1,19 +1,22 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { Icon } from '@gitbook/icons';
import { StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineLinkTooltip } from './InlineLinkTooltip';
import { Inlines } from './Inlines';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
if (!resolved) {
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
@@ -25,24 +28,33 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
return (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</StyledLink>
<InlineLinkTooltip inline={inline} context={context.contentContext} resolved={resolved}>
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
</InlineLinkTooltip>
);
}
@@ -0,0 +1,159 @@
import type { DocumentInlineLink } from '@gitbook/api';
import type { ResolvedContentRef } from '@/lib/references';
import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate';
import { languages } from '@/intl/translations';
import { getNodeText } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { GitBookAnyContext } from '@v2/lib/context';
import { Fragment } from 'react';
import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary';
import { Button, StyledLink } from '../primitives';
export async function InlineLinkTooltip(props: {
inline: DocumentInlineLink;
context: GitBookAnyContext;
children: React.ReactNode;
resolved: ResolvedContentRef;
}) {
const { inline, context, resolved, children } = props;
let breadcrumbs = resolved.ancestors;
const language =
'customization' in context ? getSpaceLanguage(context.customization) : languages.en;
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isExternal) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_external_link'),
},
];
}
if (isSamePage) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_page_anchor'),
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
resolved.subText = undefined;
}
const hasAISummary =
!isExternal &&
!isSamePage &&
'customization' in context &&
context.customization.ai?.pageLinkSummaries.enabled &&
(inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor');
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
<div className="overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
<div className="bg-tint-base p-4">
<div className="flex items-start gap-4">
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
</div>
</div>
{!isSamePage && resolved.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0
? 'place-self-center'
: null
)}
variant="blank"
href={resolved.href}
target="_blank"
label={tString(language, 'open_in_new_tab')}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{resolved.subText ? (
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
) : null}
</div>
{hasAISummary && 'page' in context && 'page' in inline.data.ref ? (
<div className="border-tint-subtle border-t bg-tint p-4">
<AIPageLinkSummary
targetPageId={
resolved.page?.id ??
inline.data.ref.page ??
context.page.id
}
targetSpaceId={inline.data.ref.space ?? context.space.id}
linkTitle={getNodeText(inline)}
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
showTrademark={
'customization' in context &&
context.customization.trademark.enabled
}
/>
</div>
) : null}
</div>
<Tooltip.Arrow className={hasAISummary ? 'fill-tint-3' : 'fill-tint-1'} />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
@@ -20,6 +20,8 @@ export const contentKitServerContext: ContentKitServerContext = {
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: (props) => <Icon icon="eye" {...props} />,
lock: (props) => <Icon icon="lock" {...props} />,
check: (props) => <Icon icon="check" {...props} />,
'check-circle': (props) => <Icon icon="check-circle" {...props} />,
},
codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
@@ -1,18 +1,14 @@
import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { OpenAPIOperation as BaseOpenAPIOperation } from '@gitbook/react-openapi';
import { resolveOpenAPIOperationBlock } from '@/lib/openapi/resolveOpenAPIOperationBlock';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
import { Heading } from '../Heading';
import './scalar.css';
import './style.css';
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
import { getOpenAPIContext } from './context';
/**
* Render an openapi block or an openapi-operation block.
@@ -55,56 +51,7 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
return (
<BaseOpenAPIOperation
data={data}
context={{
specUrl,
icons: {
chevronDown: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />,
plus: <Icon icon="plus" />,
},
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
renderDocument: (documentProps) => (
<DocumentView
document={documentProps.document as JSONDocument}
context={props.context}
style="space-y-6"
blockStyle="max-w-full"
/>
),
renderHeading: (headingProps) => (
<Heading
document={props.document}
ancestorBlocks={props.ancestorBlocks}
isEstimatedOffscreen={props.isEstimatedOffscreen}
context={props.context}
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:mt-0'
: undefined,
])}
block={{
object: 'block',
key: `${block.key}-heading`,
meta: block.meta,
data: {},
type: 'heading-2',
nodes: [
{
key: `${block.key}-heading-text`,
object: 'text',
leaves: [
{ text: headingProps.title, object: 'leaf', marks: [] },
],
},
],
}}
/>
),
defaultInteractiveOpened: context.mode === 'print',
id: block.meta?.id,
blockKey: block.key,
}}
context={getOpenAPIContext({ props, specUrl, context: context.contentContext })}
className="openapi-block"
/>
);

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