Compare commits

..

39 Commits

Author SHA1 Message Date
Nolann Biron 010b838d84 test 2026-03-11 14:46:16 +01:00
Zeno Kapitein b3875a19d7 Add assistantName property to Docs Embed (#4095) 2026-03-11 10:46:10 +01:00
conico974 884d900e38 Remove check for invalid characters in the url (#4098) 2026-03-11 10:43:32 +01:00
Taran Vohra f68755695e Update workflows with preview URL envs (#4097)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 12:13:49 +05:30
Greg Bergé 9ffde72ced Reset scroll between section navigation (#4089) 2026-03-10 18:18:40 +01:00
Viktor Renkema 9e3629b12b Apply color if tags have this set (#4094) 2026-03-10 17:02:25 +01:00
Greg Bergé 48f81c80f0 Use break-words for code (#4092) 2026-03-10 13:36:08 +00:00
Greg Bergé 0baa8e5022 Fix documentation links opening with /~gitbook/embed/ (#4090) 2026-03-10 14:10:50 +01:00
conico974 59b9663ce3 Add decodeURLPath function for URL decoding and validation (#3915) 2026-03-09 16:29:22 +01:00
Greg Bergé 9be66e3e8a Add visual test for Mermaid blocks (#4087) 2026-03-09 14:22:34 +01:00
Aiko 9a0f35c26d Fix tab content cropping inside stepper on mobile (#4088) 2026-03-09 19:11:21 +06:00
Nolann B. f1826807a7 Add backward compatibility for OpenAPI spec content URL (#4086) 2026-03-09 09:47:26 +01:00
Nolann B. 887c3f8eac Add token verification on OpenAPI proxy (#4085) 2026-03-08 16:50:43 +01:00
Nolann B. d666212674 Add support for localized site section titles (#3980) 2026-03-07 20:52:13 +01:00
conico974 8cb9131d83 Revert middleware matcher changes (#4084) 2026-03-06 17:13:53 +01:00
Greg Bergé 98b36616cb Add interactive controls to docs assistant (#4073) 2026-03-06 16:58:48 +01:00
Nolann B. eceec6f641 Move ~scalar/proxy route to site route group (#4083) 2026-03-05 19:06:06 +01:00
conico974 9f4d93502e Add real IP extraction to analytics event proxy (#4056) 2026-03-05 16:49:44 +01:00
Nolann B. 8ab419a47e Support x-enable-proxy at operation level (#4080) 2026-03-05 10:05:17 +01:00
Samy Pessé 56ce5dfd74 Version Packages (#4048) 2026-03-04 20:32:40 +01:00
Zeno Kapitein 370b731972 Docs Embed: Make trademark optional (#4079)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2026-03-04 14:53:42 +01:00
conico974 b7fb9e57ca Don't use server actions for search (#4067) 2026-03-03 18:27:44 +01:00
Viktor Renkema 5b9f5fa991 Improve wrapping of tags within TOC item (#4075) 2026-03-03 15:26:44 +01:00
Greg Bergé bf1f11794e Fix scroll reset when changing page (#4072) 2026-03-02 16:13:47 +00:00
Nolann B. 8621c7f571 Support alternative security scope groups (#3976) 2026-03-02 17:04:31 +01:00
Zeno Kapitein 4a6f91a19d Show social accounts in header (#4068) 2026-03-02 11:06:49 +01:00
Greg Bergé 38a1c85fee Fix PDF export page URL for /~space/ URLs (#4065) 2026-03-02 09:49:22 +01:00
Nolann B. cb71511cda Wrap MCP tracking call with waitUntil (#4071) 2026-02-27 17:46:01 +01:00
Nolann B. afa476c51f Add server-side insight event tracking for MCP route (#4063) 2026-02-27 17:16:21 +01:00
Zeno Kapitein 1e9ed753a1 Fix typecheck in API 0.165.0 (#4069) 2026-02-27 16:00:05 +01:00
John Cashman 22da7961d1 Include headings inside columns in published page outline (#4045)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2026-02-27 14:27:54 +00:00
Nolann B. e074283bbf Add server-side proxy for Scalar API client to bypass CORS (#4050) 2026-02-27 12:18:07 +01:00
Zeno Kapitein afa868e0e1 Show "Ask Assistant" search result immediately (#4066) 2026-02-27 11:55:21 +01:00
Greg Bergé c5bdde4401 Harden visitor session detection to improve GBO toolbar visibility (#4061) 2026-02-27 11:47:05 +01:00
Greg Bergé 5efe13e622 Fix Firefox mobile sidepanel TOC scroll by constraining sheet flex height (#4062) 2026-02-27 11:21:17 +01:00
Greg Bergé 41ef3435bc Clarify the toolbar message (#4053) 2026-02-27 11:19:57 +01:00
Claire Chabas 157d5fb9e7 Fix cards styles (#4058) 2026-02-26 16:29:39 +01:00
Greg Bergé ec28a7b686 Support scroll to text fragment (#4060) 2026-02-26 16:00:12 +01:00
Viktor Renkema b3e9ff9839 Support icons/emojis in tags (#4054) 2026-02-26 14:45:12 +01:00
119 changed files with 3417 additions and 690 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": minor
---
Support `assistantName` property to override Assistant name
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add backward compatibility for OpenAPI spec content URL
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/openapi-parser": patch
"@gitbook/react-openapi": patch
---
Support x-enable-proxy at operation level
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Track opening of search results for records and in the assistant.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Refactor embeddable context to merge local & site properties in one unified way
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Move ~scalar/proxy route to site route group
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Add token verification on OpenAPI proxy
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add support for localized site section titles
@@ -26,7 +26,7 @@ inputs:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs:
@@ -53,6 +53,7 @@ runs:
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
@@ -108,7 +109,7 @@ runs:
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
+2 -1
View File
@@ -20,7 +20,7 @@ inputs:
description: 'Environment to deploy to'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
@@ -52,6 +52,7 @@ runs:
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
+5
View File
@@ -136,6 +136,7 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-vercel
visual-testing-v2-cloudflare:
@@ -160,6 +161,7 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-cloudflare
visual-testing-customers-v2:
@@ -184,6 +186,7 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-vercel
visual-testing-customers-v2-cloudflare:
@@ -208,6 +211,7 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-cloudflare
browserless-testing-v2-vercel:
@@ -231,6 +235,7 @@ jobs:
env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
+60 -47
View File
@@ -6,9 +6,9 @@
"name": "gitbook",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.8.6",
"vercel": "^50.15.1",
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.13",
"vercel": "^50.26.1",
},
},
"packages/browser-types": {
@@ -48,7 +48,7 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.2.2",
"version": "0.3.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -109,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.22.1",
"version": "0.22.2",
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:",
@@ -188,6 +188,7 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zod-to-json-schema": "^3.25.1",
"zustand": "^5.0.3",
},
"devDependencies": {
@@ -243,7 +244,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "3.0.8",
"version": "3.0.9",
"dependencies": {
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
@@ -302,7 +303,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.5.8",
"version": "1.5.9",
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -347,7 +348,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.164.0",
"@gitbook/api": "0.167.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -546,21 +547,21 @@
"@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.14", "", { "dependencies": { "@changesets/config": "^3.1.2", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="],
"@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
"@changesets/cli": ["@changesets/cli@2.29.8", "", { "dependencies": { "@changesets/apply-release-plan": "^7.0.14", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.2", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.14", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.6", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "ci-info": "^3.7.0", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "p-limit": "^2.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA=="],
"@changesets/cli": ["@changesets/cli@2.30.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.0", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.3", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.15", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA=="],
"@changesets/config": ["@changesets/config@3.1.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog=="],
"@changesets/config": ["@changesets/config@3.1.3", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw=="],
"@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="],
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.14", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.2", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.6", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g=="],
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.15", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.3", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g=="],
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
@@ -568,11 +569,11 @@
"@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="],
"@changesets/parse": ["@changesets/parse@0.4.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA=="],
"@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="],
"@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="],
"@changesets/read": ["@changesets/read@0.6.6", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.2", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg=="],
"@changesets/read": ["@changesets/read@0.6.7", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.3", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA=="],
"@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="],
@@ -744,7 +745,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.164.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-u0npCEK253qSbn118YMMi1E5dqrLQnBlXnovXOpbzQP8BF62ktrndnhtH8AhTvV4ld9UHDKjCaxxidmtuYoU9g=="],
"@gitbook/api": ["@gitbook/api@0.167.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9A9Bsgi2aRbGf3DPejK4YQh9M/+grtNymFwUkK3B3N4m7VOxcUMQ2HaV7s51mtrBdWKcEdeFm4LEnEaAGHcM1A=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -1700,61 +1701,61 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="],
"@vercel/backends": ["@vercel/backends@0.0.37", "", { "dependencies": { "@vercel/build-utils": "13.5.0", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-RSUWaDIlcfv6Q2B34Lce77RhB5lsG0bTz6rOrPAB1ZzkUNpDVlmobGxIZTgSD3MHmJGJCP8MBjM2zqG9jY3XYQ=="],
"@vercel/backends": ["@vercel/backends@0.0.40", "", { "dependencies": { "@vercel/build-utils": "13.6.2", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-cYyfPeGrpqyxBgcgKoaZJ+9M7NRKf1eevzKkrX9QRmiBBlGduR34hV859uK4ouBJByHE7n9Tkga2dRLOH8pMcQ=="],
"@vercel/blob": ["@vercel/blob@2.3.0", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-oYWiJbWRQ7gz9Mj0X/NHFJ3OcLMOBzq/2b3j6zeNrQmtFo6dHwU8FAwNpxVIYddVMd+g8eqEi7iRueYx8FtM0Q=="],
"@vercel/build-utils": ["@vercel/build-utils@13.5.0", "", { "dependencies": { "@vercel/python-analysis": "0.7.0" } }, "sha512-hDSJbpw7KFlX5d/L7e6x5pDfln7puE6EPiy4y+19zE0yBTNuO/d8tmpMPamt6uzzIKTT8tuAdef+QDVzGshaOw=="],
"@vercel/build-utils": ["@vercel/build-utils@13.6.2", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-KiwRUd2x1ZHm73p+/hfdVWZHWXT8r+oN/5ZN1lJyb41AGK4Zhrug6X60jNyVhnw6GjpwBuPTBcys29cCsPjkWQ=="],
"@vercel/cervel": ["@vercel/cervel@0.0.24", "", { "dependencies": { "@vercel/backends": "0.0.37" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" }, "bin": { "cervel": "bin/cervel.mjs" } }, "sha512-ObnEL01U3mAfdNCEUQ/ptW0ZvBWsqKGGsBmqOkhZLjwy3cGZWuHInR/kWoaj6LlvZhfIsqUiL1+1e/ffF+2tRQ=="],
"@vercel/cervel": ["@vercel/cervel@0.0.27", "", { "dependencies": { "@vercel/backends": "0.0.40" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" }, "bin": { "cervel": "bin/cervel.mjs" } }, "sha512-w6FPgnnZD2nr43pkhXSY0tGMwCJT6kh8f8VoEL97ISSkxywZYX+Bw5FxrNavAEMC0wYm7esEosOdAEAh8Twbag=="],
"@vercel/detect-agent": ["@vercel/detect-agent@1.1.0", "", {}, "sha512-Zfq6FbIcYl9gaAmVu6ROsqUiCNwpEj3Ljz/tMX5fl12Z95OFOxzf7vlO03WE5JBU/ri1tBDFHnW41dihMINOPQ=="],
"@vercel/elysia": ["@vercel/elysia@0.1.40", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-AuKj7joDY8pc4V5d17zg8xgjVDvz3oRULqSHp4Rh6xkAqeDEw5CRExkbsxZe8/7UD5PJxsuJ8f5lOs0TfXbeoQ=="],
"@vercel/elysia": ["@vercel/elysia@0.1.43", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-Q5XCGVMuO0XF2n/XEEP43d+AIk8Yl09NwJmJ0m9w5Mx0Pyacb1Yo6dcbn6vnsj4j50dpYGeejdaqu77xOKi8mQ=="],
"@vercel/error-utils": ["@vercel/error-utils@2.0.3", "", {}, "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ=="],
"@vercel/express": ["@vercel/express@0.1.49", "", { "dependencies": { "@vercel/cervel": "0.0.24", "@vercel/nft": "1.1.1", "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-93JBGRUtbBlWtRmT3TgC+Y0F8QUetFgx8c8FLIacI3bcD7K6evnwS3J5b098w+Tt+VCsRRkTk+wlHN9w36Npug=="],
"@vercel/express": ["@vercel/express@0.1.52", "", { "dependencies": { "@vercel/cervel": "0.0.27", "@vercel/nft": "1.1.1", "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-lvZyw/7IOVMx6YqniijlMrU0ys+bcbqkY+5slGQMoXrtW7Mo2RhF9f27y+1Bl1ELv8JzyrXhOLHu2f5gn4CsgQ=="],
"@vercel/fastify": ["@vercel/fastify@0.1.43", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-scs8Y9EjW1lHjaIxNcAKCPxFG0Xt9kPEoH+DhKmEE/zbQ+pbm9YQ023+A732kmzhYrU5mhGRZkwcPHM5n6H9ng=="],
"@vercel/fastify": ["@vercel/fastify@0.1.46", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-R8uSV3SSbFJ39o3VAfsKwlWOPGDfQyNXxJ2dT8Z05GQ4O3qgGTcRd/oEothQZgnJR9fjGzDQ3q7SwWBd12150A=="],
"@vercel/fun": ["@vercel/fun@1.3.0", "", { "dependencies": { "@tootallnate/once": "2.0.0", "async-listen": "1.2.0", "debug": "4.3.4", "generic-pool": "3.4.2", "micro": "9.3.5-canary.3", "ms": "2.1.1", "node-fetch": "2.6.7", "path-to-regexp": "8.2.0", "promisepipe": "3.0.0", "semver": "7.5.4", "stat-mode": "0.3.0", "stream-to-promise": "2.2.0", "tar": "7.5.7", "tinyexec": "0.3.2", "tree-kill": "1.2.2", "uid-promise": "1.0.0", "xdg-app-paths": "5.1.0", "yauzl-promise": "2.1.3" } }, "sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg=="],
"@vercel/gatsby-plugin-vercel-analytics": ["@vercel/gatsby-plugin-vercel-analytics@1.0.11", "", { "dependencies": { "web-vitals": "0.2.4" } }, "sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw=="],
"@vercel/gatsby-plugin-vercel-builder": ["@vercel/gatsby-plugin-vercel-builder@2.0.139", "", { "dependencies": { "@sinclair/typebox": "0.25.24", "@vercel/build-utils": "13.5.0", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "sha512-Cl/7f2pfZ2BaaUzvccXBrPb/sIsf+g8E1eph8VKhfmsPIeqKQx7/Lr1DQy4Bx4Ic6phk2IY+j0hy8XErtlVm6g=="],
"@vercel/gatsby-plugin-vercel-builder": ["@vercel/gatsby-plugin-vercel-builder@2.0.142", "", { "dependencies": { "@sinclair/typebox": "0.25.24", "@vercel/build-utils": "13.6.2", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "sha512-F6Mo5fROP57wX/vAngXHA+xBZVwi98vU5YT1J+n/PYxIwbOVCa6SyPOI98o6a+uLQnchqbWbcXcV2CaYmU85VA=="],
"@vercel/go": ["@vercel/go@3.4.2", "", {}, "sha512-+Hd2W701PWajQHiPSQ+jrjNUXLbwHYRXUkzv0zqMTySQzwdvls/wnr34VYn9tyzw2YxAzHogBpSNyC1c9TDEVg=="],
"@vercel/go": ["@vercel/go@3.4.3", "", {}, "sha512-LDT4wpx7SW2UJHd3rOL4+2m2V4w7a5zjyuU0pu0yDBiuxp9bEh7QuAH5qZC7pINGLC3vGftiFVzynd5N454sVA=="],
"@vercel/h3": ["@vercel/h3@0.1.49", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-x9QVqRVzMyWSHwXyNBQs2DMnmg3PeCPg+iWzpzs0LhnDVUBsU5sxSdswQiTxw3FjM+lMY0FQdEX0SkTGntda8Q=="],
"@vercel/h3": ["@vercel/h3@0.1.52", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-bk8id7w9evxD+JeOUJt6qfDohGwJFBVh7N1qh1OG/nngzzb/Ojg+poH5EGpYwYiKymPK7NMgQl0fb0ZjhIU4bg=="],
"@vercel/hono": ["@vercel/hono@0.2.43", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-c3y1yiQe/7QFngIaV1bmoWdfP7XnTkJFq+NyY4zFbT3TBX9HAlG6pUtrdLL5jikVniLmurZW6I0gq55HudKQNA=="],
"@vercel/hono": ["@vercel/hono@0.2.46", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-rg+6PnvIV20lUM3r2fcjJ/1g1z3ND6h3EqlOQytwCuwpP/jB88TUy+guutB4rDpylPvHzleZqjgi2rE9yH4URg=="],
"@vercel/hydrogen": ["@vercel/hydrogen@1.3.5", "", { "dependencies": { "@vercel/static-config": "3.1.2", "ts-morph": "12.0.0" } }, "sha512-7EE6yVKcCnjMb1io9y069GkLyGyIzRbW3Krm3Q7EEfJ3P46h9xe9v/O5UhBoPrwtqDUHxmDngZp9YyfgY8IITA=="],
"@vercel/koa": ["@vercel/koa@0.1.23", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-Et5svjaAcA/K72qzFJxNLLtgqM8zMmLTT1D2aRUCZY/8JxWLCmY12XmBvTrSN+QwM+q/+r5O2/tJCXi4Cj0+Aw=="],
"@vercel/koa": ["@vercel/koa@0.1.26", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-TzH4xovhGbr2kmZyvMraaJ3A28qRdo8SxkBO+LIb3ahM739VyTAkH3EYR5CPaemkRv00+nTyBxJJxfnhP1n2MA=="],
"@vercel/nestjs": ["@vercel/nestjs@0.2.44", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-KBy2NllhRgFHvCtUh+IPNpO1BElm6mlOfjWXdG7HSyNBNQXCd7HXKcCr6PU9Ge1xOwlebx12PoIvBdzZCX59kw=="],
"@vercel/nestjs": ["@vercel/nestjs@0.2.47", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-/yIbLc5rv8GQ+lsJ45JkObcNiMe5yUgXx2d8wryW1SajPV1/iP+wFRK28/Q+6zdvapcgqhFRwInZh+61um/ZWg=="],
"@vercel/next": ["@vercel/next@4.15.34", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-5nohAM10Z5mmKiT8q1bn/KongZW807iEkxjD5VClHduyJmuexeLCrmnLyUlD8MBBGMmL429OdECiSNL3HH2JzQ=="],
"@vercel/next": ["@vercel/next@4.15.39", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-E3Wnn5Wfxa7e2RvguE4YNO8OBoOVZhnRXDRfn5jJ9ct+GVRZDgPS9VdXK3eQazMJUCLmQCKdyTca/I57ImPNpQ=="],
"@vercel/nft": ["@vercel/nft@1.3.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-i4EYGkCsIjzu4vorDUbqglZc5eFtQI2syHb++9ZUDm6TU4edVywGpVnYDein35x9sevONOn9/UabfQXuNXtuzQ=="],
"@vercel/node": ["@vercel/node@5.6.7", "", { "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", "@vercel/build-utils": "13.5.0", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", "es-module-lexer": "1.4.1", "esbuild": "0.27.0", "etag": "1.8.1", "mime-types": "2.1.35", "node-fetch": "2.6.9", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "sha512-a6T/59XUX2uQ5e5Z9aw8svTmaqxR54omUS88Ixfi50N40artnU1Olk9uQExf/9c5STNf8ewriQih1ep0PXx74g=="],
"@vercel/node": ["@vercel/node@5.6.10", "", { "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", "@vercel/build-utils": "13.6.2", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", "es-module-lexer": "1.4.1", "esbuild": "0.27.0", "etag": "1.8.1", "mime-types": "2.1.35", "node-fetch": "2.6.9", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "sha512-HEydd6y0KPjCyI9sM3n2/XCjS/fZV/7sO7jFjia9I5XbEJ6LCwkI6rR+GQB751NL0He0vFusy+mm394hJC/Rqg=="],
"@vercel/python": ["@vercel/python@6.16.1", "", { "dependencies": { "@vercel/python-analysis": "0.7.0" } }, "sha512-41P5yZtYKDBJxQwKY7xDwBBEMqZHN5mECyH8gxhSpcMmue+1XTkiA32DKCvFWSK94+AVUE9Ev59Q86aDem5cwQ=="],
"@vercel/python": ["@vercel/python@6.20.1", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-a/VgUJ/N7SfKgE2fen6iaNow/nWEGEQj57SNyPwo4emvPfO1dPxUlZWyAEdctfXYKjnJR2WAU2kYcCDw+zenZg=="],
"@vercel/python-analysis": ["@vercel/python-analysis@0.7.0", "", { "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", "@renovatebot/pep440": "4.2.1", "fs-extra": "11.1.1", "js-yaml": "4.1.1", "minimatch": "10.1.1", "pip-requirements-js": "1.0.2", "smol-toml": "1.5.2", "zod": "3.22.4" } }, "sha512-0l5ITyZd8V6etELQd4Xmblc2rou5Jlj6hzFHZ+Dgo7/i7HvcWvZ+thZ5IaPdiXQNYo2L8CvUAsGvybkstuFc8w=="],
"@vercel/python-analysis": ["@vercel/python-analysis@0.8.1", "", { "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", "@renovatebot/pep440": "4.2.1", "fs-extra": "11.1.1", "js-yaml": "4.1.1", "minimatch": "10.1.1", "pip-requirements-js": "1.0.2", "smol-toml": "1.5.2", "zod": "3.22.4" } }, "sha512-gW1pZDqJaTcjZYPvNhXXLOPgLu6vJW9PKweJoX2f8EKAoW+JIiYncl8AddcSlngNhQRG7SqUl2u3qosZM4kUBA=="],
"@vercel/redwood": ["@vercel/redwood@2.4.9", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "semver": "6.3.1", "ts-morph": "12.0.0" } }, "sha512-U7bYIuWfMEFMIcKKbX7lTT8pFNjig9Q3vLeCYRYQUrKVP8xLoUBXSEfW3ijtWJBUV8GmbZCDI30A16uUfNhN+g=="],
"@vercel/remix-builder": ["@vercel/remix-builder@5.5.10", "", { "dependencies": { "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0" } }, "sha512-E4fqjBaztj/5JG8HCbvqO/JZyP3b+hpse+aAMb9twvgyIRfkkl+146liFF2I8/M/cc1PWkSfaqa8LF0+5x4egA=="],
"@vercel/remix-builder": ["@vercel/remix-builder@5.6.0", "", { "dependencies": { "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0" } }, "sha512-neTpO4aGksYcPJjTbAEUhWmsOdFqgx02H47RUsXBKHdMTW4ZKrL9oAKT3pD+Bv9kUgj7uRzqAr8JeiPILPWybg=="],
"@vercel/ruby": ["@vercel/ruby@2.3.2", "", {}, "sha512-okIgMmPEePyDR9TZYaKM4oftcxVHM5Dbdl7V/tIdh3lq8MGLi7HR5vvQglmZUwZOeovE6MVtezxl960EOzeIiQ=="],
"@vercel/rust": ["@vercel/rust@1.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "execa": "5" } }, "sha512-Y03g59nv1uT6Da+PvB/50WqJSHlaFZ9MSkG00R82dUcTySslMbQdOeaXymZtabrmU8zQYhWDb1/CwBki8sWnaQ=="],
"@vercel/static-build": ["@vercel/static-build@2.8.41", "", { "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", "@vercel/gatsby-plugin-vercel-builder": "2.0.139", "@vercel/static-config": "3.1.2", "ts-morph": "12.0.0" } }, "sha512-h9Jj287fu2qp5uF/my7bj1dJZmf+8G/++U64ZsDB8dm8cKVjX8jjajzoMFzoXvFFK2n8tlBIl60dx6Z9QUD0Bw=="],
"@vercel/static-build": ["@vercel/static-build@2.8.44", "", { "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", "@vercel/gatsby-plugin-vercel-builder": "2.0.142", "@vercel/static-config": "3.1.2", "ts-morph": "12.0.0" } }, "sha512-xzRBFm+tgVoyE/qSF+BeKXKckLVYXR2dGHsp+fNRttLXi7fq2AUCTM7DNNT4TkkTxcPX5FaJ1tRHxlqLizv91A=="],
"@vercel/static-config": ["@vercel/static-config@3.1.2", "", { "dependencies": { "ajv": "8.6.3", "json-schema-to-ts": "1.6.4", "ts-morph": "12.0.0" } }, "sha512-2d+TXr6K30w86a+WbMbGm2W91O0UzO5VeemZYBBUJbCjk/5FLLGIi8aV6RS2+WmaRvtcqNTn2pUA7nCOK3bGcQ=="],
@@ -1940,8 +1941,6 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
"cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -3144,7 +3143,7 @@
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"send": ["send@1.1.0", "", { "dependencies": { "debug": "^4.3.5", "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^0.5.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA=="],
@@ -3328,19 +3327,19 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="],
"turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
"turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="],
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
"turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="],
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
@@ -3408,7 +3407,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vercel": ["vercel@50.23.2", "", { "dependencies": { "@vercel/backends": "0.0.37", "@vercel/blob": "2.3.0", "@vercel/build-utils": "13.5.0", "@vercel/detect-agent": "1.1.0", "@vercel/elysia": "0.1.40", "@vercel/express": "0.1.49", "@vercel/fastify": "0.1.43", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.2", "@vercel/h3": "0.1.49", "@vercel/hono": "0.2.43", "@vercel/hydrogen": "1.3.5", "@vercel/koa": "0.1.23", "@vercel/nestjs": "0.2.44", "@vercel/next": "4.15.34", "@vercel/node": "5.6.7", "@vercel/python": "6.16.1", "@vercel/redwood": "2.4.9", "@vercel/remix-builder": "5.5.10", "@vercel/ruby": "2.3.2", "@vercel/rust": "1.0.5", "@vercel/static-build": "2.8.41", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", "jose": "5.9.6", "luxon": "^3.4.0", "proxy-agent": "6.4.0" }, "bin": { "vc": "dist/vc.js", "vercel": "dist/vc.js" } }, "sha512-hn6dZ72piwPjNn7uKXd5RkkgN091cWo7M6+EJ8Db/3+MDe3mlzJOJIbRHMqkJca87MnkQ0iphhwL9eVCIlO9pw=="],
"vercel": ["vercel@50.26.1", "", { "dependencies": { "@vercel/backends": "0.0.40", "@vercel/blob": "2.3.0", "@vercel/build-utils": "13.6.2", "@vercel/detect-agent": "1.1.0", "@vercel/elysia": "0.1.43", "@vercel/express": "0.1.52", "@vercel/fastify": "0.1.46", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.3", "@vercel/h3": "0.1.52", "@vercel/hono": "0.2.46", "@vercel/hydrogen": "1.3.5", "@vercel/koa": "0.1.26", "@vercel/nestjs": "0.2.47", "@vercel/next": "4.15.39", "@vercel/node": "5.6.10", "@vercel/python": "6.20.1", "@vercel/redwood": "2.4.9", "@vercel/remix-builder": "5.6.0", "@vercel/ruby": "2.3.2", "@vercel/rust": "1.0.5", "@vercel/static-build": "2.8.44", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", "jose": "5.9.6", "luxon": "^3.4.0", "proxy-agent": "6.4.0" }, "bin": { "vc": "dist/vc.js", "vercel": "dist/vc.js" } }, "sha512-dFIsEkqTZYBNIZwe5cprBDe1a1AoWQXtBsk+/otAjuVk9WJ3x6kA9fE4rvEWAsnpk2jYj2ZbY04xjBt2cxGt5w=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -3504,7 +3503,7 @@
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="],
@@ -3512,6 +3511,8 @@
"@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
"@ai-sdk/ui-utils/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"@ampproject/remapping/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
@@ -4026,6 +4027,10 @@
"@babel/highlight/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
"@changesets/assemble-release-plan/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@changesets/get-dependents-graph/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@changesets/logger/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
"@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
@@ -4124,10 +4129,14 @@
"@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"@mapbox/node-pre-gyp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"@modelcontextprotocol/sdk/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
@@ -4768,6 +4777,8 @@
"send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"side-channel-list/object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"side-channel-map/get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -4794,6 +4805,8 @@
"tsdown/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"tsdown/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"type-is/mime-types": ["mime-types@3.0.0", "", { "dependencies": { "mime-db": "^1.53.0" } }, "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w=="],
"unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -4810,6 +4823,8 @@
"yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"zod-to-json-schema/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@ampproject/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@ampproject/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
@@ -4862,8 +4877,6 @@
"@argos-ci/core/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@argos-ci/core/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
"@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
+4 -4
View File
@@ -6,9 +6,9 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.8.10",
"vercel": "^50.23.2"
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.13",
"vercel": "^50.26.1"
},
"packageManager": "bun@1.3.7",
"overrides": {
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.164.0",
"@gitbook/api": "0.167.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/embed
## 0.3.0
### Minor Changes
- 370b731: Docs Embed: Make trademark optional
## 0.2.2
### Patch Changes
+28
View File
@@ -49,6 +49,7 @@ GitBook('configure', {
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */],
closeButton: true,
@@ -100,6 +101,7 @@ frame.configure({
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */],
closeButton: true
@@ -126,6 +128,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
@@ -269,6 +272,19 @@ Notes:
- The close button is rendered in the same sidebar area as tabs/actions.
- If your UI hides that sidebar or doesn't render it, the button will not be visible.
### `trademark`
Available in: Standalone script, NPM package, React components
Display GitBook branding in the embed. Defaults to true.
- **Type**: `boolean`
- **Default**: `true`
```javascript
trademark: true
```
### `actions`
Available in: Standalone script, NPM package, React components
@@ -315,6 +331,18 @@ greeting: {
}
```
### `assistantName`
Available in: Standalone script, NPM package, React components
Override the assistant name displayed in the chat header and assistant entry points (for example, sidebar tabs and action labels). This name will be limited to 32 characters to prevent text overflow.
- **Type**: `string`
```javascript
assistantName: 'Support Assistant'
```
### `suggestions`
Available in: Standalone script, NPM package, React components
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.2.2",
"version": "0.3.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -69,6 +69,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
trademark: true,
};
return {
+11
View File
@@ -62,12 +62,23 @@ export type GitBookEmbeddableConfiguration = {
subtitle: string;
};
/**
* Override the assistant name displayed in the UI.
* Limited to 32 characters.
*/
assistantName?: string;
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
/**
* Display GitBook branding in the embed.
*/
trademark?: boolean;
/**
* Display a close button inside the assistant.
*/
+15 -1
View File
@@ -25,7 +25,9 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
trademark = true,
closeButton = false,
assistantName,
} = props;
const frameRef = useRef<HTMLIFrameElement>(null);
@@ -48,8 +50,20 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions,
tools,
closeButton,
trademark,
assistantName,
});
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs, closeButton]);
}, [
gitbookFrame,
actions,
greeting,
suggestions,
tools,
tabs,
closeButton,
trademark,
assistantName,
]);
return (
<iframe
+14
View File
@@ -65,6 +65,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
trademark: true,
};
const widgetButton = document.createElement('button');
@@ -156,6 +157,19 @@ const GitBook = (...args: StandaloneCalls) => {
break;
case 'configure': {
const settings = args[1];
// If trademark is disabled, change the (branded) icon to the sparkle icon
if (
settings.trademark === false &&
!settings.button?.icon &&
frameConfiguration.button.icon === 'assistant'
) {
settings.button = {
label: frameConfiguration.button.label,
icon: 'sparkle',
};
}
frameConfiguration = {
...frameConfiguration,
...settings,
+17
View File
@@ -1,5 +1,22 @@
# gitbook
## 0.22.2
### Patch Changes
- e074283: Add server-side proxy for Scalar API client to bypass CORS
- afa868e: Show "Ask Assistant" search result immediately
- 0803db0: Track opening of search results for records and in the assistant.
- cb71511: Wrap tracking call with waitUntil
- 4a6f91a: Show social accounts in header
- 370b731: Docs Embed: Make trademark optional
- afa476c: Add server-side insight event tracking for MCP route
- Updated dependencies [e074283]
- Updated dependencies [370b731]
- @gitbook/openapi-parser@3.0.9
- @gitbook/react-openapi@1.5.9
- @gitbook/embed@0.3.0
## 0.22.1
### Patch Changes
+7 -2
View File
@@ -162,7 +162,7 @@ const searchTestCases: Test[] = [
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
// Fill search input, expecting AI search option
await searchInput.fill(AI_PROMPT);
@@ -991,6 +991,11 @@ const testCases: TestsCase[] = [
url: 'blocks/columns',
run: waitForCookiesDialog,
},
{
name: 'Mermaid',
url: 'blocks/mermaid',
run: waitForCookiesDialog,
},
],
},
{
@@ -2315,7 +2320,7 @@ const testCases: TestsCase[] = [
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
);
const toolConfirmation = iframe
.getByTestId('ai-chat-tool-confirmation')
.getByTestId('ai-chat-tool-confirm-accept')
.first();
await expect(toolConfirmation).toBeVisible({
timeout: 30000,
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.22.1",
"version": "0.22.2",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -80,6 +80,7 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zod-to-json-schema": "^3.25.1",
"zustand": "^5.0.3"
},
"devDependencies": {
@@ -0,0 +1,162 @@
import type {
ComputedPageResult,
ComputedSectionResult,
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { GitBookBaseContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import type {
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
Space,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const body = (await request.json()) as SearchSiteContentRequest;
const { query, scope } = body;
if (query.length <= 1) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
(async () => {
const result = await throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: organization,
siteId: site,
query,
scope,
})
);
return result;
})(),
(async () => {
const result = await throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: organization,
siteId: site,
siteShareKey: shareKey,
})
);
return result;
})(),
]);
const results = searchResults
.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
};
return result;
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return resultItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
);
})
.flat(2);
return NextResponse.json(results);
}
function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
},
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection?.siteSpaces?.filter(
(s) => s.space.language === space.space.language
).length > 1
).length ?? 0) > 1 && siteSpace
? {
label: siteSpace.title,
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
].filter((item) => item !== undefined),
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? [];
return [page, ...pageSections];
}
@@ -0,0 +1,32 @@
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function POST(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PUT(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function HEAD(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function OPTIONS() {
return handleOpenAPIProxyOptions();
}
@@ -1,7 +1,11 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
@@ -31,6 +35,24 @@ async function handler(
})
);
// Track the search event server-side
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request: nextRequest,
})
);
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
@@ -21,16 +21,15 @@ export async function getSpacePDFContext(
const apiToken = await getAPITokenFromMiddleware();
const basePath = getPDFRoutePath(params);
const linker = createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
});
const dataFetcher = createDataFetcher({
apiToken: apiToken,
});
const baseContext: GitBookBaseContext = {
linker,
linker: createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
}),
dataFetcher,
};
@@ -0,0 +1,55 @@
'use client';
import { Button } from '@/components/primitives';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
export const ConfirmControlOutputSchema = z.object({
result: z.enum(['confirmed', 'cancelled']),
});
export const ConfirmControlDef = createAIControl({
name: 'confirm',
exposeAsTool: false,
description:
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the user’s click.',
inputSchema: z.object({
icon: z
.string()
.optional()
.describe('Icon to be shown in the confirm button (any Fontawesome icon name).'),
label: z.string().describe('Label to be shown in the confirm button.'),
}),
outputSchema: ConfirmControlOutputSchema,
render: (props) => {
return <ConfirmControl {...props} />;
},
});
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
const { label, icon, onSubmit } = props;
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<Button
data-testid="ai-chat-tool-confirm-cancel"
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
variant="secondary"
icon="xmark"
label="Cancel"
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
className="flex-1"
variant="primary"
icon={icon}
label={label}
/>
</AIToolContainer>
);
}
@@ -0,0 +1,203 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
const OTHER_OPTION_ID = '$other';
export const SingleChoiceControlOutputSchema = z.object({
id: z.string().describe('The identifier of the option selected by the user.'),
input: z
.string()
.optional()
.describe('The custom text entered by the user when "Other" is enabled and selected.'),
});
export const SingleChoiceControlDef = createAIControl({
name: 'single-choice',
exposeAsTool: true,
description:
'Use this control when you need the user to choose exactly one option from a predefined list.',
inputSchema: z.object({
prompt: z
.string()
.describe(
'Provide the question or instruction that tells the user what single choice they need to make.'
),
options: z
.array(
z
.object({
id: z
.string()
.describe(
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
),
label: z
.string()
.describe('Provide the short label the user sees for this option.'),
description: z
.string()
.optional()
.describe(
'Optionally provide supporting details to help the user understand this option.'
),
})
.describe('Define one selectable option the user can pick.')
)
.describe(
'Provide the list of options the user can choose from. The user must select exactly one.'
),
allowOther: z
.boolean()
.optional()
.describe(
'Set to true to let the user select an "Other" option and enter a custom text response.'
),
}),
outputSchema: SingleChoiceControlOutputSchema,
render: (props) => {
return <SingleChoiceControl {...props} />;
},
});
function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControlDef>) {
const { prompt, options, allowOther, onSubmit } = props;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [otherInput, setOtherInput] = React.useState('');
const canSubmit =
selectedId !== null &&
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<div className="no-scrollbar flex flex-1 flex-col gap-1 overflow-auto">
<p className="mb-1 font-semibold text-sm">{prompt}</p>
{options.map((option) => {
const isSelected = selectedId === option.id;
return (
<button
key={option.id}
type="button"
data-testid={`ai-chat-tool-single-choice-option-${option.id}`}
onClick={() => {
setSelectedId(option.id);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
isSelected
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
)}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
isSelected
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
isSelected ? 'bg-primary-original' : 'bg-transparent'
)}
/>
</span>
<span className="min-w-0">
<p className="font-medium text-sm">{option.label}</p>
{option.description ? (
<p className="mt-0.5 text-sm text-tint-subtle">
{option.description}
</p>
) : null}
</span>
</div>
</button>
);
})}
{allowOther ? (
<button
type="button"
data-testid="ai-chat-tool-single-choice-option-other"
onClick={() => {
setSelectedId(OTHER_OPTION_ID);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
)}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
selectedId === OTHER_OPTION_ID
? 'bg-primary-original'
: 'bg-transparent'
)}
/>
</span>
<div className="flex flex-1 flex-col gap-1">
<p className="font-medium text-sm">Other</p>
{allowOther && selectedId === OTHER_OPTION_ID ? (
<Input
label="Enter your answer"
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-single-choice-other-input"
autoFocus
className="w-full"
sizing="small"
/>
) : null}
</div>
</div>
</button>
) : null}
</div>
<Button
data-testid="ai-chat-tool-single-choice-submit"
variant="primary"
label="Submit answer"
disabled={!canSubmit}
onClick={() => {
if (!canSubmit || !selectedId) {
return;
}
if (selectedId === OTHER_OPTION_ID) {
onSubmit({
id: OTHER_OPTION_ID,
input: otherInput.trim(),
});
return;
}
onSubmit({ id: selectedId });
}}
/>
</AIToolContainer>
);
}
@@ -0,0 +1,15 @@
import { tcls } from '@/lib/tailwind';
import type { ComponentPropsWithRef } from 'react';
export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
return (
<div
{...props}
className={tcls(
'min-h-0 animate-present-slow circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-subtle p-2',
props.className
)}
style={{ animationDelay: '0.5s', ...props.style }}
/>
);
}
@@ -0,0 +1,80 @@
import type {
AIStreamResponseToolCallPending,
AIToolCallResult,
AIToolDefinition,
} from '@gitbook/api';
import type { ZodType, z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
type AIUIToolContext = Pick<AIStreamResponseToolCallPending, 'toolCall' | 'toolCallId'>;
type AIControlDefinition<
Name extends string = any,
Input extends Record<string, unknown> = any,
Output extends Record<string, unknown> = any,
> = AIToolDefinition & {
createControl: (args: {
context: AIUIToolContext;
input: Input;
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
}) => AIControl<Name, Input, Output>;
exposeAsTool: boolean;
};
type AIControlProps<Input = Record<string, unknown>, Output = Record<string, unknown>> = Input & {
onSubmit: (output: Output) => Promise<void>;
};
type AIControl<Name = string, Input = Record<string, unknown>, Output = Record<string, unknown>> = {
props: AIControlProps<Input, Output>;
name: Name;
context: AIUIToolContext;
render: () => React.ReactNode;
};
type AIControlFromDef<T extends AIControlDefinition> = ReturnType<T['createControl']>;
export type GetAIControlProps<T extends AIControlDefinition> = AIControlFromDef<T>['props'];
export function createAIControl<
Name extends string,
InputSchema extends ZodType<Record<string, unknown>>,
OutputSchema extends ZodType<Record<string, unknown>>,
>(def: {
name: Name;
description: string;
inputSchema: InputSchema;
outputSchema: OutputSchema;
render: (props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>>) => React.ReactNode;
/**
* Indicates if the control should be exposed as a tool or not.
*/
exposeAsTool: boolean;
}): AIControlDefinition<Name, z.infer<InputSchema>, z.infer<OutputSchema>> {
return {
name: `ui--${def.name}`,
description: def.description,
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
createControl: ({ context, input, send }) => {
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
...input,
onSubmit: async (output) => {
await send({
output,
summary: {
icon: 'check',
text: 'Submitted',
},
});
},
};
return {
props,
name: def.name,
context,
render: () => def.render(props),
};
},
exposeAsTool: def.exposeAsTool,
};
}
@@ -0,0 +1,11 @@
import { ConfirmControlDef } from './ConfirmControl';
import { SingleChoiceControlDef } from './SingleChoiceControl';
const CONTROLS = [SingleChoiceControlDef, ConfirmControlDef];
export type AnyAIControlTool = (typeof CONTROLS)[number];
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
export function getControlTools(): AnyAIControlTool[] {
return CONTROLS.filter((control) => control.exposeAsTool);
}
@@ -0,0 +1,8 @@
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...integrationTools];
}
+5 -4
View File
@@ -17,6 +17,7 @@ export type AIConfig = {
aiMode: CustomizationAIMode;
suggestions?: string[];
trademark: boolean;
assistantName?: string;
greeting?: {
title: string;
subtitle: string;
@@ -54,10 +55,10 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
const AIContext = React.createContext<AIConfig | null>(null);
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
const { aiMode, trademark, suggestions, greeting, children } = props;
const { aiMode, trademark, suggestions, greeting, assistantName, children } = props;
const value = React.useMemo(
() => ({ aiMode, trademark, suggestions, greeting }),
[aiMode, trademark, suggestions, greeting]
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
[aiMode, trademark, suggestions, greeting, assistantName]
);
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
@@ -91,7 +92,7 @@ export function useAI(): AIContext {
if (config.aiMode === CustomizationAIMode.Assistant) {
assistants.push({
id: 'gitbook-assistant',
label: getAIChatName(language, config.trademark),
label: config.assistantName ?? getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
@@ -9,13 +9,15 @@ import {
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import * as React from 'react';
import { getVisitor, useTrackEvent } from '../Insights';
import { getSession } from '../Insights/sessions';
import { integrationsAssistantTools } from '../Integrations';
import { useSetSearchState } from '../Search';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
export type AIChatMessage = {
@@ -24,21 +26,6 @@ export type AIChatMessage = {
query?: string;
};
export type AIChatPendingTool = {
icon?: IconName;
label: string;
/**
* Confirm the tool call by calling this function.
*/
confirm: () => Promise<void>;
/**
* Tool call result to cancel it.
*/
cancelToolCall: AIToolCallResult;
};
export type AIChatState = {
/**
* If true, the chat is open.
@@ -71,9 +58,9 @@ export type AIChatState = {
followUpSuggestions: string[];
/**
* Tools that are pending confirmation to be executed.
* Control to be displayed to the user.
*/
pendingTools: AIChatPendingTool[];
control: AnyAIControl | null;
/**
* If true, the session is in progress.
@@ -127,7 +114,7 @@ const globalState = zustand.create<AIChatState>(() => {
messages: [],
query: null,
followUpSuggestions: [],
pendingTools: [],
control: null,
loading: false,
error: false,
initialQuery: null,
@@ -213,7 +200,7 @@ export function AIChatProvider(props: {
return {
...state,
followUpSuggestions: [],
pendingTools: [],
control: null,
loading: true,
error: false,
messages: [
@@ -228,10 +215,10 @@ export function AIChatProvider(props: {
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const integrationTools = integrationsAssistantTools.getState().tools;
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
const tools = getTools();
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
if (!toolDef || !('execute' in toolDef)) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
@@ -264,7 +251,7 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const integrationTools = integrationsAssistantTools.getState().tools;
const tools = getTools();
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
@@ -274,9 +261,10 @@ export function AIChatProvider(props: {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
},
tools: integrationTools.map((tool) => ({
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
@@ -320,46 +308,84 @@ export function AIChatProvider(props: {
break;
}
case 'response_tool_call_pending': {
const toolDef = integrationTools.find(
(tool) => tool.name === event.toolCall.tool
);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
const confirmation = toolDef.confirmation;
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
pendingTools: [
...state.pendingTools,
{
icon: confirmation.icon,
label: confirmation.label,
cancelToolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
cancelled: 'User did not confirm the tool call',
},
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
confirm: async () => {
await executeToolCall(event);
},
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
],
input: {
label: confirmation.label,
icon: confirmation.icon,
},
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
}));
} else {
toolToExecute = event;
break;
}
toolToExecute = event;
break;
}
}
@@ -408,7 +434,11 @@ export function AIChatProvider(props: {
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, pendingTools } = globalState.getState();
const { query, messages, control } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// For first message, update the ask parameter in URL
if (messages.length === 0) {
@@ -453,15 +483,9 @@ export function AIChatProvider(props: {
};
});
const pendingTool = pendingTools[0];
streamResponse({
message: input.message,
// If we had a pending tool call, we need to send it as being cancelled
// otherwise the AI will fail to process the message
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
});
streamResponse({ message: input.message });
},
[setSearchState, trackEvent, streamResponse]
[setSearchState, trackEvent, streamResponse, language]
);
// Clear the conversation and reset ask parameter
@@ -472,7 +496,7 @@ export function AIChatProvider(props: {
messages: [],
query: null,
followUpSuggestions: [],
pendingTools: [],
control: null,
responseId: null,
error: false,
initialQuery: null,
@@ -28,6 +28,7 @@ import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControl } from './AIChatControl';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
@@ -91,7 +92,7 @@ export function AIChat() {
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
{config.assistantName ?? getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
@@ -111,6 +112,7 @@ export function AIChat() {
chatController={chatController}
chat={chat}
suggestions={config.suggestions}
trademark={config.trademark}
/>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
@@ -141,7 +143,7 @@ export function AIChatDynamicIcon(props: {
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.pendingTools.length > 0
? chat.control
? 'confirm'
: 'done'
: 'default'
@@ -199,13 +201,13 @@ export function AIChatBody(props: {
chat: AIChatState;
welcomeMessage?: string;
suggestions?: string[];
trademark?: boolean;
greeting?: {
title: string;
subtitle: string;
};
}) {
const { chatController, chat, suggestions, greeting } = props;
const { trademark } = useAI().config;
const { chatController, chat, suggestions, greeting, trademark } = props;
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -223,7 +225,7 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
trailing={{ fade: false, button: true }}
@@ -272,17 +274,21 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex flex-col gap-2 pb-4">
<div className="flex min-h-0 flex-col gap-2 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
{chat.control ? (
<AIChatControl control={chat.control} />
) : (
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
)}
</div>
</>
);
@@ -0,0 +1,8 @@
'use client';
import type { AnyAIControl } from '../AI/controls';
export function AIChatControl(props: { control: AnyAIControl }) {
const { control } = props;
return control.render();
}
@@ -3,7 +3,6 @@ import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -67,7 +66,6 @@ export function AIChatMessages(props: {
'break-words',
'group/message',
'animate-blur-in-slow',
isLastMessage ? 'basis-full' : '',
message.role === AIMessageRole.User
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong'
@@ -96,14 +94,13 @@ export function AIChatMessages(props: {
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
@@ -1,70 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { useHotkeys } from 'react-hotkeys-hook';
import type { AIChatState } from '../AI';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
/**
* Display buttons to confirm tool calls.
*/
export function AIChatToolConfirmations(props: {
chat: AIChatState;
}) {
const { chat } = props;
const language = useLanguage();
useHotkeys(
'mod+enter',
(e) => {
e.preventDefault();
chat.pendingTools[0]?.confirm();
},
{
enableOnFormTags: true,
},
[chat.pendingTools]
);
return (
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
{chat.pendingTools.map((tool, index) => (
<div
className="flex w-full animate-present-slow flex-col gap-1"
key={index}
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
>
<Button
data-testid="ai-chat-tool-confirmation"
onClick={() => {
tool.confirm();
}}
tabIndex={index}
label={tool.label}
className="w-full justify-center"
size={index === 0 ? 'medium' : 'small'}
variant={index === 0 ? 'primary' : 'secondary'}
icon={tool.icon}
/>
{index === 0 && (
<div
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
style={{ animationDelay: '1000ms' }}
>
{t(
language,
'press_to_confirm',
<KeyboardShortcut
keys={['mod', 'enter']}
className="mx-0 text-tint"
/>
)}
</div>
)}
</div>
))}
</div>
);
}
@@ -33,7 +33,7 @@ export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
Only visible to your GitBook organization
This toolbar appears only when signed in to GitBook.
</span>
<button
type="button"
@@ -1,9 +1,9 @@
import type { DocumentInlineIcon } from '@gitbook/api';
import { textColorToStyle } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline';
import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props;
@@ -12,6 +12,7 @@ import { Heading } from '../Heading';
import './style.css';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import type { GitBookAnyContext } from '@/lib/context';
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
import type {
AnyOpenAPIOperationsBlock,
OpenAPISchemasBlock,
@@ -32,8 +33,15 @@ export function getOpenAPIContext(args: {
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
const proxyUrl = context
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
: undefined;
return {
specUrl,
resolveProxyUrl: proxyUrl
? (allowedOrigins: string[]) => buildSignedProxyUrl(proxyUrl, allowedOrigins)
: undefined,
icons: {
chevronDown: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />,
@@ -1047,4 +1047,12 @@ body:has(.openapi-select-popover) {
.openapi-required-scopes .openapi-required-scopes-description {
@apply text-xs !text-tint font-normal mb-2;
}
.openapi-schema-alternatives .openapi-securities-scopes {
@apply ml-0 pl-0;
}
.openapi-scopes-alternatives .openapi-schema-alternatives {
@apply flex flex-col gap-2;
}
@@ -48,7 +48,7 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={['flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
style={['min-w-0 flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
/>
</div>
);
@@ -133,9 +133,7 @@ export async function RecordCard(
'gap-3',
'p-4',
'text-sm',
target
? ['transition-colors', 'text-tint', 'group-hover:text-tint-strong']
: ['text-tint-strong']
'text-tint-strong'
)}
>
{view.columns.map((column) => {
@@ -172,7 +170,7 @@ export async function RecordCard(
// 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} classNames={['RecordCardStyles']}>
<LinkBox href={target.href} classNames={['RecordCardStyles', 'RecordCardLinkStyles']}>
<LinkOverlay
href={target.href}
insights={{
@@ -21,6 +21,6 @@ export const RecordCardStyles = [
'before:ring-tint-12/2',
'before:z-10',
'before:relative',
'hover:before:ring-tint-12/5',
] as ClassValue;
export const RecordCardLinkStyles = ['hover:before:ring-primary-hover'] as ClassValue;
@@ -12,8 +12,8 @@ import type {
} from '@gitbook/api';
import React from 'react';
import { backgroundColorToStyle, textColorToStyle } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import { backgroundColorToStyle, textColorToStyle } from './utils/colors';
export function Text(props: { text: DocumentText }) {
const { text } = props;
@@ -102,7 +102,8 @@ function Code(props: MarkedLeafProps<DocumentMarkCode>) {
'text-[.875em]',
// We ensure that the code is not making the parent bigger, especially in headings
'leading-[calc(max(1.20em,1.25rem))]',
'break-all'
'break-words',
'hyphens-none'
)}
>
{props.children}
@@ -59,7 +59,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
{resolvedTags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{resolvedTags.map((tag) => (
<Tag key={tag.slug} label={tag.label} />
<Tag key={tag.slug} tag={tag} />
))}
</div>
) : null}
@@ -41,9 +41,9 @@ type EmbeddableAIChatProps = {
export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const { baseURL, siteTitle } = props;
const chat = useAIChatState();
const { config } = useAI();
const { config: siteConfig } = useAI();
const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration();
const embedConfig = useEmbeddableConfiguration();
const language = useLanguage();
React.useEffect(() => {
@@ -65,7 +65,8 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const hasDocsTab = configuration.tabs.includes('docs');
const hasDocsTab = embedConfig.tabs.includes('docs');
const trademark = siteConfig.trademark;
const currentLinkContext = use(LinkContext);
const linkContext: LinkContextType = useMemo(
() =>
@@ -95,14 +96,11 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader>
{!tabsRef.current ? (
<AIChatDynamicIcon
className="animate-blur-in-slow"
trademark={config.trademark}
/>
<AIChatDynamicIcon className="animate-blur-in-slow" trademark={trademark} />
) : null}
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
{siteConfig.assistantName ?? getAIChatName(language, trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
@@ -115,8 +113,9 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
greeting={configuration.greeting}
suggestions={siteConfig.suggestions}
greeting={siteConfig.greeting}
trademark={trademark}
/>
</LinkContext>
</EmbeddableFrameBody>
@@ -0,0 +1,28 @@
'use client';
import { type AIConfig, AIContextProvider } from '@/components/AI';
import type { PropsWithChildren } from 'react';
import { useEmbeddableConfiguration } from './EmbeddableIframeAPI';
type EmbeddableAIContextProviderProps = PropsWithChildren<AIConfig>;
/**
* AI context provider for embeddable routes.
* Merges the static site AI config with runtime embed configuration updates.
*/
export function EmbeddableAIContextProvider(props: EmbeddableAIContextProviderProps) {
const { aiMode, suggestions, greeting, trademark, children } = props;
const embedConfig = useEmbeddableConfiguration();
return (
<AIContextProvider
aiMode={aiMode}
suggestions={embedConfig.suggestions ?? suggestions}
greeting={embedConfig.greeting ?? greeting}
trademark={trademark && (embedConfig.trademark ?? true)}
assistantName={embedConfig.assistantName?.substring(0, 32)} // Limit to 32 characters
>
{children}
</AIContextProvider>
);
}
@@ -17,6 +17,7 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() =
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
trademark: true,
}));
// biome-ignore lint/suspicious/noExplicitAny: expected
@@ -1,4 +1,3 @@
import { AIContextProvider } from '@/components/AI';
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayoutClientContexts,
@@ -11,7 +10,9 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { EmbeddableAIContextProvider } from './EmbeddableAIContextProvider';
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
import { IfEmbeddableTrademark } from './EmbeddableTrademark';
type EmbeddableRootLayoutProps = {
context: GitBookSiteContext;
@@ -40,7 +41,7 @@ export async function EmbeddableRootLayout({
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
>
<AIContextProvider
<EmbeddableAIContextProvider
aiMode={context.customization.ai.mode}
suggestions={context.customization.ai.suggestions}
trademark={context.customization.trademark.enabled}
@@ -58,18 +59,20 @@ export async function EmbeddableRootLayout({
<div className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
<Trademark
className="rounded-none! border-x-0 border-t border-b-0 bg-tint-solid/1 depth-flat:bg-tint-solid/1 px-4 py-2.5 text-tint/8"
context={context}
placement={SiteInsightsTrademarkPlacement.Embed}
/>
<IfEmbeddableTrademark>
<Trademark
className="rounded-none! border-x-0 border-t border-b-0 bg-tint-solid/1 depth-flat:bg-tint-solid/1 px-4 py-2.5 text-tint/8"
context={context}
placement={SiteInsightsTrademarkPlacement.Embed}
/>
</IfEmbeddableTrademark>
) : null}
</div>
<EmbeddableIframeAPI
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
/>
</SpaceLayoutServerContext>
</AIContextProvider>
</EmbeddableAIContextProvider>
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
@@ -0,0 +1,13 @@
'use client';
import type React from 'react';
import { useEmbeddableConfiguration } from './EmbeddableIframeAPI';
export function IfEmbeddableTrademark(props: {
children: React.ReactNode;
}) {
const { children } = props;
const embedTrademarkEnabled = useEmbeddableConfiguration((state) => state.trademark ?? true);
return embedTrademarkEnabled ? children : null;
}
@@ -8,7 +8,7 @@ import { tcls } from '@/lib/tailwind';
import { ThemeToggler } from '../ThemeToggler';
import { CONTAINER_STYLE } from '../layout';
import { FooterLinksGroup } from './FooterLinksGroup';
import { SocialLink } from './SocialLink';
import { SocialAccountButton } from './SocialAccounts';
const FOOTER_COLUMNS = 4;
@@ -145,7 +145,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
socialLinks.length > 0 ? (
<div className="col-span-full flex w-full grow items-center justify-center gap-2">
{socialLinks.map((account) => (
<SocialLink
<SocialAccountButton
key={`${account.platform}-${account.handle}`}
account={account}
target={customization.externalLinks.target}
@@ -4,7 +4,7 @@ import {
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives';
import { Button, type ButtonProps, DropdownMenuItem } from '../primitives';
type SocialPlatformData = {
label: string;
@@ -86,8 +86,10 @@ const SOCIAL_PLATFORMS: Record<SiteSocialAccountPlatform, SocialPlatformData> =
},
};
export function SocialLink(props: { account: SiteSocialAccount; target: SiteExternalLinksTarget }) {
const { account, target } = props;
export function SocialAccountButton(
props: { account: SiteSocialAccount; target: SiteExternalLinksTarget } & ButtonProps
) {
const { account, target, ...rest } = props;
const platform = SOCIAL_PLATFORMS[account.platform];
return (
@@ -99,6 +101,23 @@ export function SocialLink(props: { account: SiteSocialAccount; target: SiteExte
variant="blank"
size="large"
target={target}
{...rest}
/>
);
}
export function SocialAccountLink(props: {
account: SiteSocialAccount;
}) {
const { account } = props;
const platform = SOCIAL_PLATFORMS[account.platform];
return (
<DropdownMenuItem
href={platform.href.replace('$handle', account.handle)}
leadingIcon={platform.icon}
>
{platform.label}
</DropdownMenuItem>
);
}
@@ -4,6 +4,7 @@ import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import type { SiteSpace } from '@gitbook/api';
import { SocialAccountButton } from '../Footer/SocialAccounts';
import { SearchContainer } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLink } from './HeaderLink';
@@ -33,6 +34,10 @@ export function Header(props: {
visibleSections.list.some((s) => s.object === 'site-section-group'))
);
const headerSocialAccounts = customization.socialAccounts.filter(
(account) => account.display.header === true
);
return (
<header
data-gb-site-header
@@ -158,29 +163,45 @@ export function Header(props: {
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
viewport={!withTopHeader ? 'mobile' : undefined}
searchURL={context.linker.toPathInSpace('~gitbook/search')}
/>
</div>
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.length > 0 ? (
<>
{customization.header.links.map((link) => {
{customization.header.links.map((link) => {
return (
<HeaderLink
key={link.title}
link={link}
context={context}
/>
);
})}
{headerSocialAccounts.length > 0 ? (
<div className="flex items-center gap-1">
{headerSocialAccounts.map((account) => {
return (
<HeaderLink
key={link.title}
link={link}
context={context}
<SocialAccountButton
key={`${account.platform}-${account.handle}`}
account={account}
target={customization.externalLinks.target}
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
/>
);
})}
<HeaderLinkMore
label={t(getSpaceLanguage(context), 'more')}
links={customization.header.links}
context={context}
/>
</>
</div>
) : null}
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<HeaderLinkMore
label={t(getSpaceLanguage(context), 'more')}
links={customization.header.links}
socialAccounts={headerSocialAccounts}
context={context}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<TranslationsDropdown
@@ -3,6 +3,7 @@ import {
type CustomizationContentLink,
type CustomizationHeaderItem,
SiteInsightsLinkPosition,
type SiteSocialAccount,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type React from 'react';
@@ -10,8 +11,14 @@ import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { SocialAccountLink } from '../Footer/SocialAccounts';
import { ToggleChevron } from '../primitives';
import { DropdownMenu, DropdownMenuItem, DropdownSubMenu } from '../primitives/DropdownMenu';
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import styles from './headerLinks.module.css';
/**
@@ -20,9 +27,10 @@ import styles from './headerLinks.module.css';
export function HeaderLinkMore(props: {
label: React.ReactNode;
links: CustomizationHeaderItem[];
socialAccounts: SiteSocialAccount[];
context: GitBookSiteContext;
}) {
const { label, links, context } = props;
const { label, links, context, socialAccounts } = props;
const renderButton = (
<button
@@ -57,6 +65,13 @@ export function HeaderLinkMore(props: {
{links.map((link, index) => (
<MoreMenuLink key={index} link={link} context={context} />
))}
{socialAccounts.length > 0 && <DropdownMenuSeparator />}
{socialAccounts.map((account) => (
<SocialAccountLink
key={`${account.platform}-${account.handle}`}
account={account}
/>
))}
</DropdownMenu>
</div>
);
@@ -18,7 +18,7 @@
}
.containerHeaderlinks > :nth-child(n + 1) ~ .linkEllipsis {
display: flex;
& div > a:nth-of-type(n + 1) {
& div > *:nth-child(n + 1) {
display: flex;
}
}
@@ -29,7 +29,7 @@
}
.containerHeaderlinks > :nth-child(n + 2) ~ .linkEllipsis {
display: flex;
& div > a:nth-of-type(n + 2) {
& div > *:nth-child(n + 2) {
display: flex;
}
}
@@ -40,7 +40,7 @@
}
.containerHeaderlinks > :nth-child(n + 3) ~ .linkEllipsis {
display: flex;
& div > a:nth-of-type(n + 3) {
& div > *:nth-child(n + 3) {
display: flex;
}
}
@@ -51,7 +51,7 @@
}
.containerHeaderlinks > :nth-child(n + 4) ~ .linkEllipsis {
display: flex;
& div > a:nth-of-type(n + 4) {
& div > *:nth-child(n + 4) {
display: flex;
}
}
@@ -62,7 +62,7 @@
}
.containerHeaderlinks > :nth-child(n + 5) ~ .linkEllipsis {
display: flex;
& div > a:nth-of-type(n + 5) {
& div > *:nth-child(n + 5) {
display: flex;
}
}
@@ -73,7 +73,7 @@
}
.containerHeaderlinks > :nth-child(n + 6) ~ .linkEllipsis {
display: flex;
& div > a:nth-of-type(n + 6) {
& div > *:nth-child(n + 6) {
display: flex;
}
}
@@ -2,7 +2,12 @@
import { createStore, useStore } from 'zustand';
import { getBrowserCookie } from '@/lib/browser';
import {
getBrowserCookie,
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem,
} from '@/lib/browser';
import type { MaybePromise } from 'p-map';
import React from 'react';
@@ -10,6 +15,7 @@ import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
const VISITOR_UPDATED_AT_STORAGE_KEY = '__session_updated';
/**
* Visitor state when the visitor is not a signed-in GitBook user.
@@ -26,13 +32,55 @@ type AnyVisitorResponse = {
/**
* Visitor state when the visitor is also a signed-in GitBook user.
*/
type VisitorUserResponse = AnyVisitorResponse & {
type VisitorUserResponse = Pick<AnyVisitorResponse, 'deviceId'> & {
userId: string;
organizationId: string;
};
/**
* Time while a cookie is considered valid before revalidating it.
*/
const STALE_TIME_MS = 60 * 60 * 1000;
function getVisitorUpdatedAt(deviceId: string): number | null {
const value = getLocalStorageItem<{ deviceId: string; updatedAt: number } | null>(
VISITOR_UPDATED_AT_STORAGE_KEY,
null
);
if (!value) {
return null;
}
if (value.deviceId !== deviceId) {
clearVisitorUpdatedAt();
return null;
}
return value.updatedAt;
}
function clearVisitorUpdatedAt() {
removeLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY);
}
function setVisitorUpdatedAt(deviceId: string, updatedAt: number) {
setLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY, { deviceId, updatedAt });
}
export type VisitorResponse = AnyVisitorResponse | VisitorUserResponse;
function isVisitor(value: unknown): value is VisitorResponse {
return Boolean(
value &&
typeof value === 'object' &&
'deviceId' in value &&
typeof value.deviceId === 'string' &&
value.deviceId
);
}
function isSignedInVisitor(value: unknown): value is VisitorUserResponse {
return Boolean(isVisitor(value) && value?.userId && value.organizationId);
}
const visitorStore = createStore<{
visitor: VisitorResponse | null;
pendingVisitor: Promise<VisitorResponse> | null;
@@ -53,16 +101,19 @@ export function VisitorProvider(
const { appURL, visitorCookieTrackingEnabled, children } = props;
React.useEffect(() => {
const state = visitorStore.getState();
if (state.pendingVisitor || state.visitor) {
return;
}
const result = getGlobalVisitor({ appURL, visitorCookieTrackingEnabled });
visitorStore.setState(result);
const pendingVisitor = fetchGlobalVisitor({ appURL, visitorCookieTrackingEnabled });
visitorStore.setState({ pendingVisitor, visitor: null });
pendingVisitor.then((visitor) => {
visitorStore.setState({ pendingVisitor: null, visitor });
});
if (result.pendingVisitor) {
result.pendingVisitor
.then((visitor) => {
visitorStore.setState({ pendingVisitor: null, visitor });
})
.catch(() => {
// Preserve any existing visitor state; only clear the pending flag on failure.
visitorStore.setState({ pendingVisitor: null });
});
}
}, [appURL, visitorCookieTrackingEnabled]);
return <>{children}</>;
@@ -93,51 +144,78 @@ export function getVisitor(): MaybePromise<VisitorResponse> {
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
* Propose a visitor identifier to the GitBook.com server and get the deviceId back.
*/
async function fetchGlobalVisitor({
function getGlobalVisitor({
appURL,
visitorCookieTrackingEnabled,
}: {
appURL: string;
visitorCookieTrackingEnabled: boolean;
}): Promise<VisitorResponse> {
}): {
visitor: VisitorResponse | null;
pendingVisitor: Promise<VisitorResponse> | null;
} {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return {
deviceId: getProposedVisitorId(),
};
return { visitor: { deviceId: getProposedVisitorId() }, pendingVisitor: null };
}
const fetchGlobalVisitor = async () => {
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposedId);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
signal: AbortSignal.timeout(500),
});
if (!resp.ok) {
throw new Error(`Unexpected __session response: ${resp.status}`);
}
const visitor = await resp.json();
if (!isVisitor(visitor)) {
throw new Error(`Unexpected __session format: ${JSON.stringify(visitor)}`);
}
// When cookie tracking is disabled we still allow a signed-in session to be detected,
// but otherwise we preserve the no-cookie behavior by returning an anonymous visitor.
if (!isSignedInVisitor(visitor)) {
clearVisitorUpdatedAt();
return { deviceId: proposedId };
}
setVisitorUpdatedAt(visitor.deviceId, Date.now());
return visitor;
} catch (error) {
clearVisitorUpdatedAt();
console.error('Failed to fetch visitor session ID', error);
if (existing) {
return existing;
}
return { deviceId: proposedId };
}
};
const { existing, proposedId } = getVisitorFromCookies();
if (existing) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existing;
// Revalidate in background if stale.
const updatedAt = getVisitorUpdatedAt(existing.deviceId);
const isStale = !updatedAt || updatedAt < Date.now() - STALE_TIME_MS;
return { visitor: existing, pendingVisitor: isStale ? fetchGlobalVisitor() : null };
}
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposedId);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
signal: AbortSignal.timeout(500),
});
const visitor = (await resp.json()) as VisitorResponse;
return visitor;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return {
deviceId: proposedId,
};
}
return { visitor: null, pendingVisitor: fetchGlobalVisitor() };
}
/**
@@ -63,12 +63,16 @@ describe('createPDFLinker', () => {
it('keeps links to non-exported pages on the published domain', () => {
const baseLinker = createLinker({
host: 'docs.vectra.ai',
siteBasePath: '/',
spaceBasePath: '/deployment',
host: 'open-2v.gitbook.com',
siteBasePath: '/~space/HJ1ltuWFvsArFWtevnRn~gitbook/pdf',
spaceBasePath: '/~space/HJ1ltuWFvsArFWtevnRn~gitbook/pdf',
});
const linker = createPDFLinker(baseLinker, [{ page: createDocumentPage('included', '') }]);
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
'https://docs.vectra.ai/deployment/'
);
expect(
linker.toPathForPage({
@@ -81,6 +85,31 @@ describe('createPDFLinker', () => {
).toBe('https://docs.vectra.ai/deployment/respond');
});
it('preserves anchors for non-exported pages on the published domain', () => {
const baseLinker = createLinker({
host: 'open-2v.gitbook.com',
siteBasePath: '/~space/HJ1ltuWFvsArFWtevnRn~gitbook/pdf',
spaceBasePath: '/~space/HJ1ltuWFvsArFWtevnRn~gitbook/pdf',
});
const linker = createPDFLinker(
baseLinker,
[{ page: createDocumentPage('included', '') }],
'https://docs.vectra.ai/deployment/'
);
expect(
linker.toPathForPage({
pages: [
createDocumentPage('included', ''),
createDocumentPage('outside', 'respond'),
],
page: createDocumentPage('outside', 'respond'),
anchor: 'faq',
})
).toBe('https://docs.vectra.ai/deployment/respond#faq');
});
it('returns a local placeholder link for group pages not included in the PDF export', () => {
const baseLinker = createLinker({
host: 'docs.vectra.ai',
@@ -66,7 +66,7 @@ export async function PDFPage(props: {
);
// Build a linker that create anchor links for the pages rendered in the PDF page.
const linker = createPDFLinker(baseContext.linker, pages);
const linker = createPDFLinker(baseContext.linker, pages, baseContext.space.urls.published);
const context: GitBookSpaceContext = {
...baseContext,
+18 -3
View File
@@ -2,6 +2,8 @@ import type { Revision, RevisionPageDocument, RevisionPageGroup } from '@gitbook
import { RevisionPageType } from '@gitbook/api';
import type { GitBookLinker } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { withTrailingSlash } from '@/lib/paths';
/**
* Create the HTML ID for the container of a page or a given anchor in it.
@@ -23,16 +25,19 @@ export function getPagePDFContainerId(
* This linker generates in-document anchor links for pages that are included
* in the current PDF export, using `getPagePDFContainerId` to build the
* target element ID. For pages that are not part of the exported PDF, it
* falls back to absolute URLs pointing to the published site, preserving
* navigability for external content.
* falls back to URLs pointing to the published site if `publishedSpaceURL` is
* provided, preserving navigability for external content. Otherwise, it uses
* absolute URLs from the base linker.
*
* @param baseLinker - The base GitBook linker used to resolve standard paths and URLs.
* @param pages - The list of pages that are included in the current PDF export.
* @param publishedSpaceURL - Optional URL of the published space for external page links.
* @returns A `GitBookLinker` configured to generate PDF-friendly links.
*/
export function createPDFLinker(
baseLinker: GitBookLinker,
pages: Array<{ page: Revision['pages'][number] }>
pages: Array<{ page: Revision['pages'][number] }>,
publishedSpaceURL?: string
): GitBookLinker {
return {
...baseLinker,
@@ -45,6 +50,16 @@ export function createPDFLinker(
}
// For pages that are not embedded in this PDF export, keep links on the published site.
if (publishedSpaceURL) {
const pagePath = getPagePath(input.pages, input.page);
const pageURL = new URL(pagePath, withTrailingSlash(publishedSpaceURL));
if (input.anchor) {
pageURL.hash = input.anchor;
}
return pageURL.toString();
}
return baseLinker.toAbsoluteURL(baseLinker.toPathForPage(input));
},
};
@@ -22,7 +22,7 @@ export function PageTags(props: { page: RevisionPageDocument; revision: Revision
if (!resolved) {
return null;
}
return <Tag key={resolved.slug} label={resolved.label} />;
return <Tag key={resolved.slug} tag={resolved} />;
})}
</div>
);
@@ -5,7 +5,7 @@ import type React from 'react';
import { TranslateContext } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { NavigationStatusProvider } from '../hooks';
import { NavigationStatusProvider, ScrollPage } from '../hooks';
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
/**
@@ -22,6 +22,7 @@ export function RootLayoutClientContexts(props: {
<TooltipProvider delayDuration={200}>
<NavigationStatusProvider>
<LoadingStateProvider>{children}</LoadingStateProvider>
<ScrollPage />
</NavigationStatusProvider>
</TooltipProvider>
</TranslateContext.Provider>
@@ -42,6 +42,9 @@ interface SearchContainerProps {
style: CustomizationSearchStyle;
className?: string;
viewport?: 'desktop' | 'mobile';
/** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */
searchURL: string;
}
/**
@@ -57,6 +60,7 @@ export function SearchContainer({
className,
viewport,
siteSpaces,
searchURL,
}: SearchContainerProps) {
const { assistants, config } = useAI();
@@ -206,6 +210,7 @@ export function SearchContainer({
scope: state?.scope ?? 'default',
withAI,
suggestions: config.suggestions,
searchURL,
});
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
@@ -5,7 +5,7 @@ import React from 'react';
import { Tooltip } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
import { SearchResultItem } from './SearchResultItem';
import type { ComputedPageResult } from './server-actions';
import type { ComputedPageResult } from './search-types';
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
props: {
@@ -4,7 +4,7 @@ import React from 'react';
import { Favicon } from '../utils';
import { HighlightQuery } from './HighlightQuery';
import { SearchResultItem } from './SearchResultItem';
import type { ComputedRecordResult } from './server-actions';
import type { ComputedRecordResult } from './search-types';
export const SearchRecordResultItem = React.forwardRef(function SearchRecordResultItem(
props: {
@@ -12,7 +12,7 @@ import { SearchPageResultItem } from './SearchPageResultItem';
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
import { SearchRecordResultItem } from './SearchRecordResultItem';
import { SearchSectionResultItem } from './SearchSectionResultItem';
import type { OrderedComputedResult } from './server-actions';
import type { OrderedComputedResult } from './search-types';
export interface SearchResultsRef {
select(): void;
@@ -77,13 +77,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
if (fetching) {
return (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8', 'h-full')}>
<Loading className={tcls('w-6', 'text-tint/6')} />
</div>
);
}
if (error) {
return (
<div
@@ -130,7 +123,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
<div className={tcls('min-h-full')}>
{children}
{results.length === 0 ? (
query ? (
fetching ? null : query ? (
noResults
) : (
<div className="empty" />
@@ -227,9 +220,16 @@ export const SearchResults = React.forwardRef(function SearchResults(
}
})}
</div>
{!results.some((result) => result.type !== 'question') && noResults}
{!fetching && !results.some((result) => result.type !== 'question')
? noResults
: null}
</>
)}
{fetching ? (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-6')}>
<Loading className={tcls('w-6', 'text-tint/6')} />
</div>
) : null}
</div>
);
});
@@ -3,7 +3,7 @@ import React from 'react';
import { tString, useLanguage } from '@/intl/client';
import { HighlightQuery } from './HighlightQuery';
import { SearchResultItem } from './SearchResultItem';
import type { ComputedSectionResult } from './server-actions';
import type { ComputedSectionResult } from './search-types';
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
props: {
@@ -0,0 +1,48 @@
import type { IconName } from '@gitbook/icons';
export type OrderedComputedResult =
| ComputedPageResult
| ComputedSectionResult
| ComputedRecordResult;
export interface ComputedSectionResult {
type: 'section';
id: string;
title: string;
body: string;
href: string;
pageId: string;
spaceId: string;
}
export interface ComputedPageResult {
type: 'page';
id: string;
title: string;
href: string;
pageId: string;
spaceId: string;
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
}
export interface ComputedRecordResult {
type: 'record';
id: string;
title: string;
description: string | undefined;
href: string;
}
export type SearchSiteContentScope =
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
export interface SearchSiteContentRequest {
query: string;
scope: SearchSiteContentScope;
}
@@ -1,6 +1,6 @@
'use server';
import type { GitBookBaseContext, GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteContext } from '@/lib/context';
import { resolvePageId } from '@/lib/pages';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
@@ -10,12 +10,6 @@ import type {
RevisionPage,
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
Space,
} from '@gitbook/api';
import { createStreamableValue } from 'ai/rsc';
import type * as React from 'react';
@@ -24,46 +18,8 @@ import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { traceErrorOnly } from '@/lib/tracing';
import type { IconName } from '@gitbook/icons';
import { DocumentView } from '../DocumentView';
export type OrderedComputedResult =
| ComputedPageResult
| ComputedSectionResult
| ComputedRecordResult;
export interface ComputedSectionResult {
type: 'section';
id: string;
title: string;
body: string;
href: string;
pageId: string;
spaceId: string;
}
export interface ComputedPageResult {
type: 'page';
id: string;
title: string;
href: string;
pageId: string;
spaceId: string;
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
}
export interface ComputedRecordResult {
type: 'record';
id: string;
title: string;
description: string | undefined;
href: string;
}
export interface AskAnswerSource {
id: string;
title: string;
@@ -206,88 +162,6 @@ export async function streamRecommendedQuestions(args: { siteSpaceId?: string })
});
}
/**
* Search for content in a site by scoping the search to all content, a specific spaces or current space.
*/
export async function searchSiteContent({
query,
...scope
}: {
query: string;
} & (
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] }
)): Promise<OrderedComputedResult[]> {
return traceErrorOnly(`Search.searchSiteContent.${scope.mode}`, async () => {
if (query.length <= 1) {
return [];
}
const [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const [searchResults, { structure }] = await Promise.all([
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: organization,
siteId: site,
query,
scope,
})
),
throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: organization,
siteId: site,
siteShareKey: shareKey,
})
),
]);
return (
await Promise.all(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
const result: ComputedRecordResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
};
return result;
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return Promise.all(
resultItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup:
(siteSectionGroup as SiteSectionGroup) ?? undefined,
})
)
);
})
)
).flat(2);
});
}
async function transformAnswer(
context: GitBookSiteContext,
{
@@ -357,73 +231,3 @@ async function transformAnswer(
sources,
};
}
async function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): Promise<OrderedComputedResult[]> {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
},
(siteSection?.siteSpaces?.filter(
// If a space is the only one in its langauge, it's a translation variant and we don't want to show it.
(space) =>
siteSection?.siteSpaces?.filter(
// Check if there are other spaces in the same language within the section.
(s) => s.space.language === space.space.language
).length > 1 // We only want to show the space if there are other spaces in the same language within the section.
).length ?? 0) > 1 && siteSpace
? {
label: siteSpace.title,
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
].filter((item) => item !== undefined),
};
const pageSections = await Promise.all(
pageItem.sections
?.filter((section) => section.title || section.body)
.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? []
);
return [page, ...pageSections];
}
@@ -3,11 +3,8 @@ import React from 'react';
import { assert } from 'ts-essentials';
import {
type OrderedComputedResult,
searchSiteContent,
streamRecommendedQuestions,
} from './server-actions';
import type { OrderedComputedResult } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
import { type Assistant, useAI } from '@/components/AI';
import assertNever from 'assert-never';
@@ -36,8 +33,10 @@ export function useSearchResults(props: {
scope: SearchScope;
withAI: boolean;
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
}) {
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions } = props;
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL } = props;
const trackEvent = useTrackEvent();
@@ -134,28 +133,34 @@ export function useSearchResults(props: {
clearTimeout(timeout);
};
}
setResultsState((prev) => ({ results: prev.results, fetching: true, error: false }));
setResultsState({
results: withAI ? withAskTriggers([], query, assistants) : [],
fetching: true,
error: false,
});
let cancelled = false;
const abortController = new AbortController();
const timeout = setTimeout(async () => {
try {
const results = await (() => {
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal);
switch (scope) {
case 'all':
// Search all content on the site
return searchSiteContent({ query, mode: 'all' });
return fetchSearch({ mode: 'all' });
case 'default':
// Search the current section's variant + matched/default variant for other sections
return searchSiteContent({ query, mode: 'current', siteSpaceId });
return fetchSearch({ mode: 'current', siteSpaceId });
case 'extended':
// Search all variants of the current section
return searchSiteContent({ query, mode: 'specific', siteSpaceIds });
return fetchSearch({ mode: 'specific', siteSpaceIds });
case 'current':
// Search only the current section's current variant
return searchSiteContent({
query,
mode: 'specific',
siteSpaceIds: [siteSpaceId],
});
return fetchSearch({ mode: 'specific', siteSpaceIds: [siteSpaceId] });
default:
assertNever(scope);
}
@@ -195,12 +200,52 @@ export function useSearchResults(props: {
return () => {
cancelled = true;
clearTimeout(timeout);
abortController.abort();
};
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds, disabled, suggestions]);
}, [
query,
scope,
trackEvent,
withAI,
siteSpaceId,
siteSpaceIds,
disabled,
suggestions,
searchURL,
]);
return resultsState;
}
/**
* Fetch search results from the search API route.
*/
async function fetchSearchResults(
searchURL: string,
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] },
query: string,
signal?: AbortSignal
): Promise<OrderedComputedResult[]> {
const response = await fetch(searchURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
scope,
}),
signal,
});
if (!response.ok) {
throw new Error(`Search request failed: ${response.status}`);
}
return response.json() as Promise<OrderedComputedResult[]>;
}
/**
* Add a "Ask <question>" item at the top of the results list.
*/
@@ -3,7 +3,7 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
import { currentPageMetadataStore, useScrollPage } from '@/components/hooks';
import { currentPageMetadataStore, useScrollToHash } from '@/components/hooks';
import type { PageMetaLinks } from './SitePage';
/**
@@ -16,7 +16,7 @@ export function PageClientLayout({
}) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
useScrollPage();
useScrollToHash();
// The page metadata such as meta links are generated on the server side,
// but need to be registered on the client side in other parts of the layout
@@ -17,6 +17,7 @@ import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getResizedImageURL } from '@/lib/images';
import { resolveContentRef } from '@/lib/references';
import { getSiteSectionTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import { PageContextProvider } from '../PageContext';
@@ -125,6 +126,7 @@ export async function generateSitePageViewport(context: GitBookSiteContext): Pro
*/
function getSiteStructureTitle(context: GitBookSiteContext): string | null {
const { visibleSections: sections, siteSpace, visibleSiteSpaces: siteSpaces } = context;
const currentLanguage = siteSpace.space.language;
const title = [];
if (
@@ -132,7 +134,7 @@ function getSiteStructureTitle(context: GitBookSiteContext): string | null {
sections.current.default === false && // Only if the current section is not the default one
sections.list.filter((section) => section.object === 'site-section').length > 1 // Only if there are multiple sections
) {
title.push(sections.current.title);
title.push(getSiteSectionTitle(sections.current, currentLanguage));
}
if (
siteSpaces.length > 1 && // Only if there are multiple variants
@@ -1,5 +1,5 @@
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
import { getSectionURL, getSiteSpaceURL } from '@/lib/sites';
import { getSectionURL, getSiteSectionTitle, getSiteSpaceURL } from '@/lib/sites';
import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
import assertNever from 'assert-never';
@@ -99,9 +99,10 @@ function encodeChildren(
}
function encodeSection(context: GitBookSiteContext, section: SiteSection) {
const currentLanguage = context.siteSpace.space.language;
return {
id: section.id,
title: section.title,
title: getSiteSectionTitle(section, currentLanguage),
description: section.description,
icon: section.icon,
object: section.object,
@@ -201,6 +201,9 @@ export function SpaceLayout(props: SpaceLayoutProps) {
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
viewport="desktop"
searchURL={context.linker.toPathInSpace(
'~gitbook/search'
)}
/>
</div>
)}
@@ -38,12 +38,10 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
/>
) : null
}
icon={<TOCPageIcon page={page} />}
tag={page.primaryTag ? <Tag tag={page.primaryTag} /> : null}
>
<TOCPageIcon page={page} />
<span className="flex items-center gap-2">
{page.title}
{page.primaryTag ? <Tag label={page.primaryTag.label} /> : null}
</span>
{page.title}
</ToggleableLinkItem>
</li>
);
@@ -87,7 +87,9 @@ export async function TableOfContents(props: {
'lg:page-no-toc:pr-0',
'max-lg:pl-8',
'flex',
'flex-col',
'min-h-0',
'gap-4',
className
)}
@@ -13,9 +13,11 @@ export function ToggleableLinkItem(
pathnames: string[];
children: React.ReactNode;
descendants: React.ReactNode;
icon?: React.ReactNode;
tag?: React.ReactNode;
} & LinkInsightsProps
) {
const { href, children, descendants, pathnames, insights } = props;
const { href, children, descendants, pathnames, insights, icon, tag } = props;
const currentPagePath = useCurrentPagePath();
const isActive = pathnames.some((pathname) => pathname === currentPagePath);
@@ -43,7 +45,15 @@ export function ToggleableLinkItem(
if (!descendants) {
return (
<LinkItem href={href} insights={insights} isActive={isActive}>
{children}
{icon}
{tag ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{children}
<div className="flex shrink-0 items-center">{tag}</div>
</div>
) : (
children
)}
</LinkItem>
);
}
@@ -58,8 +68,21 @@ export function ToggleableLinkItem(
isActive={isActive}
onActiveClick={() => handleToggle(!isOpen)}
>
{children}
{toggler}
{icon}
{tag ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{children}
<div className="flex shrink-0 items-center">
{tag}
{toggler}
</div>
</div>
) : (
<>
{children}
{toggler}
</>
)}
</LinkItem>
{descendants}
</>
+30 -5
View File
@@ -1,22 +1,47 @@
import { Emoji } from '@/components/primitives';
import { backgroundColorToStyle, textColorToStyle } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import type { RevisionTag } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
/**
* Renders a tag, used to tag content like pages and blocks.
*/
export function Tag(props: { label: RevisionTag['label']; className?: string }) {
const { label, className } = props;
export function Tag(props: { tag: RevisionTag; className?: string }) {
const { tag, className } = props;
const hasColor = tag.color !== 'default';
return (
<span
data-tag=""
title={label}
title={tag.label}
className={tcls(
'inline-flex items-center rounded-full bg-tint-5 px-2 py-1 font-medium text-tint-strong text-xs leading-normal contrast-more:ring-1 contrast-more:ring-tint',
'inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal contrast-more:ring-1 contrast-more:ring-tint',
hasColor
? [backgroundColorToStyle[tag.color], textColorToStyle[tag.color]]
: 'bg-tint-5 text-tint-strong',
className
)}
>
<span className="truncate">{label}</span>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</span>
);
}
/**
* Renders the emoji or icon for a tag, if present.
*/
function TagIcon(props: { tag: RevisionTag }) {
const { tag } = props;
if ('emoji' in tag) {
return <Emoji code={tag.emoji} style="shrink-0" />;
}
if (tag.icon) {
return <Icon icon={tag.icon as IconName} className={tcls('size-[1em]', 'shrink-0')} />;
}
return null;
}
@@ -27,16 +27,7 @@ function getHash(): string | null {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.slice(1);
// We ignore text fragments (i.e. :~:text=) because they are not a classic hash navigation
// and we don't want to trigger scroll or other side effects on them.
if (hash.startsWith(':~:text=')) {
return null;
}
return hash || null;
return window.location.hash.slice(1);
}
export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
@@ -2,38 +2,92 @@
import React from 'react';
import { usePathname } from 'next/navigation';
import { useHash } from './useHash';
import { usePrevious } from './usePrevious';
const SCROLL_PAGE_INITIAL_LOAD_KEY = '__gitbookScrollPageInitialLoadHandled';
type ScrollPageWindow = Window & {
[SCROLL_PAGE_INITIAL_LOAD_KEY]?: boolean;
};
/**
* Scroll the page to the hash or reset scroll to the top.
* Only triggered while navigating in the app, not for initial load.
* Check if it's an initial page load outside of React tree.
*/
export function useScrollPage() {
function isInitialPageLoad() {
const scrollPageWindow = window as ScrollPageWindow;
if (scrollPageWindow[SCROLL_PAGE_INITIAL_LOAD_KEY]) {
return false;
}
scrollPageWindow[SCROLL_PAGE_INITIAL_LOAD_KEY] = true;
return true;
}
/**
* Handles scroll behavior when the URL hash changes during client-side navigation.
*/
function useScrollPage() {
const hash = useHash();
const previousHash = usePrevious(hash);
const pathname = usePathname();
const previous = usePrevious({ pathname, hash });
React.useEffect(() => {
if (hash) {
if (previousHash !== undefined && previousHash !== hash) {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
}
// Never scroll on initial rendering to avoid blocking:
// • Native browser hash scrolling
// • Scroll-to-text fragments (undetectable)
if (previous === undefined) {
// If previous is undefined, we rely on a check outside React tree,
// sections are remounting everything so we can't rely on React.
if (isInitialPageLoad()) {
return;
}
} else if (previous.hash === hash && previous.pathname === pathname) {
return;
}
// On initial load `previousHash` can be undefined,
// but if the URL contains a fragment (hash),
// we don't override native anchor scrolling
if (!previousHash && window.location.hash) {
if (hash) {
scrollToHash(hash);
return;
}
window.scrollTo(0, 0);
}, [hash, previousHash]);
}, [hash, pathname, previous]);
}
/**
* Handles scroll behavior when the URL hash changes during client-side navigation.
*/
export function ScrollPage() {
useScrollPage();
return null;
}
/**
* Scroll to the hash if present.
*/
export function useScrollToHash() {
const hash = useHash();
React.useEffect(() => {
if (hash) {
scrollToHash(hash);
}
}, [hash]);
}
/**
* Scroll to a hash, if scroll didn't work, return false.
*/
function scrollToHash(hash: string) {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
return true;
}
return false;
}
@@ -49,7 +49,7 @@ export function Card(
</span>
{postTitle ? (
<span className={tcls('text-xs', 'text-tint')}>{postTitle}</span>
<span className={tcls('text-xs', 'text-base')}>{postTitle}</span>
) : null}
</span>
<Icon
@@ -57,11 +57,11 @@ export type InputProps = CustomInputProps &
(
| ({ multiline?: false } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange'
'value' | 'onChange' | 'onSubmit'
>)
| ({ multiline: true } & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'value' | 'onChange'
'value' | 'onChange' | 'onSubmit'
>)
);
@@ -6,7 +6,7 @@ import React from 'react';
import { tcls } from '@/lib/tailwind';
import { type TrackEventInput, useTrackEvent } from '../Insights';
import { NavigationStatusContext } from '../hooks';
import { isExternalLink } from '../utils/link';
import { isExternalLink, toNonEmbedLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
@@ -162,13 +162,6 @@ export function Link(props: LinkProps) {
);
}
/**
* Remove "~gitbook/embed/page" or "~gitbook/embed" at the end of the link.
*/
function toNonEmbedLink(href: string) {
return href.replace(/~gitbook\/embed(?:\/page)?$/, '');
}
/**
* A box used to contain a link overlay.
* It is used to create a clickable area that can contain other elements.
@@ -207,7 +207,6 @@ export function SideSheet(
'fixed inset-y-0 z-41', // Above the side sheet overlay on z-40
side === 'left' ? 'left-0' : 'right-0',
withCloseButton ? 'max-w-[calc(100%-4rem)]' : 'max-w-[calc(100%-3rem)]',
shouldHide ? 'hidden' : '',
isOpen
? side === 'left'
? 'hydrated:animate-enter-from-left'
@@ -223,6 +222,10 @@ export function SideSheet(
aria-expanded={isOpen}
aria-modal={isModal}
{...rest}
style={{
...rest.style,
display: shouldHide ? 'none' : rest.style?.display,
}}
>
{children}
{withCloseButton ? (
@@ -1,7 +1,7 @@
'use client';
import type { ClassValue } from '@/lib/tailwind';
import { RecordCardStyles } from '../DocumentView/Table/styles';
import { RecordCardLinkStyles, RecordCardStyles } from '../DocumentView/Table/styles';
import {
ToggleableLinkItemActiveStyles,
ToggleableLinkItemStyles,
@@ -13,6 +13,7 @@ const styles = {
CardStyles,
ButtonStyles,
RecordCardStyles,
RecordCardLinkStyles,
ToggleableLinkItemStyles,
ToggleableLinkItemActiveStyles,
};
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { isExternalLink } from './link';
import { isExternalLink, toNonEmbedLink } from './link';
describe('isExternalLink', () => {
it('treats anchor links as internal', () => {
@@ -54,3 +54,23 @@ describe('isExternalLink', () => {
}
});
});
describe('toNonEmbedLink', () => {
it('removes "~gitbook/embed" from a path', () => {
expect(toNonEmbedLink('/~gitbook/embed/docs')).toBe('/docs');
expect(toNonEmbedLink('/docs/~gitbook/embed/other')).toBe('/docs/other');
expect(toNonEmbedLink('/docs/~gitbook/embed/page/other')).toBe('/docs/other');
});
it('removes "~gitbook/embed/page" from a path', () => {
expect(toNonEmbedLink('/~gitbook/embed/page/docs')).toBe('/docs');
});
it('removes "~gitbook/embed" with trailing slash', () => {
expect(toNonEmbedLink('/~gitbook/embed/')).toBe('/');
});
it('keeps links unchanged when no embed prefix exists', () => {
expect(toNonEmbedLink('/docs')).toBe('/docs');
});
});
@@ -42,3 +42,10 @@ export function isExternalLink(href: string, origin: string | null = null) {
// Then compare the pathname by adding "/" to ensure we don't match "gitbook.com/docs-x"
return !parsed.pathname.startsWith(withTrailingSlash(originUrl.pathname));
}
/**
* Remove "~gitbook/embed/page" or "~gitbook/embed" in the link.
*/
export function toNonEmbedLink(href: string) {
return href.replace(/~gitbook\/embed(\/page)?\/?/, '');
}
@@ -65,6 +65,13 @@ export function setLocalStorageItem(key: string, value: unknown) {
writeStorage(typeof localStorage !== 'undefined' ? localStorage : undefined, key, value);
}
/**
* Remove an item from local storage safely.
*/
export function removeLocalStorageItem(key: string) {
removeStorage(typeof localStorage !== 'undefined' ? localStorage : undefined, key);
}
/**
* Get an item from session storage safely.
*/
+122 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { getURLLookupAlternatives, normalizeURL } from './urls';
import { decodeURLPath, getURLLookupAlternatives, normalizeURL } from './urls';
describe('getURLLookupAlternatives', () => {
it('should return all URLs up to the root', () => {
@@ -406,3 +406,124 @@ describe('normalizeURL', () => {
);
});
});
describe('decodeURLPath', () => {
it('should decode encoded path components', () => {
const url = new URL('https://docs.mycompany.com/helloworld/tes%74');
const result = decodeURLPath(url);
expect(result.pathname).toBe('/helloworld/test');
expect(result.toString()).toBe('https://docs.mycompany.com/helloworld/test');
});
it('should handle multiple levels of encoding', () => {
// Double encoded: tes%2574 → tes%74 → test
// %2574 decodes as: %25 → %, leaving %74, which then decodes to t
const url = new URL('https://docs.mycompany.com/helloworld/tes%2574');
const result = decodeURLPath(url);
expect(result.pathname).toBe('/helloworld/test');
// Triple encoding (tes%252574) exceeds the 2-pass limit and is rejected
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/tes%252574'));
}).toThrow('URL path is malformed');
});
it('should throw for malformed percent-encoding in the path', () => {
// Invalid hex digits in percent-encoding
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/%ZZ'));
}).toThrow('URL path is malformed');
// Incomplete or invalid UTF-8 sequence
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/%E0%A4%A'));
}).toThrow('URL path is malformed');
// Trailing '%' without two following hex digits
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/trailing%'));
}).toThrow('URL path is malformed');
});
it.skip('should throw an error for invalid characters in the path', () => {
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello:world'));
}).toThrow('URL path contains invalid characters');
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello%3Btest'));
}).toThrow('URL path contains invalid characters');
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello%40anchor'));
}).toThrow('URL path contains invalid characters');
// %20 (space) re-encodes to %20 after decoding, so the path is stable
// and considered fully decoded — it should not throw.
expect(decodeURLPath(new URL('https://docs.mycompany.com/hello%20world')).pathname).toBe(
'/hello%20world'
);
});
it('should limit decoding iterations for nested % encodings to prevent DoS', () => {
// 3+ levels of encoding exceed the 2-pass limit and should throw.
// %25252525 needs 4 passes: %25252525 → %252525 → %2525 → %25 → %
const url = new URL('https://docs.mycompany.com/%25252525');
expect(() => {
decodeURLPath(url);
}).toThrow('URL path is malformed');
const deepUrl = new URL('https://docs.mycompany.com/%2525252525252525');
expect(() => {
decodeURLPath(deepUrl);
}).toThrow('URL path is malformed');
});
it('should throw for URL paths exceeding 2048 characters', () => {
const longPath = '/a'.repeat(1025); // 2050 chars
const url = new URL(`https://docs.mycompany.com${longPath}`);
expect(() => {
decodeURLPath(url);
}).toThrow('URL path is too long');
});
// TODO: should we do that actually?
it.skip('should throw an error if the encoded path contains /', () => {
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello%2Fworld'));
}).toThrow('URL path contains invalid characters');
});
it('should not decode search params or hash fragments', () => {
const url = new URL('https://docs.mycompany.com/helloworld/tes%74?query=%74est#sec%74ion');
const result = decodeURLPath(url);
expect(result.pathname).toBe('/helloworld/test');
expect(result.search).toBe('?query=%74est');
expect(result.hash).toBe('#sec%74ion');
});
it('should not apply the path length limit to a jwt_token query param', () => {
// A massive fake JWT token (header.payload.signature, all base64url)
const fakeJwt = `${'a'.repeat(500)}.${'b'.repeat(500)}.${'c'.repeat(500)}`;
const url = new URL(`https://docs.mycompany.com/short-path?jwt_token=${fakeJwt}`);
// The path itself is well within the limit; only the query param is huge.
expect(url.pathname.length).toBeLessThan(2048);
const result = decodeURLPath(url);
expect(result.pathname).toBe('/short-path');
// The query string must pass through untouched.
expect(result.searchParams.get('jwt_token')).toBe(fakeJwt);
});
it('should not affect rison-encoded query params', () => {
// Rison uses characters such as '(', ')', '!', "'" and ':' that are
// valid inside query strings but would be rejected if found in the path.
const risonValue = "(id:!(1,2,3),name:'hello world',flag:!t)";
const url = new URL(
`https://docs.mycompany.com/some-page?filter=${encodeURIComponent(risonValue)}`
);
const result = decodeURLPath(url);
expect(result.pathname).toBe('/some-page');
// The rison param must survive decodeURLPath intact.
expect(result.searchParams.get('filter')).toBe(risonValue);
});
});
+89
View File
@@ -1,4 +1,5 @@
import { isProxyRootRequest } from '../proxy';
import { DataFetcherError } from './errors';
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
@@ -127,6 +128,94 @@ export function normalizeURL(url: URL) {
return result;
}
/**
* This function checks if a decoded URL path segment contains characters that are not allowed
* in GitBook content paths. These characters are valid in generic RFC 3986 URL paths, but are
* rejected here as an application-level constraint for GitBook routing and security.
* https://developer.mozilla.org/en-US/docs/Glossary/Percent-encoding
* "%" itself is excluded because it could be part of percent-encoding and may require decoding.
*/
// function containsInvalidURLCharacters(segment: string): boolean {
// const invalidCharacters = [
// ':',
// '/',
// '?',
// '#',
// '[',
// ']',
// '@',
// '!',
// '$',
// '&',
// "'",
// '(',
// ')',
// '*',
// '+',
// ',',
// ';',
// '=',
// ];
// return invalidCharacters.some((char) => segment.includes(char));
// }
/**
* Decode the url path component, we redirect URLs with encoded path components
* so that we don't end up with multiple URLs for the same content (especially important because of caching).
* If after decoding the path contains invalid characters, we throw an error.
* We limit the number of decoding passes to 2 to prevent DoS attacks via deeply-nested
* percent-encoding. Legitimate URLs are at most singly encoded; double-encoding covers
* any reasonable proxy behaviour.
*/
export function decodeURLPath(url: URL): URL {
// Reject excessively long paths up-front to bound per-request work.
if (url.pathname.length > 2048) {
throw new DataFetcherError(`URL path is too long: ${url.pathname}`, 400);
}
let current = url;
for (let i = 0; i < 2; i++) {
const decoded = new URL(current);
decoded.pathname = current.pathname
.split('/')
.map((segment) => {
let result: string;
try {
result = decodeURIComponent(segment);
} catch {
throw new DataFetcherError(`URL path is malformed: ${url.pathname}`, 400);
}
// TODO: We should reenable this at one point, we just need to be more careful with what's allowed or not.
// if (containsInvalidURLCharacters(result)) {
// throw new DataFetcherError(
// `URL path contains invalid characters: ${url.pathname}`,
// 400
// );
// }
return result;
})
.join('/');
if (decoded.pathname === current.pathname) {
// Path is stable: the URL parser has re-encoded the decoded characters
// back to their percent-encoded form (e.g. space → %20). This is the
// canonical representation — decoding is complete.
return decoded;
}
current = normalizeURL(decoded);
if (!current.pathname.includes('%')) {
// No remaining encoded characters – decoding is complete.
return current;
}
}
// Still has encoded characters after the maximum number of decoding passes.
throw new DataFetcherError(`URL path is malformed: ${url.pathname}`, 400);
}
/**
* Strip the search params from a URL
*/
@@ -0,0 +1,125 @@
import { describe, expect, it, mock } from 'bun:test';
import type { GitBookAnyContext } from '@/lib/context';
import type { JSONDocument } from '@gitbook/api';
import { type ReactNode, isValidElement } from 'react';
mock.module('./openapi/resolveOpenAPIOperationBlock', () => ({
resolveOpenAPIOperationBlock: async () => ({ data: null }),
}));
mock.module('./openapi/resolveOpenAPISchemasBlock', () => ({
resolveOpenAPISchemasBlock: async () => ({ data: null }),
}));
const { getDocumentSections } = await import('./document-sections');
const context = {
dataFetcher: {
withToken: () => {
throw new Error('not used in this test');
},
},
} as unknown as GitBookAnyContext;
function reactNodeToText(node: ReactNode): string {
if (node == null || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map((child) => reactNodeToText(child)).join('');
}
if (isValidElement<{ children?: ReactNode }>(node)) {
return reactNodeToText(node.props.children);
}
return '';
}
describe('getDocumentSections', () => {
it('extracts headings inside columns', async () => {
const document: JSONDocument = {
object: 'document',
data: { schemaVersion: 2 },
nodes: [
{
object: 'block',
type: 'columns',
data: {},
isVoid: false,
nodes: [
{
object: 'block',
type: 'column',
data: {},
nodes: [
{
object: 'block',
type: 'heading-1',
data: {},
meta: { id: 'h1-in-column' },
nodes: [
{
object: 'text',
leaves: [
{
object: 'leaf',
text: 'Heading 1 in column',
marks: [],
},
],
},
],
},
{
object: 'block',
type: 'heading-2',
data: {},
meta: { id: 'h2-in-column' },
nodes: [
{
object: 'text',
leaves: [
{
object: 'leaf',
text: 'Heading 2 in column',
marks: [],
},
],
},
],
},
],
},
],
},
],
};
const sections = await getDocumentSections(context, document);
expect(
sections.map((section) => ({
id: section.id,
depth: section.depth,
title: reactNodeToText(section.title),
}))
).toEqual([
{
id: 'h1-in-column',
depth: 1,
title: 'Heading 1 in column',
},
{
id: 'h2-in-column',
depth: 2,
title: 'Heading 2 in column',
},
]);
});
});
@@ -66,6 +66,7 @@ async function getSectionsFromNodes(
});
continue;
}
case 'columns':
case 'stepper': {
const stepNodes = await Promise.all(
block.nodes.map(async (step) =>
@@ -0,0 +1,130 @@
import { describe, expect, it, mock } from 'bun:test';
mock.module('@/lib/env/globals', () => ({ GITBOOK_SECRET: 'test-secret-key' }));
const { buildSignedProxyUrl, verifyProxyRequest } = await import('./proxy-token');
describe('buildSignedProxyUrl', () => {
it('returns null for empty hosts', () => {
expect(buildSignedProxyUrl('http://localhost/proxy', [])).toBeNull();
});
it('builds a URL with allowed_origin and token params', () => {
const result = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com']);
expect(result).not.toBeNull();
// biome-ignore lint/style/noNonNullAssertion: test assertion
const url = new URL(result!);
expect(url.searchParams.getAll('allowed_origin')).toEqual(['api.example.com']);
expect(url.searchParams.get('token')).toBeTruthy();
});
it('appends params with & when base URL already has query params', () => {
const result = buildSignedProxyUrl('http://localhost/proxy?existing=1', [
'api.example.com',
]);
expect(result).toContain('?existing=1&');
});
it('deduplicates and sorts hosts', () => {
const result = buildSignedProxyUrl('http://localhost/proxy', [
'b.example.com',
'a.example.com',
'b.example.com',
]);
// biome-ignore lint/style/noNonNullAssertion: test assertion
const url = new URL(result!);
expect(url.searchParams.getAll('allowed_origin')).toEqual([
'a.example.com',
'b.example.com',
]);
});
});
describe('verifyProxyRequest', () => {
it('rejects when no token is provided', () => {
const params = new URLSearchParams();
const result = verifyProxyRequest(params, 'https://api.example.com');
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe('Missing proxy authorization token');
}
});
it('rejects when token is invalid', () => {
const params = new URLSearchParams();
params.set('allowed_origin', 'api.example.com');
params.set('token', 'invalid-token');
const result = verifyProxyRequest(params, 'https://api.example.com/v1/users');
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe('Invalid proxy authorization token');
}
});
it('rejects when target is not in the allowed origins', () => {
// biome-ignore lint/style/noNonNullAssertion: test assertion
const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!;
const params = new URL(signed).searchParams;
const result = verifyProxyRequest(params, 'https://evil.com/hack');
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe('Target URL is not in the allowed origins');
}
});
it('allows when token is valid and host matches', () => {
// biome-ignore lint/style/noNonNullAssertion: test assertion
const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!;
const params = new URL(signed).searchParams;
const result = verifyProxyRequest(params, 'https://api.example.com/v1/users');
expect(result.allowed).toBe(true);
if (result.allowed) {
expect(result.allowedOrigins).toEqual(['api.example.com']);
}
});
it('allows any protocol on an allowed host', () => {
// biome-ignore lint/style/noNonNullAssertion: test assertion
const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!;
const params = new URL(signed).searchParams;
expect(verifyProxyRequest(params, 'https://api.example.com/path').allowed).toBe(true);
expect(verifyProxyRequest(params, 'http://api.example.com/path').allowed).toBe(true);
});
it('supports multiple allowed hosts', () => {
const hosts = ['api.example.com', 'cdn.example.com'];
// biome-ignore lint/style/noNonNullAssertion: test assertion
const signed = buildSignedProxyUrl('http://localhost/proxy', hosts)!;
const params = new URL(signed).searchParams;
expect(verifyProxyRequest(params, 'https://api.example.com/v1').allowed).toBe(true);
expect(verifyProxyRequest(params, 'https://cdn.example.com/spec.json').allowed).toBe(true);
expect(verifyProxyRequest(params, 'https://other.com').allowed).toBe(false);
});
it('rejects a forged token with tampered hosts', () => {
// biome-ignore lint/style/noNonNullAssertion: test assertion
const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!;
const url = new URL(signed);
// Tamper with the allowed origins but keep the original token
url.searchParams.delete('allowed_origin');
url.searchParams.append('allowed_origin', 'evil.com');
const result = verifyProxyRequest(url.searchParams, 'https://evil.com/hack');
expect(result.allowed).toBe(false);
if (!result.allowed) {
expect(result.reason).toBe('Invalid proxy authorization token');
}
});
it('checks path prefix when origin includes a path', () => {
// biome-ignore lint/style/noNonNullAssertion: test assertion
const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com/v1'])!;
const params = new URL(signed).searchParams;
expect(verifyProxyRequest(params, 'https://api.example.com/v1/users').allowed).toBe(true);
expect(verifyProxyRequest(params, 'https://api.example.com/v2/users').allowed).toBe(false);
});
});
@@ -0,0 +1,100 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { GITBOOK_SECRET } from '@/lib/env/globals';
import { extractOrigin } from '@gitbook/react-openapi';
/**
* Sign a list of allowed origins for the OpenAPI proxy.
* Returns null if no signing key is available.
*/
function signOrigins(origins: string[]): string | null {
if (!GITBOOK_SECRET) {
return null;
}
const payload = origins.sort().join('\n');
return createHmac('sha256', GITBOOK_SECRET).update(payload).digest('hex');
}
/**
* Verify a proxy token signature against the allowed origins.
*/
function verifySignature(origins: string[], signature: string): boolean {
const expected = signOrigins(origins);
if (!expected || expected.length !== signature.length) {
return false;
}
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
/**
* Build a signed proxy URL that restricts which origins can be proxied.
* Returns null if no signing key is configured (proxy should be disabled).
*/
export function buildSignedProxyUrl(baseProxyUrl: string, allowedOrigins: string[]): string | null {
const origins = deduplicateAndSort(allowedOrigins);
if (origins.length === 0) {
return null;
}
const signature = signOrigins(origins);
if (!signature) {
return null;
}
const url = new URL(baseProxyUrl);
for (const origin of origins) {
url.searchParams.append('allowed_origin', origin);
}
url.searchParams.set('token', signature);
return url.toString();
}
/**
* Verify the proxy request's signed token and check that the target URL's
* origin is allowed by the signed origins.
*/
export function verifyProxyRequest(
searchParams: URLSearchParams,
targetUrl: string
): { allowed: true; allowedOrigins: string[] } | { allowed: false; reason: string } {
if (!GITBOOK_SECRET) {
return { allowed: false, reason: 'Proxy is disabled: no signing key configured' };
}
const allowedOrigins = searchParams.getAll('allowed_origin');
const token = searchParams.get('token');
if (allowedOrigins.length === 0 || !token) {
return { allowed: false, reason: 'Missing proxy authorization token' };
}
const sorted = deduplicateAndSort(allowedOrigins);
if (!verifySignature(sorted, token)) {
return { allowed: false, reason: 'Invalid proxy authorization token' };
}
// Check that the target URL's host+path matches one of the allowed entries
if (!isAllowedByOrigins(targetUrl, sorted)) {
return {
allowed: false,
reason: 'Target URL is not in the allowed origins',
};
}
return { allowed: true, allowedOrigins: sorted };
}
/**
* Check if a URL's host+path matches one of the allowed origin entries.
*/
export function isAllowedByOrigins(url: string, allowedOrigins: string[]): boolean {
const hostAndPath = extractOrigin(url);
if (!hostAndPath) {
return false;
}
return allowedOrigins.some((allowed) => hostAndPath.startsWith(allowed));
}
function deduplicateAndSort(values: string[]): string[] {
return [...new Set(values)].sort();
}
+25 -1
View File
@@ -30,6 +30,7 @@ import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import { resolvePageId } from './pages';
import { findSiteSpaceBy, getFallbackSiteSpacePath } from './sites';
import { getRevisionTags, resolveTag } from './tags';
import type { ClassValue } from './tailwind';
import { filterOutNullable } from './typescript';
@@ -111,6 +112,8 @@ export async function resolveContentRefInDocument(
}
}
console.log('resolveContentRefInDocument', contentRef, context, options);
return resolveContentRef(contentRef, context, options);
}
@@ -339,10 +342,31 @@ export async function resolveContentRef(
return null;
}
return {
href: openAPISpecVersionContent.url,
// @ts-expect-error - Backward compatibility: `urls.source` replaces `url` in the next API version
href: openAPISpecVersionContent.urls?.source ?? openAPISpecVersionContent.url,
text: contentRef.spec,
active: false,
openAPIFilesystem: openAPISpecVersionContent.filesystem as Filesystem,
// @ts-expect-error - Public URL is not yet supported in the API
publicURL: openAPISpecVersionContent.urls?.public,
};
}
case 'tag': {
if (isContentRefInDifferentSpace(contentRef, context)) {
return resolveContentRefInSpace(contentRef.space, context, contentRef, options);
}
const tag = resolveTag(contentRef.tag, getRevisionTags(revision));
if (!tag) {
return null;
}
return {
href: linker.toPathInSpace(''),
text: tag.label,
active: false,
space,
};
}
+26 -1
View File
@@ -1,5 +1,11 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api';
import type {
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
TranslationLanguage,
} from '@gitbook/api';
import { joinPath } from './paths';
import { flattenSectionsFromGroup } from './utils';
@@ -177,3 +183,22 @@ function findSiteSpaceByIdInSiteSpaces(
): SiteSpace | null {
return siteSpaces.find(predicate) ?? null;
}
/**
* Get the appropriate title for a siteSection.
* Uses localizedTitle if available and current language is provided, otherwise uses title.
*/
export function getSiteSectionTitle(
siteSection: SiteSection,
currentLanguage: TranslationLanguage | undefined
): string {
if (
siteSection.localizedTitle &&
currentLanguage &&
siteSection.localizedTitle[currentLanguage]
) {
return siteSection.localizedTitle[currentLanguage];
}
return siteSection.title;
}
-1
View File
@@ -4,7 +4,6 @@ import type { ContentRefTag, Revision, RevisionTag } from '@gitbook/api';
* Get the tags defined on a revision.
*/
export function getRevisionTags(revision: Revision | undefined): RevisionTag[] {
// TODO: simplify once new API lands as `tags` will always be defined.
return revision?.tags ?? [];
}

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