mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 11:52:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c87cdc8af4 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/embed": minor
|
||||
---
|
||||
|
||||
Support `assistantName` property to override Assistant name
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add backward compatibility for OpenAPI spec content URL
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Support x-enable-proxy at operation level
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track opening of search results for records and in the assistant.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Refactor embeddable context to merge local & site properties in one unified way
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Move ~scalar/proxy route to site route group
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add token verification on OpenAPI proxy
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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,7 +53,6 @@ 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
|
||||
@@ -109,7 +108,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
|
||||
|
||||
@@ -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,7 +52,6 @@ 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
|
||||
|
||||
@@ -136,7 +136,6 @@ 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:
|
||||
@@ -161,7 +160,6 @@ 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:
|
||||
@@ -186,7 +184,6 @@ 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:
|
||||
@@ -211,7 +208,6 @@ 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:
|
||||
@@ -235,7 +231,6 @@ 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)
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"name": "gitbook",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.8.13",
|
||||
"vercel": "^50.26.1",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"turbo": "^2.8.6",
|
||||
"vercel": "^50.15.1",
|
||||
},
|
||||
},
|
||||
"packages/browser-types": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"packages/embed": {
|
||||
"name": "@gitbook/embed",
|
||||
"version": "0.3.0",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.22.2",
|
||||
"version": "0.22.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
@@ -188,7 +188,6 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zustand": "^5.0.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -244,7 +243,7 @@
|
||||
},
|
||||
"packages/openapi-parser": {
|
||||
"name": "@gitbook/openapi-parser",
|
||||
"version": "3.0.9",
|
||||
"version": "3.0.8",
|
||||
"dependencies": {
|
||||
"@scalar/json-magic": "^0.9.4",
|
||||
"@scalar/openapi-parser": "^0.24.5",
|
||||
@@ -303,7 +302,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.5.9",
|
||||
"version": "1.5.8",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -348,7 +347,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.167.0",
|
||||
"@gitbook/api": "0.164.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -547,21 +546,21 @@
|
||||
|
||||
"@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="],
|
||||
|
||||
"@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/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/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.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/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/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/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/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.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-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-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
|
||||
|
||||
@@ -569,11 +568,11 @@
|
||||
|
||||
"@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="],
|
||||
|
||||
"@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="],
|
||||
"@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/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.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/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/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=="],
|
||||
|
||||
@@ -745,7 +744,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.167.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9A9Bsgi2aRbGf3DPejK4YQh9M/+grtNymFwUkK3B3N4m7VOxcUMQ2HaV7s51mtrBdWKcEdeFm4LEnEaAGHcM1A=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.164.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-u0npCEK253qSbn118YMMi1E5dqrLQnBlXnovXOpbzQP8BF62ktrndnhtH8AhTvV4ld9UHDKjCaxxidmtuYoU9g=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -1701,61 +1700,61 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="],
|
||||
|
||||
"@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/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/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.6.2", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-KiwRUd2x1ZHm73p+/hfdVWZHWXT8r+oN/5ZN1lJyb41AGK4Zhrug6X60jNyVhnw6GjpwBuPTBcys29cCsPjkWQ=="],
|
||||
"@vercel/build-utils": ["@vercel/build-utils@13.5.0", "", { "dependencies": { "@vercel/python-analysis": "0.7.0" } }, "sha512-hDSJbpw7KFlX5d/L7e6x5pDfln7puE6EPiy4y+19zE0yBTNuO/d8tmpMPamt6uzzIKTT8tuAdef+QDVzGshaOw=="],
|
||||
|
||||
"@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/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/detect-agent": ["@vercel/detect-agent@1.1.0", "", {}, "sha512-Zfq6FbIcYl9gaAmVu6ROsqUiCNwpEj3Ljz/tMX5fl12Z95OFOxzf7vlO03WE5JBU/ri1tBDFHnW41dihMINOPQ=="],
|
||||
|
||||
"@vercel/elysia": ["@vercel/elysia@0.1.43", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-Q5XCGVMuO0XF2n/XEEP43d+AIk8Yl09NwJmJ0m9w5Mx0Pyacb1Yo6dcbn6vnsj4j50dpYGeejdaqu77xOKi8mQ=="],
|
||||
"@vercel/elysia": ["@vercel/elysia@0.1.40", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-AuKj7joDY8pc4V5d17zg8xgjVDvz3oRULqSHp4Rh6xkAqeDEw5CRExkbsxZe8/7UD5PJxsuJ8f5lOs0TfXbeoQ=="],
|
||||
|
||||
"@vercel/error-utils": ["@vercel/error-utils@2.0.3", "", {}, "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ=="],
|
||||
|
||||
"@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/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/fastify": ["@vercel/fastify@0.1.46", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-R8uSV3SSbFJ39o3VAfsKwlWOPGDfQyNXxJ2dT8Z05GQ4O3qgGTcRd/oEothQZgnJR9fjGzDQ3q7SwWBd12150A=="],
|
||||
"@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/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.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/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/go": ["@vercel/go@3.4.3", "", {}, "sha512-LDT4wpx7SW2UJHd3rOL4+2m2V4w7a5zjyuU0pu0yDBiuxp9bEh7QuAH5qZC7pINGLC3vGftiFVzynd5N454sVA=="],
|
||||
"@vercel/go": ["@vercel/go@3.4.2", "", {}, "sha512-+Hd2W701PWajQHiPSQ+jrjNUXLbwHYRXUkzv0zqMTySQzwdvls/wnr34VYn9tyzw2YxAzHogBpSNyC1c9TDEVg=="],
|
||||
|
||||
"@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/h3": ["@vercel/h3@0.1.49", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-x9QVqRVzMyWSHwXyNBQs2DMnmg3PeCPg+iWzpzs0LhnDVUBsU5sxSdswQiTxw3FjM+lMY0FQdEX0SkTGntda8Q=="],
|
||||
|
||||
"@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/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/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.26", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-TzH4xovhGbr2kmZyvMraaJ3A28qRdo8SxkBO+LIb3ahM739VyTAkH3EYR5CPaemkRv00+nTyBxJJxfnhP1n2MA=="],
|
||||
"@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/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/nestjs": ["@vercel/nestjs@0.2.44", "", { "dependencies": { "@vercel/node": "5.6.7", "@vercel/static-config": "3.1.2" } }, "sha512-KBy2NllhRgFHvCtUh+IPNpO1BElm6mlOfjWXdG7HSyNBNQXCd7HXKcCr6PU9Ge1xOwlebx12PoIvBdzZCX59kw=="],
|
||||
|
||||
"@vercel/next": ["@vercel/next@4.15.39", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-E3Wnn5Wfxa7e2RvguE4YNO8OBoOVZhnRXDRfn5jJ9ct+GVRZDgPS9VdXK3eQazMJUCLmQCKdyTca/I57ImPNpQ=="],
|
||||
"@vercel/next": ["@vercel/next@4.15.34", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-5nohAM10Z5mmKiT8q1bn/KongZW807iEkxjD5VClHduyJmuexeLCrmnLyUlD8MBBGMmL429OdECiSNL3HH2JzQ=="],
|
||||
|
||||
"@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.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/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/python": ["@vercel/python@6.20.1", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-a/VgUJ/N7SfKgE2fen6iaNow/nWEGEQj57SNyPwo4emvPfO1dPxUlZWyAEdctfXYKjnJR2WAU2kYcCDw+zenZg=="],
|
||||
"@vercel/python": ["@vercel/python@6.16.1", "", { "dependencies": { "@vercel/python-analysis": "0.7.0" } }, "sha512-41P5yZtYKDBJxQwKY7xDwBBEMqZHN5mECyH8gxhSpcMmue+1XTkiA32DKCvFWSK94+AVUE9Ev59Q86aDem5cwQ=="],
|
||||
|
||||
"@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/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/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.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/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/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.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-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-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=="],
|
||||
|
||||
@@ -1941,6 +1940,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3143,7 +3144,7 @@
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -3327,19 +3328,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.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": ["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-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="],
|
||||
|
||||
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||
|
||||
@@ -3407,7 +3408,7 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
@@ -3503,7 +3504,7 @@
|
||||
|
||||
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -3511,8 +3512,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4027,10 +4026,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4129,14 +4124,10 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4777,8 +4768,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4805,8 +4794,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4823,8 +4810,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4877,6 +4862,8 @@
|
||||
|
||||
"@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
@@ -6,9 +6,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.8.13",
|
||||
"vercel": "^50.26.1"
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"turbo": "^2.8.10",
|
||||
"vercel": "^50.23.2"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
"overrides": {
|
||||
@@ -41,7 +41,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.167.0",
|
||||
"@gitbook/api": "0.164.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 370b731: Docs Embed: Make trademark optional
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -49,7 +49,6 @@ 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,
|
||||
@@ -101,7 +100,6 @@ 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
|
||||
@@ -128,7 +126,6 @@ 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={[
|
||||
{
|
||||
@@ -272,19 +269,6 @@ 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
|
||||
@@ -331,18 +315,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.3.0",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -69,7 +69,6 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -62,23 +62,12 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -25,9 +25,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
suggestions = [],
|
||||
tools = [],
|
||||
tabs = ['assistant', 'docs'],
|
||||
trademark = true,
|
||||
closeButton = false,
|
||||
assistantName,
|
||||
} = props;
|
||||
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -50,20 +48,8 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
suggestions,
|
||||
tools,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
});
|
||||
}, [
|
||||
gitbookFrame,
|
||||
actions,
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
tabs,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
]);
|
||||
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs, closeButton]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
|
||||
@@ -65,7 +65,6 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
@@ -157,19 +156,6 @@ 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,
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -162,7 +162,7 @@ const searchTestCases: Test[] = [
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill(AI_PROMPT);
|
||||
@@ -991,11 +991,6 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/columns',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Mermaid',
|
||||
url: 'blocks/mermaid',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2320,7 +2315,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-confirm-accept')
|
||||
.getByTestId('ai-chat-tool-confirmation')
|
||||
.first();
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.22.2",
|
||||
"version": "0.22.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -80,7 +80,6 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
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];
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
}
|
||||
-22
@@ -1,11 +1,7 @@
|
||||
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';
|
||||
@@ -35,24 +31,6 @@ 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,15 +21,16 @@ 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: createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
}),
|
||||
linker,
|
||||
dataFetcher,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export type AIConfig = {
|
||||
aiMode: CustomizationAIMode;
|
||||
suggestions?: string[];
|
||||
trademark: boolean;
|
||||
assistantName?: string;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -55,10 +54,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, assistantName, children } = props;
|
||||
const { aiMode, trademark, suggestions, greeting, children } = props;
|
||||
const value = React.useMemo(
|
||||
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
|
||||
[aiMode, trademark, suggestions, greeting, assistantName]
|
||||
() => ({ aiMode, trademark, suggestions, greeting }),
|
||||
[aiMode, trademark, suggestions, greeting]
|
||||
);
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
@@ -92,7 +91,7 @@ export function useAI(): AIContext {
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
label: getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
|
||||
@@ -9,15 +9,13 @@ import {
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
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 = {
|
||||
@@ -26,6 +24,21 @@ 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.
|
||||
@@ -58,9 +71,9 @@ export type AIChatState = {
|
||||
followUpSuggestions: string[];
|
||||
|
||||
/**
|
||||
* Control to be displayed to the user.
|
||||
* Tools that are pending confirmation to be executed.
|
||||
*/
|
||||
control: AnyAIControl | null;
|
||||
pendingTools: AIChatPendingTool[];
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
@@ -114,7 +127,7 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
pendingTools: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
@@ -200,7 +213,7 @@ export function AIChatProvider(props: {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
pendingTools: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -215,10 +228,10 @@ export function AIChatProvider(props: {
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools();
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
@@ -251,7 +264,7 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools();
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
@@ -261,10 +274,9 @@ export function AIChatProvider(props: {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
},
|
||||
tools: tools.map((tool) => ({
|
||||
tools: integrationTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
@@ -308,84 +320,46 @@ export function AIChatProvider(props: {
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
const toolDef = integrationTools.find(
|
||||
(tool) => tool.name === event.toolCall.tool
|
||||
);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
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;
|
||||
const confirmation = toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: {
|
||||
label: confirmation.label,
|
||||
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);
|
||||
},
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}));
|
||||
break;
|
||||
} else {
|
||||
toolToExecute = event;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -434,11 +408,7 @@ export function AIChatProvider(props: {
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, control } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
const { query, messages, pendingTools } = globalState.getState();
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
@@ -483,9 +453,15 @@ export function AIChatProvider(props: {
|
||||
};
|
||||
});
|
||||
|
||||
streamResponse({ message: input.message });
|
||||
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 } : {}),
|
||||
});
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse, language]
|
||||
[setSearchState, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
@@ -496,7 +472,7 @@ export function AIChatProvider(props: {
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
pendingTools: [],
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
|
||||
@@ -28,7 +28,6 @@ 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';
|
||||
@@ -92,7 +91,7 @@ export function AIChat() {
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{config.assistantName ?? getAIChatName(language, config.trademark)}
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
@@ -112,7 +111,6 @@ export function AIChat() {
|
||||
chatController={chatController}
|
||||
chat={chat}
|
||||
suggestions={config.suggestions}
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
@@ -143,7 +141,7 @@ export function AIChatDynamicIcon(props: {
|
||||
? 'working'
|
||||
: 'thinking'
|
||||
: chat.messages.length > 0
|
||||
? chat.control
|
||||
? chat.pendingTools.length > 0
|
||||
? 'confirm'
|
||||
: 'done'
|
||||
: 'default'
|
||||
@@ -201,13 +199,13 @@ export function AIChatBody(props: {
|
||||
chat: AIChatState;
|
||||
welcomeMessage?: string;
|
||||
suggestions?: string[];
|
||||
trademark?: boolean;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
}) {
|
||||
const { chatController, chat, suggestions, greeting, trademark } = props;
|
||||
const { chatController, chat, suggestions, greeting } = props;
|
||||
const { trademark } = useAI().config;
|
||||
|
||||
const language = useLanguage();
|
||||
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
||||
@@ -225,7 +223,7 @@ export function AIChatBody(props: {
|
||||
return (
|
||||
<>
|
||||
<ScrollContainer
|
||||
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
|
||||
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4"
|
||||
orientation="vertical"
|
||||
trailing={{ fade: false, button: true }}
|
||||
@@ -274,21 +272,17 @@ export function AIChatBody(props: {
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 pb-4">
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
{chat.control ? (
|
||||
<AIChatControl control={chat.control} />
|
||||
) : (
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { AnyAIControl } from '../AI/controls';
|
||||
|
||||
export function AIChatControl(props: { control: AnyAIControl }) {
|
||||
const { control } = props;
|
||||
return control.render();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
@@ -66,6 +67,7 @@ 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'
|
||||
@@ -94,13 +96,14 @@ export function AIChatMessages(props: {
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.control ? (
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'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">
|
||||
This toolbar appears only when signed in to GitBook.
|
||||
Only visible to your GitBook organization
|
||||
</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,7 +12,6 @@ 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,
|
||||
@@ -33,15 +32,8 @@ 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,12 +1047,4 @@ 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={['min-w-0 flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
|
||||
style={['flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,9 @@ export async function RecordCard(
|
||||
'gap-3',
|
||||
'p-4',
|
||||
'text-sm',
|
||||
'text-tint-strong'
|
||||
target
|
||||
? ['transition-colors', 'text-tint', 'group-hover:text-tint-strong']
|
||||
: ['text-tint-strong']
|
||||
)}
|
||||
>
|
||||
{view.columns.map((column) => {
|
||||
@@ -170,7 +172,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', 'RecordCardLinkStyles']}>
|
||||
<LinkBox href={target.href} classNames={['RecordCardStyles']}>
|
||||
<LinkOverlay
|
||||
href={target.href}
|
||||
insights={{
|
||||
|
||||
@@ -21,6 +21,6 @@ export const RecordCardStyles = [
|
||||
'before:ring-tint-12/2',
|
||||
'before:z-10',
|
||||
'before:relative',
|
||||
] as ClassValue;
|
||||
|
||||
export const RecordCardLinkStyles = ['hover:before:ring-primary-hover'] as ClassValue;
|
||||
'hover:before:ring-tint-12/5',
|
||||
] 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,8 +102,7 @@ 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-words',
|
||||
'hyphens-none'
|
||||
'break-all'
|
||||
)}
|
||||
>
|
||||
{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} tag={tag} />
|
||||
<Tag key={tag.slug} label={tag.label} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -41,9 +41,9 @@ type EmbeddableAIChatProps = {
|
||||
export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
const { baseURL, siteTitle } = props;
|
||||
const chat = useAIChatState();
|
||||
const { config: siteConfig } = useAI();
|
||||
const { config } = useAI();
|
||||
const chatController = useAIChatController();
|
||||
const embedConfig = useEmbeddableConfiguration();
|
||||
const configuration = useEmbeddableConfiguration();
|
||||
const language = useLanguage();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -65,8 +65,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
}, [trackEvent]);
|
||||
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasDocsTab = embedConfig.tabs.includes('docs');
|
||||
const trademark = siteConfig.trademark;
|
||||
const hasDocsTab = configuration.tabs.includes('docs');
|
||||
const currentLinkContext = use(LinkContext);
|
||||
const linkContext: LinkContextType = useMemo(
|
||||
() =>
|
||||
@@ -96,11 +95,14 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<EmbeddableFrameHeader>
|
||||
{!tabsRef.current ? (
|
||||
<AIChatDynamicIcon className="animate-blur-in-slow" trademark={trademark} />
|
||||
<AIChatDynamicIcon
|
||||
className="animate-blur-in-slow"
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
) : null}
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{siteConfig.assistantName ?? getAIChatName(language, trademark)}
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
@@ -113,9 +115,8 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
<AIChatBody
|
||||
chatController={chatController}
|
||||
chat={chat}
|
||||
suggestions={siteConfig.suggestions}
|
||||
greeting={siteConfig.greeting}
|
||||
trademark={trademark}
|
||||
suggestions={configuration.suggestions}
|
||||
greeting={configuration.greeting}
|
||||
/>
|
||||
</LinkContext>
|
||||
</EmbeddableFrameBody>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
'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,7 +17,6 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() =
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
trademark: true,
|
||||
}));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: expected
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AIContextProvider } from '@/components/AI';
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import {
|
||||
SiteLayoutClientContexts,
|
||||
@@ -10,9 +11,7 @@ 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;
|
||||
@@ -41,7 +40,7 @@ export async function EmbeddableRootLayout({
|
||||
contextId={context.contextId}
|
||||
proxyOrigin={context.site.proxy?.origin}
|
||||
>
|
||||
<EmbeddableAIContextProvider
|
||||
<AIContextProvider
|
||||
aiMode={context.customization.ai.mode}
|
||||
suggestions={context.customization.ai.suggestions}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
@@ -59,20 +58,18 @@ export async function EmbeddableRootLayout({
|
||||
<div className="fixed inset-0 flex flex-col">
|
||||
{children}
|
||||
{context.customization.trademark.enabled ? (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<EmbeddableIframeAPI
|
||||
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||
/>
|
||||
</SpaceLayoutServerContext>
|
||||
</EmbeddableAIContextProvider>
|
||||
</AIContextProvider>
|
||||
</SiteLayoutClientContexts>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
'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 { SocialAccountButton } from './SocialAccounts';
|
||||
import { SocialLink } from './SocialLink';
|
||||
|
||||
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) => (
|
||||
<SocialAccountButton
|
||||
<SocialLink
|
||||
key={`${account.platform}-${account.handle}`}
|
||||
account={account}
|
||||
target={customization.externalLinks.target}
|
||||
|
||||
+3
-22
@@ -4,7 +4,7 @@ import {
|
||||
SiteSocialAccountPlatform,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { Button, type ButtonProps, DropdownMenuItem } from '../primitives';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
type SocialPlatformData = {
|
||||
label: string;
|
||||
@@ -86,10 +86,8 @@ const SOCIAL_PLATFORMS: Record<SiteSocialAccountPlatform, SocialPlatformData> =
|
||||
},
|
||||
};
|
||||
|
||||
export function SocialAccountButton(
|
||||
props: { account: SiteSocialAccount; target: SiteExternalLinksTarget } & ButtonProps
|
||||
) {
|
||||
const { account, target, ...rest } = props;
|
||||
export function SocialLink(props: { account: SiteSocialAccount; target: SiteExternalLinksTarget }) {
|
||||
const { account, target } = props;
|
||||
const platform = SOCIAL_PLATFORMS[account.platform];
|
||||
|
||||
return (
|
||||
@@ -101,23 +99,6 @@ export function SocialAccountButton(
|
||||
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,7 +4,6 @@ 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';
|
||||
@@ -34,10 +33,6 @@ 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
|
||||
@@ -163,45 +158,29 @@ 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.map((link) => {
|
||||
return (
|
||||
<HeaderLink
|
||||
key={link.title}
|
||||
link={link}
|
||||
context={context}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{headerSocialAccounts.length > 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{headerSocialAccounts.map((account) => {
|
||||
{customization.header.links.length > 0 ? (
|
||||
<>
|
||||
{customization.header.links.map((link) => {
|
||||
return (
|
||||
<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"
|
||||
<HeaderLink
|
||||
key={link.title}
|
||||
link={link}
|
||||
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}
|
||||
/>
|
||||
<HeaderLinkMore
|
||||
label={t(getSpaceLanguage(context), 'more')}
|
||||
links={customization.header.links}
|
||||
context={context}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{!withSections && variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type CustomizationContentLink,
|
||||
type CustomizationHeaderItem,
|
||||
SiteInsightsLinkPosition,
|
||||
type SiteSocialAccount,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
@@ -11,14 +10,8 @@ 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,
|
||||
DropdownMenuSeparator,
|
||||
DropdownSubMenu,
|
||||
} from '../primitives/DropdownMenu';
|
||||
import { DropdownMenu, DropdownMenuItem, DropdownSubMenu } from '../primitives/DropdownMenu';
|
||||
import styles from './headerLinks.module.css';
|
||||
|
||||
/**
|
||||
@@ -27,10 +20,9 @@ import styles from './headerLinks.module.css';
|
||||
export function HeaderLinkMore(props: {
|
||||
label: React.ReactNode;
|
||||
links: CustomizationHeaderItem[];
|
||||
socialAccounts: SiteSocialAccount[];
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { label, links, context, socialAccounts } = props;
|
||||
const { label, links, context } = props;
|
||||
|
||||
const renderButton = (
|
||||
<button
|
||||
@@ -65,13 +57,6 @@ 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 > *:nth-child(n + 1) {
|
||||
& div > a:nth-of-type(n + 1) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 2) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > *:nth-child(n + 2) {
|
||||
& div > a:nth-of-type(n + 2) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 3) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > *:nth-child(n + 3) {
|
||||
& div > a:nth-of-type(n + 3) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 4) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > *:nth-child(n + 4) {
|
||||
& div > a:nth-of-type(n + 4) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 5) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > *:nth-child(n + 5) {
|
||||
& div > a:nth-of-type(n + 5) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 6) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > *:nth-child(n + 6) {
|
||||
& div > a:nth-of-type(n + 6) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
getBrowserCookie,
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from '@/lib/browser';
|
||||
import { getBrowserCookie } from '@/lib/browser';
|
||||
|
||||
import type { MaybePromise } from 'p-map';
|
||||
import React from 'react';
|
||||
@@ -15,7 +10,6 @@ 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.
|
||||
@@ -32,55 +26,13 @@ type AnyVisitorResponse = {
|
||||
/**
|
||||
* Visitor state when the visitor is also a signed-in GitBook user.
|
||||
*/
|
||||
type VisitorUserResponse = Pick<AnyVisitorResponse, 'deviceId'> & {
|
||||
type VisitorUserResponse = AnyVisitorResponse & {
|
||||
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;
|
||||
@@ -101,19 +53,16 @@ export function VisitorProvider(
|
||||
const { appURL, visitorCookieTrackingEnabled, children } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
const result = getGlobalVisitor({ appURL, visitorCookieTrackingEnabled });
|
||||
visitorStore.setState(result);
|
||||
|
||||
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 });
|
||||
});
|
||||
const state = visitorStore.getState();
|
||||
if (state.pendingVisitor || state.visitor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingVisitor = fetchGlobalVisitor({ appURL, visitorCookieTrackingEnabled });
|
||||
visitorStore.setState({ pendingVisitor, visitor: null });
|
||||
pendingVisitor.then((visitor) => {
|
||||
visitorStore.setState({ pendingVisitor: null, visitor });
|
||||
});
|
||||
}, [appURL, visitorCookieTrackingEnabled]);
|
||||
|
||||
return <>{children}</>;
|
||||
@@ -144,78 +93,51 @@ export function getVisitor(): MaybePromise<VisitorResponse> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose a visitor identifier to the GitBook.com server and get the deviceId back.
|
||||
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
|
||||
*/
|
||||
function getGlobalVisitor({
|
||||
async function fetchGlobalVisitor({
|
||||
appURL,
|
||||
visitorCookieTrackingEnabled,
|
||||
}: {
|
||||
appURL: string;
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
}): {
|
||||
visitor: VisitorResponse | null;
|
||||
pendingVisitor: Promise<VisitorResponse> | null;
|
||||
} {
|
||||
}): Promise<VisitorResponse> {
|
||||
const withoutCookies = isCookiesTrackingDisabled();
|
||||
|
||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
||||
return { visitor: { deviceId: getProposedVisitorId() }, pendingVisitor: null };
|
||||
return {
|
||||
deviceId: getProposedVisitorId(),
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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 };
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
return existing;
|
||||
}
|
||||
|
||||
return { visitor: null, pendingVisitor: fetchGlobalVisitor() };
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,16 +63,12 @@ describe('createPDFLinker', () => {
|
||||
|
||||
it('keeps links to 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',
|
||||
host: 'docs.vectra.ai',
|
||||
siteBasePath: '/',
|
||||
spaceBasePath: '/deployment',
|
||||
});
|
||||
|
||||
const linker = createPDFLinker(
|
||||
baseLinker,
|
||||
[{ page: createDocumentPage('included', '') }],
|
||||
'https://docs.vectra.ai/deployment/'
|
||||
);
|
||||
const linker = createPDFLinker(baseLinker, [{ page: createDocumentPage('included', '') }]);
|
||||
|
||||
expect(
|
||||
linker.toPathForPage({
|
||||
@@ -85,31 +81,6 @@ 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, baseContext.space.urls.published);
|
||||
const linker = createPDFLinker(baseContext.linker, pages);
|
||||
|
||||
const context: GitBookSpaceContext = {
|
||||
...baseContext,
|
||||
|
||||
@@ -2,8 +2,6 @@ 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.
|
||||
@@ -25,19 +23,16 @@ 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 URLs pointing to the published site if `publishedSpaceURL` is
|
||||
* provided, preserving navigability for external content. Otherwise, it uses
|
||||
* absolute URLs from the base linker.
|
||||
* falls back to absolute URLs pointing to the published site, preserving
|
||||
* navigability for external content.
|
||||
*
|
||||
* @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] }>,
|
||||
publishedSpaceURL?: string
|
||||
pages: Array<{ page: Revision['pages'][number] }>
|
||||
): GitBookLinker {
|
||||
return {
|
||||
...baseLinker,
|
||||
@@ -50,16 +45,6 @@ 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} tag={resolved} />;
|
||||
return <Tag key={resolved.slug} label={resolved.label} />;
|
||||
})}
|
||||
</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, ScrollPage } from '../hooks';
|
||||
import { NavigationStatusProvider } from '../hooks';
|
||||
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,6 @@ export function RootLayoutClientContexts(props: {
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<NavigationStatusProvider>
|
||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
||||
<ScrollPage />
|
||||
</NavigationStatusProvider>
|
||||
</TooltipProvider>
|
||||
</TranslateContext.Provider>
|
||||
|
||||
@@ -42,9 +42,6 @@ interface SearchContainerProps {
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
viewport?: 'desktop' | 'mobile';
|
||||
|
||||
/** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */
|
||||
searchURL: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +57,6 @@ export function SearchContainer({
|
||||
className,
|
||||
viewport,
|
||||
siteSpaces,
|
||||
searchURL,
|
||||
}: SearchContainerProps) {
|
||||
const { assistants, config } = useAI();
|
||||
|
||||
@@ -210,7 +206,6 @@ 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 './search-types';
|
||||
import type { ComputedPageResult } from './server-actions';
|
||||
|
||||
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 './search-types';
|
||||
import type { ComputedRecordResult } from './server-actions';
|
||||
|
||||
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 './search-types';
|
||||
import type { OrderedComputedResult } from './server-actions';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
select(): void;
|
||||
@@ -77,6 +77,13 @@ 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
|
||||
@@ -123,7 +130,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
<div className={tcls('min-h-full')}>
|
||||
{children}
|
||||
{results.length === 0 ? (
|
||||
fetching ? null : query ? (
|
||||
query ? (
|
||||
noResults
|
||||
) : (
|
||||
<div className="empty" />
|
||||
@@ -220,16 +227,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{!fetching && !results.some((result) => result.type !== 'question')
|
||||
? noResults
|
||||
: null}
|
||||
{!results.some((result) => result.type !== 'question') && noResults}
|
||||
</>
|
||||
)}
|
||||
{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 './search-types';
|
||||
import type { ComputedSectionResult } from './server-actions';
|
||||
|
||||
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
|
||||
props: {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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 { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookBaseContext, GitBookSiteContext } from '@/lib/context';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
@@ -10,6 +10,12 @@ 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';
|
||||
@@ -18,8 +24,46 @@ 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;
|
||||
@@ -162,6 +206,88 @@ 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,
|
||||
{
|
||||
@@ -231,3 +357,73 @@ 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,8 +3,11 @@ import React from 'react';
|
||||
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import type { OrderedComputedResult } from './search-types';
|
||||
import { streamRecommendedQuestions } from './server-actions';
|
||||
import {
|
||||
type OrderedComputedResult,
|
||||
searchSiteContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
|
||||
import { type Assistant, useAI } from '@/components/AI';
|
||||
import assertNever from 'assert-never';
|
||||
@@ -33,10 +36,8 @@ 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, searchURL } = props;
|
||||
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions } = props;
|
||||
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
@@ -133,34 +134,28 @@ export function useSearchResults(props: {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
setResultsState({
|
||||
results: withAI ? withAskTriggers([], query, assistants) : [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
setResultsState((prev) => ({ results: prev.results, 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 fetchSearch({ mode: 'all' });
|
||||
return searchSiteContent({ query, mode: 'all' });
|
||||
case 'default':
|
||||
// Search the current section's variant + matched/default variant for other sections
|
||||
return fetchSearch({ mode: 'current', siteSpaceId });
|
||||
return searchSiteContent({ query, mode: 'current', siteSpaceId });
|
||||
case 'extended':
|
||||
// Search all variants of the current section
|
||||
return fetchSearch({ mode: 'specific', siteSpaceIds });
|
||||
return searchSiteContent({ query, mode: 'specific', siteSpaceIds });
|
||||
case 'current':
|
||||
// Search only the current section's current variant
|
||||
return fetchSearch({ mode: 'specific', siteSpaceIds: [siteSpaceId] });
|
||||
return searchSiteContent({
|
||||
query,
|
||||
mode: 'specific',
|
||||
siteSpaceIds: [siteSpaceId],
|
||||
});
|
||||
default:
|
||||
assertNever(scope);
|
||||
}
|
||||
@@ -200,52 +195,12 @@ export function useSearchResults(props: {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
abortController.abort();
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
scope,
|
||||
trackEvent,
|
||||
withAI,
|
||||
siteSpaceId,
|
||||
siteSpaceIds,
|
||||
disabled,
|
||||
suggestions,
|
||||
searchURL,
|
||||
]);
|
||||
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds, disabled, suggestions]);
|
||||
|
||||
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, useScrollToHash } from '@/components/hooks';
|
||||
import { currentPageMetadataStore, useScrollPage } 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
|
||||
useScrollToHash();
|
||||
useScrollPage();
|
||||
|
||||
// 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,7 +17,6 @@ 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';
|
||||
@@ -126,7 +125,6 @@ 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 (
|
||||
@@ -134,7 +132,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(getSiteSectionTitle(sections.current, currentLanguage));
|
||||
title.push(sections.current.title);
|
||||
}
|
||||
if (
|
||||
siteSpaces.length > 1 && // Only if there are multiple variants
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
|
||||
import { getSectionURL, getSiteSectionTitle, getSiteSpaceURL } from '@/lib/sites';
|
||||
import { getSectionURL, getSiteSpaceURL } from '@/lib/sites';
|
||||
import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
@@ -99,10 +99,9 @@ function encodeChildren(
|
||||
}
|
||||
|
||||
function encodeSection(context: GitBookSiteContext, section: SiteSection) {
|
||||
const currentLanguage = context.siteSpace.space.language;
|
||||
return {
|
||||
id: section.id,
|
||||
title: getSiteSectionTitle(section, currentLanguage),
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
icon: section.icon,
|
||||
object: section.object,
|
||||
|
||||
@@ -201,9 +201,6 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={visibleSiteSpaces}
|
||||
viewport="desktop"
|
||||
searchURL={context.linker.toPathInSpace(
|
||||
'~gitbook/search'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -38,10 +38,12 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
icon={<TOCPageIcon page={page} />}
|
||||
tag={page.primaryTag ? <Tag tag={page.primaryTag} /> : null}
|
||||
>
|
||||
{page.title}
|
||||
<TOCPageIcon page={page} />
|
||||
<span className="flex items-center gap-2">
|
||||
{page.title}
|
||||
{page.primaryTag ? <Tag label={page.primaryTag.label} /> : null}
|
||||
</span>
|
||||
</ToggleableLinkItem>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -87,9 +87,7 @@ 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,11 +13,9 @@ export function ToggleableLinkItem(
|
||||
pathnames: string[];
|
||||
children: React.ReactNode;
|
||||
descendants: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
tag?: React.ReactNode;
|
||||
} & LinkInsightsProps
|
||||
) {
|
||||
const { href, children, descendants, pathnames, insights, icon, tag } = props;
|
||||
const { href, children, descendants, pathnames, insights } = props;
|
||||
|
||||
const currentPagePath = useCurrentPagePath();
|
||||
const isActive = pathnames.some((pathname) => pathname === currentPagePath);
|
||||
@@ -45,15 +43,7 @@ export function ToggleableLinkItem(
|
||||
if (!descendants) {
|
||||
return (
|
||||
<LinkItem href={href} insights={insights} isActive={isActive}>
|
||||
{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
|
||||
)}
|
||||
{children}
|
||||
</LinkItem>
|
||||
);
|
||||
}
|
||||
@@ -68,21 +58,8 @@ export function ToggleableLinkItem(
|
||||
isActive={isActive}
|
||||
onActiveClick={() => handleToggle(!isOpen)}
|
||||
>
|
||||
{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}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
{toggler}
|
||||
</LinkItem>
|
||||
{descendants}
|
||||
</>
|
||||
|
||||
@@ -1,47 +1,22 @@
|
||||
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: { tag: RevisionTag; className?: string }) {
|
||||
const { tag, className } = props;
|
||||
const hasColor = tag.color !== 'default';
|
||||
export function Tag(props: { label: RevisionTag['label']; className?: string }) {
|
||||
const { label, className } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tag=""
|
||||
title={tag.label}
|
||||
title={label}
|
||||
className={tcls(
|
||||
'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',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TagIcon tag={tag} />
|
||||
<span className="truncate">{tag.label}</span>
|
||||
<span className="truncate">{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,7 +27,16 @@ function getHash(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.location.hash.slice(1);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
|
||||
@@ -2,92 +2,38 @@
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if it's an initial page load outside of React tree.
|
||||
* Scroll the page to the hash or reset scroll to the top.
|
||||
* Only triggered while navigating in the app, not for initial load.
|
||||
*/
|
||||
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() {
|
||||
export function useScrollPage() {
|
||||
const hash = useHash();
|
||||
const pathname = usePathname();
|
||||
const previous = usePrevious({ pathname, hash });
|
||||
const previousHash = usePrevious(hash);
|
||||
|
||||
React.useEffect(() => {
|
||||
// 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;
|
||||
if (hash) {
|
||||
if (previousHash !== undefined && previousHash !== hash) {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (previous.hash === hash && previous.pathname === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hash) {
|
||||
scrollToHash(hash);
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}, [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;
|
||||
}, [hash, previousHash]);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function Card(
|
||||
</span>
|
||||
|
||||
{postTitle ? (
|
||||
<span className={tcls('text-xs', 'text-base')}>{postTitle}</span>
|
||||
<span className={tcls('text-xs', 'text-tint')}>{postTitle}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Icon
|
||||
|
||||
@@ -57,11 +57,11 @@ export type InputProps = CustomInputProps &
|
||||
(
|
||||
| ({ multiline?: false } & Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'value' | 'onChange' | 'onSubmit'
|
||||
'value' | 'onChange'
|
||||
>)
|
||||
| ({ multiline: true } & Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'value' | 'onChange' | 'onSubmit'
|
||||
'value' | 'onChange'
|
||||
>)
|
||||
);
|
||||
|
||||
|
||||
@@ -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, toNonEmbedLink } from '../utils/link';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
||||
|
||||
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
|
||||
@@ -162,6 +162,13 @@ 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,6 +207,7 @@ 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'
|
||||
@@ -222,10 +223,6 @@ 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 { RecordCardLinkStyles, RecordCardStyles } from '../DocumentView/Table/styles';
|
||||
import { RecordCardStyles } from '../DocumentView/Table/styles';
|
||||
import {
|
||||
ToggleableLinkItemActiveStyles,
|
||||
ToggleableLinkItemStyles,
|
||||
@@ -13,7 +13,6 @@ const styles = {
|
||||
CardStyles,
|
||||
ButtonStyles,
|
||||
RecordCardStyles,
|
||||
RecordCardLinkStyles,
|
||||
ToggleableLinkItemStyles,
|
||||
ToggleableLinkItemActiveStyles,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { isExternalLink, toNonEmbedLink } from './link';
|
||||
import { isExternalLink } from './link';
|
||||
|
||||
describe('isExternalLink', () => {
|
||||
it('treats anchor links as internal', () => {
|
||||
@@ -54,23 +54,3 @@ 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,10 +42,3 @@ 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,13 +65,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { decodeURLPath, getURLLookupAlternatives, normalizeURL } from './urls';
|
||||
import { getURLLookupAlternatives, normalizeURL } from './urls';
|
||||
|
||||
describe('getURLLookupAlternatives', () => {
|
||||
it('should return all URLs up to the root', () => {
|
||||
@@ -406,124 +406,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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.
|
||||
@@ -128,94 +127,6 @@ 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
|
||||
*/
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
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,7 +66,6 @@ async function getSectionsFromNodes(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
case 'columns':
|
||||
case 'stepper': {
|
||||
const stepNodes = await Promise.all(
|
||||
block.nodes.map(async (step) =>
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -30,7 +30,6 @@ 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';
|
||||
|
||||
@@ -112,8 +111,6 @@ export async function resolveContentRefInDocument(
|
||||
}
|
||||
}
|
||||
|
||||
console.log('resolveContentRefInDocument', contentRef, context, options);
|
||||
|
||||
return resolveContentRef(contentRef, context, options);
|
||||
}
|
||||
|
||||
@@ -342,31 +339,10 @@ export async function resolveContentRef(
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
// @ts-expect-error - Backward compatibility: `urls.source` replaces `url` in the next API version
|
||||
href: openAPISpecVersionContent.urls?.source ?? openAPISpecVersionContent.url,
|
||||
href: 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type {
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
SiteStructure,
|
||||
TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api';
|
||||
import { joinPath } from './paths';
|
||||
import { flattenSectionsFromGroup } from './utils';
|
||||
|
||||
@@ -183,22 +177,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
Reference in New Issue
Block a user