mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb46b5b38b | |||
| 8a9f163d31 | |||
| 7c365f9162 | |||
| 10a7095182 | |||
| 614a50373a | |||
| d172684b88 | |||
| 7646cb301c | |||
| d2ce6adeb6 | |||
| 937c8b8eb2 | |||
| 36dbdb48cc | |||
| 2dc76a2c3c | |||
| 0edc378c57 | |||
| 2eb313194a | |||
| 882dfcda83 | |||
| 3ff88ba22f | |||
| 51bd768042 | |||
| daadd91eba | |||
| f4469b2df3 | |||
| 91b325ca08 | |||
| 1616028426 | |||
| 6c15d8314a | |||
| 63e4968987 | |||
| 6c967c9bcc | |||
| 6eb3d3b4e5 | |||
| 2ba90833a3 | |||
| c44d822fe1 | |||
| b0553d5fc8 | |||
| af3cda1c53 | |||
| a06da70f5f | |||
| a0ad403a91 | |||
| 0ca49523a2 | |||
| 76550415ac | |||
| 53ec8b9f29 | |||
| 6f63c00300 | |||
| c334ea2748 | |||
| d75cdf1334 | |||
| cb649c55cb | |||
| 205c512cb6 | |||
| 334a31d7fc | |||
| f157cfc9a7 | |||
| 509389c7e5 | |||
| 3485c0192b | |||
| 88b94b5d36 | |||
| 27d9ad6fe4 | |||
| 96602eb552 | |||
| 17d092acdc | |||
| 107018f570 | |||
| 3e152b0662 | |||
| 6a8720894d | |||
| 8a5ed60c26 | |||
| 52fc156b06 | |||
| 251630c49f | |||
| acf2719d06 | |||
| 46091b65d1 | |||
| ddba8031ec | |||
| 72a1596ae8 | |||
| 5b36c59061 | |||
| 77c717c251 | |||
| 60310b4c83 | |||
| 6eabaecbad | |||
| 423bbd06d9 | |||
| 36f19c8018 | |||
| 2ecbdd8f62 | |||
| c727cc8b9d | |||
| 2c729d461e | |||
| d83ea4ce14 | |||
| bac8608e78 | |||
| 7a1236a3a5 | |||
| ed80bd9e7a | |||
| 7036b67307 | |||
| 6bd522b322 | |||
| 74fc3f74d7 | |||
| 3e384c679e | |||
| f1fa1061db | |||
| 4f754a73a3 | |||
| 62d6d28888 | |||
| fd73a9912b | |||
| 595435ac4a | |||
| aa9d41a1b6 | |||
| 30089b404c | |||
| 92b68560e1 | |||
| f158064817 | |||
| 2885a137f8 | |||
| 0ffb74a6f8 | |||
| b116a0a2a4 | |||
| b9bb535d52 | |||
| 257ca09a5e | |||
| f405fffa06 | |||
| 16a54b6bb9 | |||
| 526a928420 | |||
| c201abab5f | |||
| 3697dfefed | |||
| 287203d197 | |||
| 0b38f6c93b | |||
| 8ce64f4862 | |||
| 127ef54b0f | |||
| 67f31aaa7a | |||
| 51fef84d39 | |||
| 0c577a00de | |||
| 8b6dc6dbbd | |||
| 2e7ba40a25 | |||
| b70b8fd2ca | |||
| a9ba996953 | |||
| 47c8ccba6f | |||
| e9842feeef | |||
| f4b4fe15c1 | |||
| 06b9fd542a | |||
| 76077f9f6d | |||
| 7b65f7c0d5 | |||
| 37a9453917 | |||
| d994875bea | |||
| a9b5521f3a | |||
| 91fadb08ed | |||
| b55605f25e | |||
| 6ba5e54d19 | |||
| 56aeb19e49 | |||
| ebab8188f6 | |||
| 8064c95267 | |||
| 2cef2e02c8 | |||
| 8bfced2e0d | |||
| 61ee4e3637 | |||
| 927aa89213 | |||
| f29d1e2f16 | |||
| 3a9efb8aed | |||
| f71ab8426c | |||
| a3d1558f99 | |||
| ca53869e2d | |||
| 33598e665b | |||
| 9045ba5248 | |||
| 7061fc2223 | |||
| 0c9e3952b7 | |||
| 4b78672135 | |||
| 10289e4881 | |||
| 8b6a6df1d6 | |||
| 83df84ac28 | |||
| 5e5075a137 | |||
| 90c1f68e19 | |||
| 0e10124626 | |||
| 01e1c2ae6a | |||
| 65558cfbb1 | |||
| 10d894ce19 | |||
| d1a2958212 | |||
| 894b6adc4c | |||
| b3b819f8e5 | |||
| 2b98af0cc0 | |||
| 54a56ed772 | |||
| 071627d4f0 | |||
| 7595706b92 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add "Content-Signal" to "robots.txt"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support variant selector in hidden section
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix embeddable search links
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "🚀 Dev server",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "bun install && bun dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -55,6 +55,7 @@ runs:
|
||||
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_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
|
||||
@@ -57,6 +57,7 @@ runs:
|
||||
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_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
|
||||
@@ -15,6 +15,10 @@ jobs:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
env:
|
||||
# The repo is managed by Bun, but publishing uses npm for provenance.
|
||||
# Keep Corepack from rejecting packageManager: bun@... when npm runs.
|
||||
COREPACK_ENABLE_PROJECT_SPEC: 0
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gitbook",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.6",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.18",
|
||||
"vercel": "50.37.3",
|
||||
},
|
||||
},
|
||||
@@ -47,7 +48,7 @@
|
||||
},
|
||||
"packages/embed": {
|
||||
"name": "@gitbook/embed",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
@@ -73,7 +74,7 @@
|
||||
},
|
||||
"packages/expr": {
|
||||
"name": "@gitbook/expr",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-loose": "^8.5.2",
|
||||
@@ -108,7 +109,7 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.25.1",
|
||||
"version": "0.27.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
@@ -126,8 +127,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "3.10.1",
|
||||
"@opennextjs/cloudflare": "1.19.1",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -139,7 +140,7 @@
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.2.1",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -147,6 +148,7 @@
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
@@ -158,19 +160,22 @@
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.12.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "^16.2.3",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"p-retry": "^8.0.0",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -184,6 +189,7 @@
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"tldts": "^7.0.30",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
@@ -226,7 +232,7 @@
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@gitbook/icons",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.0",
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||
},
|
||||
@@ -264,7 +270,7 @@
|
||||
},
|
||||
"packages/react-contentkit": {
|
||||
"name": "@gitbook/react-contentkit",
|
||||
"version": "0.7.15",
|
||||
"version": "0.7.16",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -306,7 +312,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.5.12",
|
||||
"version": "1.5.14",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -341,7 +347,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"next@16.2.3": "patches/next@16.2.3.patch",
|
||||
"next@16.2.6": "patches/next@16.2.6.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
@@ -354,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.177.0",
|
||||
"@gitbook/api": "0.185.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -369,6 +375,7 @@
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.1",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||
@@ -549,21 +556,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.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@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-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="],
|
||||
|
||||
"@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/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="],
|
||||
|
||||
"@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.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@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-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="],
|
||||
|
||||
"@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.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@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-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="],
|
||||
|
||||
"@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-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="],
|
||||
|
||||
"@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.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="],
|
||||
|
||||
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
|
||||
|
||||
@@ -749,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.177.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-krrkGWBQZkH+0pvtxKMjT9i2j/A23n8ZlJEWItX6LTxNaSJk5YfzK/qze/vS9F1M6WeXOi+nwgwoIRIVwUvYUw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -907,29 +914,29 @@
|
||||
|
||||
"@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@0.2.2", "", { "dependencies": { "@zenuml/core": "^3.35.2" }, "peerDependencies": { "mermaid": "^10 || ^11" } }, "sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw=="],
|
||||
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="],
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.5", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
|
||||
|
||||
@@ -965,9 +972,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.10.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.15 || >=16.2.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-Pn8hLuP3M9EWlsZnS/O7Bm41Nt+lIOEsCaW/QJ3KlmIbtAeixnSj1CS80Z9PlQe/LQCe0GnQ8vGJeOdg5a4iWg=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.0.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.10.1", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.15 || >=16.2.3", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-qW6ivDkwDzomX4uBTwIHiSFMvsXjZ2GuVf/GITxGByr8sA3eyx3IMfFjWyaqBHVqcqtJaCiFo8IPxpLHv0jWbg=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.1", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -1583,17 +1590,17 @@
|
||||
|
||||
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="],
|
||||
|
||||
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
|
||||
|
||||
@@ -1719,7 +1726,9 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="],
|
||||
|
||||
"@vercel/agent-readability": ["@vercel/agent-readability@0.2.1", "", { "peerDependencies": { "next": ">=14" }, "optionalPeers": ["next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-ShT7BzIS/dwKompii8tm5do+NR1g4xL5M3wM7S01xsH6yuYQ7wiTPZEcmHMFLHCsAQg45/mD0hgrufpS3NVunw=="],
|
||||
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
|
||||
|
||||
"@vercel/agent-readability": ["@vercel/agent-readability@0.4.0", "", { "peerDependencies": { "@sveltejs/kit": ">=2", "h3": ">=1.8 <2", "next": ">=14" }, "optionalPeers": ["@sveltejs/kit", "h3", "next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-+qcRxDEbKKJzRlLqsZUnet63kepr8RikEOlT9pVEru/GNkiSinwZEJxyfUhdUpzav8El2v6DZybpyQuvlynAhQ=="],
|
||||
|
||||
"@vercel/backends": ["@vercel/backends@0.0.53", "", { "dependencies": { "@vercel/build-utils": "13.12.0", "@vercel/nft": "1.5.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-f8JK9Jatlco6kT/NsLYVR88iqS7oZxUXzXW8TofLmyTT5o+OEXSY2hzAQSjul6r0qz/9yAhtQkMhcHG8Ne01ZQ=="],
|
||||
|
||||
@@ -1965,6 +1974,8 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -2119,7 +2130,7 @@
|
||||
|
||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||
|
||||
"dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="],
|
||||
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
@@ -2329,6 +2340,8 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.6.1", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-nB8y4nQl8PshahLpGKZOq1sb0xrMVFSn6at7u/qOsBZTlZRzaapISGENcB6mOkoezbClZyiMwEF/dGY8AZ00rA=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
@@ -2525,6 +2538,8 @@
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
|
||||
|
||||
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
@@ -2739,7 +2754,7 @@
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="],
|
||||
"mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
@@ -2847,7 +2862,7 @@
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
@@ -2915,6 +2930,8 @@
|
||||
|
||||
"p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="],
|
||||
|
||||
"p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
||||
@@ -2987,7 +3004,7 @@
|
||||
|
||||
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
@@ -3301,6 +3318,10 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
@@ -3335,7 +3356,7 @@
|
||||
|
||||
"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.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
|
||||
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
|
||||
|
||||
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||
|
||||
@@ -3547,10 +3568,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=="],
|
||||
@@ -4015,6 +4032,8 @@
|
||||
|
||||
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
|
||||
|
||||
"@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@zenuml/core/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
|
||||
|
||||
"@zenuml/core/tailwind-merge": ["tailwind-merge@3.4.1", "", {}, "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q=="],
|
||||
@@ -4587,6 +4606,8 @@
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -4595,6 +4616,8 @@
|
||||
|
||||
"@zenuml/core/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
|
||||
@@ -4767,6 +4790,8 @@
|
||||
|
||||
"@zenuml/core/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"micro/raw-body/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
|
||||
|
||||
"micro/raw-body/http-errors/setprototypeof": ["setprototypeof@1.1.1", "", {}, "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="],
|
||||
|
||||
+6
-5
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.6",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.18",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.177.0",
|
||||
"@gitbook/api": "0.185.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -55,12 +55,13 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.1"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.2.3": "patches/next@16.2.3.patch"
|
||||
"next@16.2.6": "patches/next@16.2.6.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 37a9453: Allow copying in embed
|
||||
- Updated dependencies [47c8ccb]
|
||||
- @gitbook/icons@0.5.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -44,6 +44,16 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
if (!iframe.contentWindow) {
|
||||
throw new Error('Iframe must have a content window');
|
||||
}
|
||||
|
||||
const allowTokens = iframe.allow
|
||||
.split(';')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!allowTokens.includes('clipboard-write')) {
|
||||
iframe.allow = [...allowTokens, 'clipboard-write'].join('; ');
|
||||
}
|
||||
|
||||
const channel = createChannel(iframe.contentWindow);
|
||||
|
||||
channel.receive((message: FrameToParentMessage) => {
|
||||
|
||||
@@ -76,6 +76,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
src={frameURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
allow="clipboard-write"
|
||||
className={className}
|
||||
style={colorScheme ? { colorScheme } : undefined}
|
||||
/>
|
||||
|
||||
@@ -101,6 +101,7 @@ function getIframe() {
|
||||
widgetIframe?.remove();
|
||||
widgetIframe = document.createElement('iframe');
|
||||
widgetIframe.id = 'gitbook-widget-iframe';
|
||||
widgetIframe.allow = 'clipboard-write';
|
||||
if (frameOptions?.colorScheme) {
|
||||
widgetIframe.style.colorScheme = frameOptions.colorScheme;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6eabaec: Implement a getVariables function for ExpressionRuntime
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -195,6 +195,49 @@ describe('ExpressionRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('getVariables', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'single variable',
|
||||
condition: 'isBetaUser === true',
|
||||
expectedVariables: ['isBetaUser'],
|
||||
},
|
||||
{
|
||||
scenario: 'multiple variables',
|
||||
condition: 'useProductA && !isBetaUser',
|
||||
expectedVariables: ['useProductA', 'isBetaUser'],
|
||||
},
|
||||
{
|
||||
scenario: 'member expression',
|
||||
condition: 'user.role === "admin"',
|
||||
expectedVariables: ['user.role'],
|
||||
},
|
||||
{
|
||||
scenario: 'nested member expression with method call',
|
||||
condition: 'products.includes("productA") && userSegments.alpha',
|
||||
expectedVariables: ['products.includes', 'userSegments.alpha'],
|
||||
},
|
||||
])(
|
||||
'should return variables used in expression: $scenario',
|
||||
({ condition, expectedVariables }) => {
|
||||
expect(runtime.getVariables(condition)).toEqual(expectedVariables);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'invalid syntax',
|
||||
condition: 't}=d',
|
||||
},
|
||||
{
|
||||
scenario: 'non conditional expression',
|
||||
condition: 'const a = 1;',
|
||||
},
|
||||
])('should return an empty array for invalid expressions: $scenario', ({ condition }) => {
|
||||
expect(runtime.getVariables(condition)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('generate', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { parse as parseLoose } from 'acorn-loose';
|
||||
import escodegen from 'escodegen';
|
||||
import evalESTreeExpr from 'eval-estree-expression';
|
||||
const { evaluate } = evalESTreeExpr;
|
||||
const { evaluate, variables } = evalESTreeExpr;
|
||||
|
||||
import { AutoComplete } from './autocomplete';
|
||||
import { ExpressionError } from './errors';
|
||||
@@ -162,6 +162,28 @@ export class ExpressionRuntime {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an expression, returns a list of variables used in the expression.
|
||||
*/
|
||||
public getVariables(expr: string): string[] {
|
||||
try {
|
||||
const parsed = this.parse(expr);
|
||||
|
||||
if (parsed.invalidNodes.length > 0) {
|
||||
throw new ExpressionError('Invalid nodes found when parsing');
|
||||
}
|
||||
|
||||
return variables(parsed.result, {
|
||||
functions: true,
|
||||
withMembers: true,
|
||||
generate: escodegen.generate,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.error(`Error while parsing expression ${expr} to get variables`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||
*/
|
||||
|
||||
+10
@@ -41,6 +41,16 @@ declare module 'eval-estree-expression' {
|
||||
options?: EvalESTreeExpressionOptions
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Given an ESTree-compliant AST node, returns a list of variables used in the expression.
|
||||
* @param ast An object representing an ESTree-compliant AST node.
|
||||
* @param options Options for evaluation and compilation.
|
||||
*/
|
||||
export function variables<ASTNode>(
|
||||
ast: ASTNode,
|
||||
options?: EvalESTreeExpressionOptions
|
||||
): string[];
|
||||
|
||||
/**
|
||||
* Evaluates an ESTree expression synchronously against a given context.
|
||||
* @param expression - An object representing an ESTree-compliant AST node.
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# gitbook
|
||||
|
||||
## 0.27.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- acf2719: Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
|
||||
- 72a1596: Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
|
||||
- 77c717c: Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
|
||||
- c44d822: Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured.
|
||||
- 7655041: Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts).
|
||||
- af3cda1: Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
|
||||
- 0ca4952: Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive.
|
||||
- 3ff88ba: Add a Prompt block
|
||||
- 251630c: Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
- ddba803: Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
|
||||
- f4469b2: Support OpenAPI one-operation-per-page mode
|
||||
- 6c967c9: Add a client-side search field to table blocks, with per-column filters for select and checkbox columns
|
||||
- 882dfcd: Tidy prompt and add e2e test
|
||||
- Updated dependencies [f4469b2]
|
||||
- Updated dependencies [88b94b5]
|
||||
- @gitbook/react-openapi@1.5.14
|
||||
|
||||
## 0.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- aa9d41a: Update llms.txt footer for GitBook sites
|
||||
- 4f754a7: Add GitBook information to footer of markdown pages
|
||||
- 2ecbdd8: Add a `navigateToPage` assistant tool that lets the AI open a documentation page on the reader's behalf. The page opens instantly (no confirmation) and is pushed to the browser history so the reader can navigate back.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7036b67: Fix custom assistants (e.g. Kapa, Inkeep) being opened twice when clicking "Ask with …" in the search bar.
|
||||
- 2885a13: Fix heading anchor alignment with heading text and translate its accessibility label.
|
||||
- b9bb535: Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view.
|
||||
- b116a0a: Fix OpenAPI CodeBlock scroll
|
||||
- d83ea4c: Improve a11y of list item collapse/extend toggle
|
||||
- 92b6856: Add Ask AI in CodeBlock
|
||||
- 7a1236a: Fix search results linking to the page instead of the section anchor when a section snippet is displayed.
|
||||
- b9bb535: Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger.
|
||||
- Updated dependencies [6eabaec]
|
||||
- Updated dependencies [f158064]
|
||||
- @gitbook/expr@1.3.0
|
||||
- @gitbook/react-openapi@1.5.13
|
||||
|
||||
## 0.26.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 67f31aa: Allow user-triggered AI assistants (ChatGPT-User, Claude-User, Perplexity-User) to read pages on non-indexable sites, so end-users can pull content into an LLM without an MCP connection. Search engines and training crawlers remain blocked.
|
||||
- 0c577a0: Allow CORS for the same root domain
|
||||
- 51fef84: Remove the date-gated header logo height logic and always apply the compact logo size to prevent size shifts between sections and translations.
|
||||
- f405fff: Add icon support in tab items
|
||||
- 3697dfe: Add support for hiding page actions from page layout options.
|
||||
- 127ef54: Add tag filtering controls for Updates blocks.
|
||||
|
||||
## 0.26.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b70b8fd: Fix llms-full.txt pagination to include pages from all site sections.
|
||||
- 6ba5e54: Support isComposing for search
|
||||
- 47c8ccb: Render icons as svg markup
|
||||
- a9ba996: Fix left/right aligment of images with caption
|
||||
- 56aeb19: Fix events tracked from the middleware missing some properties.
|
||||
- Updated dependencies [47c8ccb]
|
||||
- Updated dependencies [37a9453]
|
||||
- @gitbook/icons@0.5.0
|
||||
- @gitbook/embed@0.5.1
|
||||
- @gitbook/react-contentkit@0.7.16
|
||||
|
||||
## 0.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d3d5150: Add "Content-Signal" to "robots.txt"
|
||||
- 7595706: Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
- a17ab65: Support variant selector in hidden section
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d1a2958: Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
- e3aca9e: Fix embeddable search links
|
||||
- 4b78672: Fix links to other spaces/sections in the llms.txt.
|
||||
- d1a2958: Pass visitor session to Ask AI.
|
||||
|
||||
## 0.25.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -110,11 +110,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// Disabling for now, one of the image is making the tests timeout
|
||||
// {
|
||||
// name: 'docs.keeper.io',
|
||||
// contentBaseURL: 'https://docs.keeper.io',
|
||||
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'adiblar.gitbook.io',
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
@@ -222,11 +223,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.meshchain.ai',
|
||||
contentBaseURL: 'https://docs.meshchain.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// This one redirects to binance now
|
||||
// {
|
||||
// name: 'docs.meshchain.ai',
|
||||
// contentBaseURL: 'https://docs.meshchain.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.thousandeyes.com',
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
|
||||
@@ -31,12 +31,24 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForAIChatResponse,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
|
||||
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
|
||||
// single fixed search, a concise answer, and a fixed number of follow-ups. The
|
||||
// model is never perfectly deterministic, so `overrideAIResponse` still
|
||||
// normalizes the rendered content below — this prompt just narrows the variance.
|
||||
const AI_PROMPT = [
|
||||
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
|
||||
'Follow these instructions exactly and do not deviate:',
|
||||
'1. Do not write any preamble, commentary, or reasoning before acting.',
|
||||
'2. Perform a single search for exactly "Lorem ipsum".',
|
||||
'3. Reply with only the first sentence of the first page you find, and nothing else.',
|
||||
'4. Always end by proposing exactly 3 follow-up suggestions.',
|
||||
].join('\n');
|
||||
|
||||
const overrideAIInitialState = () => {
|
||||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||
@@ -44,21 +56,45 @@ const overrideAIInitialState = () => {
|
||||
greeting.textContent = 'Good morning';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize the non-deterministic content of an AI response before screenshotting,
|
||||
* while preserving the surrounding structure (message bubbles, tool/activity
|
||||
* summary, response container, suggestion buttons) so visual regressions in the
|
||||
* chat chrome are still caught. The actual answer formatting is covered separately
|
||||
* by the deterministic page tests, since the AI response renders through the same
|
||||
* `DocumentView`.
|
||||
*
|
||||
* Must run only once the chat is no longer `aria-busy` (the response has fully
|
||||
* settled), otherwise React re-renders from late stream events will clobber these
|
||||
* mutations. See `waitForAIChatResponse`.
|
||||
*/
|
||||
const overrideAIResponse = () => {
|
||||
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
|
||||
if (userMessage) {
|
||||
// The user's prompt varies in length; pin it to a fixed string.
|
||||
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
|
||||
userMessage.textContent = '[Replaced message] Chat message sent by the user';
|
||||
}
|
||||
const assistantMessage = document.querySelectorAll(
|
||||
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
|
||||
);
|
||||
assistantMessage.forEach((message) => {
|
||||
message.innerHTML = '[Replaced message] AI chat response';
|
||||
});
|
||||
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
|
||||
suggestions.forEach((suggestion) => {
|
||||
suggestion.textContent = 'Follow-up suggestion';
|
||||
|
||||
// The assistant's answer text is non-deterministic; replace the rendered
|
||||
// document body while keeping the `.ai-response-document` container.
|
||||
document
|
||||
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
|
||||
.forEach((message) => {
|
||||
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
|
||||
});
|
||||
|
||||
// The "Explored with N tools" activity label varies with the number of tool
|
||||
// calls; pin it (the chevron sibling is left intact).
|
||||
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
|
||||
summary.textContent = 'Explored';
|
||||
});
|
||||
|
||||
// Follow-up suggestion text varies; pin each label.
|
||||
document
|
||||
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
|
||||
.forEach((suggestion) => {
|
||||
suggestion.textContent = 'Follow-up suggestion';
|
||||
});
|
||||
};
|
||||
|
||||
const searchTestCases: Test[] = [
|
||||
@@ -82,8 +118,6 @@ const searchTestCases: Test[] = [
|
||||
});
|
||||
const pageResults = await page.getByTestId('search-page-result').all();
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||
},
|
||||
},
|
||||
@@ -169,12 +203,13 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||||
await waitForAIChatResponse(page);
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
|
||||
@@ -188,9 +223,9 @@ const searchTestCases: Test[] = [
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Button',
|
||||
@@ -204,9 +239,9 @@ const searchTestCases: Test[] = [
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||||
@@ -221,9 +256,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-input')).toBeEmpty();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Results)',
|
||||
@@ -239,12 +274,13 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||||
await waitForAIChatResponse(page);
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -876,6 +912,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
fullPage: true,
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -1057,8 +1094,8 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
await page.waitForSelector('[data-annotation]');
|
||||
await page.click('[data-annotation]');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1076,6 +1113,11 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/mermaid',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Prompt',
|
||||
url: 'blocks/prompt',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1324,6 +1366,7 @@ const testCases: TestsCase[] = [
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
@@ -2222,9 +2265,13 @@ const testCases: TestsCase[] = [
|
||||
|
||||
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
|
||||
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
|
||||
|
||||
await iframe.owner().evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'API - navigateToPage',
|
||||
@@ -2258,8 +2305,15 @@ const testCases: TestsCase[] = [
|
||||
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
|
||||
AI_PROMPT
|
||||
);
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// Wait for the full response to settle before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Suggested questions',
|
||||
@@ -2285,8 +2339,13 @@ const testCases: TestsCase[] = [
|
||||
await expect(
|
||||
iframe.getByTestId('ai-chat-suggested-question').nth(2)
|
||||
).toHaveText('What can you do?');
|
||||
await iframe.owner().evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Custom action buttons',
|
||||
@@ -2368,8 +2427,15 @@ const testCases: TestsCase[] = [
|
||||
await actions.nth(3).click();
|
||||
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
|
||||
await page.locator('#gitbook-widget-button').click();
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// Wait for the response posted above to settle before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Custom tools',
|
||||
@@ -2413,8 +2479,16 @@ const testCases: TestsCase[] = [
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// The turn settles (aria-busy clears) once the stream pauses on the
|
||||
// confirmation control; wait for that before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLinksStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationPageActionType,
|
||||
CustomizationSearchStyle,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
@@ -21,7 +22,14 @@ import {
|
||||
type SiteCustomizationSettings,
|
||||
SiteExternalLinksTarget,
|
||||
} from '@gitbook/api';
|
||||
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
|
||||
import {
|
||||
type BrowserContext,
|
||||
type FrameLocator,
|
||||
type Page,
|
||||
type Response,
|
||||
expect,
|
||||
test,
|
||||
} from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
@@ -39,6 +47,16 @@ export interface Test {
|
||||
* Test to run
|
||||
*/
|
||||
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||
/**
|
||||
* Re-applied right before every viewport screenshot (after Argos
|
||||
* stabilization), so it survives re-renders triggered by viewport resizing.
|
||||
*
|
||||
* Use this — rather than mutating the DOM once in `run` — to normalize
|
||||
* non-deterministic content (e.g. AI responses). A one-time mutation in `run`
|
||||
* is clobbered when React re-renders on resize (e.g. crossing the mobile
|
||||
* breakpoint), so only the first viewport ends up normalized.
|
||||
*/
|
||||
normalizeBeforeScreenshot?: (page: Page) => Promise<void> | void;
|
||||
/**
|
||||
* Mode for the test.
|
||||
*/
|
||||
@@ -75,6 +93,10 @@ export type TestsCase = {
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
contentBaseURL?: string;
|
||||
/**
|
||||
* Whether screenshots in this test case should capture the full scrollable page by default.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
export const allLocales: CustomizationLocale[] = [
|
||||
@@ -155,6 +177,28 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an AI chat response to be fully settled before asserting or
|
||||
* screenshotting it.
|
||||
*
|
||||
* The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`),
|
||||
* which stays true from the moment a message is sent until the stream — including
|
||||
* the follow-up suggestion phase — completes. Gating on it avoids the two main
|
||||
* sources of flakiness: capturing a "thinking" placeholder or a half-streamed
|
||||
* answer, and running the content normalization while React is still re-rendering
|
||||
* (which would clobber the replacements).
|
||||
*
|
||||
* Argos also waits for `aria-busy` to clear during its own stabilization
|
||||
* (`waitForAriaBusy`), so this is both an explicit gate and a backstop.
|
||||
*
|
||||
* Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe).
|
||||
*/
|
||||
export async function waitForAIChatResponse(scope: Page | FrameLocator) {
|
||||
await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTimeToMorning(page: Page) {
|
||||
const now = new Date();
|
||||
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
|
||||
@@ -240,15 +284,18 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
await waitForIcons(page);
|
||||
// Re-apply per viewport, last — after any resize-driven
|
||||
// re-render — so normalized content survives to capture.
|
||||
await testEntry.normalizeBeforeScreenshot?.(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -368,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
externalAI: true,
|
||||
markdown: true,
|
||||
mcp: true,
|
||||
items: [
|
||||
CustomizationPageActionType.Markdown,
|
||||
CustomizationPageActionType.ExternalAi,
|
||||
CustomizationPageActionType.Mcp,
|
||||
],
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
@@ -392,11 +444,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
export async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urlStates: Record<
|
||||
type IconURLStates = Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
>;
|
||||
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
|
||||
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
|
||||
iconStatesWindow.__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
@@ -441,7 +495,15 @@ export async function waitForIcons(page: Page) {
|
||||
|
||||
const maskImage = icon.querySelector('[data-testid="mask-image"]');
|
||||
if (!maskImage) {
|
||||
throw new Error('No mask-image element');
|
||||
const inlineContent = icon.querySelector(
|
||||
'path, circle, ellipse, line, polygon, polyline, rect, g, use'
|
||||
);
|
||||
if (inlineContent) {
|
||||
icon.setAttribute('data-argos-state', 'loaded');
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('Icon has no inline SVG content or mask-image element');
|
||||
}
|
||||
|
||||
const url = maskImage.getAttribute('href');
|
||||
|
||||
@@ -48,6 +48,7 @@ const nextConfig = {
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
|
||||
@@ -5,7 +5,8 @@ export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
//TODO: bump it again once I figured out the race condition
|
||||
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
|
||||
// and cause race conditions. This will be fixed in a future release of OpenNext
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
bypassTagCacheOnCacheHit: false,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
//TODO: remove, reducing cache ttl of regional cache to help debugging
|
||||
defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.25.1",
|
||||
"version": "0.27.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -19,20 +19,20 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "3.10.1",
|
||||
"@opennextjs/cloudflare": "1.19.1",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.2.1",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -40,6 +40,7 @@
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
@@ -51,19 +52,22 @@
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.12.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "^16.2.3",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"p-retry": "^8.0.0",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -77,6 +81,7 @@
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"tldts": "^7.0.30",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
|
||||
@@ -18,6 +18,16 @@ export default defineConfig({
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
launchOptions: {
|
||||
args: [
|
||||
// Disable subpixel (LCD) text so glyphs always render with
|
||||
// grayscale antialiasing — removes the red/blue edge fringing
|
||||
// that varies between macOS (local) and Linux (CI) runs.
|
||||
'--disable-lcd-text',
|
||||
// Disable font hinting so glyph rasterization is platform-independent.
|
||||
'--font-render-hinting=none',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+12
-1
@@ -1,4 +1,4 @@
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
@@ -20,6 +20,17 @@ export async function handleMcpRequest(
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
// Use the configured `items` list when the API provides it, and fall back to the deprecated
|
||||
// `mcp` flag otherwise (legacy mode), since this endpoint is called directly and cannot rely on
|
||||
// any page-rendering fallback.
|
||||
const { pageActions } = context.customization;
|
||||
const isMcpEnabled = pageActions.items
|
||||
? pageActions.items.includes(CustomizationPageActionType.Mcp)
|
||||
: pageActions.mcp;
|
||||
if (!isMcpEnabled) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
|
||||
requestURL.search = rawRequest.nextUrl.search;
|
||||
|
||||
+16
-2
@@ -158,7 +158,7 @@ function transformSitePageResult(args: {
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
let sectionHref = linker.toPathInSpace(pageItem.path);
|
||||
let sectionHref = linker.toPathInSpace(section.path);
|
||||
|
||||
if (spaceURL) {
|
||||
if (asEmbeddable) {
|
||||
@@ -186,5 +186,19 @@ function transformSitePageResult(args: {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return [page, ...pageSections];
|
||||
// Find the best-scoring section to use as a body preview on the page result.
|
||||
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
|
||||
(best, section) => (!best || section.score > best.score ? section : best),
|
||||
undefined
|
||||
);
|
||||
if (bestSection) {
|
||||
page.bestSection = {
|
||||
href: bestSection.href,
|
||||
title: bestSection.title,
|
||||
body: bestSection.body,
|
||||
score: bestSection.score,
|
||||
};
|
||||
}
|
||||
|
||||
return [page];
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { StructurePreview } from '@/components/StructurePreview';
|
||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||
import type { Metadata } from 'next';
|
||||
import { getStructurePreviewSnapshot } from '../snapshot';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const { context } = await getDynamicSiteContext(await props.params);
|
||||
return (
|
||||
<StructurePreview
|
||||
initialSnapshot={getStructurePreviewSnapshot(context)}
|
||||
GITBOOK_APP_URL={GITBOOK_APP_URL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { SiteLayoutClientContexts } from '@/components/SiteLayout/SiteLayoutClientContexts';
|
||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||
|
||||
interface SiteDynamicLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||
const { context } = await getDynamicSiteContext(await props.params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
return (
|
||||
<CustomizationRootLayout
|
||||
htmlClassName="sheet-open:gutter-stable overflow-hidden site-background"
|
||||
bodyClassName="site-background"
|
||||
forcedTheme={forcedTheme}
|
||||
context={context}
|
||||
>
|
||||
<SiteLayoutClientContexts
|
||||
contextId={context.contextId}
|
||||
forcedTheme={
|
||||
forcedTheme ??
|
||||
(context.customization.themes.toggeable
|
||||
? undefined
|
||||
: context.customization.themes.default)
|
||||
}
|
||||
defaultTheme={context.customization.themes.default}
|
||||
themeStorageKey={`gitbook-theme-structure:${context.site.id}`}
|
||||
externalLinksTarget={context.customization.externalLinks.target}
|
||||
proxyOrigin={context.site.proxy?.origin}
|
||||
>
|
||||
{children}
|
||||
</SiteLayoutClientContexts>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
CustomizationContentLink,
|
||||
CustomizationHeaderItem,
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import type {
|
||||
ClientSiteSection,
|
||||
ClientSiteSectionGroup,
|
||||
ClientSiteSections,
|
||||
} from '@/components/SiteSections';
|
||||
import { categorizeVariants } from '@/components/SpaceLayout/categorizeVariants';
|
||||
import type { StructurePreviewSnapshot } from '@/components/StructurePreview';
|
||||
import type { PreviewContentLink, PreviewHeaderLink } from '@/components/StructurePreview/types';
|
||||
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
|
||||
import { getLocalizedDescription, getLocalizedTitle } from '@/lib/sites';
|
||||
|
||||
export function getStructurePreviewSnapshot(context: GitBookSiteContext): StructurePreviewSnapshot {
|
||||
const variants = categorizeVariants(context);
|
||||
const sections = context.visibleSections ?? context.sections;
|
||||
|
||||
return {
|
||||
site: {
|
||||
title: context.site.title,
|
||||
},
|
||||
locale: context.locale,
|
||||
customization: encodePreviewCustomization(context),
|
||||
siteSpace: encodePreviewSiteSpace(context.siteSpace, context),
|
||||
variants: {
|
||||
generic: variants.generic.map((siteSpace) =>
|
||||
encodePreviewDropdownSpace(siteSpace, context)
|
||||
),
|
||||
translations: variants.translations.map((siteSpace) =>
|
||||
encodePreviewDropdownSpace(siteSpace, context)
|
||||
),
|
||||
},
|
||||
sections: sections ? encodePreviewSiteSections(context, sections) : null,
|
||||
icons: {
|
||||
large: {
|
||||
light: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
|
||||
dark: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodePreviewCustomization(
|
||||
context: GitBookSiteContext
|
||||
): StructurePreviewSnapshot['customization'] {
|
||||
const { customization, locale } = context;
|
||||
|
||||
return {
|
||||
styling: {
|
||||
search: customization.styling.search,
|
||||
},
|
||||
favicon:
|
||||
'emoji' in customization.favicon && customization.favicon.emoji
|
||||
? { emoji: customization.favicon.emoji }
|
||||
: {},
|
||||
header: {
|
||||
preset: customization.header.preset,
|
||||
logo: customization.header.logo
|
||||
? {
|
||||
light: customization.header.logo.light,
|
||||
dark: customization.header.logo.dark,
|
||||
}
|
||||
: undefined,
|
||||
links: customization.header.links.map((link) => encodePreviewHeaderLink(link, locale)),
|
||||
},
|
||||
ai: {
|
||||
mode: customization.ai.mode,
|
||||
},
|
||||
trademark: {
|
||||
enabled: customization.trademark.enabled,
|
||||
},
|
||||
socialAccounts: customization.socialAccounts
|
||||
.filter((account) => account.display.header === true)
|
||||
.map((account) => ({
|
||||
platform: account.platform,
|
||||
handle: account.handle,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function encodePreviewHeaderLink(
|
||||
link: CustomizationHeaderItem,
|
||||
locale: GitBookSiteContext['locale']
|
||||
): PreviewHeaderLink {
|
||||
return {
|
||||
title: getLocalizedTitle(link, locale),
|
||||
style: link.style,
|
||||
hasTarget: Boolean(link.to),
|
||||
links: link.links.map((subLink) => encodePreviewContentLink(subLink, locale)),
|
||||
};
|
||||
}
|
||||
|
||||
function encodePreviewContentLink(
|
||||
link: CustomizationContentLink,
|
||||
locale: GitBookSiteContext['locale']
|
||||
): PreviewContentLink {
|
||||
return {
|
||||
title: getLocalizedTitle(link, locale),
|
||||
hasTarget: Boolean(link.to),
|
||||
};
|
||||
}
|
||||
|
||||
function encodePreviewSiteSpace(
|
||||
siteSpace: SiteSpace,
|
||||
context: GitBookSiteContext
|
||||
): StructurePreviewSnapshot['siteSpace'] {
|
||||
return {
|
||||
id: siteSpace.id,
|
||||
title: getLocalizedTitle(siteSpace, context.locale),
|
||||
path: siteSpace.path,
|
||||
};
|
||||
}
|
||||
|
||||
function encodePreviewDropdownSpace(
|
||||
siteSpace: SiteSpace,
|
||||
context: GitBookSiteContext
|
||||
): StructurePreviewSnapshot['variants']['generic'][number] {
|
||||
return {
|
||||
id: siteSpace.id,
|
||||
title: getLocalizedTitle(siteSpace, context.locale),
|
||||
isActive: siteSpace.id === context.siteSpace.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodePreviewSiteSections(
|
||||
context: Pick<GitBookSiteContext, 'locale'>,
|
||||
sections: SiteSections
|
||||
): ClientSiteSections {
|
||||
return {
|
||||
list: sections.list.flatMap((item) => encodePreviewSectionItem(context, item)),
|
||||
current: encodePreviewSection(context, sections.current),
|
||||
};
|
||||
}
|
||||
|
||||
function encodePreviewSectionItem(
|
||||
context: Pick<GitBookSiteContext, 'locale'>,
|
||||
item: SiteSection | SiteSectionGroup
|
||||
): (ClientSiteSection | ClientSiteSectionGroup)[] {
|
||||
switch (item.object) {
|
||||
case 'site-section':
|
||||
return [encodePreviewSection(context, item)];
|
||||
case 'site-section-group': {
|
||||
const children = item.children.flatMap((child) =>
|
||||
encodePreviewSectionItem(context, child)
|
||||
);
|
||||
if (children.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
title: getLocalizedTitle(item, context.locale),
|
||||
icon: item.icon,
|
||||
object: item.object,
|
||||
children,
|
||||
},
|
||||
];
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
}
|
||||
|
||||
function encodePreviewSection(
|
||||
context: Pick<GitBookSiteContext, 'locale'>,
|
||||
section: SiteSection
|
||||
): ClientSiteSection {
|
||||
return {
|
||||
id: section.id,
|
||||
title: getLocalizedTitle(section, context.locale),
|
||||
description: getLocalizedDescription(section, context.locale),
|
||||
icon: section.icon,
|
||||
object: section.object,
|
||||
url: '#',
|
||||
};
|
||||
}
|
||||
@@ -11,5 +11,5 @@ export async function GET(
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
|
||||
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
/**
|
||||
* Serve an AI answer as markdown for a page, steered by the end goal ("goal")
|
||||
* the calling agent provided via the `?goal=` search parameter.
|
||||
*
|
||||
* The goal is encoded as a path segment (rather than read from the query) because
|
||||
* this route is statically rendered, mirroring how the question itself is handled.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { question: string; goal: string }> }
|
||||
) {
|
||||
const { question: encodedQuestion, goal: encodedGoal } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
const goal = decodeURIComponent(encodedGoal);
|
||||
|
||||
return serveAskMarkdown(context, question, { goal });
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
|
||||
) {
|
||||
const { question: encodedQuestion } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
|
||||
return serveAskMarkdown(context, question);
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type RevisionPage,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
SiteVisibility,
|
||||
} from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLocalizedTitle,
|
||||
listAllSiteSpaces,
|
||||
} from '@/lib/sites';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
|
||||
|
||||
interface IndexPageEntry {
|
||||
page: RevisionPageDocument;
|
||||
ancestors: AncestorPage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the page tree and return all indexable document pages together with
|
||||
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
|
||||
*/
|
||||
function getIndexablePagesWithAncestors(
|
||||
rootPages: RevisionPage[],
|
||||
ancestors: AncestorPage[] = []
|
||||
): IndexPageEntry[] {
|
||||
const results: IndexPageEntry[] = [];
|
||||
|
||||
for (const page of rootPages) {
|
||||
if (page.type === 'link' || page.type === 'computed') continue;
|
||||
if (page.hidden || !isPageIndexable([], page)) continue;
|
||||
|
||||
if (page.type === 'document') {
|
||||
results.push({ page, ancestors });
|
||||
// Recurse into children with this document as an ancestor
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
} else if (page.type === 'group') {
|
||||
// Groups themselves are not documents — push them only as ancestors
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const revalidate = 86400; // 1 day in seconds
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, structure } = context;
|
||||
|
||||
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
|
||||
|
||||
const revisions = await Promise.all(
|
||||
visibleSpaces.map((ss) =>
|
||||
throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: ss.space.id,
|
||||
revisionId: ss.space.revision,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const pages: RawIndexPage[] = [];
|
||||
|
||||
for (let i = 0; i < visibleSpaces.length; i++) {
|
||||
const siteSpace = visibleSpaces[i]!;
|
||||
const revision = revisions[i]!;
|
||||
const forkedLinker = linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
|
||||
});
|
||||
|
||||
const lang = siteSpace.space.language ?? undefined;
|
||||
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
|
||||
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
|
||||
|
||||
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
|
||||
const cacheKey = `${siteSpace.id}:${page.id}`;
|
||||
if (seen.has(cacheKey)) continue;
|
||||
seen.add(cacheKey);
|
||||
|
||||
const breadcrumbs: Breadcrumb[] = [
|
||||
siteSectionGroup
|
||||
? {
|
||||
label: getLocalizedTitle(siteSectionGroup, lang),
|
||||
icon: siteSectionGroup.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
siteSection
|
||||
? {
|
||||
label: getLocalizedTitle(siteSection, lang),
|
||||
icon: siteSection.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
...ancestors.map((a) => ({
|
||||
label: a.title,
|
||||
icon: a.icon ?? undefined,
|
||||
emoji: a.emoji ?? undefined,
|
||||
})),
|
||||
].filter((c) => c !== undefined);
|
||||
|
||||
pages.push({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
|
||||
siteSpaceId: siteSpace.id,
|
||||
lang,
|
||||
icon: page.icon ?? undefined,
|
||||
emoji: page.emoji ?? undefined,
|
||||
description: page.description ?? undefined,
|
||||
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
|
||||
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
|
||||
const shouldCacheOnClient =
|
||||
context.site.visibility === SiteVisibility.Public ||
|
||||
context.site.visibility === SiteVisibility.Unlisted;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
|
||||
version: 1,
|
||||
pages,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
|
||||
'Cache-Control': shouldCacheOnClient
|
||||
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
|
||||
: 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_SECRET,
|
||||
@@ -32,6 +33,7 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
|
||||
// Secret envs
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { normalizePathname, resolveNavigationTarget, toInSiteHref } from './navigation';
|
||||
|
||||
const location = {
|
||||
href: 'https://docs.example.com/guides/intro',
|
||||
origin: 'https://docs.example.com',
|
||||
};
|
||||
|
||||
describe('resolveNavigationTarget', () => {
|
||||
it('resolves an absolute same-origin URL to a relative href and pathname', () => {
|
||||
expect(
|
||||
resolveNavigationTarget('https://docs.example.com/reference/models', location)
|
||||
).toEqual({ href: '/reference/models', pathname: '/reference/models' });
|
||||
});
|
||||
|
||||
it('keeps the query string and section anchor in href but not in pathname', () => {
|
||||
expect(
|
||||
resolveNavigationTarget(
|
||||
'https://docs.example.com/reference/models?tab=api#usage',
|
||||
location
|
||||
)
|
||||
).toEqual({ href: '/reference/models?tab=api#usage', pathname: '/reference/models' });
|
||||
});
|
||||
|
||||
it('resolves a relative path against the current location', () => {
|
||||
expect(resolveNavigationTarget('/reference/models', location)).toEqual({
|
||||
href: '/reference/models',
|
||||
pathname: '/reference/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a URL pointing to an external site', () => {
|
||||
const result = resolveNavigationTarget('https://evil.example.org/phishing', location);
|
||||
expect('error' in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toInSiteHref', () => {
|
||||
// A docs site served under `/docs/` on a host shared with other content/sites.
|
||||
const linker = {
|
||||
siteBasePath: '/docs/',
|
||||
toLinkForContent: (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
// Mirrors the real linker: in-site iff same host AND under the site base path.
|
||||
if (parsed.hostname === 'gitbook.com' && parsed.pathname.startsWith('/docs/')) {
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts an in-site absolute URL and returns a relative path', () => {
|
||||
expect(toInSiteHref('https://gitbook.com/docs/guides/intro', linker)).toBe(
|
||||
'/docs/guides/intro'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an in-site relative path under the site base path', () => {
|
||||
expect(toInSiteHref('/docs/guides/intro?x=1#y', linker)).toBe('/docs/guides/intro?x=1#y');
|
||||
});
|
||||
|
||||
it('rejects another page on the same host but outside the site base path', () => {
|
||||
// The reviewer's case: same host, different site/section.
|
||||
expect(toInSiteHref('https://gitbook.com/pricing', linker)).toBeNull();
|
||||
expect(toInSiteHref('/pricing', linker)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an external host', () => {
|
||||
expect(toInSiteHref('https://evil.example.org/docs/guides', linker)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePathname', () => {
|
||||
it('strips a trailing slash', () => {
|
||||
expect(normalizePathname('/guides/intro/')).toBe('/guides/intro');
|
||||
});
|
||||
|
||||
it('keeps the root slash', () => {
|
||||
expect(normalizePathname('/')).toBe('/');
|
||||
});
|
||||
|
||||
it('decodes percent-encoding so encoded and decoded paths compare equal', () => {
|
||||
expect(normalizePathname('/h%C3%A9llo')).toBe(normalizePathname('/héllo'));
|
||||
});
|
||||
|
||||
it('treats encoded and decoded paths with a trailing slash as equal', () => {
|
||||
expect(normalizePathname('/caf%C3%A9/')).toBe(normalizePathname('/café'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { withTrailingSlash } from '@/lib/paths';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
|
||||
/**
|
||||
* Resolve a link into a same-site target to navigate to.
|
||||
*
|
||||
* Returns an `error` when the URL is malformed or points outside of the documentation site,
|
||||
* so the assistant can be told it could not navigate.
|
||||
*/
|
||||
export function resolveNavigationTarget(
|
||||
url: string,
|
||||
location: { href: string; origin: string }
|
||||
): { href: string; pathname: string } | { error: string } {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url, location.href);
|
||||
} catch {
|
||||
return { error: `Invalid URL: ${url}` };
|
||||
}
|
||||
|
||||
// Only allow navigating within the current documentation site to avoid sending the user to
|
||||
// an external website without their consent.
|
||||
if (isExternalLink(target.href, location.origin)) {
|
||||
return { error: 'Cannot navigate to a page outside of this documentation site.' };
|
||||
}
|
||||
|
||||
return { href: `${target.pathname}${target.search}${target.hash}`, pathname: target.pathname };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a site-relative href if `url` points within the current site, otherwise `null`.
|
||||
*
|
||||
* Unlike a bare same-origin check, this enforces the site base path, so an assistant cannot
|
||||
* navigate the reader to another page on the same host (e.g. a marketing page, or a different
|
||||
* docs site sharing the host such as `gitbook.com/other` or another `/url/...` proxied site).
|
||||
*/
|
||||
export function toInSiteHref(
|
||||
url: string,
|
||||
linker: { toLinkForContent: (url: string) => string; siteBasePath: string }
|
||||
): string | null {
|
||||
if (URL.canParse(url)) {
|
||||
// toLinkForContent returns a site-relative path for in-site URLs (matching host AND site
|
||||
// base path), or the raw absolute URL otherwise.
|
||||
const link = linker.toLinkForContent(url);
|
||||
return URL.canParse(link) ? null : link;
|
||||
}
|
||||
|
||||
// Relative path: it must live under the site base path.
|
||||
let pathname: string;
|
||||
let rest = '';
|
||||
try {
|
||||
const parsed = new URL(url, 'https://navigation.invalid');
|
||||
pathname = parsed.pathname;
|
||||
rest = `${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return withTrailingSlash(pathname).startsWith(linker.siteBasePath)
|
||||
? `${pathname}${rest}`
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pathname so two equivalent paths compare equal regardless of percent-encoding or a
|
||||
* trailing slash (e.g. `/h%C3%A9llo/` and `/héllo`). Used to detect when an SPA navigation has
|
||||
* committed by comparing against `window.location.pathname`.
|
||||
*/
|
||||
export function normalizePathname(pathname: string): string {
|
||||
let decoded = pathname;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
// Keep the raw value if it isn't valid percent-encoding.
|
||||
}
|
||||
return decoded.length > 1 && decoded.endsWith('/') ? decoded.slice(0, -1) : decoded;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
|
||||
describe('serializeReferences', () => {
|
||||
it('returns an empty string when there are no references', () => {
|
||||
expect(serializeReferences([])).toBe('');
|
||||
});
|
||||
|
||||
it('serializes a single page reference as a markdown link', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('The user is referring to the following page they are reading');
|
||||
expect(result).toContain('- [Getting started](/getting-started)');
|
||||
expect(result.endsWith('\n\n---\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the path when no href is provided', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('- [Overview](getting-started)');
|
||||
});
|
||||
|
||||
it('renders a quoted label when neither href nor path is provided', () => {
|
||||
const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('- "Overview"');
|
||||
expect(result).not.toContain('](');
|
||||
});
|
||||
|
||||
it('uses the plural form for multiple pages', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'One' },
|
||||
{ type: 'page', id: 'page-2', label: 'Two' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('the following pages they are reading');
|
||||
expect(result).toContain('in the context of them');
|
||||
});
|
||||
|
||||
it('serializes a single code block reference', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{
|
||||
type: 'code-block',
|
||||
id: 'code-1',
|
||||
label: 'main.ts',
|
||||
content: 'const a = 1;',
|
||||
syntax: 'ts',
|
||||
},
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('The user is referring to the following code block');
|
||||
expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('escapes backtick fences inside the code content', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('````md\na ``` b\n````');
|
||||
});
|
||||
|
||||
it('groups page and code references into distinct sections', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Quickstart' },
|
||||
{ type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
const pageIndex = result.indexOf('referring to the following page');
|
||||
const codeIndex = result.indexOf('referring to the following code block');
|
||||
expect(pageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(codeIndex).toBeGreaterThan(pageIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
type BaseAIChatReference = {
|
||||
id: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type CodeBlockReference = BaseAIChatReference & {
|
||||
type: 'code-block';
|
||||
content: string;
|
||||
syntax?: string;
|
||||
};
|
||||
|
||||
export type PageReference = BaseAIChatReference & {
|
||||
type: 'page';
|
||||
/** Title of the page being referenced. */
|
||||
label: string;
|
||||
/** Path of the page, used to help the assistant identify it. */
|
||||
path?: string;
|
||||
/** Site-relative href of the page, used to navigate back to it from the chip. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type TextReference = BaseAIChatReference & {
|
||||
type: 'text';
|
||||
/** The selected text content. */
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AIChatReference = CodeBlockReference | PageReference | TextReference;
|
||||
|
||||
/**
|
||||
* Serialize the staged references into a preamble prepended to the user's message,
|
||||
* so the assistant is informed about the context the user is referring to.
|
||||
*/
|
||||
export function serializeReferences(refs: AIChatReference[]): string {
|
||||
if (refs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page');
|
||||
if (pageRefs.length > 0) {
|
||||
sections.push(serializePageReferences(pageRefs));
|
||||
}
|
||||
|
||||
const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block');
|
||||
if (codeRefs.length > 0) {
|
||||
sections.push(serializeCodeBlockReferences(codeRefs));
|
||||
}
|
||||
|
||||
const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
|
||||
if (textRefs.length > 0) {
|
||||
sections.push(serializeTextReferences(textRefs));
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${sections.join('\n\n')}\n\n---\n\n`;
|
||||
}
|
||||
|
||||
function serializePageReferences(refs: PageReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const list = refs
|
||||
.map((ref) => {
|
||||
const url = ref.href ?? ref.path;
|
||||
return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`;
|
||||
})
|
||||
.join('\n');
|
||||
return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`;
|
||||
}
|
||||
|
||||
function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const blocks = refs.map(buildCodeBlockFence).join('\n\n');
|
||||
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function serializeTextReferences(refs: TextReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
|
||||
return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function quoteText(content: string): string {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildCodeBlockFence(ref: CodeBlockReference): string {
|
||||
const { label, content, syntax } = ref;
|
||||
let max = 2;
|
||||
for (const match of content.matchAll(/`+/g)) {
|
||||
max = Math.max(max, match[0].length);
|
||||
}
|
||||
const fence = '`'.repeat(max + 1);
|
||||
const heading = label ? `${label}\n` : '';
|
||||
return `${heading}${fence}${syntax ?? ''}\n${content}\n${fence}`;
|
||||
}
|
||||
@@ -71,13 +71,13 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
}
|
||||
}
|
||||
|
||||
function DescriptionForPageContentToolCall(props: {
|
||||
async function DescriptionForPageContentToolCall(props: {
|
||||
toolCall: AIToolCallGetPageContent;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -101,13 +101,13 @@ function DescriptionForPageContentToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForMCPToolCall(props: {
|
||||
async function DescriptionForMCPToolCall(props: {
|
||||
toolCall: AIToolCallMCP;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -135,7 +135,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
// Resolve all hrefs for search results in parallel
|
||||
const searchResultsWithHrefs = await Promise.all(
|
||||
@@ -287,13 +287,13 @@ async function DescriptionForSearchToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForGetPagesToolCall(props: {
|
||||
async function DescriptionForGetPagesToolCall(props: {
|
||||
toolCall: AIToolCallGetPages;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './chat';
|
||||
export * from './navigate';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import { toInSiteHref } from '../navigation';
|
||||
|
||||
/**
|
||||
* Resolve a link provided by the assistant into a path that can be navigated to within the site.
|
||||
*
|
||||
* The assistant references pages using the stable content-ref scheme (e.g.
|
||||
* `/spaces/<spaceId>/pages/<pageId>`). Those URLs are not directly navigable in the published
|
||||
* site, so we resolve them to the real site link using the site context. Any other URL is only
|
||||
* accepted if it points within the current site, so the assistant cannot navigate the reader off
|
||||
* the documentation site.
|
||||
*/
|
||||
export async function resolveAINavigationLink(
|
||||
url: string
|
||||
): Promise<{ href: string } | { error: string }> {
|
||||
return traceErrorOnly('AI.resolveAINavigationLink', async () => {
|
||||
const baseContext = await getServerActionBaseContext();
|
||||
const context = await fetchServerActionSiteContext(baseContext);
|
||||
|
||||
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
|
||||
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
|
||||
let path = url;
|
||||
if (URL.canParse(url)) {
|
||||
const parsed = new URL(url);
|
||||
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
|
||||
const contentRef = resolveStringContentRef(path);
|
||||
if (contentRef) {
|
||||
const resolved = await resolveContentRef(contentRef, context);
|
||||
if (!resolved) {
|
||||
return { error: `Could not resolve page for ${url}` };
|
||||
}
|
||||
return { href: resolved.href };
|
||||
}
|
||||
|
||||
// Not a content reference: only navigate to it if it points within the current site.
|
||||
const inSiteHref = toInSiteHref(url, context.linker);
|
||||
if (!inSiteHref) {
|
||||
return { error: 'Cannot navigate to a page outside of this documentation site.' };
|
||||
}
|
||||
return { href: inSiteHref };
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { type AnyAIControlTool, getControlTools } from './controls';
|
||||
|
||||
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
export function getTools(
|
||||
builtInTools: GitBookIntegrationTool[] = []
|
||||
): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
return [...getControlTools(), ...integrationTools];
|
||||
return [...getControlTools(), ...builtInTools, ...integrationTools];
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): Rea
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
|
||||
function useAIConfig(): AIConfig {
|
||||
export function useAIConfig(): AIConfig {
|
||||
const ctx = React.useContext(AIContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAI must be used within AIContextProvider');
|
||||
@@ -95,7 +95,7 @@ export function useAI(): AIContext {
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
state={chat.responding ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
className="size-text-lg"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
@@ -13,19 +14,22 @@ import {
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { getVisitor, useTrackEvent } from '../Insights';
|
||||
import { getSession } from '../Insights/sessions';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
|
||||
export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
content: React.ReactNode;
|
||||
query?: string;
|
||||
references?: AIChatReference[];
|
||||
activity?: AIChatMessageActivity;
|
||||
};
|
||||
|
||||
@@ -82,7 +86,23 @@ export type AIChatState = {
|
||||
control: AnyAIControl | null;
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
* If true, the assistant is actively producing its answer — from the moment a
|
||||
* message is sent until the `response_finish` event. It is cleared at that point
|
||||
* (even though follow-up suggestions may still trickle in) so the input can be
|
||||
* re-enabled. Drives the local "are we still answering" UI: the disabled input,
|
||||
* the loading shim, and the thinking/exploring/working status.
|
||||
*/
|
||||
responding: boolean;
|
||||
|
||||
/**
|
||||
* If true, the turn is still in progress overall: from the moment a message is
|
||||
* sent until the stream fully completes, including the follow-up suggestion
|
||||
* phase.
|
||||
*
|
||||
* Unlike `responding` — which clears on `response_finish` — this stays true
|
||||
* until the response is truly settled. It is the global busyness indicator,
|
||||
* surfaced as `aria-busy` on the chat so that assistive tech (and visual tests)
|
||||
* can wait for a complete, stable response.
|
||||
*/
|
||||
loading: boolean;
|
||||
|
||||
@@ -92,13 +112,19 @@ export type AIChatState = {
|
||||
* display an error alert. Clearing the conversation will reset this flag.
|
||||
*/
|
||||
error: boolean;
|
||||
|
||||
/**
|
||||
* References staged on the next user message.
|
||||
*/
|
||||
references: AIChatReference[];
|
||||
};
|
||||
|
||||
export type AIChatEvent =
|
||||
| { type: 'open' }
|
||||
| { type: 'postMessage'; message: string }
|
||||
| { type: 'clear' }
|
||||
| { type: 'close' };
|
||||
| { type: 'close' }
|
||||
| { type: 'focus' };
|
||||
|
||||
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||
Extract<AIChatEvent, { type: T }>,
|
||||
@@ -116,6 +142,14 @@ export type AIChatController = {
|
||||
postMessage: (input: { message: string }) => void;
|
||||
/** Clear the conversation */
|
||||
clear: () => void;
|
||||
/** Stage a reference on the next message */
|
||||
addReference: (ref: AIChatReference) => string;
|
||||
/** Remove a staged reference */
|
||||
removeReference: (id: string) => void;
|
||||
/** Clear all staged references */
|
||||
clearReferences: () => void;
|
||||
/** Focus the chat input */
|
||||
focus: () => void;
|
||||
/** Register an event listener */
|
||||
on: <T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -134,9 +168,11 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -171,8 +207,13 @@ export function AIChatProvider(props: {
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
const setSearchState = useSetSearchState();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
|
||||
// identity, so it can be referenced directly from the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
@@ -212,6 +253,8 @@ export function AIChatProvider(props: {
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** User-typed prompt; compared against state.query to abort stale streams */
|
||||
userQuery?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
@@ -220,6 +263,7 @@ export function AIChatProvider(props: {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -233,9 +277,19 @@ export function AIChatProvider(props: {
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
@@ -271,16 +325,13 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
},
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -298,8 +349,8 @@ export function AIChatProvider(props: {
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (input.message && globalState.getState().query !== input.message) {
|
||||
// Chat was cleared, stop processing the stream
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -310,9 +361,9 @@ export function AIChatProvider(props: {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not loading when the response is finished
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
loading: false,
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
@@ -430,23 +481,40 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -455,20 +523,32 @@ export function AIChatProvider(props: {
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
navigateToPageTool,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, control } = globalState.getState();
|
||||
const { query, messages, control, references, responding } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// Ignore duplicates while a previous turn is still streaming
|
||||
if (responding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wireMessage = `${serializeReferences(references)}${input.message}`;
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
if (siteSpaceId) {
|
||||
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
|
||||
}
|
||||
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
@@ -479,8 +559,9 @@ export function AIChatProvider(props: {
|
||||
|
||||
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||
|
||||
if (query === input.message) {
|
||||
if (query === input.message && references.length === 0) {
|
||||
// Return early if the message is the same as the previous message
|
||||
// (unless new references are staged, which change the payload)
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
opened: true,
|
||||
@@ -500,25 +581,28 @@ export function AIChatProvider(props: {
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
references,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
responding: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
streamResponse({ message: input.message });
|
||||
streamResponse({ message: wireMessage, userQuery: input.message });
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse, language]
|
||||
[setSearchState, siteSpaceId, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
responding: false,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
@@ -527,6 +611,7 @@ export function AIChatProvider(props: {
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
@@ -538,6 +623,44 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onAddReference = React.useCallback((ref: AIChatReference) => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: [...state.references, ref],
|
||||
};
|
||||
});
|
||||
return ref.id;
|
||||
}, []);
|
||||
|
||||
const onRemoveReference = React.useCallback((id: string) => {
|
||||
globalState.setState((state) => {
|
||||
if (!state.references.some((ref) => ref.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: state.references.filter((ref) => ref.id !== id),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClearReferences = React.useCallback(() => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, references: [] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFocus = React.useCallback(() => {
|
||||
notify(eventsRef.current.get('focus'), {});
|
||||
}, []);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -563,9 +686,23 @@ export function AIChatProvider(props: {
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
addReference: onAddReference,
|
||||
removeReference: onRemoveReference,
|
||||
clearReferences: onClearReferences,
|
||||
focus: onFocus,
|
||||
on: onEvent,
|
||||
};
|
||||
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
onClear,
|
||||
onPostMessage,
|
||||
onAddReference,
|
||||
onRemoveReference,
|
||||
onClearReferences,
|
||||
onFocus,
|
||||
onEvent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
@@ -595,7 +732,7 @@ export function getAIChatStatus(chat: AIChatState): AIChatStatus {
|
||||
return 'confirm';
|
||||
}
|
||||
|
||||
if (chat.loading) {
|
||||
if (chat.responding) {
|
||||
const latestMessage = getLatestAssistantMessage(chat.messages);
|
||||
const phase = latestMessage?.activity?.currentPhase;
|
||||
switch (phase) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import type { AIToolDefinition } from '@gitbook/api';
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { NavigationStatusContext } from '../hooks';
|
||||
import { normalizePathname, resolveNavigationTarget } from './navigation';
|
||||
import { resolveAINavigationLink } from './server-actions';
|
||||
|
||||
const NavigateToPageInputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
|
||||
* after a timeout. App Router updates `window.location` only when the navigation commits, so this
|
||||
* lets the tool hold its turn until the user is actually on the new page — after which the
|
||||
* tool-result server action's router refresh can no longer cancel the navigation.
|
||||
*/
|
||||
function waitForNavigationCommit(pathname: string): Promise<boolean> {
|
||||
const target = normalizePathname(pathname);
|
||||
if (normalizePathname(window.location.pathname) === target) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
const check = () => {
|
||||
if (normalizePathname(window.location.pathname) === target) {
|
||||
resolve(true);
|
||||
} else if (Date.now() - startedAt > 3000) {
|
||||
resolve(false);
|
||||
} else {
|
||||
requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the built-in `navigateToPage` tool exposed to the assistant.
|
||||
*
|
||||
* The tool opens a page within the current documentation site without confirmation. It navigates
|
||||
* instantly with the Next.js router (adding a browser history entry, so the user can navigate
|
||||
* back) and waits for the navigation to commit before reporting back, so the assistant's
|
||||
* follow-up does not cancel the navigation.
|
||||
*/
|
||||
export function useNavigateToPageTool(): GitBookIntegrationTool {
|
||||
const router = useRouter();
|
||||
const language = useLanguage();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
|
||||
// The tool object is memoized once, so read the latest values from a ref at call time.
|
||||
const ref = React.useRef({ router, language, onNavigationClick });
|
||||
React.useEffect(() => {
|
||||
ref.current = { router, language, onNavigationClick };
|
||||
});
|
||||
|
||||
return React.useMemo<GitBookIntegrationTool>(
|
||||
() => ({
|
||||
name: 'navigateToPage',
|
||||
description:
|
||||
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
|
||||
inputSchema: zodToJsonSchema(
|
||||
NavigateToPageInputSchema as any
|
||||
) as AIToolDefinition['inputSchema'],
|
||||
execute: async (input) => {
|
||||
const { router, language, onNavigationClick } = ref.current;
|
||||
const { url } = NavigateToPageInputSchema.parse(input);
|
||||
|
||||
// The assistant references pages using the stable content-ref scheme
|
||||
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
|
||||
const resolved = await resolveAINavigationLink(url);
|
||||
const target =
|
||||
'error' in resolved
|
||||
? resolved
|
||||
: resolveNavigationTarget(resolved.href, window.location);
|
||||
|
||||
if ('error' in target) {
|
||||
return {
|
||||
output: { error: target.error },
|
||||
summary: {
|
||||
icon: 'triangle-exclamation',
|
||||
text: tString(language, 'ai_chat_tools_navigate_failed'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onNavigationClick(target.href);
|
||||
router.push(target.href);
|
||||
const committed = await waitForNavigationCommit(target.pathname);
|
||||
if (!committed) {
|
||||
// biome-ignore lint/suspicious/noConsole: surfaces a navigation that never committed
|
||||
console.warn(`navigateToPage: navigation to ${target.href} did not commit`);
|
||||
}
|
||||
|
||||
return {
|
||||
output: { navigated: true, url: target.href },
|
||||
summary: {
|
||||
icon: 'book-open',
|
||||
text: tString(language, 'ai_chat_tools_navigated_to_page'),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,11 @@ import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { AIChatControl } from './AIChatControl';
|
||||
import { AIChatControlButton } from './AIChatControlButton';
|
||||
import { AIChatExpandButton } from './AIChatExpandButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
import { AIChatResizeHandle } from './AIChatResizeHandle';
|
||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
|
||||
export function AIChat() {
|
||||
@@ -83,12 +85,14 @@ export function AIChat() {
|
||||
}
|
||||
}}
|
||||
withOverlay={true}
|
||||
data-ai-chat
|
||||
className={tcls(
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:w-(--ai-chat-width)'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<AIChatResizeHandle />
|
||||
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
|
||||
<EmbeddableFrameHeader className="not-embed:px-4">
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
@@ -99,6 +103,7 @@ export function AIChat() {
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
<AIChatExpandButton />
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
@@ -225,7 +230,7 @@ export function AIChatBody(props: {
|
||||
return (
|
||||
<>
|
||||
<ScrollContainer
|
||||
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
|
||||
className="min-h-[20%] max-w-full shrink grow animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
|
||||
orientation="vertical"
|
||||
trailing={{ fade: false, button: true }}
|
||||
@@ -280,8 +285,8 @@ export function AIChatBody(props: {
|
||||
|
||||
{chat.control ? <AIChatControl control={chat.control} /> : null}
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
responding={chat.responding}
|
||||
disabled={chat.responding || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
|
||||
@@ -1,12 +1,58 @@
|
||||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { Assistant } from '../AI';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
|
||||
/**
|
||||
* Button visual for an AI assistant in the header.
|
||||
*/
|
||||
export function AIChatButtonView(props: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
showLabel?: boolean;
|
||||
withShortcut?: boolean;
|
||||
inert?: boolean;
|
||||
}) {
|
||||
const { icon, label, onClick, showLabel = true, withShortcut = true, inert = false } = props;
|
||||
const language = useLanguage();
|
||||
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={icon}
|
||||
data-testid="ai-chat-button"
|
||||
iconOnly={!showLabel || isMobile}
|
||||
size="medium"
|
||||
variant="header"
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', label)}
|
||||
{withShortcut ? (
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'i']}
|
||||
className="border-tint-11 text-tint-1"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
aria-label={tString(language, 'ai_chat_ask', label)}
|
||||
onClick={inert ? undefined : onClick}
|
||||
tabIndex={inert ? -1 : undefined}
|
||||
className={tcls(inert ? 'pointer-events-none select-none' : null)}
|
||||
>
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
@@ -17,30 +63,14 @@ export function AIChatButton(props: {
|
||||
withShortcut?: boolean;
|
||||
}) {
|
||||
const { assistant, showLabel = true, withShortcut = true } = props;
|
||||
const language = useLanguage();
|
||||
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
|
||||
|
||||
return (
|
||||
<Button
|
||||
<AIChatButtonView
|
||||
icon={assistant.icon}
|
||||
data-testid="ai-chat-button"
|
||||
iconOnly={!showLabel || isMobile}
|
||||
size="medium"
|
||||
variant="header"
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
{withShortcut ? (
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'i']}
|
||||
className="border-tint-11 text-tint-1"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label={assistant.label}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
</Button>
|
||||
showLabel={showLabel}
|
||||
withShortcut={withShortcut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Button } from '../primitives';
|
||||
import { useAIChatWidthStore, useIsAIChatMaxWidth } from './useAIChatWidthStore';
|
||||
|
||||
export function AIChatExpandButton() {
|
||||
const language = useLanguage();
|
||||
const toggleWidth = useAIChatWidthStore((state) => state.toggleWidth);
|
||||
const isMaxWidth = useIsAIChatMaxWidth();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggleWidth}
|
||||
iconOnly
|
||||
icon={
|
||||
<Icon
|
||||
icon={
|
||||
isMaxWidth
|
||||
? 'arrow-down-left-and-arrow-up-right-to-center'
|
||||
: 'arrow-up-right-and-arrow-down-left-from-center'
|
||||
}
|
||||
className="scale-90"
|
||||
/>
|
||||
}
|
||||
label={tString(
|
||||
language,
|
||||
isMaxWidth ? 'ai_chat_collapse_panel' : 'ai_chat_expand_panel'
|
||||
)}
|
||||
variant="blank"
|
||||
className="max-lg:hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,27 +3,29 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAIChatState } from '../AI/useAIChat';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
import { Input } from '../primitives/Input';
|
||||
import { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
|
||||
export function AIChatInput(props: {
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When true, the input is disabled
|
||||
*/
|
||||
loading: boolean;
|
||||
responding: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
}) {
|
||||
const { onSubmit, disabled, loading } = props;
|
||||
const { onSubmit, disabled, responding } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.opened && !disabled && !loading) {
|
||||
if (chat.opened && !disabled && !responding) {
|
||||
// Add a small delay to ensure the input is rendered before focusing
|
||||
// This fixes inconsistent focus behaviour across browsers
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -32,7 +34,14 @@ export function AIChatInput(props: {
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [disabled, loading, chat.opened]);
|
||||
}, [disabled, responding, chat.opened]);
|
||||
|
||||
// Explicit focus requests (e.g. clicking "Ask" while the chat is already open).
|
||||
useEffect(() => {
|
||||
return chatController.on('focus', () => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
}, [chatController]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
@@ -42,6 +51,9 @@ export function AIChatInput(props: {
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
|
||||
// instead of focusing the Assistant input. RND-11340.
|
||||
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -66,16 +78,23 @@ export function AIChatInput(props: {
|
||||
rows={1}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={
|
||||
!disabled && !loading
|
||||
!disabled && !responding
|
||||
? {
|
||||
keys: ['mod', 'i'],
|
||||
className: 'bg-tint-base group-focus-within/input:hidden',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading || chat.control !== null}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || responding || chat.control !== null}
|
||||
aria-busy={responding}
|
||||
ref={inputRef}
|
||||
header={
|
||||
<AIChatReferenceChips
|
||||
references={chat.references}
|
||||
onRemove={chatController.removeReference}
|
||||
disabled={responding || disabled}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<HoverCardRoot openDelay={500}>
|
||||
<HoverCard
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { Fragment } from 'react';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatMessage,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { ToggleChevron } from '../primitives';
|
||||
import { Button } from '../primitives/Button';
|
||||
import { Collapsible, CollapsibleTrigger } from '../primitives/Collapsible';
|
||||
import { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
@@ -20,7 +22,7 @@ export function AIChatMessages(props: {
|
||||
}) {
|
||||
const { chat, chatController } = props;
|
||||
const status = getAIChatStatus(chat);
|
||||
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
|
||||
const showLoadingShim = chat.responding && status !== 'working' && status !== 'done';
|
||||
|
||||
// Group messages: user messages start a new group, all following messages until next user message belong to that group
|
||||
type MessageGroup = { message: AIChatMessage; originalIndex: number };
|
||||
@@ -49,6 +51,29 @@ export function AIChatMessages(props: {
|
||||
|
||||
return messageGroups.map((group, groupIndex) => {
|
||||
const isLastGroup = group === messageGroups[messageGroups.length - 1];
|
||||
|
||||
const userItems = group.filter(({ message }) => message.role === AIMessageRole.User);
|
||||
const assistantItems = group.filter(
|
||||
({ message }) => message.role === AIMessageRole.Assistant
|
||||
);
|
||||
|
||||
// Each client-side tool call splits a turn into a separate assistant message, so without
|
||||
// aggregating here every tool would render its own "Explored with 1 tool" expandable.
|
||||
// We collapse the whole assistant run behind a single expandable instead, reusing the
|
||||
// commentary (hidden) vs final-answer (visible) distinction from AIMessageView.
|
||||
const toolCount = assistantItems.reduce(
|
||||
(acc, { message }) => acc + (message.activity?.toolCount ?? 0),
|
||||
0
|
||||
);
|
||||
const hasCommentary =
|
||||
assistantItems.some(({ message }) => message.activity?.hasCommentary) || toolCount > 0;
|
||||
const hasFinalAnswer =
|
||||
assistantItems.some(({ message }) => message.activity?.hasFinalAnswer) || !isLastGroup;
|
||||
|
||||
const firstAssistantIndex = assistantItems.at(0)?.originalIndex ?? -1;
|
||||
const lastAssistantIndex = assistantItems.at(-1)?.originalIndex ?? -1;
|
||||
const isLastMessage = lastAssistantIndex === chat.messages.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupIndex}
|
||||
@@ -61,52 +86,60 @@ export function AIChatMessages(props: {
|
||||
)}
|
||||
style={{ animationDelay: '.2s' }}
|
||||
>
|
||||
{group.map(({ message, originalIndex }) => {
|
||||
const isLastMessage = originalIndex === chat.messages.length - 1;
|
||||
const toolCount = message.activity?.toolCount ?? 0;
|
||||
const hasCommentary =
|
||||
(message.activity?.hasCommentary ?? false) || toolCount > 0;
|
||||
const hasFinalAnswer =
|
||||
(message.activity?.hasFinalAnswer ?? false) || !isLastMessage;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={!hasFinalAnswer}
|
||||
disabled={!hasFinalAnswer}
|
||||
key={originalIndex}
|
||||
data-testid={
|
||||
message.role === AIMessageRole.User
|
||||
? 'ai-chat-message-user'
|
||||
: 'ai-chat-message-assistant'
|
||||
}
|
||||
{userItems.map(({ message, originalIndex }) => (
|
||||
<Fragment key={originalIndex}>
|
||||
{message.references?.length ? (
|
||||
<div className="flex max-w-[80%] origin-top-right justify-end self-end">
|
||||
<AIChatReferenceChips references={message.references} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="ai-chat-message-user"
|
||||
id={`message-${originalIndex}`}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
message.role === AIMessageRole.User
|
||||
? 'mb-4 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',
|
||||
isLastMessage && message.role === AIMessageRole.Assistant
|
||||
? 'grow'
|
||||
: ''
|
||||
'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{message.role === AIMessageRole.Assistant &&
|
||||
hasCommentary &&
|
||||
hasFinalAnswer ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="small"
|
||||
label={tString(language, 'ai_chat_view_activity')}
|
||||
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{message.content}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{assistantItems.length > 0 ? (
|
||||
<Collapsible
|
||||
open={!hasFinalAnswer}
|
||||
disabled={!hasFinalAnswer}
|
||||
data-testid="ai-chat-message-assistant"
|
||||
id={`message-${firstAssistantIndex}`}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
'origin-top-left text-tint-strong',
|
||||
isLastMessage ? 'grow' : ''
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(firstAssistantIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{hasCommentary && hasFinalAnswer ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="small"
|
||||
label={tString(language, 'ai_chat_view_activity')}
|
||||
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span data-testid="ai-chat-activity-summary">
|
||||
{toolCount > 0
|
||||
? t(
|
||||
language,
|
||||
@@ -120,50 +153,58 @@ export function AIChatMessages(props: {
|
||||
)
|
||||
)
|
||||
: t(language, 'ai_chat_explored')}
|
||||
<ToggleChevron orientation="right-to-down" />
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
</span>
|
||||
<ToggleChevron orientation="right-to-down" />
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
|
||||
{message.content}
|
||||
{assistantItems.map(({ message, originalIndex }) => (
|
||||
<Fragment key={originalIndex}>{message.content}</Fragment>
|
||||
))}
|
||||
|
||||
{isLastMessage && message.role === AIMessageRole.Assistant ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
|
||||
showLoadingShim
|
||||
? 'max-h-48 opacity-11'
|
||||
: 'pointer-events-none max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage className={message.content ? 'hidden' : ''} />
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
{isLastMessage ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
|
||||
showLoadingShim
|
||||
? 'max-h-48 opacity-11'
|
||||
: 'pointer-events-none max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage
|
||||
className={
|
||||
assistantItems.some(({ message }) => message.content)
|
||||
? 'hidden'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.responding &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIChatReference } from '../AI/references';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
const triggerClassName =
|
||||
'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint';
|
||||
|
||||
export function AIChatReferenceChips(props: {
|
||||
references: AIChatReference[];
|
||||
onRemove?: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { references, onRemove, disabled } = props;
|
||||
|
||||
if (references.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5">
|
||||
{references.map((ref) => {
|
||||
const content = (
|
||||
<>
|
||||
<Icon icon={getReferenceIcon(ref)} className="size-3 shrink-0 opacity-7" />
|
||||
<span
|
||||
className={tcls(
|
||||
'min-w-0 truncate',
|
||||
ref.type === 'code-block' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{ref.type === 'text' ? ref.content : ref.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
|
||||
>
|
||||
{ref.type === 'page' && ref.href ? (
|
||||
// A page reference may be clicked from anywhere: render a link so it
|
||||
// navigates back to the page (and supports cmd/ctrl-click to open in a
|
||||
// new tab).
|
||||
<Link
|
||||
href={ref.href}
|
||||
prefetch={false}
|
||||
className={triggerClassName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : ref.type === 'text' ? (
|
||||
// A text selection has no persistent DOM anchor to navigate to, so the
|
||||
// excerpt is shown as plain (non-interactive) content.
|
||||
<span className={triggerClassName}>{content}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
focusReference(ref);
|
||||
}}
|
||||
className={triggerClassName}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(ref.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
|
||||
>
|
||||
<Icon icon="xmark" className="size-2.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReferenceIcon(ref: AIChatReference): IconName {
|
||||
switch (ref.type) {
|
||||
case 'code-block':
|
||||
return 'code';
|
||||
case 'page':
|
||||
return 'memo';
|
||||
case 'text':
|
||||
return 'quote-left';
|
||||
default:
|
||||
assertNever(ref);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the content a reference points at, for chips that aren't rendered as links:
|
||||
* - a code block: scroll it into view on the current page and focus it;
|
||||
* - a page without a known href: scroll back to the top (the reader is most likely on it).
|
||||
*/
|
||||
function focusReference(ref: AIChatReference) {
|
||||
if (ref.type === 'page') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = document.querySelectorAll<HTMLElement>(`#${CSS.escape(ref.id)}`);
|
||||
const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]'));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.querySelector<HTMLElement>('[data-codeblock-focus]')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import React from 'react';
|
||||
import { useAIChatWidthStore } from './useAIChatWidthStore';
|
||||
|
||||
function setResizing(active: boolean) {
|
||||
document.documentElement.dataset.aiChatResizing = String(active);
|
||||
}
|
||||
|
||||
export function AIChatResizeHandle() {
|
||||
const setWidth = useAIChatWidthStore((state) => state.setWidth);
|
||||
const frameRef = React.useRef<number | null>(null);
|
||||
const widthRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onResize = () => useAIChatWidthStore.getState().syncWidth();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
setResizing(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopResizing = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
setResizing(false);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
widthRef.current = useAIChatWidthStore.getState().width;
|
||||
setResizing(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
// Panel is right-anchored, so its width is the distance from the cursor to the right edge.
|
||||
widthRef.current = window.innerWidth - event.clientX;
|
||||
if (frameRef.current === null) {
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
widthRef.current = setWidth(widthRef.current);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
setWidth(widthRef.current);
|
||||
stopResizing(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={stopResizing}
|
||||
className={tcls(
|
||||
'group -translate-x-1/2 absolute inset-y-0 left-0 z-10 hidden w-3 cursor-col-resize touch-none lg:flex',
|
||||
'items-stretch justify-center'
|
||||
)}
|
||||
>
|
||||
<span className="h-full w-px rounded-full bg-transparent transition-all duration-150 ease-out group-hover:w-0.5 group-hover:bg-primary-solid/40 group-active:w-0.5 group-active:bg-primary-solid" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { AIChatController } from '../AI';
|
||||
import { useRecentSearchQueries } from '../Search/recent-queries';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export default function AIChatSuggestedQuestions(props: {
|
||||
@@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const language = useLanguage();
|
||||
const { chatController, suggestions: _suggestions } = props;
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
|
||||
const { chatController, suggestions: configuredSuggestions } = props;
|
||||
|
||||
const suggestions =
|
||||
_suggestions && _suggestions.length > 0
|
||||
? _suggestions
|
||||
: [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const defaultSuggestions = [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const baseSuggestions =
|
||||
configuredSuggestions && configuredSuggestions.length > 0
|
||||
? configuredSuggestions
|
||||
: defaultSuggestions;
|
||||
|
||||
const suggestions = [
|
||||
...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query),
|
||||
...baseSuggestions,
|
||||
].reduce<string[]>((acc, suggestion) => {
|
||||
if (acc.includes(suggestion)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(suggestion);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { useIsMobile } from '@/components/hooks/useIsMobile';
|
||||
import { useIsMounted } from '@/components/hooks/useIsMounted';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
|
||||
import { AIChatIcon } from '../AIChatIcon';
|
||||
import { useStableTextSelection } from './useStableTextSelection';
|
||||
|
||||
/** Gap between the selection and the button. */
|
||||
const GAP = 8;
|
||||
/** Minimum distance to the viewport edges. */
|
||||
const MARGIN = 8;
|
||||
|
||||
/**
|
||||
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
|
||||
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
|
||||
*/
|
||||
export function AskAITextSelection() {
|
||||
const config = useAIConfig();
|
||||
const language = useLanguage();
|
||||
const chatController = useAIChatController();
|
||||
const isMobile = useIsMobile();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
|
||||
|
||||
const toolbarRef = React.useRef<HTMLDivElement>(null);
|
||||
const { selection, clear } = useStableTextSelection({
|
||||
rootSelector: '[data-content-ref-root]',
|
||||
enabled,
|
||||
ignoreRef: toolbarRef,
|
||||
});
|
||||
|
||||
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Position once the button has been measured, so it can be centered and clamped to the viewport.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const el = toolbarRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = el.offsetWidth;
|
||||
const height = el.offsetHeight;
|
||||
const { anchor } = selection;
|
||||
|
||||
let top = anchor.top - GAP - height;
|
||||
if (top < MARGIN) {
|
||||
// Not enough room above the selection: drop below it.
|
||||
top = anchor.bottom + GAP;
|
||||
}
|
||||
top = Math.min(top, window.innerHeight - height - MARGIN);
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(anchor.centerX - width / 2, MARGIN),
|
||||
window.innerWidth - width - MARGIN
|
||||
);
|
||||
|
||||
setCoords({ top, left });
|
||||
}, [selection]);
|
||||
|
||||
const onClick = () => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const content = selection.text;
|
||||
if (!content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatController.addReference({
|
||||
type: 'text',
|
||||
id: `text-${fnv1a(content, { size: 32 })}`,
|
||||
content,
|
||||
});
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
clear();
|
||||
};
|
||||
|
||||
if (!enabled || !isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{selection ? (
|
||||
<motion.div
|
||||
ref={toolbarRef}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.12, ease: 'easeOut' }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? 0,
|
||||
left: coords?.left ?? 0,
|
||||
zIndex: 40,
|
||||
}}
|
||||
// Keep the selection alive: prevent the button from stealing focus on click.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
icon={<AIChatIcon state="default" trademark={config.trademark} />}
|
||||
onClick={onClick}
|
||||
className="shadow-sm"
|
||||
>
|
||||
{t(language, 'ask')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AskAITextSelection';
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
type SelectionAnchor = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
centerX: number;
|
||||
};
|
||||
|
||||
export type StableTextSelection = {
|
||||
anchor: SelectionAnchor;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
/** Selector of the content root; both selection endpoints must be inside it. */
|
||||
rootSelector: string;
|
||||
enabled: boolean;
|
||||
/** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
|
||||
ignoreRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
const KEYBOARD_DEBOUNCE_MS = 250;
|
||||
const SCROLL_SETTLE_MS = 200;
|
||||
// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
|
||||
const POINTER_SETTLE_MS = 120;
|
||||
|
||||
/**
|
||||
* Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
|
||||
* doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
|
||||
* when a new gesture starts.
|
||||
*/
|
||||
export function useStableTextSelection(options: Options): {
|
||||
selection: StableTextSelection | null;
|
||||
clear: () => void;
|
||||
} {
|
||||
const { rootSelector, enabled, ignoreRef } = options;
|
||||
const [selection, setSelection] = React.useState<StableTextSelection | null>(null);
|
||||
|
||||
const clear = React.useCallback(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelection(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let pointerDown = false;
|
||||
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearKeyboardTimer = () => {
|
||||
if (keyboardTimer) {
|
||||
clearTimeout(keyboardTimer);
|
||||
keyboardTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearFinalizeTimer = () => {
|
||||
if (finalizeTimer) {
|
||||
clearTimeout(finalizeTimer);
|
||||
finalizeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
// A new gesture started; it will reschedule its own finalize.
|
||||
if (pointerDown) {
|
||||
return;
|
||||
}
|
||||
setSelection(readStableSelection(rootSelector));
|
||||
};
|
||||
|
||||
const schedulePointerFinalize = () => {
|
||||
clearKeyboardTimer();
|
||||
clearFinalizeTimer();
|
||||
finalizeTimer = setTimeout(() => {
|
||||
finalizeTimer = null;
|
||||
finalize();
|
||||
}, POINTER_SETTLE_MS);
|
||||
};
|
||||
|
||||
const isIgnored = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!ignoreRef.current?.contains(target);
|
||||
|
||||
const onSelectionChange = () => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
|
||||
clearKeyboardTimer();
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
// Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
|
||||
if (pointerDown || finalizeTimer) {
|
||||
return;
|
||||
}
|
||||
clearKeyboardTimer();
|
||||
keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (isIgnored(event.target)) {
|
||||
return;
|
||||
}
|
||||
// New gesture: hide and cancel a pending show so it can't fire mid double-click.
|
||||
pointerDown = true;
|
||||
clearFinalizeTimer();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (isIgnored(event.target)) {
|
||||
return;
|
||||
}
|
||||
pointerDown = false;
|
||||
schedulePointerFinalize();
|
||||
};
|
||||
|
||||
// pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
|
||||
const onPointerCancel = () => {
|
||||
pointerDown = false;
|
||||
schedulePointerFinalize();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
setSelection(null);
|
||||
if (scrollTimer) {
|
||||
clearTimeout(scrollTimer);
|
||||
}
|
||||
scrollTimer = setTimeout(() => {
|
||||
if (!pointerDown) {
|
||||
finalize();
|
||||
}
|
||||
}, SCROLL_SETTLE_MS);
|
||||
};
|
||||
|
||||
const onWindowBlur = () => {
|
||||
clearKeyboardTimer();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', onPointerCancel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', onPointerCancel, true);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
clearKeyboardTimer();
|
||||
clearFinalizeTimer();
|
||||
if (scrollTimer) {
|
||||
clearTimeout(scrollTimer);
|
||||
}
|
||||
};
|
||||
}, [enabled, rootSelector, ignoreRef]);
|
||||
|
||||
return { selection, clear };
|
||||
}
|
||||
|
||||
function readStableSelection(rootSelector: string): StableTextSelection | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = sel.toString().trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = document.querySelector(rootSelector);
|
||||
if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = getSelectionAnchor(sel);
|
||||
return anchor ? { anchor, text } : null;
|
||||
}
|
||||
|
||||
/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
|
||||
function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) {
|
||||
return null;
|
||||
}
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
return null;
|
||||
}
|
||||
return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './AIChatButton';
|
||||
export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
export * from './AIChatControlButton';
|
||||
export * from './AskAITextSelection';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from '@/lib/browser/local-storage';
|
||||
import { create } from 'zustand';
|
||||
import { type StorageValue, persist } from 'zustand/middleware';
|
||||
|
||||
const AI_CHAT_MIN_WIDTH = 384;
|
||||
const AI_CHAT_MAX_WIDTH = 640;
|
||||
const MIN_CONTENT_WIDTH = 720;
|
||||
|
||||
type AIChatWidthStore = {
|
||||
width: number;
|
||||
toggleWidth: () => void;
|
||||
setWidth: (width: number) => number;
|
||||
syncWidth: () => void;
|
||||
};
|
||||
|
||||
type PersistedState = Pick<AIChatWidthStore, 'width'>;
|
||||
|
||||
export const useAIChatWidthStore = create<AIChatWidthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
width: AI_CHAT_MIN_WIDTH,
|
||||
toggleWidth: () =>
|
||||
get().setWidth(
|
||||
get().width >= AI_CHAT_MAX_WIDTH ? AI_CHAT_MIN_WIDTH : AI_CHAT_MAX_WIDTH
|
||||
),
|
||||
setWidth: (width) => {
|
||||
const clamped = clampWidth(width);
|
||||
if (get().width !== clamped) {
|
||||
set({ width: clamped });
|
||||
}
|
||||
setWidthOnViewport(clamped);
|
||||
return clamped;
|
||||
},
|
||||
syncWidth: () => setWidthOnViewport(get().width),
|
||||
}),
|
||||
{
|
||||
name: '@gitbook/ai-chat-width',
|
||||
storage: {
|
||||
getItem: (name) =>
|
||||
getLocalStorageItem<StorageValue<PersistedState> | null>(name, null),
|
||||
setItem: (name, value) => setLocalStorageItem(name, value),
|
||||
removeItem: (name) => removeLocalStorageItem(name),
|
||||
},
|
||||
partialize: (state) => ({ width: state.width }),
|
||||
onRehydrateStorage: () => (state) => state?.syncWidth(),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the panel is at its maximum width.
|
||||
*/
|
||||
export const useIsAIChatMaxWidth = () =>
|
||||
useAIChatWidthStore((state) => state.width >= AI_CHAT_MAX_WIDTH);
|
||||
|
||||
// Hoisted so the synchronous persist rehydrate (during create() above) can call them before this point.
|
||||
function setWidthOnViewport(width: number) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--ai-chat-width', `${capToViewport(width)}px`);
|
||||
}
|
||||
}
|
||||
|
||||
function clampWidth(width: number) {
|
||||
return Math.min(AI_CHAT_MAX_WIDTH, Math.max(AI_CHAT_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
// Cap a width so the remaining content keeps a usable minimum at the current viewport.
|
||||
function capToViewport(width: number) {
|
||||
return typeof window === 'undefined'
|
||||
? width
|
||||
: Math.min(width, Math.max(AI_CHAT_MIN_WIDTH, window.innerWidth - MIN_CONTENT_WIDTH));
|
||||
}
|
||||
@@ -132,7 +132,7 @@ function AdSponsoredLink(props: { spaceId: string }) {
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
|
||||
return (
|
||||
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
|
||||
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint')}>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={viaUrl.toString()}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AnnouncementBanner(props: {
|
||||
className="theme-bold:bg-header-background pt-4 pb-2"
|
||||
data-nosnippet=""
|
||||
>
|
||||
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
|
||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||
<Tag
|
||||
href={contentRef?.href ?? ''}
|
||||
|
||||
@@ -80,8 +80,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
'max-w-md',
|
||||
'text-balance',
|
||||
'sm:left-auto',
|
||||
'lg:chat-open:mr-80',
|
||||
'xl:chat-open:mr-100',
|
||||
'lg:chat-open:mr-(--ai-chat-width)',
|
||||
'transition-all',
|
||||
'motion-reduce:transition-none',
|
||||
'duration-300',
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
|
||||
arrowProps={{ className: 'fill-tint-1' }}
|
||||
>
|
||||
<dfn
|
||||
data-testid="annotation-button"
|
||||
data-annotation=""
|
||||
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
|
||||
tabIndex={0}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SkeletonHeading,
|
||||
SkeletonImage,
|
||||
SkeletonParagraph,
|
||||
SkeletonSmall,
|
||||
SkeletonUpdate,
|
||||
} from '@/components/primitives';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
@@ -29,6 +28,7 @@ import { ListItem } from './ListItem';
|
||||
import { BlockMath } from './Math';
|
||||
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
|
||||
import { Paragraph } from './Paragraph';
|
||||
import { Prompt } from './Prompt';
|
||||
import { Quote } from './Quote';
|
||||
import { ReusableContent } from './ReusableContent';
|
||||
import { Stepper } from './Stepper';
|
||||
@@ -112,6 +112,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
return <Updates {...props} block={block} />;
|
||||
case 'update':
|
||||
return <Update {...props} block={block} />;
|
||||
case 'prompt':
|
||||
return <Prompt {...props} block={block} />;
|
||||
case 'if':
|
||||
// If block should be processed by the API.
|
||||
return null;
|
||||
@@ -142,7 +144,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'file':
|
||||
return <SkeletonHeading id={id} style={style} />;
|
||||
case 'paragraph':
|
||||
return <SkeletonSmall id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'list-ordered':
|
||||
case 'list-unordered':
|
||||
case 'list-tasks':
|
||||
@@ -152,8 +154,9 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'hint':
|
||||
case 'tabs':
|
||||
case 'stepper-step':
|
||||
case 'prompt':
|
||||
case 'if':
|
||||
return <SkeletonParagraph id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'expandable':
|
||||
case 'table':
|
||||
case 'swagger':
|
||||
@@ -173,7 +176,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'drawing':
|
||||
return <SkeletonImage id={id} style={style} />;
|
||||
case 'updates':
|
||||
return <SkeletonUpdate id={id} style={style} />;
|
||||
return <SkeletonUpdate id={id} className={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DocumentBlockEmbed,
|
||||
DocumentBlockFile,
|
||||
DocumentBlockImage,
|
||||
DocumentBlockImages,
|
||||
JSONDocument,
|
||||
} from '@gitbook/api';
|
||||
|
||||
@@ -12,6 +13,14 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { Inlines } from './Inlines';
|
||||
|
||||
export type CaptionAlign = NonNullable<DocumentBlockImages['data']['align']>;
|
||||
|
||||
const captionAlignStyles = {
|
||||
center: 'mx-auto',
|
||||
left: 'mr-auto ml-0',
|
||||
right: 'mr-0 ml-auto',
|
||||
} satisfies Record<CaptionAlign, ClassValue>;
|
||||
|
||||
/**
|
||||
* Wrap a content of a block that has a potential caption.
|
||||
*/
|
||||
@@ -22,6 +31,7 @@ export function Caption(
|
||||
style?: ClassValue;
|
||||
fit?: boolean;
|
||||
wrapperStyle?: ClassValue;
|
||||
align?: CaptionAlign;
|
||||
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
|
||||
withBorder?: boolean;
|
||||
withFrame?: boolean;
|
||||
@@ -49,6 +59,7 @@ export function Caption(
|
||||
withFrame && 'p-2',
|
||||
],
|
||||
style,
|
||||
align = 'center',
|
||||
} = props;
|
||||
|
||||
const caption = getNodeFragmentByName(block, 'caption');
|
||||
@@ -64,7 +75,7 @@ export function Caption(
|
||||
|
||||
return (
|
||||
<picture className={tcls('relative', style)}>
|
||||
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
|
||||
<div className={tcls(wrapperStyle, captionAlignStyles[align])}>{children}</div>
|
||||
<figcaption
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { AIChatIcon } from '@/components/AIChat';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getCodeTextFromId } from './utils';
|
||||
|
||||
export function AskAICodeButton(props: {
|
||||
codeId: string;
|
||||
title?: string;
|
||||
syntax?: string;
|
||||
style: ClassValue;
|
||||
}) {
|
||||
const { codeId, title, syntax, style } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const config = useAIConfig();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
if (config.aiMode !== CustomizationAIMode.Assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
const codeText = getCodeTextFromId(codeId);
|
||||
if (!codeText?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatController.addReference({
|
||||
type: 'code-block',
|
||||
id: codeId,
|
||||
label: title ?? 'Code',
|
||||
content: codeText,
|
||||
syntax,
|
||||
});
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
icon={<AIChatIcon state="default" trademark={config.trademark} className="" />}
|
||||
onClick={onClick}
|
||||
className={tcls(style, 'translate-y-0!', 'print:hidden', '[[data-ai-chat]_&]:hidden')}
|
||||
>
|
||||
{t(language, 'ask')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
style={style}
|
||||
theme={theme ?? plainTheme}
|
||||
id={codeBlockBodyId}
|
||||
isPrint={mode === 'print'}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -81,7 +81,14 @@ export async function CodeBlock(
|
||||
const theme = await highlight(block, richInlines, {
|
||||
themes: themes,
|
||||
});
|
||||
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
|
||||
return (
|
||||
<CodeBlockRenderer
|
||||
block={block}
|
||||
style={style}
|
||||
theme={theme}
|
||||
isPrint={context.mode === 'print'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const variables = context.contentContext
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { AskAICodeButton } from './AskAICodeButton';
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
|
||||
|
||||
@@ -13,6 +14,11 @@ type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'sty
|
||||
theme: HighlightTheme;
|
||||
'aria-busy'?: boolean;
|
||||
id?: string;
|
||||
/**
|
||||
* Whether the block is rendered for print/PDF.
|
||||
* When true, the "Ask AI" button is not rendered.
|
||||
*/
|
||||
isPrint?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,11 +28,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
props: CodeBlockRendererProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { block, style, theme, 'aria-busy': ariaBusy } = props;
|
||||
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
|
||||
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
const isSingleLine = theme.lines.length <= 1 && !withWrap;
|
||||
|
||||
const id = useId();
|
||||
const codeId = props.id || id;
|
||||
@@ -39,15 +46,24 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={codeId}
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls(
|
||||
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
|
||||
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
|
||||
style
|
||||
)}
|
||||
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
|
||||
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
|
||||
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
|
||||
>
|
||||
<span
|
||||
data-codeblock-focus
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute size-0 outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
{title ? (
|
||||
<div
|
||||
@@ -63,14 +79,26 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<CopyCodeButton
|
||||
codeId={codeId}
|
||||
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<div className="relative flex min-h-0 flex-col">
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
|
||||
isSingleLine ? 'inset-y-0' : 'top-2'
|
||||
)}
|
||||
>
|
||||
{!isPrint ? (
|
||||
<AskAICodeButton
|
||||
codeId={codeId}
|
||||
title={title}
|
||||
syntax={block.data.syntax}
|
||||
style="backdrop-blur-md"
|
||||
/>
|
||||
) : null}
|
||||
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
|
||||
</div>
|
||||
<pre
|
||||
className={tcls(
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2.5 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
|
||||
title && 'rounded-ss-none!'
|
||||
)}
|
||||
@@ -82,7 +110,6 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
}}
|
||||
>
|
||||
<code
|
||||
id={codeId}
|
||||
className={tcls(
|
||||
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap',
|
||||
@@ -114,27 +141,30 @@ function CodeHighlightLine(props: {
|
||||
withLineNumbers: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers, bg, fg } = props;
|
||||
const lineStyle = {
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={tcls('highlight-line', line.highlighted && 'highlighted')}
|
||||
style={{
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
}}
|
||||
>
|
||||
{withLineNumbers && (
|
||||
<span
|
||||
className="highlight-line-number"
|
||||
style={{
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
}}
|
||||
/>
|
||||
className={tcls(
|
||||
'highlight-line',
|
||||
line.diff === 'added' && 'diff-added',
|
||||
line.diff === 'deleted' && 'diff-deleted',
|
||||
line.highlighted && 'highlighted'
|
||||
)}
|
||||
aria-label={
|
||||
line.diff === 'added'
|
||||
? 'Added line'
|
||||
: line.diff === 'deleted'
|
||||
? 'Removed line'
|
||||
: undefined
|
||||
}
|
||||
style={lineStyle}
|
||||
>
|
||||
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
|
||||
<span className="highlight-line-content">
|
||||
<CodeHighlightTokens tokens={line.tokens} />
|
||||
{!isLast && '\n'}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getCodeTextFromId } from './utils';
|
||||
|
||||
/**
|
||||
* Client component to copy the code of a code block.
|
||||
* To avoid passing large payload to the client, the code is computed from the DOM.
|
||||
@@ -31,12 +33,12 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
||||
}, [copied]);
|
||||
|
||||
const onClick = () => {
|
||||
const element = document.getElementById(codeId);
|
||||
if (!element) {
|
||||
const codeText = getCodeTextFromId(codeId);
|
||||
if (codeText === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(getCodeText(element));
|
||||
navigator.clipboard.writeText(codeText);
|
||||
|
||||
setCopied(true);
|
||||
};
|
||||
@@ -52,31 +54,3 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the code text from the DOM,
|
||||
* ignoring the empty white space we use for empty lines (represented with a class "ew").
|
||||
*/
|
||||
function getCodeText(code: HTMLElement): string {
|
||||
let text = '';
|
||||
|
||||
const iterate = (node: Node) => {
|
||||
if (node instanceof HTMLBRElement) {
|
||||
text += '\n';
|
||||
} else if (node instanceof HTMLSpanElement) {
|
||||
if (node.classList.contains('ew')) {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += node.innerText;
|
||||
}
|
||||
} else if (node instanceof HTMLElement) {
|
||||
node.childNodes.forEach(iterate);
|
||||
} else {
|
||||
text += node.textContent;
|
||||
}
|
||||
};
|
||||
|
||||
iterate(code);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
import { Loading } from '@/components/primitives/Loading';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import Panzoom from '@panzoom/panzoom';
|
||||
import type { RenderResult } from 'mermaid';
|
||||
import { FocusScope, usePreventScroll } from 'react-aria';
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { getPlainCodeBlock } from './highlight';
|
||||
|
||||
/** Duration of the fullscreen dialog enter/exit animation, must match `animate-blur-in/out`. */
|
||||
const DIALOG_ANIMATION_MS = 200;
|
||||
|
||||
/**
|
||||
* Used to render a Mermaid diagram from a CodeBlock.
|
||||
*/
|
||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style } = props;
|
||||
const { block, mode, style } = props;
|
||||
const source = getPlainCodeBlock(block);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
// A stable container that holds the diagram subtree. We portal the diagram into it and
|
||||
// only ever move this plain node between the inline slot and the dialog — never the
|
||||
// React-managed subtree itself — so React stays in control and panzoom/SVG are preserved.
|
||||
const diagramHostRef = useRef<HTMLDivElement | null>(null);
|
||||
if (diagramHostRef.current === null && typeof document !== 'undefined') {
|
||||
const host = document.createElement('div');
|
||||
// `display: contents` so the host adds no box of its own (the diagram becomes a
|
||||
// direct flex child of the dialog panel and can fill it).
|
||||
host.style.display = 'contents';
|
||||
diagramHostRef.current = host;
|
||||
}
|
||||
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// `isFullscreen` is the open intent; `isExiting` keeps the dialog mounted while it
|
||||
// animates closed. `isPresent` is true whenever the diagram lives in the dialog.
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const isPresent = isFullscreen || isExiting;
|
||||
const { resolvedTheme } = useTheme();
|
||||
const darkMode = resolvedTheme === 'dark';
|
||||
const id = useSafeId();
|
||||
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
|
||||
const shouldRender = mode === 'print' || hasBeenInViewport;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = diagramRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!container || !wrapper) {
|
||||
@@ -36,57 +67,165 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
let cleanupPanZoom: (() => void) | undefined;
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
setPanZoom(null);
|
||||
container.innerHTML = '';
|
||||
|
||||
const cancelScheduledRender = scheduleMermaidWork(() => {
|
||||
enqueueMermaidRender(async () => {
|
||||
if (cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderMermaidDiagram({
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.svg;
|
||||
if (container.querySelector('svg')) {
|
||||
result.bindFunctions?.(container);
|
||||
}
|
||||
|
||||
renderMermaidDiagram({
|
||||
container,
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
})
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
cleanupPanZoom = initPanzoom({
|
||||
container,
|
||||
wrapper,
|
||||
onInit: setPanZoom,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelScheduledRender();
|
||||
cleanupPanZoom?.();
|
||||
setPanZoom(null);
|
||||
};
|
||||
}, [source, id, darkMode]);
|
||||
}, [source, id, darkMode, shouldRender]);
|
||||
|
||||
// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
|
||||
usePreventScroll({ isDisabled: !isPresent });
|
||||
|
||||
const openFullscreen = useCallback(() => {
|
||||
// Reserve the inline slot's current height before the diagram is detached, so the
|
||||
// page layout does not jump. Measured here while still inline and un-restyled.
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
root.style.minHeight = `${root.offsetHeight}px`;
|
||||
}
|
||||
setIsExiting(false);
|
||||
setIsFullscreen(true);
|
||||
// Re-center the diagram for the larger view.
|
||||
panZoom?.reset();
|
||||
}, [panZoom]);
|
||||
|
||||
const closeFullscreen = useCallback(() => {
|
||||
setIsFullscreen(false);
|
||||
setIsExiting(true);
|
||||
}, []);
|
||||
|
||||
// Keep the dialog mounted until the exit animation finishes, then unmount it.
|
||||
useEffect(() => {
|
||||
if (!isExiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsExiting(false);
|
||||
panZoom?.reset();
|
||||
}, DIALOG_ANIMATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isExiting, panZoom]);
|
||||
|
||||
// Allow Escape to close the dialog.
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isFullscreen, closeFullscreen]);
|
||||
|
||||
// Keep the diagram host in the inline slot on mount (and whenever it isn't in the dialog).
|
||||
useLayoutEffect(() => {
|
||||
const host = diagramHostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (host && root && !host.parentNode) {
|
||||
root.appendChild(host);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Move the diagram host into the dialog panel (and back) as the panel mounts/unmounts.
|
||||
// Done in the panel's ref callback so it happens during commit, before FocusScope reads
|
||||
// focus. The inline slot's reserved height (set in openFullscreen) is cleared on return.
|
||||
const setPanel = useCallback((panel: HTMLDivElement | null) => {
|
||||
panelRef.current = panel;
|
||||
const host = diagramHostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel) {
|
||||
panel.appendChild(host);
|
||||
} else if (root) {
|
||||
root.appendChild(host);
|
||||
root.style.minHeight = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', style)} contentEditable={false}>
|
||||
// The live diagram subtree. It is portaled into a stable host that moves between the
|
||||
// inline slot and the dialog, so its markup must not depend on where it currently lives.
|
||||
const diagram = (
|
||||
<div
|
||||
className={tcls(
|
||||
'group/mermaid relative',
|
||||
isPresent ? 'flex h-full w-full flex-col' : null
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={
|
||||
className={tcls(
|
||||
isLoading
|
||||
? 'invisible absolute inset-x-0 overflow-hidden'
|
||||
: 'cursor-grab overflow-hidden active:cursor-grabbing'
|
||||
}
|
||||
: 'cursor-grab overflow-hidden active:cursor-grabbing',
|
||||
isPresent && !isLoading ? 'flex-1' : null
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
className={tcls(
|
||||
'overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full',
|
||||
isPresent
|
||||
? 'flex h-full items-center justify-center [&_svg]:max-h-full'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
@@ -94,24 +233,62 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
<Loading className="h-8 w-8" />
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
|
||||
{!isLoading && panZoom ? (
|
||||
<MermaidPanZoomControls
|
||||
panZoom={panZoom}
|
||||
isFullscreen={isPresent}
|
||||
onToggleFullscreen={isFullscreen ? closeFullscreen : openFullscreen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */}
|
||||
<div ref={rootRef} className={tcls('relative', style)} contentEditable={false} />
|
||||
{diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null}
|
||||
{isPresent
|
||||
? createPortal(
|
||||
<FocusScope contain restoreFocus>
|
||||
{/* Backdrop: dims and blurs the page, closes on click. */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={tcls(
|
||||
'fixed inset-0 z-40 bg-tint-base/3 backdrop-blur-md dark:bg-tint-base/6',
|
||||
isFullscreen ? 'animate-fade-in' : 'animate-fade-out'
|
||||
)}
|
||||
onClick={closeFullscreen}
|
||||
/>
|
||||
{/* Centered panel. The padding area lets clicks fall through to the backdrop. */}
|
||||
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center p-3 sm:p-5 lg:p-8">
|
||||
<div
|
||||
ref={setPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid diagram"
|
||||
className={tcls(
|
||||
'pointer-events-auto relative flex h-full w-full max-w-[110rem] flex-col overflow-hidden rounded-2xl border border-tint-subtle bg-tint-base shadow-2xl',
|
||||
isFullscreen ? 'animate-blur-in' : 'animate-blur-out'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FocusScope>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMermaidDiagram(args: {
|
||||
container: HTMLElement;
|
||||
source: string;
|
||||
id: string;
|
||||
darkMode: boolean;
|
||||
}) {
|
||||
const { container, source, id, darkMode } = args;
|
||||
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
|
||||
import('mermaid'),
|
||||
import('@mermaid-js/mermaid-zenuml'),
|
||||
]);
|
||||
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
}): Promise<RenderResult> {
|
||||
const { source, id, darkMode } = args;
|
||||
const { mermaid } = await loadMermaid();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -120,11 +297,115 @@ async function renderMermaidDiagram(args: {
|
||||
theme: darkMode ? 'dark' : undefined,
|
||||
});
|
||||
|
||||
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
|
||||
container.innerHTML = svg;
|
||||
bindFunctions?.(container);
|
||||
const renderContainer = createMermaidRenderContainer();
|
||||
|
||||
try {
|
||||
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
|
||||
} finally {
|
||||
renderContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid measures labels while rendering, so the temporary render target must be
|
||||
* connected to the document. Keep it fixed and contained so those layout reads do
|
||||
* not walk the visible document flow.
|
||||
*/
|
||||
function createMermaidRenderContainer() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(container.style, {
|
||||
contain: 'strict',
|
||||
height: '100vh',
|
||||
isolation: 'isolate',
|
||||
left: '0',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
visibility: 'hidden',
|
||||
width: '100vw',
|
||||
zIndex: '-1',
|
||||
});
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
let mermaidLoadPromise: Promise<{
|
||||
mermaid: typeof import('mermaid')['default'];
|
||||
}> | null = null;
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidLoadPromise) {
|
||||
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
|
||||
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
return { mermaid };
|
||||
})
|
||||
.catch((error) => {
|
||||
mermaidLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return mermaidLoadPromise;
|
||||
}
|
||||
|
||||
let mermaidRenderQueue = Promise.resolve();
|
||||
|
||||
function enqueueMermaidRender<T>(task: () => Promise<T>) {
|
||||
const result = mermaidRenderQueue.then(task, task);
|
||||
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function waitForNextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleMermaidWork(callback: () => void) {
|
||||
const win = window as WindowWithIdleCallback;
|
||||
let cancelled = false;
|
||||
|
||||
if (win.requestIdleCallback && win.cancelIdleCallback) {
|
||||
const handle = win.requestIdleCallback(
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
win.cancelIdleCallback?.(handle);
|
||||
};
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}
|
||||
|
||||
type WindowWithIdleCallback = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize panzoom on the diagram container.
|
||||
*/
|
||||
@@ -138,7 +419,6 @@ function initPanzoom(args: {
|
||||
const instance = Panzoom(container, {
|
||||
maxScale: 5,
|
||||
minScale: 0.5,
|
||||
contain: 'outside',
|
||||
cursor: 'grab',
|
||||
panOnlyWhenZoomed: true,
|
||||
});
|
||||
|
||||
@@ -3,77 +3,54 @@
|
||||
import type { PanzoomObject } from '@panzoom/panzoom';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
|
||||
const PAN_STEP = 50;
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
|
||||
* Zoom and fullscreen controls for mermaid diagrams, grouped into a single vertical
|
||||
* toolbar positioned in the bottom-right corner. Panning is done by dragging the diagram.
|
||||
*/
|
||||
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
|
||||
const { panZoom } = props;
|
||||
export function MermaidPanZoomControls(props: {
|
||||
panZoom: PanzoomObject;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const { panZoom, isFullscreen, onToggleFullscreen } = props;
|
||||
const btnProps = {
|
||||
variant: 'secondary' as const,
|
||||
size: 'xsmall' as const,
|
||||
iconOnly: true,
|
||||
className: tcls('p-1 [&_svg]:size-3.5 opacity-90'),
|
||||
// Non-interactive tooltips: the Tooltip primitive makes their popper wrapper
|
||||
// pointer-transparent so it can't steal the hover that reveals these controls.
|
||||
tooltipProps: {
|
||||
rootProps: { disableHoverableContent: true },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
|
||||
{/* Row 1: empty, pan up, zoom in */}
|
||||
<div />
|
||||
<Button
|
||||
label="Pan up"
|
||||
icon="chevron-up"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button
|
||||
label="Zoom in"
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.zoomIn()}
|
||||
/>
|
||||
{/* Row 2: pan left, reset, pan right */}
|
||||
<Button
|
||||
label="Pan left"
|
||||
icon="chevron-left"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute right-2 bottom-2 z-10 flex flex-col items-center gap-0.5 rounded-lg transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none',
|
||||
// Keep the controls always visible in fullscreen, otherwise only reveal on hover/focus.
|
||||
isFullscreen ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<Button {...btnProps} icon="plus" label="Zoom in" onClick={() => panZoom.zoomIn()} />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="arrows-to-dot"
|
||||
label="Reset view"
|
||||
icon="refresh"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.reset()}
|
||||
/>
|
||||
<Button {...btnProps} icon="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />
|
||||
|
||||
<div className="my-0.5 h-px w-4 bg-tint-subtle" />
|
||||
|
||||
<Button
|
||||
label="Pan right"
|
||||
icon="chevron-right"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
{/* Row 3: empty, pan down, zoom out */}
|
||||
<div />
|
||||
<Button
|
||||
label="Pan down"
|
||||
icon="chevron-down"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button
|
||||
label="Zoom out"
|
||||
icon="minus"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.zoomOut()}
|
||||
{...btnProps}
|
||||
icon={isFullscreen ? 'compress' : 'expand'}
|
||||
label={isFullscreen ? 'Exit full page' : 'View in full page'}
|
||||
onClick={onToggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@gitbook/api';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { DocumentContext } from '../DocumentView';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { convertCodeStringToBlock } from './utils';
|
||||
|
||||
@@ -15,10 +16,11 @@ import { convertCodeStringToBlock } from './utils';
|
||||
export function PlainCodeBlock(props: {
|
||||
code: string;
|
||||
syntax: string;
|
||||
mode?: DocumentContext['mode'];
|
||||
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
}) {
|
||||
const { code, syntax, themeKey, themes } = props;
|
||||
const { code, syntax, mode = 'default', themeKey, themes } = props;
|
||||
const id = useId();
|
||||
|
||||
const block = convertCodeStringToBlock({ key: id, code, syntax });
|
||||
@@ -33,7 +35,7 @@ export function PlainCodeBlock(props: {
|
||||
<CodeBlock
|
||||
document={document}
|
||||
context={{
|
||||
mode: 'default',
|
||||
mode,
|
||||
}}
|
||||
themeKey={themeKey}
|
||||
themes={themes}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { type RenderedInline, getInlines, highlight } from './highlight';
|
||||
import {
|
||||
type HighlightLine,
|
||||
type HighlightToken,
|
||||
type RenderedInline,
|
||||
getInlines,
|
||||
highlight,
|
||||
} from './highlight';
|
||||
|
||||
async function highlightWithInlines(block: DocumentBlockCode) {
|
||||
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
|
||||
@@ -690,6 +696,138 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
function joinLineContent(line: HighlightLine): string {
|
||||
const visit = (tokens: HighlightToken[]): string =>
|
||||
tokens
|
||||
.map((t) => {
|
||||
if (t.type === 'plain') return t.content;
|
||||
if (t.type === 'shiki') return t.token.content;
|
||||
return visit(t.children);
|
||||
})
|
||||
.join('');
|
||||
return visit(line.tokens);
|
||||
}
|
||||
|
||||
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
|
||||
return {
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: syntax ? { syntax } : {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('classifies and strips trailing // [!code ++] in JS', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('javascript', 'const a = 1 // [!code ++]')
|
||||
);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('const a = 1');
|
||||
});
|
||||
|
||||
it('classifies and strips trailing # [!code --] in Python', async () => {
|
||||
const lines = await highlightWithInlines(singleLineBlock('python', 'x = 1 # [!code --]'));
|
||||
expect(lines[0]!.diff).toBe('deleted');
|
||||
expect(joinLineContent(lines[0]!)).toBe('x = 1');
|
||||
});
|
||||
|
||||
it('classifies and strips <!-- [!code ++] --> in HTML', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('html', '<div></div> <!-- [!code ++] -->')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('<div></div>');
|
||||
});
|
||||
|
||||
it('classifies and strips /* [!code --] */ in CSS', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('css', '.a { color: red; } /* [!code --] */')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('deleted');
|
||||
expect(joinLineContent(lines[0]!)).toBe('.a { color: red; }');
|
||||
});
|
||||
|
||||
it('does not classify when marker is not at end of line', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('javascript', 'const x = 1 // [!code ++] trailing')
|
||||
);
|
||||
expect(lines[0]!.diff).toBeNull();
|
||||
expect(joinLineContent(lines[0]!)).toBe('const x = 1 // [!code ++] trailing');
|
||||
});
|
||||
|
||||
it('returns diff: null for lines without a marker', async () => {
|
||||
const lines = await highlightWithInlines(singleLineBlock('javascript', 'console.log("hi")'));
|
||||
expect(lines[0]!.diff).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies and strips marker via plainHighlighting fallback', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock(undefined, 'plain text // [!code ++]')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('preserves inline annotation when marker is stripped', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: { syntax: 'javascript' },
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'console.' }],
|
||||
},
|
||||
{
|
||||
object: 'inline',
|
||||
type: 'annotation',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'log' }],
|
||||
},
|
||||
],
|
||||
isVoid: false,
|
||||
fragments: [],
|
||||
},
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: '("Hi") // [!code ++]' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tokens[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(tokens[0]!)).toBe('console.log("Hi")');
|
||||
// inline annotation around "log" must be preserved
|
||||
const hasAnnotation = tokens[0]!.tokens.some(
|
||||
(t) =>
|
||||
t.type === 'annotation' &&
|
||||
t.children.some((c) => c.type === 'shiki' && c.token.content === 'log')
|
||||
);
|
||||
expect(hasAnnotation).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle \\r', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
|
||||
@@ -34,11 +34,114 @@ export type HighlightTheme = {
|
||||
lines: HighlightLine[];
|
||||
};
|
||||
|
||||
export type LineDiffNotation = 'added' | 'deleted';
|
||||
|
||||
export type HighlightLine = {
|
||||
highlighted: boolean;
|
||||
diff: LineDiffNotation | null;
|
||||
tokens: HighlightToken[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects an in-source diff notation marker at the end of a line, e.g.
|
||||
* `// [!code ++]`, `# [!code --]`, `<!-- [!code ++] -->`, `/* [!code --] */`.
|
||||
* Mirror of the gitbook-x parser; keep regex byte-for-byte identical.
|
||||
*/
|
||||
const NOTATION_PATTERN =
|
||||
/[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\s+(\+\+|--)\]|<!--\s*\[!code\s+(\+\+|--)\]\s*-->|\/\*\s*\[!code\s+(\+\+|--)\]\s*\*\/)\s*$/;
|
||||
|
||||
export function parseDiffNotation(
|
||||
line: string
|
||||
): { diff: LineDiffNotation; markerStart: number } | null {
|
||||
const match = NOTATION_PATTERN.exec(line);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const variant = match[1] ?? match[2] ?? match[3];
|
||||
return {
|
||||
diff: variant === '++' ? 'added' : 'deleted',
|
||||
markerStart: match.index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a sequence of HighlightTokens so only the first `maxLen` characters
|
||||
* (counted across all tokens, recursing into annotations) remain. Used to strip
|
||||
* trailing diff-notation markers from rendered output.
|
||||
*/
|
||||
export function truncateHighlightTokens(
|
||||
tokens: HighlightToken[],
|
||||
maxLen: number
|
||||
): HighlightToken[] {
|
||||
const out: HighlightToken[] = [];
|
||||
let remaining = maxLen;
|
||||
for (const token of tokens) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const len = highlightTokenLength(token);
|
||||
if (len <= remaining) {
|
||||
out.push(token);
|
||||
remaining -= len;
|
||||
continue;
|
||||
}
|
||||
out.push(sliceHighlightToken(token, remaining));
|
||||
remaining = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function highlightTokenLength(token: HighlightToken): number {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return token.content.length;
|
||||
case 'shiki':
|
||||
return token.token.content.length;
|
||||
case 'annotation':
|
||||
return token.children.reduce((acc, child) => acc + highlightTokenLength(child), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate the text content of a sequence of HighlightTokens, recursing
|
||||
* into annotation children. Mirror of {@link highlightTokenLength}.
|
||||
*/
|
||||
export function getHighlightTokensText(tokens: HighlightToken[]): string {
|
||||
return tokens
|
||||
.map((token) => {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return token.content;
|
||||
case 'shiki':
|
||||
return token.token.content;
|
||||
case 'annotation':
|
||||
return getHighlightTokensText(token.children);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function sliceHighlightToken(token: HighlightToken, maxLen: number): HighlightToken {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return { type: 'plain', content: token.content.slice(0, maxLen) };
|
||||
case 'shiki': {
|
||||
const inner = token.token as ThemedToken & { start?: number; end?: number };
|
||||
const newContent = inner.content.slice(0, maxLen);
|
||||
const newToken: ThemedToken & { start?: number; end?: number } = {
|
||||
...inner,
|
||||
content: newContent,
|
||||
};
|
||||
if (typeof inner.start === 'number') {
|
||||
newToken.end = inner.start + newContent.length;
|
||||
}
|
||||
return { type: 'shiki', token: newToken };
|
||||
}
|
||||
case 'annotation':
|
||||
return { ...token, children: truncateHighlightTokens(token.children, maxLen) };
|
||||
}
|
||||
}
|
||||
|
||||
export type HighlightToken =
|
||||
| { type: 'plain'; content: string }
|
||||
| { type: 'shiki'; token: ThemedToken }
|
||||
@@ -136,6 +239,9 @@ export async function highlight(
|
||||
const lineBlock = block.nodes[index];
|
||||
const result: HighlightToken[] = [];
|
||||
|
||||
const lineText = tokens.map((token) => token.content).join('');
|
||||
const notation = parseDiffNotation(lineText);
|
||||
|
||||
const eatToken = (): PositionedToken | null => {
|
||||
const token = tokens.shift();
|
||||
if (token) {
|
||||
@@ -152,9 +258,14 @@ export async function highlight(
|
||||
|
||||
currentIndex += 1; // for the \n
|
||||
|
||||
const finalTokens = notation
|
||||
? truncateHighlightTokens(result, notation.markerStart)
|
||||
: result;
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock?.data.highlighted),
|
||||
tokens: result,
|
||||
diff: notation?.diff ?? null,
|
||||
tokens: finalTokens,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { bundledThemesInfo } from 'shiki/themes';
|
||||
import { customThemes } from './customThemes';
|
||||
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
|
||||
import {
|
||||
type HighlightTheme,
|
||||
type HighlightToken,
|
||||
type RenderedInline,
|
||||
getHighlightTokensText,
|
||||
parseDiffNotation,
|
||||
truncateHighlightTokens,
|
||||
} from './highlight';
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
@@ -62,9 +69,14 @@ export function plainHighlight(
|
||||
};
|
||||
});
|
||||
|
||||
// Detect diff notation against the built tokens (not the raw nodes)
|
||||
// so any evaluated inline expressions are included in the offset math.
|
||||
const notation = parseDiffNotation(getHighlightTokensText(tokens));
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock.data.highlighted),
|
||||
tokens,
|
||||
diff: notation?.diff ?? null,
|
||||
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -65,3 +65,40 @@ export function convertCodeStringToBlock(args: {
|
||||
nodes: lines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the code text of a code block from its wrapper id.
|
||||
* Returns null when the block isn't in the DOM (e.g. not yet hydrated).
|
||||
*/
|
||||
export function getCodeTextFromId(codeId: string): string | null {
|
||||
const element = document.getElementById(codeId)?.querySelector('code');
|
||||
return element ? getCodeText(element) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the code text from the DOM,
|
||||
* ignoring the empty white space we use for empty lines (represented with a class "ew").
|
||||
*/
|
||||
export function getCodeText(code: HTMLElement): string {
|
||||
let text = '';
|
||||
|
||||
const iterate = (node: Node) => {
|
||||
if (node instanceof HTMLBRElement) {
|
||||
text += '\n';
|
||||
} else if (node instanceof HTMLSpanElement) {
|
||||
if (node.classList.contains('ew')) {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += node.innerText;
|
||||
}
|
||||
} else if (node instanceof HTMLElement) {
|
||||
node.childNodes.forEach(iterate);
|
||||
} else {
|
||||
text += node.textContent;
|
||||
}
|
||||
};
|
||||
|
||||
iterate(code);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script' });
|
||||
ReactDOM.preload('https://iframely.net/embed.js', { as: 'script' });
|
||||
|
||||
const embed = await getDataOrNull(
|
||||
context.contentContext.dataFetcher.getEmbedByUrl({
|
||||
@@ -41,7 +41,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
}}
|
||||
data-visual-test="blackout"
|
||||
/>
|
||||
<Script strategy="lazyOnload" src="https://cdn.iframe.ly/embed.js" />
|
||||
<Script strategy="lazyOnload" src="https://iframely.net/embed.js" />
|
||||
</>
|
||||
) : embed.type === 'integration' ? (
|
||||
<IntegrationBlock
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { DocumentBlockExpandable } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { defaultLanguage } from '@/intl/translations';
|
||||
import { getNodeFragmentByType } from '@/lib/document';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -10,7 +12,7 @@ import { Blocks } from '../Blocks';
|
||||
import { Inlines } from '../Inlines';
|
||||
import { Details } from './Details';
|
||||
|
||||
export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
const { block, style, ancestorBlocks, document, context } = props;
|
||||
|
||||
const title = getNodeFragmentByType(block, 'expandable-title');
|
||||
@@ -26,6 +28,10 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
let id = block.meta?.id ?? '';
|
||||
id = context.getId ? context.getId(id) : id;
|
||||
|
||||
const language = context.contentContext
|
||||
? await getSpaceLanguage(context.contentContext)
|
||||
: defaultLanguage;
|
||||
|
||||
return (
|
||||
<Details
|
||||
id={id}
|
||||
@@ -68,7 +74,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
/>
|
||||
<a
|
||||
href={`#${id}`}
|
||||
aria-label="Direct link to heading"
|
||||
aria-label={tString(language, 'direct_link_to_heading')}
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'top-2',
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const language = getSpaceLanguage(context.contentContext);
|
||||
const language = await getSpaceLanguage(context.contentContext);
|
||||
const contentType = getSimplifiedContentType(file.contentType);
|
||||
const insights = {
|
||||
type: 'link_click' as const,
|
||||
|
||||
@@ -13,11 +13,11 @@ export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
|
||||
export function HashLinkButton(props: {
|
||||
id: string;
|
||||
block: DocumentBlockTabs | DocumentBlockHeading;
|
||||
label?: string;
|
||||
label: string;
|
||||
className?: ClassValue;
|
||||
iconClassName?: ClassValue;
|
||||
}) {
|
||||
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
|
||||
const { id, block, className, iconClassName, label } = props;
|
||||
const textStyle = getBlockTextStyle(block);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { DocumentBlockHeading } from '@gitbook/api';
|
||||
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { defaultLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from './Block';
|
||||
@@ -8,7 +10,7 @@ import { Inlines } from './Inlines';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
import { getTextAlignment } from './utils';
|
||||
|
||||
export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
export async function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
const { block, style, context, ...rest } = props;
|
||||
|
||||
const textStyle = getBlockTextStyle(block);
|
||||
@@ -18,6 +20,10 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
let id = block.meta?.id ?? '';
|
||||
id = context.getId ? context.getId(id) : id;
|
||||
|
||||
const language = context.contentContext
|
||||
? await getSpaceLanguage(context.contentContext)
|
||||
: defaultLanguage;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
id={id}
|
||||
@@ -39,11 +45,11 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
id={id}
|
||||
block={block}
|
||||
className={tcls(
|
||||
'-ml-6 pr-2',
|
||||
'-ml-6 self-center pr-2',
|
||||
'[.flip-heading-hash_&]:order-last [.flip-heading-hash_&]:ml-1 [.flip-heading-hash_&]:pl-2'
|
||||
)}
|
||||
iconClassName={tcls('size-4')}
|
||||
label="Direct link to heading"
|
||||
label={tString(language, 'direct_link_to_heading')}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -5,13 +5,13 @@ import { validateIconName } from '@gitbook/icons/icons';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { languages } from '@/intl/translations';
|
||||
import { defaultLanguage } from '@/intl/translations';
|
||||
import { isHeadingBlock } from '@/lib/document';
|
||||
import { Block, type BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
|
||||
export function Hint({
|
||||
export async function Hint({
|
||||
block,
|
||||
style,
|
||||
ancestorBlocks,
|
||||
@@ -27,8 +27,8 @@ export function Hint({
|
||||
const hasHeading = isHeadingBlock(firstNode);
|
||||
|
||||
const language = contextProps.context.contentContext
|
||||
? getSpaceLanguage(contextProps.context.contentContext)
|
||||
: languages.en;
|
||||
? await getSpaceLanguage(contextProps.context.contentContext)
|
||||
: defaultLanguage;
|
||||
|
||||
const label = tString(language, `hint_${block.data.style}`);
|
||||
|
||||
@@ -92,6 +92,11 @@ export function Hint({
|
||||
'empty:p-0',
|
||||
'-row-end-1',
|
||||
'-col-end-1',
|
||||
// Allow the grid track to shrink below its content's min-content size so wide
|
||||
// nested blocks (e.g. code blocks, tables) scroll internally instead of
|
||||
// overflowing the hint. Without this, the `1fr` column keeps its default
|
||||
// `min-width: auto` and grows past the hint's width.
|
||||
'min-w-0',
|
||||
'space-y-3',
|
||||
'[&_.hint]:border',
|
||||
'[&_pre]:border',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { resolveContentRefInDocument } from '@/lib/references';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from './Block';
|
||||
import { Caption } from './Caption';
|
||||
import { Caption, type CaptionAlign } from './Caption';
|
||||
import type { DocumentContext } from './DocumentView';
|
||||
|
||||
export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
@@ -52,6 +52,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
withFrame={withFrame}
|
||||
align={align}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -80,8 +81,9 @@ async function ImageBlock(props: {
|
||||
siblings: number;
|
||||
isEstimatedOffscreen: boolean;
|
||||
withFrame?: boolean;
|
||||
align?: CaptionAlign;
|
||||
}) {
|
||||
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
|
||||
const { document, block, context, isEstimatedOffscreen, withFrame, align } = props;
|
||||
|
||||
const [src, darkSrc] = await Promise.all([
|
||||
context.contentContext
|
||||
@@ -98,7 +100,7 @@ async function ImageBlock(props: {
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', 'overflow-hidden')}>
|
||||
<Caption {...props} fit>
|
||||
<Caption {...props} fit align={align}>
|
||||
<Image
|
||||
alt={block.data.alt ?? ''}
|
||||
sizes={imageBlockSizes}
|
||||
@@ -122,7 +124,11 @@ async function ImageBlock(props: {
|
||||
width: getImageDimension(block.data.width, undefined),
|
||||
height: getImageDimension(block.data.height, 'auto'),
|
||||
}}
|
||||
style={withFrame ? 'rounded-xl' : undefined}
|
||||
style={
|
||||
withFrame
|
||||
? 'rounded-xl'
|
||||
: 'circular-corners:rounded-2xl rounded-corners:rounded-sm'
|
||||
}
|
||||
/>
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,8 @@ export function InlineActionButton(
|
||||
className: 'text-[1em]',
|
||||
}}
|
||||
maxLength={action === 'ask' ? 2048 : 512}
|
||||
disabled={action === 'ask' && chatState.loading}
|
||||
aria-busy={action === 'ask' && chatState.loading}
|
||||
disabled={action === 'ask' && chatState.responding}
|
||||
aria-busy={action === 'ask' && chatState.responding}
|
||||
leading={icon}
|
||||
keyboardShortcut={false}
|
||||
onSubmit={(value) => handleSubmit(value as string)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ContentRef, type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { type TranslationLanguage, languages } from '@/intl/translations';
|
||||
import { type TranslationLanguage, defaultLanguage } from '@/intl/translations';
|
||||
import {
|
||||
type ResolvedContentRef,
|
||||
resolveContentRefFallback,
|
||||
@@ -60,7 +60,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
);
|
||||
|
||||
if (context.withLinkPreviews) {
|
||||
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
|
||||
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
|
||||
|
||||
return (
|
||||
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
|
||||
@@ -151,10 +151,7 @@ function InlineLinkTooltipWrapper(props: {
|
||||
<InlineLinkTooltip
|
||||
breadcrumbs={breadcrumbs}
|
||||
isExternal={isExternal}
|
||||
isSamePage={isSamePage}
|
||||
openInNewTabLabel={tString(language, 'open_in_new_tab')}
|
||||
target={{
|
||||
href: resolved.href,
|
||||
text: resolved.text,
|
||||
subText: resolved.subText,
|
||||
icon: resolved.icon,
|
||||
|
||||
@@ -2,91 +2,69 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Fragment } from 'react';
|
||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
|
||||
|
||||
export function InlineLinkTooltip(props: {
|
||||
isSamePage: boolean;
|
||||
isExternal: boolean;
|
||||
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
|
||||
target: {
|
||||
href: string;
|
||||
text: string;
|
||||
subText?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
openInNewTabLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
|
||||
const { isExternal, target, breadcrumbs, children } = props;
|
||||
|
||||
return (
|
||||
<HoverCardRoot>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCard className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex flex-col">
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const Tag = crumb.href ? StyledLink : 'div';
|
||||
<div className="flex flex-col">
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const Tag = crumb.href ? StyledLink : 'div';
|
||||
|
||||
return (
|
||||
<Fragment key={crumb.label}>
|
||||
{index !== 0 ? (
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 text-tint-subtle"
|
||||
/>
|
||||
return (
|
||||
<Fragment key={crumb.label}>
|
||||
{index !== 0 ? (
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 text-tint-subtle"
|
||||
/>
|
||||
) : null}
|
||||
<Tag
|
||||
className={tcls(
|
||||
'flex gap-1',
|
||||
crumb.href &&
|
||||
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||
)}
|
||||
href={crumb.href ?? '#'}
|
||||
>
|
||||
{crumb.icon ? (
|
||||
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||
{crumb.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<Tag
|
||||
className={tcls(
|
||||
'flex gap-1',
|
||||
crumb.href &&
|
||||
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||
)}
|
||||
href={crumb.href ?? '#'}
|
||||
>
|
||||
{crumb.icon ? (
|
||||
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||
{crumb.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{crumb.label}
|
||||
</Tag>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-2 leading-snug',
|
||||
isExternal && 'wrap-anywhere text-sm'
|
||||
)}
|
||||
>
|
||||
{target.icon ? (
|
||||
<div className="mt-1 text-tint-subtle empty:hidden">
|
||||
{target.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h5 className="font-semibold">{target.text}</h5>
|
||||
{crumb.label}
|
||||
</Tag>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!isSamePage && target.href ? (
|
||||
<Button
|
||||
className={tcls(
|
||||
'-mx-2 -my-2 ml-auto',
|
||||
breadcrumbs?.length === 0 ? 'place-self-center' : null
|
||||
)}
|
||||
variant="blank"
|
||||
href={target.href}
|
||||
target="_blank"
|
||||
label={openInNewTabLabel}
|
||||
size="small"
|
||||
icon="arrow-up-right-from-square"
|
||||
iconOnly={true}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-2 leading-snug',
|
||||
isExternal && 'wrap-anywhere text-sm'
|
||||
)}
|
||||
>
|
||||
{target.icon ? (
|
||||
<div className="mt-1 text-tint-subtle empty:hidden">{target.icon}</div>
|
||||
) : null}
|
||||
<h5 className="font-semibold">{target.text}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
|
||||
</HoverCard>
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import React from 'react';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that need client-only adaptive context.
|
||||
*/
|
||||
export function ContentKitWithAdaptiveVisitorContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext>
|
||||
) {
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: () => ({
|
||||
visitor: visitorClaims?.visitor ?? null,
|
||||
}),
|
||||
}),
|
||||
[visitorClaims]
|
||||
);
|
||||
|
||||
return <ContentKit {...props} clientContext={clientContext} />;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
|
||||
import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import './contentkit.css';
|
||||
import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext';
|
||||
import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import { fetchSafeIntegrationUI } from './render';
|
||||
import { renderIntegrationUi } from './server-actions';
|
||||
@@ -12,8 +14,12 @@ import { renderIntegrationUi } from './server-actions';
|
||||
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
if (!context.contentContext?.space) {
|
||||
throw new Error('integration block requires a content.spaceId');
|
||||
if (!context.contentContext) {
|
||||
throw new Error('Expected a content context to render an block');
|
||||
}
|
||||
|
||||
if (!context.contentContext.space && !block.meta?.spaceId) {
|
||||
throw new Error('integration block requires a spaceId from the context or API');
|
||||
}
|
||||
|
||||
const initialInput: RenderIntegrationUI = {
|
||||
@@ -22,16 +28,28 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
action: block.data.action,
|
||||
context: {
|
||||
type: 'document',
|
||||
spaceId: context.contentContext?.space.id,
|
||||
// When the block originates from a cross-space reusable content, the server adds a spaceId so the integration is
|
||||
// looked up in the correct source space.
|
||||
spaceId: block.meta?.spaceId ?? context.contentContext.space.id,
|
||||
editable: false,
|
||||
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
|
||||
},
|
||||
};
|
||||
|
||||
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
|
||||
integrationName: block.data.integration,
|
||||
request: initialInput,
|
||||
});
|
||||
const dataFetcher = block.meta?.token
|
||||
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
|
||||
: context.contentContext.dataFetcher;
|
||||
|
||||
const initialResponse = await fetchSafeIntegrationUI(
|
||||
{
|
||||
...context.contentContext,
|
||||
dataFetcher,
|
||||
},
|
||||
{
|
||||
integrationName: block.data.integration,
|
||||
request: initialInput,
|
||||
}
|
||||
);
|
||||
|
||||
if (initialResponse.error) {
|
||||
if (initialResponse.error.code === 404) {
|
||||
@@ -52,19 +70,34 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
return null;
|
||||
}
|
||||
|
||||
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
initialOutput
|
||||
)
|
||||
? ContentKitWithAdaptiveVisitorContext
|
||||
: ContentKit;
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKit
|
||||
<ContentKitComponent
|
||||
renderContext={{
|
||||
integrationName: block.data.integration,
|
||||
}}
|
||||
security={{ firstPartyDomains: [GITBOOK_INTEGRATIONS_HOST] }}
|
||||
security={{
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
}}
|
||||
initialInput={initialInput}
|
||||
initialOutput={initialOutput}
|
||||
render={renderIntegrationUi}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKit>
|
||||
</ContentKitComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
ContentKitDescendantElement,
|
||||
ContentKitRenderOutput,
|
||||
ContentKitRootElement,
|
||||
ContentKitStepper,
|
||||
} from '@gitbook/api';
|
||||
|
||||
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
|
||||
|
||||
/**
|
||||
* Decide whether an integration block should expose Adaptive visitor context to webframes.
|
||||
*/
|
||||
export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
output: ContentKitRenderOutput
|
||||
) {
|
||||
if (output.type === 'complete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
output.canAccessVisitorClaims === true &&
|
||||
doesContentKitElementContainWebframe(output.element)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a ContentKit element tree contains a webframe element.
|
||||
*/
|
||||
function doesContentKitElementContainWebframe(element: ContentKitElement): boolean {
|
||||
switch (element.type) {
|
||||
case 'webframe':
|
||||
return true;
|
||||
case 'block':
|
||||
case 'box':
|
||||
case 'hstack':
|
||||
case 'vstack':
|
||||
case 'step':
|
||||
case 'modal':
|
||||
case 'configuration':
|
||||
case 'stepper':
|
||||
case 'card':
|
||||
return doesContentKitElementArrayContainWebframe(element.children);
|
||||
case 'codeblock':
|
||||
return (
|
||||
doesContentKitElementArrayContainWebframe(element.header) ||
|
||||
doesContentKitElementArrayContainWebframe(element.footer)
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function doesContentKitElementArrayContainWebframe(elements: unknown): boolean {
|
||||
if (!Array.isArray(elements)) {
|
||||
return doesContentKitElementContainWebframeValue(elements);
|
||||
}
|
||||
|
||||
return elements.some(doesContentKitElementContainWebframeValue);
|
||||
}
|
||||
|
||||
function doesContentKitElementContainWebframeValue(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null || !('type' in value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return doesContentKitElementContainWebframe(value as ContentKitElement);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { languages } from '@/intl/translations';
|
||||
import { defaultLanguage } from '@/intl/translations';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import type { DocumentContextProps } from '../DocumentView';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
/**
|
||||
* Hover card displayed for a link not found.
|
||||
*/
|
||||
export function NotFoundRefHoverCard(
|
||||
export async function NotFoundRefHoverCard(
|
||||
props: DocumentContextProps & {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function NotFoundRefHoverCard(
|
||||
context: { contentContext },
|
||||
children,
|
||||
} = props;
|
||||
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
|
||||
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
|
||||
return (
|
||||
<HoverCardRoot>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
|
||||
@@ -54,6 +54,7 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
||||
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
|
||||
expandAllModelSections:
|
||||
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
|
||||
headless: block.type === 'openapi-operation' ? block.data.headless : undefined,
|
||||
})}
|
||||
className="openapi-block"
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,7 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
|
||||
expandAllModelSections:
|
||||
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
|
||||
headless: block.data.headless,
|
||||
})}
|
||||
className="openapi-block"
|
||||
/>
|
||||
|
||||
@@ -26,8 +26,9 @@ export function getOpenAPIContext(args: {
|
||||
context: GitBookAnyContext | undefined;
|
||||
expandAllResponses?: boolean;
|
||||
expandAllModelSections?: boolean;
|
||||
headless?: boolean;
|
||||
}): OpenAPIContextInput {
|
||||
const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
|
||||
const { props, specUrl, context, expandAllResponses, expandAllModelSections, headless } = args;
|
||||
const { block } = props;
|
||||
|
||||
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
|
||||
@@ -54,6 +55,7 @@ export function getOpenAPIContext(args: {
|
||||
renderCodeBlock: (codeProps) => (
|
||||
<PlainCodeBlock
|
||||
{...codeProps}
|
||||
mode={props.context.mode}
|
||||
themeKey="openapi"
|
||||
themes={
|
||||
context && 'customization' in context
|
||||
@@ -95,6 +97,7 @@ export function getOpenAPIContext(args: {
|
||||
),
|
||||
expandAllResponses: expandAllResponses || props.context.mode === 'print',
|
||||
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
|
||||
headless,
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
locale,
|
||||
|
||||
@@ -272,7 +272,7 @@ body {
|
||||
}
|
||||
.scalar-activate-button {
|
||||
@apply flex gap-2 items-center;
|
||||
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
|
||||
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-lg straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
|
||||
@apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs;
|
||||
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
|
||||
@apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user