Compare commits

...

45 Commits

Author SHA1 Message Date
Viktor Renkema 538aea26ac Check ancestorBlocks for tables before applying text alignment 2025-06-24 11:39:00 +02:00
Samy Pessé 07eab986d5 Optimize fetching of revisions (#3382) 2025-06-23 19:15:17 +02:00
Samy Pessé 711cf38f9b Use getRevision in getRevisionFile (#3379) 2025-06-23 18:43:41 +02:00
Zeno Kapitein 4f7c0eea9f Clicking an active TOC item toggles its descendants (#3378) 2025-06-23 17:27:34 +02:00
conico974 f3448de2da Improve efficiency of the queue (#3373)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-23 15:09:29 +02:00
Viktor Renkema ec92075ef8 Set tw classes correctly for horizontal alignment in tables (#3375) 2025-06-23 14:35:32 +02:00
Nolann B. 1e013cd87c Optional label in OpenAPI x-codeSamples (#3376) 2025-06-23 13:54:15 +02:00
Samy Pessé f033734308 Support for site customization option to change how external links open (#3372) 2025-06-22 15:49:15 +02:00
Nolann B. 50c05164f7 Bump @scalar/api-client-react (#3371) 2025-06-22 12:03:10 +02:00
conico974 7fb9004b43 Properly redirect to the root page (#3369)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-20 19:15:21 +02:00
Greg Bergé 4721403986 Hide scrollbar on sections (#3367) 2025-06-20 12:31:18 +00:00
Samy Pessé df848ef64e Icon in buttons and text alignment (#3365) 2025-06-20 12:08:28 +02:00
conico974 0b40ccd286 Remove buildId from composable cache key generation (#3366)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-20 12:06:44 +02:00
Greg Bergé caaa692f0b Allow to zoom image on mobile if relevant (#3364) 2025-06-20 10:25:58 +02:00
Greg Bergé 42d43e09c4 Always show scrollbars (#3363) 2025-06-20 08:41:21 +02:00
Samy Pessé 7ccbf55ba7 Set myself as codeowner for data cache (#3355) 2025-06-20 08:41:02 +02:00
conico974 81f5bfc2f9 Filter out malicious request (#3357)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-19 17:00:37 +02:00
Greg Bergé a7a713bca2 Scroll to active TOC when clicking a link (#3362) 2025-06-19 16:50:13 +02:00
conico974 d6613c787f Make skeletons client component (#3361)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-19 16:46:31 +02:00
Greg Bergé 427f748e1c Add metadata for adding site to Apple devices home (#3360) 2025-06-19 16:35:07 +02:00
Greg Bergé 4f5fec7213 Fix CodeBlock rendering (#3359) 2025-06-19 15:55:19 +02:00
Greg Bergé 8c0a53ab40 Fix page group not expanded by default (#3358) 2025-06-19 15:08:59 +02:00
conico974 2dfd5aab20 Update SpacesDropdown to manage active state based on current space (#3356)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-19 13:39:59 +02:00
conico974 8d3c6562a2 Implement a StylesProvider (#3352)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-18 19:02:32 +02:00
Zeno Kapitein dae019c115 Consistently show variant selector in section bar if site has sections (#3349) 2025-06-18 17:48:49 +02:00
Steven H 73e0cbb2d6 Fix an issue where PDF export URLs were not bringing their query params. (#3351) 2025-06-18 13:26:20 +01:00
Steven H 392f59450c Improve performance of InlineLinkTooltip (#3339) 2025-06-18 12:15:45 +01:00
conico974 b4918f60ce fix variant space change when pathname equals space-path (#3343)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-17 22:31:32 +02:00
Samy Pessé 8f7c304d58 Fix crash when rendering ogimage with invalid icon (#3348) 2025-06-17 20:25:21 +02:00
Samy Pessé a3a944d7dd Fix crash during rendering of ogimage for VA sites with default icon (#3347) 2025-06-17 19:38:47 +02:00
Samy Pessé 88a35ed057 Fix crash when integration is triggering invalid requests (#3346) 2025-06-17 19:23:52 +02:00
Samy Pessé 67998b6f15 Fix ogimage generation failing with some JPEG images (#3345) 2025-06-17 19:10:15 +02:00
Samy Pessé ff6d1150a5 Use getRevisionPageDocument on v2 (#3342) 2025-06-17 16:12:58 +02:00
Samy Pessé 500c8cb649 Don't crash ogimage generation on RTL text (#3341) 2025-06-17 13:04:50 +02:00
Samy Pessé 6859f7d239 Fix rendering of ogimage when logo or icon are AVIF images (#3336)
Co-authored-by: Steven H <steven@gitbook.io>
2025-06-17 00:31:07 +02:00
Samy Pessé dfa8a37be1 Don't cache unexpected API errors for more than a few minutes (#3334) 2025-06-17 00:16:52 +02:00
Samy Pessé 11a6511b7a Fix crash when integration script fails to render block (#3332) 2025-06-16 20:06:33 +02:00
Samy Pessé af98402655 Add support for inline icons (#3329) 2025-06-16 19:15:22 +02:00
Samy Pessé b7a0db3339 Fix rendering of ogimage with SVG logos (#3328) 2025-06-16 16:59:54 +02:00
Greg Bergé 382a19885b Hide empty section groups (#3327) 2025-06-16 13:52:42 +00:00
Utku Ufuk d99da6a3ae Ignore case while pattern-matching to highlight search results (#3326) 2025-06-16 12:33:00 +00:00
Samy Pessé 72cd0e59e6 Replace withoutConcurrency by a smarter React.cache (#3325) 2025-06-16 13:37:45 +02:00
Utku Ufuk 42d88da73c Fix UX issue about highlighting the search term in search result sections (#3323) 2025-06-16 13:31:15 +03:00
Taran Vohra 2863fe0dc1 Use resolvePublishedContentByUrl instead of the deprecated resolution endpoint (#3310) 2025-06-16 09:59:55 +05:30
conico974 87218062be Use Promise.all whenever possible (#3319)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-14 11:10:26 +02:00
136 changed files with 2562 additions and 2030 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix UX issue about highlighting the search term in search result sections
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide scrollbar on sections
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add support for inline icons.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash when integration script fails to render block.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show scrollbars
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Optimize performances by using a smarter per-request cache arround data cached functions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add support for icons in buttons.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Fix rendering of ogimage with SVG logos.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash when integration is triggering invalid requests.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Optimize the fetch of revision files by using only the getRevision cache.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix CodeBlock layout
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Scroll to active TOC when clicking a link
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add support for text alignment for headings and paragraphs.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Add basic error handling when transitioning between states.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Don't crash ogimage generation on RTL text, as a workaround until we can support it.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add support for site customization option to change how external links open.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix rendering of ogimage when logo or icon are AVIF images.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ogimage generation failing with some JPEG images.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix page group not expanded by default
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Don't cache unexpected API errors for more than a few minutes.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Fix an issue where PDF export URLs were not keeping their query params.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Ignore case while highlighting search results.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Consistently show variant selector in section bar if site has sections
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Clicking an active TOC item toggles its descendants
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages.
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
---
Optional label in OpenAPI x-codeSamples
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add metadata for adding site to Apple devices home
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash during rendering of ogimage for VA sites with default icon.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Allow to zoom images on mobile if relevant
+3
View File
@@ -0,0 +1,3 @@
# Changes to the API data cache functions can invalidate all existing data cache
# causing a massive amount of revalidation, impacting our API.
packages/gitbook-v2/src/lib/data/api.ts @SamyPesse
+8 -2
View File
@@ -56,13 +56,19 @@ git clone https://github.com/gitbookIO/gitbook.git
bun install
```
4. Start your local development server.
4. Run build.
```
bun build:v2
```
5. Start your local development server.
```
bun dev:v2
```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
+80 -21
View File
@@ -85,8 +85,10 @@
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
@@ -169,6 +171,7 @@
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"object-identity": "^0.1.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
@@ -254,7 +257,7 @@
"version": "1.3.0",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.2.19",
"@scalar/api-client-react": "^1.3.16",
"@scalar/oas-utils": "^0.2.130",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
@@ -283,7 +286,7 @@
"react-dom": "^19.0.0",
},
"catalog": {
"@gitbook/api": "^0.120.0",
"@gitbook/api": "^0.123.0",
},
"packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
@@ -648,7 +651,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.120.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-FiRmPiSBwobMxmNjd14QkkOdM95BAPLDDRShgpS9Vsd8lHjNMyZfrJKVJTsJUuFcgYoi4cqNw9yu/TiUBUgv3g=="],
"@gitbook/api": ["@gitbook/api@0.123.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-BszoIk4H/wWb0jnGSJPSSXSAO8tV4B4/LHwl5ygfERHqk9rOGpM/U7+yOlmbX/tnm30vE+MQ3QQ5i8hN8wVk1w=="],
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
@@ -838,6 +841,8 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.51.1", "", { "dependencies": { "playwright": "1.51.1" }, "bin": { "playwright": "cli.js" } }, "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q=="],
@@ -1122,19 +1127,21 @@
"@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="],
"@scalar/api-client": ["@scalar/api-client@2.3.19", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.0", "@headlessui/vue": "^1.7.20", "@scalar/components": "0.13.47", "@scalar/draggable": "0.1.11", "@scalar/icons": "0.1.3", "@scalar/import": "0.3.13", "@scalar/oas-utils": "0.2.130", "@scalar/object-utils": "1.1.13", "@scalar/openapi-parser": "0.10.14", "@scalar/openapi-types": "0.2.0", "@scalar/postman-to-openapi": "0.2.3", "@scalar/snippetz": "0.2.19", "@scalar/themes": "0.9.86", "@scalar/types": "0.1.7", "@scalar/use-codemirror": "0.11.92", "@scalar/use-hooks": "0.1.40", "@scalar/use-toasts": "0.7.9", "@scalar/use-tooltip": "1.0.6", "@vueuse/core": "^10.10.0", "@vueuse/integrations": "^11.2.0", "focus-trap": "^7", "fuse.js": "^7.0.0", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "pretty-bytes": "^6.1.1", "pretty-ms": "^8.0.0", "shell-quote": "^1.8.1", "type-fest": "^4.20.0", "vue": "^3.5.12", "vue-router": "^4.3.0", "whatwg-mimetype": "^4.0.0", "yaml": "^2.4.5", "zod": "^3.23.8" } }, "sha512-1Scff4QL6UExxcmSYv5j1dktvQZTXbmDUJp99RqmUROEhneNWEeWaZe+GZWsda5mvDoe4vP9zZKaymkulBDKYQ=="],
"@scalar/api-client": ["@scalar/api-client@2.5.11", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.0", "@headlessui/vue": "^1.7.20", "@scalar/components": "0.14.12", "@scalar/draggable": "0.2.0", "@scalar/helpers": "0.0.4", "@scalar/icons": "0.4.4", "@scalar/import": "0.4.11", "@scalar/oas-utils": "0.4.7", "@scalar/object-utils": "1.2.1", "@scalar/openapi-parser": "0.18.0", "@scalar/openapi-types": "0.3.3", "@scalar/postman-to-openapi": "0.3.10", "@scalar/snippetz": "0.3.1", "@scalar/themes": "0.13.4", "@scalar/types": "0.2.3", "@scalar/use-codemirror": "0.12.13", "@scalar/use-hooks": "0.2.2", "@scalar/use-toasts": "0.8.0", "@scalar/use-tooltip": "1.1.0", "@vueuse/core": "^10.10.0", "@vueuse/integrations": "^11.2.0", "focus-trap": "^7", "fuse.js": "^7.0.0", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "pretty-bytes": "^6.1.1", "pretty-ms": "^8.0.0", "shell-quote": "^1.8.1", "type-fest": "^4.20.0", "vue": "^3.5.12", "vue-router": "^4.3.0", "whatwg-mimetype": "^4.0.0", "yaml": "^2.4.5", "zod": "3.24.1" } }, "sha512-ubREY9whWXH1MdBIVUXdG/Vq4un2m1G918Z3jQhEmrkUP4wyZ0VEiV/h1N71JIYyql9apoj3HqAQb+D2U+rsDQ=="],
"@scalar/api-client-react": ["@scalar/api-client-react@1.2.19", "", { "dependencies": { "@scalar/api-client": "2.3.19", "@scalar/types": "0.1.7", "vue": "^3.5.12" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-zWUQvzAgOQ+oAbzswOlN42OJhwKku5RIIfBlukFMT31ceun3cp8e7+RT5DS6qwwQ2FROhSaqqsdM+o7VQ6pXkA=="],
"@scalar/api-client-react": ["@scalar/api-client-react@1.3.16", "", { "dependencies": { "@scalar/api-client": "2.5.11", "@scalar/types": "0.2.3", "vue": "^3.5.12" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-dt9TxdeEaMrOfwH5WuK2QW/TxVJNkccG3DQle3SHVe9Hfl7nK+i8mMjTR8QMsuUU1MCIFpQzewDtNODq0pVXiw=="],
"@scalar/code-highlight": ["@scalar/code-highlight@0.0.27", "", { "dependencies": { "hast-util-to-text": "^4.0.2", "highlight.js": "^11.9.0", "highlightjs-curl": "^1.3.0", "highlightjs-vue": "^1.0.0", "lowlight": "^3.1.0", "rehype-external-links": "^3.0.0", "rehype-format": "^5.0.0", "rehype-parse": "^9.0.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-A61FUxqD278L+iLtdbMl4+Pg72wtMrnAYft8v1FNY44uf6UfmM47eDVmzWrc7bSvDevg3ho5QA8cKiJBHXZHJA=="],
"@scalar/code-highlight": ["@scalar/code-highlight@0.1.4", "", { "dependencies": { "hast-util-to-text": "^4.0.2", "highlight.js": "^11.9.0", "highlightjs-curl": "^1.3.0", "highlightjs-vue": "^1.0.0", "lowlight": "^3.1.0", "rehype-external-links": "^3.0.0", "rehype-format": "^5.0.0", "rehype-parse": "^9.0.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-vE5q8Jn+I8x99APk4FGVff+ofVQ6y2Fc9qr8zPUncL+ASo0APCqjmVlHiMaFIC1f5nEApHU879B5N4CsLAvnFA=="],
"@scalar/components": ["@scalar/components@0.13.47", "", { "dependencies": { "@floating-ui/utils": "^0.2.2", "@floating-ui/vue": "^1.0.2", "@headlessui/vue": "^1.7.20", "@scalar/code-highlight": "0.0.27", "@scalar/themes": "0.9.86", "@scalar/use-hooks": "0.1.40", "@scalar/use-toasts": "0.7.9", "@vueuse/core": "^10.10.0", "cva": "1.0.0-beta.2", "nanoid": "^5.1.5", "pretty-bytes": "^6.1.1", "radix-vue": "^1.9.3", "tailwind-merge": "^2.5.5", "vue": "^3.5.12" } }, "sha512-e88mKKsCEspd06bpPQPnhtEvCo/jjoFFOX9yUSV9sr0sWFZHi0ihq1zvnpLKpULyS+C5zzyoN/tGVhmaYpXgyg=="],
"@scalar/components": ["@scalar/components@0.14.12", "", { "dependencies": { "@floating-ui/utils": "^0.2.2", "@floating-ui/vue": "^1.0.2", "@headlessui/vue": "^1.7.20", "@scalar/code-highlight": "0.1.4", "@scalar/icons": "0.4.4", "@scalar/oas-utils": "0.4.7", "@scalar/themes": "0.13.4", "@scalar/use-hooks": "0.2.2", "@scalar/use-toasts": "0.8.0", "@vueuse/core": "^10.10.0", "cva": "1.0.0-beta.2", "nanoid": "^5.1.5", "pretty-bytes": "^6.1.1", "radix-vue": "^1.9.3", "vue": "^3.5.12" } }, "sha512-y+2LK84INfD8vCGwyDvpcT+1W+gwfEdjpBGZwrdKXYzJ13Z+fLf5BDDPMCVT4cPWnzQ2MrVgyBh8UnznM8JLFw=="],
"@scalar/draggable": ["@scalar/draggable@0.1.11", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-EQW9N1+mDORhsbjdtCI3XDvmUKsuKw1uf6r3kT1Mm2zQKT+rWwA0ChsAkEN6OG62C0YumMuXpH71h1seAWptxw=="],
"@scalar/draggable": ["@scalar/draggable@0.2.0", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-UetHRB5Bqo5egVYlS21roWBcICmyk8CKh2htsidO+bFGAjl2e7Te+rY0borhNrMclr0xezHlPuLpEs1dvgLS2g=="],
"@scalar/icons": ["@scalar/icons@0.1.3", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-Bl46u7WsJ7NYjW1Fva7SMvw9c/92pGBP8B68tvDc+QevQ04DVNxw6+ny1NU/PnLtpuu1rUpPdtSCAkV1OdQGZQ=="],
"@scalar/helpers": ["@scalar/helpers@0.0.4", "", {}, "sha512-AkWdiU8zhztb4bJ0U2LHgCtJs+kwWB5UFXebfy9QCLwCP0guhhFR2UJtRCU3QZ8xmZnI0AX0i7BjrxU/MN1roQ=="],
"@scalar/import": ["@scalar/import@0.3.13", "", { "dependencies": { "@scalar/oas-utils": "0.2.130", "@scalar/openapi-parser": "0.10.14", "yaml": "^2.4.5" } }, "sha512-ooKyRxwtvMpxBnoLt9mSJF8er5rCR6RzGJaIMRCj7ViN776eY4mbLiYcXre/LO8XfLSHb1p7XbNDhfFyTHaUlw=="],
"@scalar/icons": ["@scalar/icons@0.4.4", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^20.17.10", "chalk": "^5.4.1", "vue": "^3.5.12" } }, "sha512-622QsIkRPw5UG4IU4QzcTUV1UHEw7aBaZMu6QYEHPO0Wn3ue1lfFIqLZOKsoXxvDuuXGvZUqyoZahkXdRHccmQ=="],
"@scalar/import": ["@scalar/import@0.4.11", "", { "dependencies": { "@scalar/helpers": "0.0.4", "@scalar/openapi-parser": "0.18.0", "yaml": "^2.4.5" } }, "sha512-s8GRknImMjevimqv+jLkW+7LsBuOWgQPv/p29dukLqPtCGddIyNoWgVot6/IaREEXXeu5m/PmoG+uN58AVN4Vg=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.2.130", "", { "dependencies": { "@hyperjump/json-schema": "^1.9.6", "@scalar/object-utils": "1.1.13", "@scalar/openapi-types": "0.2.0", "@scalar/themes": "0.9.86", "@scalar/types": "0.1.7", "flatted": "^3.3.1", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "yaml": "^2.4.5", "zod": "^3.23.8" } }, "sha512-sVpdc3+3c/WiNrKEIwzJ+ml2ZQBjarMOTDJCM/IrvYhrJE0nHrdkzxlJgNPi++vJbVl0saYt8LhEItALv7NziA=="],
@@ -1144,21 +1151,21 @@
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="],
"@scalar/postman-to-openapi": ["@scalar/postman-to-openapi@0.2.3", "", { "dependencies": { "@scalar/oas-utils": "0.2.130", "@scalar/openapi-types": "0.2.0" } }, "sha512-/I5QbDFy+Sh29EIEgub/ztI+1eNtHRn+mln726hR+uWOyVyaDk0FfNC0R4XOn8SsDNyu5eqPbXBb9vceTVG6jQ=="],
"@scalar/postman-to-openapi": ["@scalar/postman-to-openapi@0.3.10", "", { "dependencies": { "@scalar/helpers": "0.0.4", "@scalar/oas-utils": "0.4.7", "@scalar/openapi-types": "0.3.3" } }, "sha512-Jg+uJg56VASihtfL0sOzB8onyt7lfz/8Ztwo57WIAxUTvLcwBeCBgdTvUIw+Z4h7E2KfmKQY9HdDP/GHWMv6DQ=="],
"@scalar/snippetz": ["@scalar/snippetz@0.2.19", "", { "dependencies": { "stringify-object": "^5.0.0" } }, "sha512-fxC5mL3AZWiXAM21sMe1QU1/mu5KceN8ZmzFaP3xmdK26o/MkPKSLGVWW7w6OQkZi5hNloLHXXQiaI235qomEg=="],
"@scalar/snippetz": ["@scalar/snippetz@0.3.1", "", { "dependencies": { "stringify-object": "^5.0.0" } }, "sha512-r5tjYhHxSgyxEPbzIp21xUVR3oy6hjrG4B4ozQWR8qvzpST+Am1KZI+dS9l69PKzfQ0YvQROOvfCMIh3JAFTFQ=="],
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
"@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"@scalar/types": ["@scalar/types@0.2.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.3", "nanoid": "^5.1.5", "zod": "3.24.1" } }, "sha512-K1/Vp5xaQ8TEGDYwHg88OBuQWJRWrE3ToZ0Z2LJwskcY1Eg+uDiRQXKF4QPngZzu5rrWgRErAq4yte4Y7omqgA=="],
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.11.92", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.8", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.7", "@codemirror/lint": "^6.8.4", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", "@lezer/lr": "^1.4.2", "@replit/codemirror-css-color-picker": "^6.3.0", "@scalar/components": "0.13.47", "codemirror": "^6.0.0", "style-mod": "^4.1.2", "vue": "^3.5.12" } }, "sha512-WDd50xGLV+q1T36cKzmhqYP+TyHe4MOW0tIiu09ed9aMXGUk6kQgnf0J3rYPsGeNj9IkVG3znZ7oYT3QplLGVA=="],
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.12.13", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.8", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.7", "@codemirror/lint": "^6.8.4", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-css-color-picker": "^6.3.0", "@scalar/components": "0.14.12", "codemirror": "^6.0.0", "vue": "^3.5.12" } }, "sha512-XOXCUT3b1l0gkp4CY51e7xMgR6IP/AMpYIZ97fBOOjnrUIJ8B/J+U/BMS6+lPKiN0hexaSALRWlVBf90txdT8w=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.1.40", "", { "dependencies": { "@scalar/themes": "0.9.86", "@scalar/use-toasts": "0.7.9", "@vueuse/core": "^10.10.0", "vue": "^3.5.12", "zod": "^3.23.8" } }, "sha512-z8qtgIcW9Z3PCrP2cbKG+D2EVhpNgl1N0ucGtDg5SMl/fvCyXNfqB9j+u3ygxkouatfQ9zRZuhxreNMkW9/H5g=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.2.2", "", { "dependencies": { "@scalar/use-toasts": "0.8.0", "@vueuse/core": "^10.10.0", "cva": "1.0.0-beta.2", "tailwind-merge": "^2.5.5", "vue": "^3.5.12", "zod": "3.24.1" } }, "sha512-9XxfqIKyUp727C2zzyc+cExsK3Gsg5M/v3pEG2TB665RZMAYV1YK8MBT1JGkJDkkugBgHKGl5PQiXsYme1rmTQ=="],
"@scalar/use-toasts": ["@scalar/use-toasts@0.7.9", "", { "dependencies": { "nanoid": "^5.0.9", "vue": "^3.5.12", "vue-sonner": "^1.0.3" } }, "sha512-EcUDJY8VozLS9sfoQKvvipStQJ9RuH/nKOzf0BBr+mZDmumi1WFZ1iIJnHVXIN3iSLcSAr5ej6rOqa6jIv4bCQ=="],
"@scalar/use-toasts": ["@scalar/use-toasts@0.8.0", "", { "dependencies": { "nanoid": "^5.1.5", "vue": "^3.5.12", "vue-sonner": "^1.0.3" } }, "sha512-u+o77cdTNZ5ePqHPu8ZcFw1BLlISv+cthN0bR1zJHXmqBjvanFTy2kL+Gmv3eW9HxZiHdqycKVETlYd0mWiqJQ=="],
"@scalar/use-tooltip": ["@scalar/use-tooltip@1.0.6", "", { "dependencies": { "tippy.js": "^6.3.7", "vue": "^3.5.12" } }, "sha512-f0gadIaUnILfi9qYAk7g+fNTsvLGXnam8oOUTxovavC1ocYuGTEykdz3g2MTqnAqRS8OkAB64h9mHf0FBfg6mg=="],
"@scalar/use-tooltip": ["@scalar/use-tooltip@1.1.0", "", { "dependencies": { "tippy.js": "^6.3.7", "vue": "^3.5.12" } }, "sha512-KJConD/JDyGP8GPGpD+TXA6FEcKT9bmHQb/JyBmME+tJoJGHGtNcGy7kcezFakaKCqfKyY7cgPzL1ctUaGIRag=="],
"@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="],
@@ -1318,6 +1325,8 @@
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
@@ -1706,6 +1715,8 @@
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -2066,6 +2077,8 @@
"ignore": ["ignore@7.0.3", "", {}, "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA=="],
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-lazy": ["import-lazy@2.1.0", "", {}, "sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A=="],
@@ -2438,6 +2451,8 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="],
"object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="],
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
@@ -3860,19 +3875,45 @@
"@rollup/pluginutils/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"@scalar/api-client/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/api-client/@scalar/oas-utils": ["@scalar/oas-utils@0.4.7", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.0.4", "@scalar/object-utils": "1.2.1", "@scalar/openapi-types": "0.3.3", "@scalar/themes": "0.13.4", "@scalar/types": "0.2.3", "@types/har-format": "^1.2.15", "flatted": "^3.3.1", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "yaml": "^2.4.5", "zod": "3.24.1" } }, "sha512-Vp8iZZCjNKXcPpL0Yr6dmy+VOSN1LZ5CdlGAHtp+pxuQTeahV6FxiVjMc9kdFve77fTNyut4seVAD6d7rDUDcA=="],
"@scalar/api-client/@scalar/object-utils": ["@scalar/object-utils@1.2.1", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "^4.20.0" } }, "sha512-2P/0In6XSrV1Ye8yU3LyOWJKWkzXpxf0thHmTfJriqGBZz41s06td2KKqoqHSEEi+FAsl3O1ngmOOzLM3QUkkw=="],
"@scalar/api-client/@scalar/openapi-parser": ["@scalar/openapi-parser@0.18.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-qAWEfJR5f/LwSFULFeSkD56LHgU9ovyj+e1uqy9XaW9Hb9P0ewIGKi+Gd4wTyxfFkdPLXTFYXApjviP6jV+new=="],
"@scalar/api-client/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/api-client/@scalar/themes": ["@scalar/themes@0.13.4", "", { "dependencies": { "@scalar/types": "0.2.3", "nanoid": "^5.1.5" } }, "sha512-PMiSqX+MZF29u32ogEjTVA5c/LBd5cE/QPF7lK0EJeemXRy2r/nWbvrXtqD9Y7Sw54g1caVNjK8yJcCYJdB6hw=="],
"@scalar/api-client/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="],
"@scalar/api-client/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/components/@scalar/oas-utils": ["@scalar/oas-utils@0.4.7", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.0.4", "@scalar/object-utils": "1.2.1", "@scalar/openapi-types": "0.3.3", "@scalar/themes": "0.13.4", "@scalar/types": "0.2.3", "@types/har-format": "^1.2.15", "flatted": "^3.3.1", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "yaml": "^2.4.5", "zod": "3.24.1" } }, "sha512-Vp8iZZCjNKXcPpL0Yr6dmy+VOSN1LZ5CdlGAHtp+pxuQTeahV6FxiVjMc9kdFve77fTNyut4seVAD6d7rDUDcA=="],
"@scalar/components/@scalar/themes": ["@scalar/themes@0.13.4", "", { "dependencies": { "@scalar/types": "0.2.3", "nanoid": "^5.1.5" } }, "sha512-PMiSqX+MZF29u32ogEjTVA5c/LBd5cE/QPF7lK0EJeemXRy2r/nWbvrXtqD9Y7Sw54g1caVNjK8yJcCYJdB6hw=="],
"@scalar/icons/@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
"@scalar/import/@scalar/openapi-parser": ["@scalar/openapi-parser@0.18.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-qAWEfJR5f/LwSFULFeSkD56LHgU9ovyj+e1uqy9XaW9Hb9P0ewIGKi+Gd4wTyxfFkdPLXTFYXApjviP6jV+new=="],
"@scalar/oas-utils/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/oas-utils/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"@scalar/object-utils/flatted": ["flatted@3.3.1", "", {}, "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="],
"@scalar/postman-to-openapi/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/postman-to-openapi/@scalar/oas-utils": ["@scalar/oas-utils@0.4.7", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.0.4", "@scalar/object-utils": "1.2.1", "@scalar/openapi-types": "0.3.3", "@scalar/themes": "0.13.4", "@scalar/types": "0.2.3", "@types/har-format": "^1.2.15", "flatted": "^3.3.1", "microdiff": "^1.4.0", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "yaml": "^2.4.5", "zod": "3.24.1" } }, "sha512-Vp8iZZCjNKXcPpL0Yr6dmy+VOSN1LZ5CdlGAHtp+pxuQTeahV6FxiVjMc9kdFve77fTNyut4seVAD6d7rDUDcA=="],
"@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/postman-to-openapi/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/use-toasts/nanoid": ["nanoid@5.1.2", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/use-hooks/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@shikijs/core/hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
@@ -4930,6 +4971,24 @@
"@scalar/api-client/pretty-ms/parse-ms": ["parse-ms@3.0.0", "", {}, "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw=="],
"@scalar/components/@scalar/oas-utils/@scalar/object-utils": ["@scalar/object-utils@1.2.1", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "^4.20.0" } }, "sha512-2P/0In6XSrV1Ye8yU3LyOWJKWkzXpxf0thHmTfJriqGBZz41s06td2KKqoqHSEEi+FAsl3O1ngmOOzLM3QUkkw=="],
"@scalar/components/@scalar/oas-utils/@scalar/openapi-types": ["@scalar/openapi-types@0.3.3", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-mr3OvbCyvuzSDgv9gR9D3pJAsl82BRNBp1P8ldgRgKF4Wiswfa7nQeKibpX0kHSGXLgiyvxWJULUFwgdWMXNNA=="],
"@scalar/components/@scalar/oas-utils/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/icons/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/object-utils": ["@scalar/object-utils@1.2.1", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "^4.20.0" } }, "sha512-2P/0In6XSrV1Ye8yU3LyOWJKWkzXpxf0thHmTfJriqGBZz41s06td2KKqoqHSEEi+FAsl3O1ngmOOzLM3QUkkw=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/@scalar/themes": ["@scalar/themes@0.13.4", "", { "dependencies": { "@scalar/types": "0.2.3", "nanoid": "^5.1.5" } }, "sha512-PMiSqX+MZF29u32ogEjTVA5c/LBd5cE/QPF7lK0EJeemXRy2r/nWbvrXtqD9Y7Sw54g1caVNjK8yJcCYJdB6hw=="],
"@scalar/postman-to-openapi/@scalar/oas-utils/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/postman-to-openapi/@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@shikijs/core/hast-util-to-html/property-information": ["property-information@7.0.0", "", {}, "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg=="],
"@smithy/chunked-blob-reader-native/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug=="],
+1 -1
View File
@@ -36,7 +36,7 @@
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.120.0"
"@gitbook/api": "^0.123.0"
}
},
"patchedDependencies": {
@@ -122,6 +122,11 @@
]
},
"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"
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
@@ -98,7 +98,11 @@
},
"production": {
"vars": {
// R2 is strongly consistent, so we can disable SQLite
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
// We don't want to pollute the memory with broken cache entries
// Most of the time, those are fake requests.
"NEXT_CACHE_DO_QUEUE_MAX_RETRIES": "1",
"STAGE": "production"
},
"r2_buckets": [
@@ -183,7 +183,14 @@ class GitbookIncrementalCache implements IncrementalCache {
}
// Utility function to generate keys for R2/Cache API
getR2Key(key: string, cacheType: CacheEntryType = 'cache'): string {
getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string {
let key = initialKey;
if (cacheType === 'composable') {
// For composable cache, we need to discard the build ID from the key
const [_buildId, ...restOfTheKey] = JSON.parse(initialKey);
key = JSON.stringify([...restOfTheKey]);
}
const hash = createHash('sha256').update(key).digest('hex');
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
/\/+/g,
+2 -1
View File
@@ -14,7 +14,8 @@
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
"warn-once": "^0.1.1",
"object-identity": "^0.1.2"
},
"devDependencies": {
"gitbook": "*",
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test';
import { withStableRef } from './cache';
describe('withStableRef', () => {
it('should return primitive values as is', () => {
const toStableRef = withStableRef();
expect(toStableRef(42)).toBe(42);
expect(toStableRef('hello')).toBe('hello');
expect(toStableRef(true)).toBe(true);
expect(toStableRef(null)).toBe(null);
expect(toStableRef(undefined)).toBe(undefined);
});
it('should return the same reference for identical objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return the same reference for identical arrays', () => {
const toStableRef = withStableRef();
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const ref1 = toStableRef(arr1);
const ref2 = toStableRef(arr2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(arr1);
expect(ref1).not.toBe(arr2);
});
it('should return the same reference for identical nested objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: { b: 1 }, c: [2, 3] };
const obj2 = { a: { b: 1 }, c: [2, 3] };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return different references for different objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1 };
const obj2 = { a: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).not.toBe(ref2);
});
it('should maintain reference stability across multiple calls', () => {
const toStableRef = withStableRef();
const obj = { a: 1 };
const ref1 = toStableRef(obj);
const ref2 = toStableRef(obj);
const ref3 = toStableRef(obj);
expect(ref1).toBe(ref2);
expect(ref2).toBe(ref3);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { identify } from 'object-identity';
import * as React from 'react';
/**
* Equivalent to `React.cache` but with support for non-primitive arguments.
* As `React.cache` only uses `Object.is` to compare arguments, it will not work with non-primitive arguments.
*/
export function cache<Args extends any[], Return>(fn: (...args: Args) => Return) {
const cached = React.cache(fn);
return (...args: Args) => {
const toStableRef = getWithStableRef();
const stableArgs = args.map((value) => {
return toStableRef(value);
}) as Args;
return cached(...stableArgs);
};
}
/**
* To ensure memory is garbage collected between each request, we use a per-request cache to store the ref maps.
*/
const getWithStableRef = React.cache(withStableRef);
/**
* Create a function that converts a value to a stable reference.
*/
export function withStableRef(): <T>(value: T) => T {
const reverseIndex = new WeakMap<object, string>();
const refIndex = new Map<string, object>();
return <T>(value: T) => {
if (isPrimitive(value)) {
return value;
}
const objectValue = value as object;
const index = reverseIndex.get(objectValue);
if (index !== undefined) {
return refIndex.get(index) as T;
}
const hash = identify(objectValue);
reverseIndex.set(objectValue, hash);
const existing = refIndex.get(hash);
if (existing !== undefined) {
return existing as T;
}
// first time we've seen this shape
refIndex.set(hash, objectValue);
return value;
};
}
function isPrimitive(value: any): boolean {
return value === null || typeof value !== 'object';
}
+7 -14
View File
@@ -2,7 +2,7 @@ import { getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
PublishedSiteContent,
RevisionPage,
Revision,
RevisionPageDocument,
Site,
SiteCustomizationSettings,
@@ -84,11 +84,8 @@ export type GitBookSpaceContext = GitBookBaseContext & {
space: Space;
changeRequest: ChangeRequest | null;
/** ID of the current revision. */
revisionId: string;
/** Pages of the space. */
pages: RevisionPage[];
/** Revision of the space. */
revision: Revision;
/** Share key of the space. */
shareKey: string | undefined;
@@ -351,20 +348,17 @@ export async function fetchSpaceContextByIds(
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
const pages = await getDataOrNull(
dataFetcher.getRevisionPages({
const revision = await getDataOrNull(
dataFetcher.getRevision({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
}),
// When trying to render a revision with an invalid / non-existing ID,
// we should handle gracefully the 404 and throw notFound.
ids.revision ? [404] : undefined
);
if (!pages) {
if (!revision) {
notFound();
}
@@ -372,9 +366,8 @@ export async function fetchSpaceContextByIds(
...baseContext,
organizationId: space.organization,
space,
pages,
revision,
changeRequest,
revisionId,
shareKey: ids.shareKey,
};
}
File diff suppressed because it is too large Load Diff
+46 -5
View File
@@ -1,4 +1,5 @@
import { GitBookAPIError } from '@gitbook/api';
import { unstable_cacheLife as cacheLife } from 'next/cache';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
export class DataFetcherError extends Error {
@@ -47,11 +48,7 @@ export function getDataOrNull<T>(
return response.then((result) => getDataOrNull(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) return null;
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null;
}
/**
@@ -93,6 +90,50 @@ export async function wrapDataFetcherError<T>(
}
}
/**
* Wrap an async execution to handle errors and return a DataFetcherResponse.
* This should be used inside 'use cache' functions.
*/
export async function wrapCacheDataFetcherError<T>(
fn: () => Promise<T>
): Promise<DataFetcherResponse<T>> {
const result = await wrapDataFetcherError(fn);
if (result.error && result.error.code >= 500) {
// We don't want to cache errors for too long.
// as the API might
cacheLife('minutes');
}
return result;
}
/**
* Ignore some data fetcher errors.
*/
export function ignoreDataFetcherErrors<T>(
response: DataFetcherResponse<T>,
ignoreErrors?: number[]
): DataFetcherResponse<T>;
export function ignoreDataFetcherErrors<T>(
response: Promise<DataFetcherResponse<T>>,
ignoreErrors?: number[]
): Promise<DataFetcherResponse<T>>;
export function ignoreDataFetcherErrors<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>,
ignoreErrors: number[] = [404]
): DataFetcherResponse<T> | Promise<DataFetcherResponse<T>> {
if (response instanceof Promise) {
return response.then((result) => ignoreDataFetcherErrors(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) {
return response;
}
throw new DataFetcherError(response.error.message, response.error.code);
}
return response;
}
/**
* Get a data fetcher exposable error from a JS error.
*/
@@ -5,3 +5,4 @@ export * from './errors';
export * from './lookup';
export * from './visitor';
export * from './pages';
export * from './revisions';
+22 -75
View File
@@ -1,7 +1,7 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
@@ -18,85 +18,32 @@ interface LookupPublishedContentByUrlInput {
* 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>> {
export async function lookupPublishedContentByUrl(
input: LookupPublishedContentByUrlInput
): 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 callResult = await input.fetchLookupAPIResult({
url: alternative.url,
signal,
});
const api = apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'resolvePublishedContentByUrl',
name: alternative.url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url: alternative.url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
if (callResult.error) {
if (alternative.primary) {
@@ -1,92 +0,0 @@
import { cache } from 'react';
// This is used to create a context specific to the current request.
// This version works both in cloudflare and in vercel.
const getRequestContext = cache(() => ({}));
/**
* 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>(
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 = getRequestContext();
/**
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
* to avoid "Cannot perform I/O on behalf of a different request" errors.
*/
const promiseCache =
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
globalPromiseCache.set(globalContext, promiseCache);
const concurrent = promiseCache.get(key);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped(key, ...args);
return result;
} finally {
promiseCache.delete(key);
}
})();
promiseCache.set(key, promise);
return promise;
};
}
/**
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args));
}
function deepSortValue(value: unknown): unknown {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (Array.isArray(value)) {
return value.map(deepSortValue);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, subValue]) => {
return [key, deepSortValue(subValue)] as const;
})
.sort((a, b) => {
return a[0].localeCompare(b[0]);
});
}
return value;
}
+2 -33
View File
@@ -1,4 +1,4 @@
import { waitUntil } from '@/lib/waitUntil';
import { isV2 } from '@/lib/v2';
import type { JSONDocument, RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext, GitBookSpaceContext } from '../context';
import { getDataOrNull } from './errors';
@@ -12,10 +12,7 @@ export async function getPageDocument(
): Promise<JSONDocument | null> {
const { dataFetcher, space } = context;
if (
'site' in context &&
(context.site.id === 'site_JOVzv' || context.site.id === 'site_IxAYj')
) {
if (isV2()) {
return getDataOrNull(
dataFetcher.getRevisionPageDocument({
spaceId: space.id,
@@ -41,33 +38,5 @@ export async function getPageDocument(
);
}
// Pre-fetch the document to start filling the cache before we migrate to this API.
if (process.env.NODE_ENV === 'development') {
// Disable for now to investigate side-effects
if (isInPercentRollout(space.id, 10) || process.env.VERCEL_ENV === 'preview') {
await waitUntil(
getDataOrNull(
dataFetcher.getRevisionPageDocument({
spaceId: space.id,
revisionId: space.revision,
pageId: page.id,
})
)
);
}
}
return null;
}
function isInPercentRollout(value: string, rollout: number) {
return getRandomPercent(value) < rollout;
}
function getRandomPercent(value: string) {
const hash = value.split('').reduce((acc, char) => {
return acc + char.charCodeAt(0);
}, 0);
return hash % 100;
}
@@ -0,0 +1,36 @@
import type { Revision, RevisionFile, RevisionReusableContent } from '@gitbook/api';
import * as React from 'react';
const getRevisionReusableContents = React.cache((revision: Revision) => {
return new Map(
revision.reusableContents.map((reusableContent) => [reusableContent.id, reusableContent])
);
});
const getRevisionFiles = React.cache((revision: Revision) => {
return new Map(revision.files.map((file) => [file.id, file]));
});
/**
* Get a revision file by its ID.
*/
export function getRevisionFile(input: {
revision: Revision;
fileId: string;
}): RevisionFile | null {
const { revision, fileId } = input;
const files = getRevisionFiles(revision);
return files.get(fileId) ?? null;
}
/**
* Get a revision reusable content by its ID.
*/
export function getRevisionReusableContent(input: {
revision: Revision;
reusableContentId: string;
}): RevisionReusableContent | null {
const { revision, reusableContentId } = input;
const reusableContents = getRevisionReusableContents(revision);
return reusableContents.get(reusableContentId) ?? null;
}
-28
View File
@@ -67,27 +67,8 @@ export interface GitBookDataFetcher {
getRevision(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
* Get the revision pages by its space ID and revision ID.
*/
getRevisionPages(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<DataFetcherResponse<api.RevisionPage[]>>;
/**
* Get a revision file by its space ID, revision ID and file ID.
*/
getRevisionFile(params: {
spaceId: string;
revisionId: string;
fileId: string;
}): Promise<DataFetcherResponse<api.RevisionFile>>;
/**
* Get a revision page by its path.
*/
@@ -132,15 +113,6 @@ export interface GitBookDataFetcher {
seed: string;
}): Promise<DataFetcherResponse<api.JSONDocument>>;
/**
* Get a reusable content by its space ID, revision ID and reusable content ID.
*/
getReusableContent(params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}): Promise<DataFetcherResponse<api.RevisionReusableContent>>;
/**
* Get the latest OpenAPI spec version content by its organization ID and slug.
*/
@@ -13,7 +13,7 @@ export interface CloudflareImageJsonFormat {
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
format?: 'webp' | 'avif' | 'json' | 'jpeg' | 'png';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
+6 -4
View File
@@ -35,8 +35,9 @@ describe('toPathInSpace', () => {
expect(variantInSection.toPathInSpace('some/path/')).toBe('/section/variant/some/path');
});
it('should not add a trailing slash', () => {
expect(root.toPathInSpace('')).toBe('');
it('should not add an unnecessary trailing slash', () => {
// The index page should not be an empty path
expect(root.toPathInSpace('')).toBe('/');
expect(variantInSection.toPathInSpace('')).toBe('/section/variant');
});
});
@@ -52,8 +53,9 @@ describe('toPathInSite', () => {
expect(siteGitBookIO.toPathInSite('some/path/')).toBe('/sitename/some/path');
});
it('should not add a trailing slash', () => {
expect(root.toPathInSite('')).toBe('');
it('should not add an unnecessary trailing slash', () => {
// The index page should not be an empty path
expect(root.toPathInSite('')).toBe('/');
expect(siteGitBookIO.toPathInSite('')).toBe('/sitename');
});
});
+2 -1
View File
@@ -128,7 +128,8 @@ export function createLinker(
function joinPaths(prefix: string, path: string): string {
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
return removeTrailingSlash(prefixPath + suffixPath);
const pathWithoutTrailingSlash = removeTrailingSlash(prefixPath + suffixPath);
return pathWithoutTrailingSlash === '' ? '/' : pathWithoutTrailingSlash;
}
function removeTrailingSlash(path: string): string {
+37 -18
View File
@@ -16,10 +16,9 @@ import {
import { serveResizedImage } from '@/routes/image';
import {
DataFetcherError,
getPublishedContentByURL,
getVisitorAuthBasePath,
lookupPublishedContentByUrl,
normalizeURL,
resolvePublishedContentByUrl,
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
@@ -34,18 +33,6 @@ 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',
'paypal.gitbook.com',
'adaptive-docs.gitbook-staging.com',
'enriched-content-playground.gitbook-staging.io',
'docs.testgitbook.com',
'launchdarkly-site.gitbook.education',
];
export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
@@ -56,6 +43,12 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(normalized.toString());
}
// Reject malicious requests
const rejectResponse = await validateServerActionRequest(request);
if (rejectResponse) {
return rejectResponse;
}
for (const handler of [serveSiteRoutes, serveSpacePDFRoutes]) {
const result = await handler(requestURL, request);
if (result) {
@@ -69,6 +62,25 @@ export async function middleware(request: NextRequest) {
}
}
async function validateServerActionRequest(request: NextRequest) {
// We need to reject incorrect server actions requests
// We do not do it in cloudflare workers as there is a bug that prevents us from reading the request body.
if (request.headers.has('next-action') && process.env.GITBOOK_RUNTIME !== 'cloudflare') {
// We just test that the json body is parseable
try {
const clonedRequest = request.clone();
await clonedRequest.json();
} catch (e) {
console.warn('Invalid server action request', e);
// If the body is not parseable, we reject the request
return new Response('Invalid request', {
status: 400,
headers: { 'content-type': 'text/plain' },
});
}
}
}
/**
* Handle request that are targetting the site routes group.
*/
@@ -95,6 +107,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
});
}
// We want to filter hostnames that contains a port here as this is likely a malicious request.
if (siteRequestURL.host.includes(':')) {
return new Response('Invalid request', {
status: 400,
headers: { 'content-type': 'text/plain' },
});
}
//
// Detect and extract the visitor authentication token from the request
//
@@ -104,11 +124,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
});
const withAPIToken = async (apiToken: string | null) => {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError(
resolve({
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
@@ -275,6 +292,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
rewrittenURL.search = request.nextUrl.search; // Preserve the original search params
const response = NextResponse.rewrite(rewrittenURL, {
request: {
headers: requestHeaders,
+5 -5
View File
@@ -117,11 +117,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.gradient.network',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'mygate-network.gitbook.io',
contentBaseURL: 'https://mygate-network.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
// {
// name: 'mygate-network.gitbook.io',
// contentBaseURL: 'https://mygate-network.gitbook.io',
// tests: [{ name: 'Home', url: '/' }],
// },
{
name: 'treasurenft.gitbook.io',
contentBaseURL: 'https://treasurenft.gitbook.io',
+10
View File
@@ -603,11 +603,21 @@ const testCases: TestsCase[] = [
url: 'blocks/emojis',
run: waitForCookiesDialog,
},
{
name: 'Icons',
url: 'blocks/icons',
run: waitForCookiesDialog,
},
{
name: 'Links',
url: 'blocks/links',
run: waitForCookiesDialog,
},
{
name: 'Buttons',
url: 'blocks/buttons',
run: waitForCookiesDialog,
},
{
name: 'Lists',
url: 'blocks/lists',
+170
View File
@@ -0,0 +1,170 @@
import { argosScreenshot } from '@argos-ci/playwright';
import { expect, test } from '@playwright/test';
import { getContentTestURL } from '../tests/utils';
import { waitForIcons } from './util';
test.describe('PDF export', () => {
test('export all pages as PDF (e2e)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(
getContentTestURL(
'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/~gitbook/pdf?limit=10'
)
);
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
test('export all pages as PDF (GitBook docs)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(getContentTestURL('https://gitbook.com/docs/~gitbook/pdf?limit=10'));
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
test('export a single page as PDF (e2e)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(
getContentTestURL(
'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/~gitbook/pdf?page=Bw7LjWwgTjV8nIV4s7rs&only=yes&limit=2'
)
);
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
test('export a single page as PDF (GitBook docs)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(
getContentTestURL(
'https://gitbook.com/docs/~gitbook/pdf?page=DfnNkU49mvLe2ythHAyx&only=yes&limit=2'
)
);
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
});
+5 -1
View File
@@ -16,6 +16,7 @@ import {
CustomizationThemeMode,
type CustomizationThemedColor,
type SiteCustomizationSettings,
SiteExternalLinksTarget,
} from '@gitbook/api';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge';
@@ -317,6 +318,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
aiSearch: {
enabled: true,
},
externalLinks: {
target: SiteExternalLinksTarget.Self,
},
advancedCustomization: {
enabled: true,
},
@@ -346,7 +350,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
/**
* Wait for all icons present on the page to be loaded.
*/
async function waitForIcons(page: Page) {
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<
string,
+4 -2
View File
@@ -8,7 +8,7 @@
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
"start": "next start",
"typecheck": "tsc --noEmit",
"e2e": "playwright test e2e/internal.spec.ts",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
@@ -81,7 +81,9 @@
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"zustand": "^5.0.3"
"zustand": "^5.0.3",
"image-size": "^2.0.2",
"direction": "^2.0.1"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.3",
@@ -4,7 +4,6 @@ import { headers } from 'next/headers';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { throwIfDataError } from '@v2/lib/data';
import { DateRelative } from '../primitives';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
@@ -59,7 +58,7 @@ export async function AdminToolbar(props: AdminToolbarProps) {
return <ChangeRequestToolbar context={context} />;
}
if (context.revisionId !== context.space.revision) {
if (context.revision.id !== context.space.revision) {
return <RevisionToolbar context={context} />;
}
@@ -106,15 +105,7 @@ async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
const { context } = props;
const { space, revisionId } = context;
const revision = await throwIfDataError(
context.dataFetcher.getRevision({
spaceId: space.id,
revisionId,
metadata: true,
})
);
const { revision } = context;
return (
<ToolbarLayout>
@@ -19,14 +19,14 @@ export async function AdClassicRendering({
insightsAd: SiteInsightsAd | null;
context: GitBookBaseContext;
}) {
const smallImgSrc =
const [smallImgSrc, logoSrc] = await Promise.all([
'smallImage' in ad
? await getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
: null;
const logoSrc =
? getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
: null,
'logo' in ad
? await getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
: null;
? getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
: null,
]);
return (
<Link
rel="sponsored noopener"
@@ -43,10 +43,12 @@ interface FetchPlaceholderAdOptions {
* and properly access user-agent and IP.
*/
export async function renderAd(options: FetchAdOptions) {
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const [context, result] = await Promise.all([
isV2() ? getServerActionBaseContext() : getV1BaseContext(),
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
]);
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
@@ -7,7 +7,8 @@ import { tcls } from '@/lib/tailwind';
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { CONTAINER_STYLE } from '../layout';
import { Link, linkStyles } from '../primitives';
import { Link } from '../primitives';
import { LinkStyles } from '../primitives/styles';
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
/**
@@ -58,7 +59,7 @@ export function AnnouncementBanner(props: {
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
@@ -1,37 +0,0 @@
.highlight-line {
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-[1] rounded;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
@apply bg-tint-active;
@apply first:rounded-t-md *:first:mt-1;
@apply last:rounded-b-md *:last:mb-1;
@apply rounded-none;
}
&:not(.highlighted) + .highlighted {
@apply rounded-t-md *:mt-1;
}
&.highlighted:has(+ :not(.highlighted)) {
@apply rounded-b-md *:mb-1;
}
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
@apply rounded-md;
}
}
.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-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 > & {
@apply before:text-tint bg-gradient-to-r from-80% from-tint-active to-transparent;
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
}
@@ -9,9 +9,6 @@ import type { BlockProps } from '../Block';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import './theme.css';
import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
'aria-busy'?: boolean;
@@ -50,7 +47,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/>
<pre
className={tcls(
'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',
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base p-2 [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'
)}
@@ -58,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
<code
id={id}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap',
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
)}
>
@@ -12,6 +12,7 @@ import {
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { type BundledLanguage, bundledLanguages } from 'shiki/langs';
import { nullIfNever } from '@/lib/typescript';
import { plainHighlight } from './plain-highlight';
export type HighlightLine = {
@@ -263,16 +264,28 @@ function getPlainCodeBlockLine(
if (node.object === 'text') {
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
} else {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
switch (node.type) {
case 'annotation': {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
break;
}
case 'expression': {
break;
}
default: {
nullIfNever(node);
break;
}
}
}
}
@@ -1,67 +0,0 @@
:root {
--shiki-color-text: theme("colors.tint.11");
--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 {
/* 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");
}
@@ -28,6 +28,14 @@ export interface DocumentContext {
* @default true
*/
wrapBlocksInSuspense?: boolean;
/**
* True if link previews should be rendered.
* This is used to limit the number of link previews rendered in a document.
* If false, no link previews will be rendered.
* @default false
*/
shouldRenderLinkPreviews?: boolean;
}
export interface DocumentContextProps {
@@ -6,9 +6,10 @@ import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
import { getTextAlignment } from './utils';
export function Heading(props: BlockProps<DocumentBlockHeading>) {
const { block, style, context, ...rest } = props;
const { block, style, context, ancestorBlocks, ...rest } = props;
const textStyle = getBlockTextStyle(block);
@@ -42,7 +43,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
'grid-area-1-1',
'z-[1]',
'justify-self-start',
'text-left',
getTextAlignment(block.data.align, ancestorBlocks),
textStyle.lineHeight,
textStyle.marginTop
)}
@@ -5,6 +5,7 @@ import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton';
import { InlineIcon } from './InlineIcon';
import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink';
import { InlineMath } from './Math';
@@ -47,6 +48,8 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
return <InlineImage {...contextProps} inline={inline} />;
case 'button':
return <InlineButton {...contextProps} inline={inline} />;
case 'icon':
return <InlineIcon {...contextProps} inline={inline} />;
case 'expression':
// The GitBook API should take care of evaluating expressions.
// We should never need to render them.
@@ -1,5 +1,6 @@
import { resolveContentRef } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives';
import type { InlineProps } from './Inline';
@@ -25,6 +26,7 @@ export async function InlineButton(props: InlineProps<api.DocumentInlineButton>)
// TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind}
className="leading-normal"
icon={inline.data.icon as IconName | undefined}
insights={{
type: 'link_click',
link: {
@@ -0,0 +1,10 @@
import type { DocumentInlineIcon } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props;
return <Icon icon={inline.data.icon as IconName} className="inline size-[1em]" />;
}
@@ -1,61 +0,0 @@
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, {
// We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link.
resolveAnchorText: false,
})
: null;
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
return (
<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,153 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { Icon } from '@gitbook/icons';
import type { GitBookAnyContext } from '@v2/lib/context';
import { StyledLink } from '../../primitives';
import type { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
// We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link.
resolveAnchorText: false,
})
: null;
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
const content = (
<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>
);
if (context.shouldRenderLinkPreviews) {
return (
<InlineLinkTooltipWrapper
inline={inline}
context={context.contentContext}
resolved={resolved}
>
{content}
</InlineLinkTooltipWrapper>
);
}
return content;
}
/**
* An SSR component that renders a link with a tooltip.
* Essentially it pulls the minimum amount of props from the context to render the tooltip.
*/
function InlineLinkTooltipWrapper(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 aiSummary: { pageId: string; spaceId: string } | undefined = (() => {
if (isExternal) {
return;
}
if (isSamePage) {
return;
}
if (!('customization' in context) || !context.customization.ai?.pageLinkSummaries.enabled) {
return;
}
if (!('page' in context) || !('page' in inline.data.ref)) {
return;
}
if (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor') {
return {
pageId: resolved.page?.id ?? inline.data.ref.page ?? context.page.id,
spaceId: inline.data.ref.space ?? context.space.id,
};
}
})();
return (
<InlineLinkTooltip
breadcrumbs={breadcrumbs}
isExternal={isExternal}
isSamePage={isSamePage}
aiSummary={aiSummary}
openInNewTabLabel={tString(language, 'open_in_new_tab')}
target={{
href: resolved.href,
text: resolved.text,
subText: resolved.subText,
icon: resolved.icon,
}}
>
{children}
</InlineLinkTooltip>
);
}
@@ -0,0 +1,73 @@
'use client';
import dynamic from 'next/dynamic';
import React from 'react';
const LoadingValueContext = React.createContext<React.ReactNode>(null);
// To avoid polluting the RSC payload with the tooltip implementation,
// we lazily load it on the client side. This way, the tooltip is only loaded
// when the user interacts with the link, and it doesn't block the initial render.
const InlineLinkTooltipImpl = dynamic(
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
{
// Disable server-side rendering for this component, it's only
// visible on user interaction.
ssr: false,
loading: () => {
// The fallback should be the children (the content of the link),
// but as next/dynamic is aiming for feature parity with React.lazy,
// it doesn't support passing children to the loading component.
// https://github.com/vercel/next.js/issues/7906
const children = React.useContext(LoadingValueContext);
return <>{children}</>;
},
}
);
/**
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
* and polluting the RSC payload.
*
* The link text and href have already been rendered on the server for good SEO,
* so we can be as lazy as possible with the tooltip.
*/
export function InlineLinkTooltip(props: {
isSamePage: boolean;
isExternal: boolean;
aiSummary?: { pageId: string; spaceId: string };
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { children, ...rest } = props;
const [shouldLoad, setShouldLoad] = React.useState(false);
// Once the browser is idle, we set shouldLoad to true.
// NOTE: to be slightly more performant, we could load when a link is hovered.
// But I found this was too much of a delay for the tooltip to appear.
// Loading on idle is a good compromise, as it allows the initial render to be fast,
// while still loading the tooltip in the background and not polluting the RSC payload.
React.useEffect(() => {
if ('requestIdleCallback' in window) {
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true));
} else {
// fallback for old browsers
setTimeout(() => setShouldLoad(true), 2000);
}
}, []);
return shouldLoad ? (
<LoadingValueContext.Provider value={children}>
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl>
</LoadingValueContext.Provider>
) : (
children
);
}
@@ -1,55 +1,27 @@
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';
'use client';
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';
import { AIPageLinkSummary } from '../../Adaptive';
import { Button, StyledLink } from '../../primitives';
export async function InlineLinkTooltip(props: {
inline: DocumentInlineLink;
context: GitBookAnyContext;
export function InlineLinkTooltipImpl(props: {
isSamePage: boolean;
isExternal: boolean;
aiSummary?: { pageId: string; spaceId: string };
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
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');
const { isSamePage, isExternal, aiSummary, openInNewTabLabel, target, breadcrumbs, children } =
props;
return (
<Tooltip.Provider delayDuration={200}>
@@ -100,15 +72,15 @@ export async function InlineLinkTooltip(props: {
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && resolved.href ? (
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
@@ -117,40 +89,35 @@ export async function InlineLinkTooltip(props: {
: null
)}
variant="blank"
href={resolved.href}
href={target.href}
target="_blank"
label={tString(language, 'open_in_new_tab')}
label={openInNewTabLabel}
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>
{target.subText ? (
<p className="mt-1 text-sm text-tint">{target.subText}</p>
) : null}
</div>
{hasAISummary && 'page' in context && 'page' in inline.data.ref ? (
{aiSummary ? (
<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
}
targetPageId={aiSummary.pageId}
targetSpaceId={aiSummary.spaceId}
showTrademark
/>
</div>
) : null}
</div>
<Tooltip.Arrow className={hasAISummary ? 'fill-tint-3' : 'fill-tint-1'} />
<Tooltip.Arrow
className={
typeof aiSummary !== 'undefined' ? 'fill-tint-3' : 'fill-tint-1'
}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
@@ -0,0 +1 @@
export * from './InlineLink';
@@ -5,8 +5,8 @@ import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env';
import type { BlockProps } from '../Block';
import './contentkit.css';
import { getDataOrNull } from '@v2/lib/data';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
@@ -16,8 +16,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
throw new Error('integration block requires a content.spaceId');
}
const { dataFetcher } = context.contentContext;
const initialInput: RenderIntegrationUI = {
componentId: block.data.block,
props: block.data.props,
@@ -30,17 +28,27 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
},
};
const initialOutput = await getDataOrNull(
dataFetcher.renderIntegrationUi({
integrationName: block.data.integration,
request: initialInput,
}),
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
integrationName: block.data.integration,
request: initialInput,
});
// The API can respond with a 400 error if the integration is not installed
// and 404 if the integration is not found.
[404, 400]
);
if (!initialOutput || initialOutput.type === 'complete') {
if (initialResponse.error) {
if (initialResponse.error.code === 404) {
return null;
}
return (
<div className={tcls(style)}>
<pre>
Unexpected error with integration {block.data.integration}:{' '}
{initialResponse.error.message}
</pre>
</div>
);
}
const initialOutput = initialResponse.data;
if (initialOutput.type === 'complete') {
return null;
}
@@ -0,0 +1,34 @@
import type { RenderIntegrationUI } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { ignoreDataFetcherErrors } from '@v2/lib/data';
/**
* Render an integration UI while ignoring some errors.
*/
export async function fetchSafeIntegrationUI(
context: GitBookBaseContext,
{
integrationName,
request,
}: {
integrationName: string;
request: RenderIntegrationUI;
}
) {
const output = await ignoreDataFetcherErrors(
context.dataFetcher.renderIntegrationUi({
integrationName,
request,
}),
// The API can respond with certain errors that are expected to happen.
[
404, // Integration has been uninstalled
400, // Integration is rejecting its own request
422, // Integration is triggering an invalid request, failing at the validation step
502, // Integration is failing in an unexpected way
]
);
return output;
}
@@ -4,9 +4,9 @@ import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import type { RenderIntegrationUI } from '@gitbook/api';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import { throwIfDataError } from '@v2/lib/data';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
/**
* Server action to render an integration UI request from <ContentKit />.
@@ -22,16 +22,19 @@ export async function renderIntegrationUi({
request: RenderIntegrationUI;
}) {
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const output = await fetchSafeIntegrationUI(serverAction, {
integrationName: renderContext.integrationName,
request,
});
const output = await throwIfDataError(
serverAction.dataFetcher.renderIntegrationUi({
integrationName: renderContext.integrationName,
request,
})
);
if (output.error) {
return {
error: output.error.message,
};
}
return {
children: <ContentKitOutput output={output} context={contentKitServerContext} />,
output: output,
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
output: output.data,
};
}
@@ -680,9 +680,7 @@ body:has(.openapi-select-popover) {
/* Tabs */
.openapi-panel-header,
.openapi-tabs-list {
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
scrollbar-width: none;
-ms-overflow-style: none;
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-auto no-scrollbar;
}
.openapi-tabs-tab {
@@ -4,12 +4,13 @@ import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { Inlines } from './Inlines';
import { getTextAlignment } from './utils';
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<p className={tcls(style)}>
<p className={tcls(style, getTextAlignment(block.data?.align, ancestorBlocks))}>
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
</p>
);
@@ -23,19 +23,19 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
dataFetcher,
});
if (!resolved?.reusableContent) {
if (!resolved) {
return null;
}
const reusableContent = resolved.reusableContent.revisionReusableContent;
if (!reusableContent.document) {
const { reusableContent } = resolved;
if (!reusableContent || !reusableContent.revisionReusableContent.document) {
return null;
}
const document = await getDataOrNull(
dataFetcher.getDocument({
spaceId: resolved.reusableContent.space.id,
documentId: reusableContent.document,
spaceId: reusableContent.space.id,
documentId: reusableContent.revisionReusableContent.document,
})
);
@@ -47,18 +47,20 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
// the data fetcher with the token from the block meta and the correct
// space and revision pointers.
const reusableContentContext: GitBookSpaceContext =
context.contentContext.space.id === resolved.reusableContent.space.id
context.contentContext.space.id === reusableContent.space.id
? context.contentContext
: {
...context.contentContext,
dataFetcher,
space: resolved.reusableContent.space,
revisionId: resolved.reusableContent.revision,
space: reusableContent.space,
// When the reusable content is in a different space, we don't resolve relative links to pages
// as this space might not be part of the current site.
// In the future, we might expand the logic to look up the space from the list of all spaces in the site
// and adapt the relative links to point to the correct variant.
pages: [],
revision: {
...reusableContent.revision,
pages: [], // TODO: check with Steven
},
shareKey: undefined,
};
@@ -7,10 +7,11 @@ import {
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { RecordCardStyles } from './styles';
import { getRecordValue } from './utils';
export async function RecordCard(
@@ -23,18 +24,18 @@ export async function RecordCard(
const coverFile = view.coverDefinition
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
: null;
const cover =
coverFile && context.contentContext
? await resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
: null;
const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef)
: null;
const target =
const [cover, target] = await Promise.all([
coverFile && context.contentContext
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
: null,
targetRef && context.contentContext
? await resolveContentRef(targetRef, context.contentContext)
: null;
? resolveContentRef(targetRef, context.contentContext)
: null,
]);
const coverIsSquareOrPortrait =
cover?.file?.dimensions &&
@@ -143,35 +144,12 @@ export async function RecordCard(
</div>
);
const style = [
'group',
'grid',
'shadow-1xs',
'shadow-tint-9/1',
'depth-flat:shadow-none',
'rounded',
'straight-corners:rounded-none',
'circular-corners:rounded-xl',
'dark:shadow-transparent',
'before:pointer-events-none',
'before:grid-area-1-1',
'before:transition-shadow',
'before:w-full',
'before:h-full',
'before:rounded-[inherit]',
'before:ring-1',
'before:ring-tint-12/2',
'before:z-10',
'before:relative',
] as ClassValue;
if (target && targetRef) {
return (
// We don't use `Link` directly here because we could end up in a situation where
// a link is rendered inside a link, which is not allowed in HTML.
// It causes an hydration error in React.
<LinkBox href={target.href} className={tcls(style, 'hover:before:ring-tint-12/5')}>
<LinkBox href={target.href} classNames={['RecordCardStyles']}>
<LinkOverlay
href={target.href}
insights={{
@@ -187,5 +165,5 @@ export async function RecordCard(
);
}
return <div className={tcls(style)}>{body}</div>;
return <div className={tcls(RecordCardStyles)}>{body}</div>;
}
@@ -22,6 +22,12 @@ import { FileIcon } from '../FileIcon';
import type { TableRecordKV } from './Table';
import { type VerticalAlignment, getColumnAlignment } from './utils';
const alignmentMap: Record<'text-left' | 'text-center' | 'text-right', string> = {
'text-left': '[&_*]:text-left text-left',
'text-center': '[&_*]:text-center text-center',
'text-right': '[&_*]:text-right text-right',
};
/**
* Render the value for a column in a record.
*/
@@ -116,13 +122,13 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const horizontalAlignment = getColumnAlignment(definition);
const childrenHorizontalAlignment = `[&_*]:${horizontalAlignment}`;
const horizontalClasses = alignmentMap[horizontalAlignment];
return (
<Blocks
tag={Tag}
document={document}
ancestorBlocks={[]}
ancestorBlocks={[...props.ancestorBlocks, block]}
nodes={fragment.nodes}
style={[
'blocks',
@@ -131,8 +137,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
'lg:space-y-3',
'leading-normal',
verticalAlignment,
horizontalAlignment,
childrenHorizontalAlignment,
horizontalClasses,
]}
context={context}
blockStyle={['w-full', 'max-w-[unset]']}
@@ -0,0 +1,26 @@
import type { ClassValue } from '@/lib/tailwind';
export const RecordCardStyles = [
'group',
'grid',
'shadow-1xs',
'shadow-tint-9/1',
'depth-flat:shadow-none',
'rounded',
'straight-corners:rounded-none',
'circular-corners:rounded-xl',
'dark:shadow-transparent',
'before:pointer-events-none',
'before:grid-area-1-1',
'before:transition-shadow',
'before:w-full',
'before:h-full',
'before:rounded-[inherit]',
'before:ring-1',
'before:ring-tint-12/2',
'before:z-10',
'before:relative',
'hover:before:ring-tint-12/5',
] as ClassValue;
@@ -1 +1,2 @@
export * from './isBlockOffscreen';
export * from './textAlignment';
@@ -0,0 +1,32 @@
import type { ClassValue } from '@/lib/tailwind';
import { nullIfNever } from '@/lib/typescript';
import { type DocumentBlock, TextAlignment } from '@gitbook/api';
/**
* Get the tailwind class for a text alignment.
*/
export function getTextAlignment(
textAlignment: TextAlignment | undefined,
ancestorBlocks: DocumentBlock[]
): ClassValue {
// Text nodes within a table will have their alignment governed by columns instead.)
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
const ancestor = ancestorBlocks[i];
if (ancestor.type === 'table') {
return null;
}
}
// If not inside a table, use the normal alignment logic
switch (textAlignment) {
case undefined:
case TextAlignment.Start:
return ['text-start', 'justify-self-start'];
case TextAlignment.Center:
return ['text-center', 'justify-self-center'];
case TextAlignment.End:
return ['text-end', 'justify-self-end'];
default:
return nullIfNever(textAlignment);
}
}
@@ -183,9 +183,8 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
<div
className={tcls(
'w-full',
'overflow-x-scroll',
'overflow-y-hidden',
'hide-scroll',
'overflow-x-auto',
'no-scrollbar',
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
!sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
)}
@@ -202,7 +201,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
{siteSpaces.length > 1 && (
<div
id="variants"
className="my-2 mr-5 page-no-toc:flex hidden grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
className="my-2 mr-5 grow border-tint border-r pr-5 *:grow only:mr-0 only:border-none only:pr-0 sm:max-w-64"
>
<SpacesDropdown
context={context}
@@ -4,7 +4,7 @@ import { getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import type { GitBookSiteContext } from '@v2/lib/context';
import { DropdownChevron, DropdownMenu } from './DropdownMenu';
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
export function SpacesDropdown(props: {
context: GitBookSiteContext;
@@ -65,17 +65,15 @@ export function SpacesDropdown(props: {
</div>
}
>
{siteSpaces.map((otherSiteSpace, index) => (
<SpacesDropdownMenuItem
key={`${otherSiteSpace.id}-${index}`}
variantSpace={{
id: otherSiteSpace.id,
title: otherSiteSpace.title,
url: getSiteSpaceURL(context, otherSiteSpace),
}}
active={otherSiteSpace.id === siteSpace.id}
/>
))}
<SpacesDropdownMenuItems
slimSpaces={siteSpaces.map((space) => ({
id: space.id,
title: space.title,
url: getSiteSpaceURL(context, space),
isActive: space.id === siteSpace.id,
}))}
curPath={siteSpace.path}
/>
</DropdownMenu>
);
}
@@ -6,12 +6,29 @@ import { joinPath } from '@/lib/paths';
import { useCurrentPagePath } from '../hooks';
import { DropdownMenuItem } from './DropdownMenu';
function useVariantSpaceHref(variantSpaceUrl: string) {
interface VariantSpace {
id: Space['id'];
title: Space['title'];
url: string;
isActive: boolean;
}
// When switching to a different variant space, we reconstruct the URL by swapping the space path.
function useVariantSpaceHref(variantSpaceUrl: string, currentSpacePath: string, active = false) {
const currentPathname = useCurrentPagePath();
// We need to ensure that the variant space URL is not the same as the current space path.
// If it is, we return only the variant space URL to redirect to the root of the variant space.
// This is necessary in case the currentPathname is the same as the variantSpaceUrl,
// otherwise we would redirect to the same space if the variant space that we are switching to is the default one.
if (!active && currentPathname.startsWith(`${currentSpacePath}/`)) {
return variantSpaceUrl;
}
if (URL.canParse(variantSpaceUrl)) {
const targetUrl = new URL(variantSpaceUrl);
targetUrl.pathname = joinPath(targetUrl.pathname, currentPathname);
targetUrl.searchParams.set('fallback', 'true');
return targetUrl.toString();
@@ -22,11 +39,12 @@ function useVariantSpaceHref(variantSpaceUrl: string) {
}
export function SpacesDropdownMenuItem(props: {
variantSpace: { id: Space['id']; title: Space['title']; url: string };
variantSpace: VariantSpace;
active: boolean;
currentSpacePath: string;
}) {
const { variantSpace, active } = props;
const variantHref = useVariantSpaceHref(variantSpace.url);
const { variantSpace, active, currentSpacePath } = props;
const variantHref = useVariantSpaceHref(variantSpace.url, currentSpacePath, active);
return (
<DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}>
@@ -34,3 +52,23 @@ export function SpacesDropdownMenuItem(props: {
</DropdownMenuItem>
);
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: VariantSpace[];
curPath: string;
}) {
const { slimSpaces, curPath } = props;
return (
<>
{slimSpaces.map((space) => (
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
currentSpacePath={curPath}
/>
))}
</>
);
}
@@ -53,14 +53,14 @@ export async function PDFPage(props: {
}) {
const baseContext = props.context;
const searchParams = new URLSearchParams(props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
const pdfParams = getPDFSearchParams(searchParams);
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = getSpaceLanguage(customization);
// Compute the pages to render
const { pages, total } = selectPages(baseContext.pages, pdfParams);
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
const pageIds = pages.map(
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string]
);
@@ -245,6 +245,7 @@ async function PDFPageDocument(props: {
page,
},
getId: (id) => getPagePDFContainerId(page, id),
shouldRenderLinkPreviews: false, // We don't want to render link previews in the PDF.
}}
// We consider all pages as offscreen in PDF mode
// to ensure we can efficiently render as many pages as possible
@@ -4,7 +4,7 @@ import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
import { hasFullWidthBlock, hasMoreThan, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -17,6 +17,8 @@ import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PreservePageLayout } from './PreservePageLayout';
const LINK_PREVIEW_MAX_COUNT = 100;
export function PageBody(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
@@ -28,6 +30,15 @@ export function PageBody(props: {
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
// Render link previews only if there are less than LINK_PREVIEW_MAX_COUNT links in the document.
const shouldRenderLinkPreviews = document
? !hasMoreThan(
document,
(inline) => inline.object === 'inline' && inline.type === 'link',
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q';
const asFullWidth = pageFullWidth || contentFullWidth;
const language = getSpaceLanguage(customization);
@@ -68,6 +79,7 @@ export function PageBody(props: {
context={{
mode: 'default',
contentContext: context,
shouldRenderLinkPreviews,
}}
/>
</React.Suspense>
@@ -58,7 +58,10 @@ export async function PageBodyBlankslate(props: {
/>
);
}
const href = context.linker.toPathForPage({ pages: context.pages, page: child });
const href = context.linker.toPathForPage({
pages: context.revision.pages,
page: child,
});
return <Card key={child.id} title={child.title} leadingIcon={icon} href={href} />;
})
);
@@ -21,8 +21,10 @@ export async function PageCover(props: {
context: GitBookSiteContext;
}) {
const { as, page, cover, context } = props;
const resolved = cover.ref ? await resolveContentRef(cover.ref, context) : null;
const resolvedDark = cover.refDark ? await resolveContentRef(cover.refDark, context) : null;
const [resolved, resolvedDark] = await Promise.all([
cover.ref ? resolveContentRef(cover.ref, context) : null,
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
]);
return (
<div
@@ -17,11 +17,13 @@ export async function PageFooterNavigation(props: {
page: RevisionPageDocument;
}) {
const { context, page } = props;
const { customization, pages, linker } = context;
const { previous, next } = resolvePrevNextPages(pages, page);
const { customization, revision, linker } = context;
const { previous, next } = resolvePrevNextPages(revision.pages, page);
const language = getSpaceLanguage(customization);
const previousHref = previous ? linker.toPathForPage({ pages, page: previous }) : '';
const nextHref = next ? linker.toPathForPage({ pages, page: next }) : '';
const previousHref = previous
? linker.toPathForPage({ pages: revision.pages, page: previous })
: '';
const nextHref = next ? linker.toPathForPage({ pages: revision.pages, page: next }) : '';
return (
<div
@@ -15,7 +15,7 @@ export async function PageHeader(props: {
ancestors: AncestorRevisionPage[];
}) {
const { context, page, ancestors } = props;
const { pages, linker } = context;
const { revision, linker } = context;
if (!page.layout.title && !page.layout.description) {
return null;
@@ -36,7 +36,10 @@ export async function PageHeader(props: {
<nav>
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
{ancestors.map((breadcrumb, index) => {
const href = linker.toPathForPage({ pages, page: breadcrumb });
const href = linker.toPathForPage({
pages: revision.pages,
page: breadcrumb,
});
return (
<Fragment key={breadcrumb.id}>
<li key={breadcrumb.id}>
@@ -62,15 +62,6 @@
}
@layer utilities {
/* hide scroll bar but still scroll */
.hide-scroll::-webkit-scrollbar {
display: none;
}
.hide-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.linear-mask-gradient {
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
}
@@ -167,3 +158,111 @@ html.dark {
html.announcement-hidden #announcement-banner {
@apply hidden;
}
/* Shiki theme */
:root {
--shiki-color-text: theme("colors.tint.11");
--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 {
/* 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");
}
/* Code blocks */
.highlight-line {
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-[1] rounded;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
@apply bg-tint-active;
@apply first:rounded-t-md *:first:mt-1;
@apply last:rounded-b-md *:last:mb-1;
@apply rounded-none;
}
&:not(.highlighted) + .highlighted {
@apply rounded-t-md *:mt-1;
}
&.highlighted:has(+ :not(.highlighted)) {
@apply rounded-b-md *:mb-1;
}
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
@apply rounded-md;
}
}
.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-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 > & {
@apply before:text-tint bg-gradient-to-r from-80% from-tint-active to-transparent;
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
}
@@ -66,11 +66,7 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
<HighlightQuery query={query} text={item.title} />
</p>
) : null}
{item.body ? (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<HighlightQuery query={query} text={item.body} />
</p>
) : null}
{item.body ? highlightQueryInBody(item.body, query) : null}
</div>
<div
className={tcls(
@@ -90,3 +86,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
</Link>
);
});
function highlightQueryInBody(body: string, query: string) {
const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
// Ensure the query to be highlighted is visible in the body.
return (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
</p>
);
}
@@ -5,6 +5,7 @@ import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import type {
Revision,
RevisionPage,
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
@@ -128,7 +129,7 @@ export async function streamAskQuestion({
{ format: 'document' }
);
const spacePromises = new Map<string, Promise<RevisionPage[]>>();
const spacePromises = new Map<string, Promise<Revision>>();
for await (const chunk of stream) {
const answer = chunk.answer;
@@ -143,10 +144,9 @@ export async function streamAskQuestion({
spacePromises.set(
source.space,
throwIfDataError(
context.dataFetcher.getRevisionPages({
context.dataFetcher.getRevision({
spaceId: source.space,
revisionId: source.revision,
metadata: false,
})
)
);
@@ -159,8 +159,8 @@ export async function streamAskQuestion({
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const pages = await spacePromises.get(space);
return { space, pages };
const revision = await spacePromises.get(space);
return { space, pages: revision?.pages };
})
).then((results) => {
return results.reduce((map, result) => {
@@ -345,6 +345,7 @@ async function transformAnswer(
mode: 'default',
contentContext: undefined,
wrapBlocksInSuspense: false,
shouldRenderLinkPreviews: false, // We don't want to render link previews in the AI answer.
}}
style={['space-y-5']}
/>
@@ -1,15 +1,17 @@
'use client';
import type { CustomizationThemeMode } from '@gitbook/api';
import type { CustomizationThemeMode, SiteExternalLinksTarget } from '@gitbook/api';
import { ThemeProvider } from 'next-themes';
import type React from 'react';
import { LinkSettingsContext } from '../primitives';
export function ClientContexts(props: {
nonce?: string;
forcedTheme: CustomizationThemeMode | undefined;
externalLinksTarget: SiteExternalLinksTarget;
children: React.ReactNode;
}) {
const { children, forcedTheme } = props;
const { children, forcedTheme, externalLinksTarget } = props;
/**
* A bug in ThemeProvider is causing the nonce to be included incorrectly
@@ -22,7 +24,9 @@ export function ClientContexts(props: {
return (
<ThemeProvider nonce={nonce} attribute="class" enableSystem forcedTheme={forcedTheme}>
{children}
<LinkSettingsContext.Provider value={{ externalLinksTarget }}>
{children}
</LinkSettingsContext.Provider>
</ThemeProvider>
);
}
@@ -54,6 +54,7 @@ export async function SiteLayout(props: {
forcedTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
externalLinksTarget={customization.externalLinks.target}
>
<SpaceLayout
context={context}
@@ -102,30 +103,37 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
const faviconSize = 48;
const icons = [
{
url: customIcon?.light
? await getResizedImageURL(imageResizer, customIcon.light, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=small&theme=light')
),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url: customIcon?.dark
? await getResizedImageURL(imageResizer, customIcon.dark, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(linker.toPathInSpace('~gitbook/icon?size=small&theme=dark')),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
];
const icons = await Promise.all(
[
{
url: customIcon?.light
? getResizedImageURL(imageResizer, customIcon.light, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=small&theme=light')
),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url: customIcon?.dark
? getResizedImageURL(imageResizer, customIcon.dark, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=small&theme=dark')
),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
].map(async (icon) => ({
...icon,
url: await icon.url,
}))
);
return {
title: site.title,
@@ -134,6 +142,12 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
icon: icons,
apple: icons,
},
appleWebApp: {
capable: true,
title: site.title,
statusBarStyle:
customization.themes.default === CustomizationThemeMode.Dark ? 'black' : 'default',
},
robots: (await isSiteIndexable(context)) ? 'index, follow' : 'noindex, nofollow',
};
}

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