mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +00:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a716d11ab3 | |||
| af3cda1c53 | |||
| a0ad403a91 | |||
| 0ca49523a2 | |||
| 76550415ac | |||
| 509389c7e5 | |||
| 88b94b5d36 | |||
| 251630c49f | |||
| acf2719d06 | |||
| 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 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add "Content-Signal" to "robots.txt"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support variant selector in hidden section
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/icons": patch
|
||||
---
|
||||
|
||||
Icon clipping fix
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix embeddable search links
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Pass visitor session to Ask AI.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "🚀 Dev server",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "bun install && bun dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"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",
|
||||
},
|
||||
},
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"packages/embed": {
|
||||
"name": "@gitbook/embed",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
@@ -74,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",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.25.1",
|
||||
"version": "0.27.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
@@ -127,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",
|
||||
@@ -164,15 +164,18 @@
|
||||
"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",
|
||||
@@ -186,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",
|
||||
@@ -228,7 +232,7 @@
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@gitbook/icons",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.0",
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||
},
|
||||
@@ -266,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:*",
|
||||
@@ -308,7 +312,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.5.12",
|
||||
"version": "1.5.13",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -343,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",
|
||||
@@ -356,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.179.0",
|
||||
"@gitbook/api": "0.184.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -552,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=="],
|
||||
|
||||
@@ -752,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.179.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-DrD/Pdfkdv6WWQb1nE0o91O4+gzIhRh+v8l9fhL5Wq57yt0Tk0kTCUycVpI1+H9qUt39bopN4O+KTns4mR2eLQ=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -916,23 +920,23 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -968,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=="],
|
||||
|
||||
@@ -1586,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=="],
|
||||
|
||||
@@ -1970,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=="],
|
||||
@@ -2532,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=="],
|
||||
@@ -2854,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=="],
|
||||
|
||||
@@ -2922,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=="],
|
||||
@@ -2994,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=="],
|
||||
|
||||
@@ -3308,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=="],
|
||||
@@ -3342,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=="],
|
||||
|
||||
@@ -3554,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=="],
|
||||
@@ -4022,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=="],
|
||||
@@ -4594,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=="],
|
||||
@@ -4602,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=="],
|
||||
@@ -4774,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=="],
|
||||
|
||||
+4
-4
@@ -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.179.0",
|
||||
"@gitbook/api": "0.184.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -62,6 +62,6 @@
|
||||
"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,69 @@
|
||||
# gitbook
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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[] = [
|
||||
@@ -167,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',
|
||||
@@ -186,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',
|
||||
@@ -202,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)',
|
||||
@@ -219,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)',
|
||||
@@ -237,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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1056,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]');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1323,6 +1361,7 @@ const testCases: TestsCase[] = [
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
@@ -2221,9 +2260,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',
|
||||
@@ -2257,8 +2300,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',
|
||||
@@ -2284,8 +2334,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',
|
||||
@@ -2367,8 +2422,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',
|
||||
@@ -2412,8 +2474,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.
|
||||
*/
|
||||
@@ -159,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)
|
||||
@@ -253,6 +293,9 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -372,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,
|
||||
@@ -396,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);
|
||||
@@ -445,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');
|
||||
|
||||
@@ -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.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -19,16 +19,16 @@
|
||||
"@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",
|
||||
@@ -56,15 +56,18 @@
|
||||
"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",
|
||||
@@ -78,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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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,78 @@
|
||||
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 AIChatReference = CodeBlockReference | PageReference;
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
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 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 {
|
||||
@@ -15,16 +16,20 @@ import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -81,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;
|
||||
|
||||
@@ -91,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 }>,
|
||||
@@ -115,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,
|
||||
@@ -133,9 +168,11 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -170,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());
|
||||
|
||||
@@ -211,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;
|
||||
}) => {
|
||||
@@ -219,6 +263,7 @@ export function AIChatProvider(props: {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -232,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)) {
|
||||
@@ -270,7 +325,7 @@ 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,
|
||||
@@ -294,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;
|
||||
}
|
||||
|
||||
@@ -306,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;
|
||||
@@ -426,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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -451,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,
|
||||
@@ -475,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,
|
||||
@@ -496,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,
|
||||
@@ -523,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)
|
||||
@@ -534,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,
|
||||
@@ -559,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}>
|
||||
@@ -591,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'),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -83,12 +83,13 @@ 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'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<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>
|
||||
@@ -225,7 +226,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 +281,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,6 +1,6 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import type { Assistant } from '../AI';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from '../primitives';
|
||||
@@ -38,6 +38,7 @@ export function AIChatButton(props: {
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
|
||||
@@ -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,120 @@
|
||||
'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.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>
|
||||
) : (
|
||||
<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';
|
||||
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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,7 +28,7 @@ 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';
|
||||
@@ -39,15 +45,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,11 +78,18 @@ 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="absolute top-2 right-2 z-2 flex items-start gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11">
|
||||
{!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',
|
||||
@@ -82,7 +104,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 +135,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,17 +1,22 @@
|
||||
'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.
|
||||
*/
|
||||
@@ -19,11 +24,28 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
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();
|
||||
@@ -96,27 +118,114 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
};
|
||||
}, [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 (
|
||||
// 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
|
||||
ref={rootRef}
|
||||
className={tcls('group/mermaid relative', style)}
|
||||
contentEditable={false}
|
||||
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 ? (
|
||||
@@ -124,9 +233,53 @@ 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: {
|
||||
|
||||
@@ -3,50 +3,55 @@
|
||||
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 opacity-0 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">
|
||||
{/* Row 1: empty, pan up, zoom in */}
|
||||
<div />
|
||||
<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="chevron-up"
|
||||
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
|
||||
icon="arrows-to-dot"
|
||||
label="Reset view"
|
||||
onClick={() => panZoom.reset()}
|
||||
/>
|
||||
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
|
||||
{/* Row 2: pan left, reset, pan right */}
|
||||
<Button {...btnProps} icon="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />
|
||||
|
||||
<div className="my-0.5 h-px w-4 bg-tint-subtle" />
|
||||
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-left"
|
||||
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
|
||||
icon={isFullscreen ? 'compress' : 'expand'}
|
||||
label={isFullscreen ? 'Exit full page' : 'View in full page'}
|
||||
onClick={onToggleFullscreen}
|
||||
/>
|
||||
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-right"
|
||||
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
{/* Row 3: empty, pan down, zoom out */}
|
||||
<div />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-down"
|
||||
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
|
||||
</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} />;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ 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,9 +70,15 @@ 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,
|
||||
}}
|
||||
@@ -64,7 +88,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
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 @@ export function getOpenAPIContext(args: {
|
||||
renderCodeBlock: (codeProps) => (
|
||||
<PlainCodeBlock
|
||||
{...codeProps}
|
||||
mode={props.context.mode}
|
||||
themeKey="openapi"
|
||||
themes={
|
||||
context && 'customization' in context
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface TabsState {
|
||||
@@ -56,6 +56,7 @@ const TITLES_MAX = 5;
|
||||
export interface TabsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: IconName;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -319,6 +320,7 @@ function TabsDropdownMenu(props: {
|
||||
key={tab.id}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
active={tab.id === activeTabId}
|
||||
leadingIcon={tab.icon}
|
||||
>
|
||||
{tab.title}
|
||||
</DropdownMenuItem>
|
||||
@@ -347,7 +349,8 @@ const TabItem = memo(function TabItem(props: {
|
||||
id={getTabButtonId(tab.id)}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
{tab.icon && <Icon icon={tab.icon} className="size-4 shrink-0" />}
|
||||
<span className="min-w-0 truncate">{tab.title}</span>
|
||||
</TabButton>
|
||||
);
|
||||
});
|
||||
@@ -411,7 +414,7 @@ function TabButton(
|
||||
{...rest}
|
||||
type="button"
|
||||
className={tcls(
|
||||
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
|
||||
'relative inline-flex max-w-full items-center gap-1.5 px-3.5 py-2 font-medium text-sm transition-[color]',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { DocumentBlockTabs } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { validateIconName } from '@gitbook/icons/icons';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -20,9 +22,13 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
throw new Error('Tab block is missing a key');
|
||||
}
|
||||
|
||||
const icon: IconName | undefined =
|
||||
tab.data.icon && validateIconName(tab.data.icon) ? tab.data.icon : undefined;
|
||||
|
||||
return {
|
||||
id: tab.meta?.id ?? tab.key,
|
||||
title: tab.data.title ?? '',
|
||||
icon,
|
||||
body: (
|
||||
<Blocks
|
||||
key={tab.key}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { assert } from 'ts-essentials';
|
||||
import { Tag } from '../Tag';
|
||||
import type { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
import { FilteredUpdate } from './UpdatesFilter';
|
||||
|
||||
export function Update(props: BlockProps<DocumentBlockUpdate>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
@@ -43,7 +44,10 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
|
||||
const resolvedTags = resolveBlockTags(block.data.tags, revisionTags);
|
||||
|
||||
return (
|
||||
<div className={tcls('relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-8', style)}>
|
||||
<FilteredUpdate
|
||||
tagSlugs={resolvedTags.map((tag) => tag.slug)}
|
||||
className={tcls('relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-8', style)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
// Date is only sticky on larger screens when we use flex-row layout, with 0px fallback to prevent flicker and flaky tests before JS sets the variable
|
||||
@@ -71,7 +75,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
style="[&>*:first-child]:!pt-0 flex flex-1 flex-col [&>*+*]:mt-5"
|
||||
/>
|
||||
</div>
|
||||
</FilteredUpdate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { RevisionTag } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import { TagIcon } from '../Tag';
|
||||
|
||||
const UPDATES_FILTER_SEARCH_PARAM = 'tag';
|
||||
|
||||
type UpdatesFilterContextValue = {
|
||||
selectedTags: string[];
|
||||
selectedTagSet: Set<string>;
|
||||
toggleTag: (tag: string) => void;
|
||||
clearTags: () => void;
|
||||
};
|
||||
|
||||
const emptyUpdatesFilterContext: UpdatesFilterContextValue = {
|
||||
selectedTags: [],
|
||||
selectedTagSet: new Set(),
|
||||
toggleTag: () => {},
|
||||
clearTags: () => {},
|
||||
};
|
||||
|
||||
const UpdatesFilterContext = React.createContext<UpdatesFilterContextValue | null>(null);
|
||||
|
||||
export function UpdatesFilterProvider(props: {
|
||||
tagSlugs: string[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { tagSlugs, children } = props;
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]);
|
||||
|
||||
const sanitizeTags = React.useCallback(
|
||||
(tags: string[]) => {
|
||||
const next: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!availableTags.has(tag) || seen.has(tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
next.push(tag);
|
||||
seen.add(tag);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
[availableTags]
|
||||
);
|
||||
|
||||
const rawSelectedTags = React.useMemo(
|
||||
() => searchParams?.getAll(UPDATES_FILTER_SEARCH_PARAM) ?? [],
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const urlSelectedTags = React.useMemo(
|
||||
() => sanitizeTags(rawSelectedTags),
|
||||
[sanitizeTags, rawSelectedTags]
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags);
|
||||
const selectedTagsRef = React.useRef(selectedTags);
|
||||
|
||||
const replaceTags = React.useCallback(
|
||||
(nextTags: string[]) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.delete(UPDATES_FILTER_SEARCH_PARAM);
|
||||
|
||||
for (const tag of sanitizeTags(nextTags)) {
|
||||
params.append(UPDATES_FILTER_SEARCH_PARAM, tag);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const hash = typeof window === 'undefined' ? '' : window.location.hash;
|
||||
router.replace(`${pathname}${query ? `?${query}` : ''}${hash}`, { scroll: false });
|
||||
},
|
||||
[pathname, router, sanitizeTags, searchParams]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!areTagsEqual(rawSelectedTags, urlSelectedTags)) {
|
||||
replaceTags(urlSelectedTags);
|
||||
}
|
||||
}, [rawSelectedTags, replaceTags, urlSelectedTags]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTagsRef.current = urlSelectedTags;
|
||||
setSelectedTags(urlSelectedTags);
|
||||
}, [urlSelectedTags]);
|
||||
|
||||
// Keep tag clicks responsive while the URL update from router.replace is still pending.
|
||||
const updateSelectedTags = React.useCallback(
|
||||
(getNextTags: (currentTags: string[]) => string[]) => {
|
||||
const nextTags = sanitizeTags(getNextTags(selectedTagsRef.current));
|
||||
|
||||
selectedTagsRef.current = nextTags;
|
||||
setSelectedTags(nextTags);
|
||||
replaceTags(nextTags);
|
||||
},
|
||||
[replaceTags, sanitizeTags]
|
||||
);
|
||||
|
||||
const toggleTag = React.useCallback(
|
||||
(tag: string) => {
|
||||
if (!availableTags.has(tag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectedTags((currentTags) =>
|
||||
currentTags.includes(tag)
|
||||
? currentTags.filter((currentTag) => currentTag !== tag)
|
||||
: [...currentTags, tag]
|
||||
);
|
||||
},
|
||||
[availableTags, updateSelectedTags]
|
||||
);
|
||||
|
||||
const clearTags = React.useCallback(() => {
|
||||
updateSelectedTags(() => []);
|
||||
}, [updateSelectedTags]);
|
||||
|
||||
const selectedTagSet = React.useMemo(() => new Set(selectedTags), [selectedTags]);
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
selectedTags,
|
||||
selectedTagSet,
|
||||
toggleTag,
|
||||
clearTags,
|
||||
}),
|
||||
[selectedTags, selectedTagSet, toggleTag, clearTags]
|
||||
);
|
||||
|
||||
return <UpdatesFilterContext.Provider value={value}>{children}</UpdatesFilterContext.Provider>;
|
||||
}
|
||||
|
||||
export function useUpdatesFilter(): UpdatesFilterContextValue {
|
||||
return React.useContext(UpdatesFilterContext) ?? emptyUpdatesFilterContext;
|
||||
}
|
||||
|
||||
export function UpdatesTagFilters(props: {
|
||||
tags: RevisionTag[];
|
||||
tagsLabel: string;
|
||||
clearLabel: string;
|
||||
}) {
|
||||
const { tags, tagsLabel, clearLabel } = props;
|
||||
const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter();
|
||||
const isFiltering = selectedTags.length > 0;
|
||||
|
||||
if (tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-tint-subtle border-b pt-px pb-4">
|
||||
<div className="mb-3 ml-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 font-semibold text-tint text-xs uppercase leading-wider">
|
||||
<Icon icon="tags" className="size-3" />
|
||||
{tagsLabel}
|
||||
</div>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="xsmall"
|
||||
icon="xmark"
|
||||
label={clearLabel}
|
||||
onClick={isFiltering ? clearTags : undefined}
|
||||
aria-hidden={!isFiltering}
|
||||
tabIndex={isFiltering ? undefined : -1}
|
||||
className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 px-3">
|
||||
{tags.map((tag) => {
|
||||
const selected = selectedTagSet.has(tag.slug);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag.slug}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleTag(tag.slug)}
|
||||
className={tcls(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors',
|
||||
'circular-corners:rounded-2xl straight-corners:rounded-xs',
|
||||
'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary',
|
||||
selected
|
||||
? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
|
||||
: 'bg-tint-5 text-tint-strong hover:bg-tint-hover',
|
||||
isFiltering && !selected && 'opacity-8 hover:opacity-11'
|
||||
)}
|
||||
>
|
||||
<TagIcon tag={tag} />
|
||||
<span className="truncate">{tag.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function areTagsEqual(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((tag, index) => tag === right[index]);
|
||||
}
|
||||
|
||||
export function FilteredUpdate(props: {
|
||||
tagSlugs: string[];
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { tagSlugs, className, children } = props;
|
||||
const { selectedTagSet } = useUpdatesFilter();
|
||||
|
||||
const isVisible =
|
||||
selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug));
|
||||
|
||||
return (
|
||||
<div className={className} hidden={!isVisible}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
<EmbeddableIframeButtons />
|
||||
<EmbeddableIframeCloseButton />
|
||||
</EmbeddableFrameSidebar>
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
|
||||
<EmbeddableFrameHeader>
|
||||
{!tabsRef.current ? (
|
||||
<AIChatDynamicIcon className="animate-blur-in-slow" trademark={trademark} />
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TranslationsDropdown } from './SpacesDropdown';
|
||||
/**
|
||||
* Render the header for the space.
|
||||
*/
|
||||
export function Header(props: {
|
||||
export async function Header(props: {
|
||||
context: GitBookSiteContext;
|
||||
withTopHeader?: boolean;
|
||||
variants: {
|
||||
@@ -28,6 +28,7 @@ export function Header(props: {
|
||||
const { context, withTopHeader, variants } = props;
|
||||
const { siteSpace, visibleSections, customization } = context;
|
||||
const searchProps = getSearchBaseProps(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
const withSections = Boolean(
|
||||
visibleSections &&
|
||||
@@ -180,7 +181,7 @@ export function Header(props: {
|
||||
{customization.header.links.length > 0 ||
|
||||
headerSocialAccounts.length > 0 ? (
|
||||
<HeaderLinkMore
|
||||
label={t(getSpaceLanguage(context), 'more')}
|
||||
label={t(language, 'more')}
|
||||
links={customization.header.links}
|
||||
socialAccounts={headerSocialAccounts}
|
||||
context={context}
|
||||
|
||||
@@ -11,14 +11,6 @@ interface HeaderLogoProps {
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* We previously gave the logo too much height, making it too big to look good.
|
||||
* As a response, many orgs added padding inside their logos to make them look better.
|
||||
* The new logo max-height looks better but might make existing logos look too small.
|
||||
* To avoid disruption, we only use the new (correct) logo height for logos updated after this date.
|
||||
*/
|
||||
const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
|
||||
|
||||
/**
|
||||
* Render the logo for a space using the customization settings.
|
||||
*/
|
||||
@@ -26,10 +18,6 @@ const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
|
||||
export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
const { context } = props;
|
||||
const { customization, linker } = context;
|
||||
const canUseCompactHeaderLogo =
|
||||
customization.updatedAt &&
|
||||
!Number.isNaN(Date.parse(customization.updatedAt)) &&
|
||||
Date.parse(customization.updatedAt) >= Date.parse(HEADER_COMPACT_LOGO_DATE);
|
||||
|
||||
const primaryLink = customization.header.primaryLink
|
||||
? await resolveContentRef(customization.header.primaryLink, context)
|
||||
@@ -71,7 +59,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
'max-w-40',
|
||||
'lg:max-w-64',
|
||||
'lg:site-header-none:page-no-toc:max-w-56',
|
||||
canUseCompactHeaderLogo ? 'max-h-8' : 'max-h-10',
|
||||
'max-h-8',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'object-contain',
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getSession } from './sessions';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
const VISITORID_COOKIE = '__session';
|
||||
const VISITOR_UPDATED_AT_STORAGE_KEY = '__session_updated';
|
||||
const VISITOR_STORAGE_KEY = '__session_updated';
|
||||
|
||||
/**
|
||||
* Visitor state when the visitor is not a signed-in GitBook user.
|
||||
@@ -40,31 +40,38 @@ type VisitorUserResponse = Pick<AnyVisitorResponse, 'deviceId'> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Time while a cookie is considered valid before revalidating it.
|
||||
* Time while a stored visitor is considered valid before revalidating it.
|
||||
*/
|
||||
const STALE_TIME_MS = 60 * 60 * 1000;
|
||||
|
||||
function getVisitorUpdatedAt(deviceId: string): number | null {
|
||||
const value = getLocalStorageItem<{ deviceId: string; updatedAt: number } | null>(
|
||||
VISITOR_UPDATED_AT_STORAGE_KEY,
|
||||
null
|
||||
);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value.deviceId !== deviceId) {
|
||||
clearVisitorUpdatedAt();
|
||||
return null;
|
||||
}
|
||||
return value.updatedAt;
|
||||
interface StoredVisitor {
|
||||
visitor: VisitorResponse;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function clearVisitorUpdatedAt() {
|
||||
removeLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY);
|
||||
interface StoredVisitorRaw {
|
||||
visitor?: unknown;
|
||||
deviceId?: unknown;
|
||||
updatedAt?: unknown;
|
||||
}
|
||||
|
||||
function setVisitorUpdatedAt(deviceId: string, updatedAt: number) {
|
||||
setLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY, { deviceId, updatedAt });
|
||||
function getStoredVisitor(): StoredVisitor | null {
|
||||
const value = getLocalStorageItem<StoredVisitorRaw | null>(VISITOR_STORAGE_KEY, null);
|
||||
if (!value || typeof value.updatedAt !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (isVisitor(value.visitor)) {
|
||||
return { visitor: value.visitor, updatedAt: value.updatedAt };
|
||||
}
|
||||
// Migrate the legacy { deviceId, updatedAt } shape written by older clients.
|
||||
if (typeof value.deviceId === 'string' && value.deviceId) {
|
||||
return { visitor: { deviceId: value.deviceId }, updatedAt: value.updatedAt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setStoredVisitor(visitor: VisitorResponse, updatedAt: number) {
|
||||
setLocalStorageItem(VISITOR_STORAGE_KEY, { visitor, updatedAt });
|
||||
}
|
||||
|
||||
export type VisitorResponse = AnyVisitorResponse | VisitorUserResponse;
|
||||
@@ -79,10 +86,6 @@ function isVisitor(value: unknown): value is VisitorResponse {
|
||||
);
|
||||
}
|
||||
|
||||
function isSignedInVisitor(value: unknown): value is VisitorUserResponse {
|
||||
return Boolean(isVisitor(value) && value?.userId && value.organizationId);
|
||||
}
|
||||
|
||||
const visitorStore = createStore<{
|
||||
visitor: VisitorResponse | null;
|
||||
pendingVisitor: Promise<VisitorResponse> | null;
|
||||
@@ -171,13 +174,21 @@ function getGlobalVisitor({
|
||||
const withoutCookies = isCookiesTrackingDisabled();
|
||||
|
||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
||||
// Consent withdrawn or tracking disabled: stop using AND retaining the stored id.
|
||||
removeLocalStorageItem(VISITOR_STORAGE_KEY);
|
||||
return { visitor: { deviceId: getProposedVisitorId() }, pendingVisitor: null };
|
||||
}
|
||||
|
||||
const { existing, proposedId } = getVisitorFromCookies();
|
||||
const stored = getStoredVisitor();
|
||||
|
||||
// Cookie wins, localStorage survives third-party cookie blocking, proposed id is the fallback
|
||||
const stableId = existing?.deviceId ?? stored?.visitor.deviceId ?? proposedId;
|
||||
|
||||
const fetchGlobalVisitor = async () => {
|
||||
const url = new URL(appURL);
|
||||
url.pathname = '/__session/2/';
|
||||
url.searchParams.set('proposed', proposedId);
|
||||
url.searchParams.set('proposed', stableId);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
@@ -198,33 +209,26 @@ function getGlobalVisitor({
|
||||
throw new Error(`Unexpected __session format: ${JSON.stringify(visitor)}`);
|
||||
}
|
||||
|
||||
// When cookie tracking is disabled we still allow a signed-in session to be detected,
|
||||
// but otherwise we preserve the no-cookie behavior by returning an anonymous visitor.
|
||||
if (!isSignedInVisitor(visitor)) {
|
||||
clearVisitorUpdatedAt();
|
||||
return { deviceId: proposedId };
|
||||
}
|
||||
|
||||
setVisitorUpdatedAt(visitor.deviceId, Date.now());
|
||||
setStoredVisitor(visitor, Date.now());
|
||||
|
||||
return visitor;
|
||||
} catch (error) {
|
||||
clearVisitorUpdatedAt();
|
||||
console.error('Failed to fetch visitor session ID', error);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return { deviceId: proposedId };
|
||||
return existing ?? stored?.visitor ?? { deviceId: stableId };
|
||||
}
|
||||
};
|
||||
|
||||
const { existing, proposedId } = getVisitorFromCookies();
|
||||
|
||||
if (existing) {
|
||||
// Revalidate in background if stale.
|
||||
const updatedAt = getVisitorUpdatedAt(existing.deviceId);
|
||||
const isStale = !updatedAt || updatedAt < Date.now() - STALE_TIME_MS;
|
||||
return { visitor: existing, pendingVisitor: isStale ? fetchGlobalVisitor() : null };
|
||||
// Return immediately if we already have a visitor, revalidate in the background only when stale
|
||||
const immediate = existing ?? stored?.visitor ?? null;
|
||||
if (immediate) {
|
||||
const isStale =
|
||||
!stored ||
|
||||
stored.visitor.deviceId !== immediate.deviceId ||
|
||||
stored.updatedAt < Date.now() - STALE_TIME_MS;
|
||||
return {
|
||||
visitor: immediate,
|
||||
pendingVisitor: isStale ? fetchGlobalVisitor() : null,
|
||||
};
|
||||
}
|
||||
|
||||
return { visitor: null, pendingVisitor: fetchGlobalVisitor() };
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function PDFPage(props: {
|
||||
|
||||
const customization =
|
||||
'customization' in baseContext ? baseContext.customization : defaultCustomization();
|
||||
const language = getSpaceLanguage(baseContext);
|
||||
const language = await getSpaceLanguage(baseContext);
|
||||
|
||||
// Compute the pages to render
|
||||
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAIChatState } from '@/components/AI';
|
||||
import { useAIChatController, useAIChatState } from '@/components/AI';
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||
@@ -15,12 +15,28 @@ import { createStore, useStore } from 'zustand';
|
||||
|
||||
type PageActionType = 'button' | 'dropdown-menu-item';
|
||||
|
||||
/**
|
||||
* Context about the current page, attached to the assistant as a reference when opened.
|
||||
*/
|
||||
export type PageActionAssistantContext = {
|
||||
id: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
/** Site-relative href of the page, used to navigate back to it from the chip. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Action to open the GitBook Assistant.
|
||||
*/
|
||||
export function ActionOpenAssistant(props: { assistant: Assistant; type: PageActionType }) {
|
||||
const { assistant, type } = props;
|
||||
export function ActionOpenAssistant(props: {
|
||||
assistant: Assistant;
|
||||
type: PageActionType;
|
||||
page?: PageActionAssistantContext;
|
||||
}) {
|
||||
const { assistant, type, page } = props;
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
@@ -30,8 +46,20 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct
|
||||
label={assistant.label}
|
||||
shortLabel={tString(language, 'ask')}
|
||||
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
|
||||
disabled={chat.loading}
|
||||
disabled={chat.responding}
|
||||
onClick={() => {
|
||||
// Stage a reference to the current page so the assistant is informed about
|
||||
// the context the user is asking from. Only the sidebar GitBook Assistant
|
||||
// uses the chat reference system.
|
||||
if (page && assistant.mode === 'sidebar') {
|
||||
chatController.addReference({
|
||||
type: 'page',
|
||||
id: page.id,
|
||||
label: page.title,
|
||||
path: page.path,
|
||||
href: page.href,
|
||||
});
|
||||
}
|
||||
assistant.open();
|
||||
}}
|
||||
/>
|
||||
@@ -86,7 +114,7 @@ function useCopiedStore(stateKey: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for the markdown versbion of the page.
|
||||
* Cache for the markdown version of the page.
|
||||
*/
|
||||
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
|
||||
|
||||
@@ -109,7 +137,8 @@ export function ActionCopyMarkdown(props: {
|
||||
const fetchMarkdown = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const result = await fetch(markdownPageURL).then((res) => res.text());
|
||||
const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
|
||||
const result = await fetch(humanURL).then((res) => res.text());
|
||||
markdownCache.set(markdownPageURL, result);
|
||||
|
||||
setLoading(false);
|
||||
@@ -160,7 +189,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag
|
||||
icon="markdown"
|
||||
label={tString(language, 'view_page_markdown')}
|
||||
description={tString(language, 'view_page_plaintext')}
|
||||
href={markdownPageURL}
|
||||
href={`${markdownPageURL}?displayAgentInstructions=false`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -183,7 +212,6 @@ export function ActionOpenInLLM(props: {
|
||||
type={type}
|
||||
icon={provider}
|
||||
label={tString(language, 'open_in', providerLabel)}
|
||||
shortLabel={providerLabel}
|
||||
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
|
||||
href={getLLMURL(provider, url, language)}
|
||||
/>
|
||||
@@ -435,6 +463,7 @@ function PageActionWrapper(props: {
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
label={label ?? shortLabel}
|
||||
aria-label={shortLabel}
|
||||
className="bg-tint-base"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
|
||||
import React, { useRef } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
import type {
|
||||
CustomizationPageActionType,
|
||||
GitSyncState,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
import { type Assistant, useAI } from '../AI';
|
||||
import { ToggleChevron } from '../primitives';
|
||||
import {
|
||||
ActionCopyMCPCommand,
|
||||
@@ -18,8 +22,35 @@ import {
|
||||
ActionViewAsMarkdown,
|
||||
ActionViewAsPDF,
|
||||
ActionViewAsRSS,
|
||||
type PageActionAssistantContext,
|
||||
} from './PageActions';
|
||||
|
||||
/**
|
||||
* Type of a built-in page action that can be displayed in the page actions menu.
|
||||
*/
|
||||
type PageActionType = `${CustomizationPageActionType}`;
|
||||
|
||||
/**
|
||||
* Order used to derive the list of actions from the deprecated boolean flags when the API does not
|
||||
* provide `items` yet. It matches the order the page actions menu used before the `items` model, so
|
||||
* existing sites keep the same dropdown ordering until they are migrated.
|
||||
*/
|
||||
const LEGACY_PAGE_ACTION_ORDER: PageActionType[] = [
|
||||
'assistant',
|
||||
'markdown',
|
||||
'external-ai',
|
||||
'mcp',
|
||||
'git',
|
||||
'pdf',
|
||||
];
|
||||
|
||||
/**
|
||||
* Default-button priority used in legacy mode (no `items`). It reproduces the previous behavior,
|
||||
* which only ever surfaced the assistant, the Git edit link or the markdown copy as the default
|
||||
* action — never ChatGPT, MCP or PDF.
|
||||
*/
|
||||
const LEGACY_DEFAULT_ACTION_PRIORITY: PageActionType[] = ['assistant', 'git', 'markdown'];
|
||||
|
||||
export type PageActionsDropdownURLs = {
|
||||
html: string;
|
||||
markdown: string;
|
||||
@@ -37,22 +68,95 @@ interface PageActionsDropdownProps {
|
||||
urls: PageActionsDropdownURLs;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
/** The current page, referenced by the assistant when opened. */
|
||||
page: PageActionAssistantContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
||||
* Dropdown menu for the page actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM…).
|
||||
*
|
||||
* The order and enabled state of the built-in actions are driven by `actions.items`, the ordered
|
||||
* list of enabled page actions, with its first available action shown as a quick-access button.
|
||||
* When the API does not provide `items` yet, the menu falls back to the previous ordering and
|
||||
* default-action priority so existing sites keep their current behavior.
|
||||
*/
|
||||
export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const language = useLanguage();
|
||||
const { siteTitle, urls } = props;
|
||||
|
||||
const defaultAction = usePageDefaultAction(props);
|
||||
const dropdownActions = getPageDropdownActions(props);
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
// `items` is the source of truth when the API provides it. Until then (legacy mode), we derive
|
||||
// the list from the deprecated boolean flags using the previous ordering.
|
||||
const configuredItems = getConfiguredPageActionItems(props.actions);
|
||||
const isLegacy = configuredItems === null;
|
||||
const items = configuredItems ?? deriveLegacyPageActionItems(props.actions);
|
||||
|
||||
return defaultAction || dropdownActions.length > 0 ? (
|
||||
let defaultAction: ReactNode = null;
|
||||
let markdownIsDefault = false;
|
||||
if (urls.rss) {
|
||||
// The RSS feed is not part of the configurable `items` list: it is only available on the
|
||||
// relevant pages (e.g. blog/changelog index). It is promoted as the default action whenever
|
||||
// present, as a contextual override of the configured list.
|
||||
defaultAction = <ActionViewAsRSS url={urls.rss} type="button" />;
|
||||
} else {
|
||||
// The default button is the first available action. With `items`, that is simply the first
|
||||
// entry of the configured list. In legacy mode we keep the previous default-action priority
|
||||
// (assistant → Git → markdown), so existing sites don't suddenly surface ChatGPT/MCP/PDF.
|
||||
const defaultPriority = isLegacy ? LEGACY_DEFAULT_ACTION_PRIORITY : items;
|
||||
const defaultActionType = defaultPriority.find(
|
||||
(type) => items.includes(type) && isActionTypeAvailable(type, urls, assistants)
|
||||
);
|
||||
if (defaultActionType) {
|
||||
defaultAction = renderDefaultActionForType(defaultActionType, {
|
||||
siteTitle,
|
||||
urls,
|
||||
assistants,
|
||||
page: props.page,
|
||||
});
|
||||
markdownIsDefault = defaultActionType === 'markdown';
|
||||
}
|
||||
}
|
||||
|
||||
// Build the dropdown menu items, grouped by action type. RSS is appended as its own group
|
||||
// since it is not part of the configurable `items` list.
|
||||
const groups: { key: string; items: ReactNode[] }[] = items
|
||||
.map((type) => ({
|
||||
key: type,
|
||||
items: renderDropdownActionsForType(type, {
|
||||
siteTitle,
|
||||
urls,
|
||||
markdownIsDefault,
|
||||
assistants,
|
||||
page: props.page,
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
if (urls.rss) {
|
||||
groups.push({
|
||||
key: 'rss',
|
||||
items: [<ActionViewAsRSS key="rss" url={urls.rss} type="dropdown-menu-item" />],
|
||||
});
|
||||
}
|
||||
|
||||
// Count the actual menu items (not the groups): the dropdown toggle must stay visible when a
|
||||
// single action type still exposes more than one item beyond the default button (e.g. markdown
|
||||
// exposes both "Copy page" and "View as Markdown").
|
||||
const menuItemCount = groups.reduce((total, group) => total + group.items.length, 0);
|
||||
|
||||
// Insert a separator before each group; the leading one is hidden via `first:hidden`.
|
||||
const dropdownActions = groups.flatMap((group) => [
|
||||
<DropdownMenuSeparator key={`separator-${group.key}`} className="first:hidden" />,
|
||||
...group.items,
|
||||
]);
|
||||
|
||||
return defaultAction || menuItemCount > 0 ? (
|
||||
<ButtonGroup ref={ref} className={props.className}>
|
||||
{defaultAction}
|
||||
{!defaultAction || dropdownActions.length > 1 ? (
|
||||
{!defaultAction || menuItemCount > 1 ? (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-60 max-w-max"
|
||||
@@ -75,122 +179,212 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of actions to show in the dropdown menu.
|
||||
* Return the configured ordered list of enabled page actions, or `null` when the API does not
|
||||
* provide it yet (legacy mode).
|
||||
*/
|
||||
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
|
||||
const { siteTitle, urls, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
function getConfiguredPageActionItems(
|
||||
actions: SiteCustomizationSettings['pageActions']
|
||||
): PageActionType[] | null {
|
||||
return actions.items ?? null;
|
||||
}
|
||||
|
||||
return [
|
||||
...assistants.map((assistant) => (
|
||||
<ActionOpenAssistant
|
||||
key={assistant.label}
|
||||
assistant={assistant}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
)),
|
||||
/**
|
||||
* Derive the ordered list of enabled page actions from the deprecated boolean flags, following the
|
||||
* ordering used before the `items` model. Used only when the API does not provide `items`.
|
||||
*/
|
||||
function deriveLegacyPageActionItems(
|
||||
actions: SiteCustomizationSettings['pageActions']
|
||||
): PageActionType[] {
|
||||
return LEGACY_PAGE_ACTION_ORDER.filter((type) => {
|
||||
switch (type) {
|
||||
case 'external-ai':
|
||||
return actions.externalAI;
|
||||
case 'markdown':
|
||||
return actions.markdown;
|
||||
case 'mcp':
|
||||
return actions.mcp;
|
||||
// `assistant` is governed by the AI mode setting, and `git`/`pdf` are not represented
|
||||
// by the legacy `pageActions` flags; all three are gated by availability at render time.
|
||||
case 'assistant':
|
||||
case 'git':
|
||||
case 'pdf':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actions.markdown ? (
|
||||
<React.Fragment key="markdown">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
/**
|
||||
* Whether an action type can be rendered given the available URLs and assistants.
|
||||
*/
|
||||
function isActionTypeAvailable(
|
||||
type: PageActionType,
|
||||
urls: PageActionsDropdownURLs,
|
||||
assistants: Assistant[]
|
||||
): boolean {
|
||||
switch (type) {
|
||||
case 'assistant':
|
||||
return assistants.length > 0;
|
||||
case 'external-ai':
|
||||
case 'markdown':
|
||||
return true;
|
||||
case 'mcp':
|
||||
return !!urls.mcp;
|
||||
case 'git':
|
||||
return !!urls.editOnGit;
|
||||
case 'pdf':
|
||||
return !!urls.pdf;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of menu items shown in the dropdown for a given action type.
|
||||
*
|
||||
* Returns a flat array of items (without separators); the caller groups them and inserts the
|
||||
* separators between groups.
|
||||
*/
|
||||
function renderDropdownActionsForType(
|
||||
type: PageActionType,
|
||||
params: {
|
||||
siteTitle: string;
|
||||
urls: PageActionsDropdownURLs;
|
||||
markdownIsDefault: boolean;
|
||||
assistants: Assistant[];
|
||||
page: PageActionAssistantContext;
|
||||
}
|
||||
): ReactNode[] {
|
||||
const { siteTitle, urls, markdownIsDefault, assistants, page } = params;
|
||||
|
||||
switch (type) {
|
||||
case 'assistant':
|
||||
return assistants.map((assistant) => (
|
||||
<ActionOpenAssistant
|
||||
key={`assistant-${assistant.id}`}
|
||||
assistant={assistant}
|
||||
type="dropdown-menu-item"
|
||||
page={page}
|
||||
/>
|
||||
));
|
||||
case 'external-ai':
|
||||
return [
|
||||
<ActionOpenInLLM
|
||||
key="chatgpt"
|
||||
provider="chatgpt"
|
||||
url={urls.html}
|
||||
type="dropdown-menu-item"
|
||||
/>,
|
||||
<ActionOpenInLLM
|
||||
key="claude"
|
||||
provider="claude"
|
||||
url={urls.html}
|
||||
type="dropdown-menu-item"
|
||||
/>,
|
||||
];
|
||||
case 'markdown':
|
||||
return [
|
||||
<ActionCopyMarkdown
|
||||
isDefaultAction={!assistants.length}
|
||||
key="copy-markdown"
|
||||
isDefaultAction={markdownIsDefault}
|
||||
markdownPageURL={urls.markdown}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ActionViewAsMarkdown markdownPageURL={urls.markdown} type="dropdown-menu-item" />
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
actions.externalAI ? (
|
||||
<React.Fragment key="externalAI">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionOpenInLLM provider="chatgpt" url={urls.html} type="dropdown-menu-item" />
|
||||
<ActionOpenInLLM provider="claude" url={urls.html} type="dropdown-menu-item" />
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
actions.mcp && urls.mcp ? (
|
||||
<React.Fragment key="mcp">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionCopyMCPURL mcpURL={urls.mcp} type="dropdown-menu-item" />
|
||||
/>,
|
||||
<ActionViewAsMarkdown
|
||||
key="view-markdown"
|
||||
markdownPageURL={urls.markdown}
|
||||
type="dropdown-menu-item"
|
||||
/>,
|
||||
];
|
||||
case 'mcp':
|
||||
if (!urls.mcp) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
<ActionCopyMCPURL key="copy-mcp" mcpURL={urls.mcp} type="dropdown-menu-item" />,
|
||||
<ActionOpenMCP
|
||||
key="mcp-vscode"
|
||||
provider="vscode"
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
/>,
|
||||
<ActionCopyMCPCommand
|
||||
key="mcp-claude-code"
|
||||
provider="claude-code"
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
/>,
|
||||
<ActionCopyMCPCommand
|
||||
key="mcp-codex"
|
||||
provider="codex"
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
urls.editOnGit || urls.pdf || urls.rss ? (
|
||||
<React.Fragment key="editOnGit">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
{urls.editOnGit ? (
|
||||
<ActionOpenEditOnGit
|
||||
type="dropdown-menu-item"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
) : null}
|
||||
{urls.rss ? <ActionViewAsRSS url={urls.rss} type="dropdown-menu-item" /> : null}
|
||||
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
/>,
|
||||
];
|
||||
case 'git':
|
||||
if (!urls.editOnGit) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
<ActionOpenEditOnGit
|
||||
key="edit-git"
|
||||
type="dropdown-menu-item"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>,
|
||||
];
|
||||
case 'pdf':
|
||||
if (!urls.pdf) {
|
||||
return [];
|
||||
}
|
||||
return [<ActionViewAsPDF key="pdf" url={urls.pdf} type="dropdown-menu-item" />];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
* Render the action shown as the quick-access default button for a given action type.
|
||||
*/
|
||||
function usePageDefaultAction(props: PageActionsDropdownProps) {
|
||||
const { urls, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
if (urls.rss) {
|
||||
return <ActionViewAsRSS url={urls.rss} type="button" />;
|
||||
function renderDefaultActionForType(
|
||||
type: PageActionType,
|
||||
params: {
|
||||
siteTitle: string;
|
||||
urls: PageActionsDropdownURLs;
|
||||
assistants: Assistant[];
|
||||
page: PageActionAssistantContext;
|
||||
}
|
||||
): ReactNode {
|
||||
const { urls, assistants, page } = params;
|
||||
|
||||
const assistant = assistants[0];
|
||||
if (assistant) {
|
||||
return <ActionOpenAssistant assistant={assistant} type="button" />;
|
||||
switch (type) {
|
||||
case 'assistant':
|
||||
return assistants[0] ? (
|
||||
<ActionOpenAssistant assistant={assistants[0]} type="button" page={page} />
|
||||
) : null;
|
||||
case 'external-ai':
|
||||
return <ActionOpenInLLM provider="chatgpt" url={urls.html} type="button" />;
|
||||
case 'markdown':
|
||||
return (
|
||||
<ActionCopyMarkdown isDefaultAction markdownPageURL={urls.markdown} type="button" />
|
||||
);
|
||||
case 'mcp':
|
||||
return urls.mcp ? <ActionCopyMCPURL mcpURL={urls.mcp} type="button" /> : null;
|
||||
case 'git':
|
||||
return urls.editOnGit ? (
|
||||
<ActionOpenEditOnGit
|
||||
type="button"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
) : null;
|
||||
case 'pdf':
|
||||
return urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="button" /> : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (urls.editOnGit) {
|
||||
return (
|
||||
<ActionOpenEditOnGit
|
||||
type="button"
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<ActionCopyMarkdown
|
||||
isDefaultAction={!assistant}
|
||||
markdownPageURL={urls.markdown}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { getSpaceLanguage, t, tString } from '@/intl/server';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getDocumentSections } from '@/lib/document-sections';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import {
|
||||
type JSONDocument,
|
||||
type RevisionPageDocument,
|
||||
type RevisionTag,
|
||||
SiteAdsStatus,
|
||||
SiteInsightsAdPlacement,
|
||||
} from '@gitbook/api';
|
||||
@@ -12,6 +13,7 @@ import React from 'react';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Ad } from '../Ads';
|
||||
import { UpdatesTagFilters } from '../DocumentView/UpdatesFilter';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
import { ThemeToggler } from '../ThemeToggler';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
@@ -22,17 +24,18 @@ import { ScrollToTopButton } from './ScrollToTopButton';
|
||||
/**
|
||||
* Aside listing the headings in the document.
|
||||
*/
|
||||
export function PageAside(props: {
|
||||
export async function PageAside(props: {
|
||||
page: RevisionPageDocument;
|
||||
document: JSONDocument | null;
|
||||
filterableTags: RevisionTag[];
|
||||
context: GitBookSiteContext;
|
||||
withHeaderOffset: { sectionsHeader: boolean; topHeader: boolean };
|
||||
withFullPageCover: boolean;
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
const { page, document, withPageFeedback, context } = props;
|
||||
const { page, document, filterableTags, withPageFeedback, context } = props;
|
||||
const { customization, site } = context;
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
@@ -103,7 +106,19 @@ export function PageAside(props: {
|
||||
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
|
||||
{page.layout.outline ? (
|
||||
<>
|
||||
<div className="mb-3 ml-3 flex page-no-outline:hidden items-center justify-between max-lg:hidden">
|
||||
{filterableTags.length > 0 ? (
|
||||
<UpdatesTagFilters
|
||||
tags={filterableTags}
|
||||
tagsLabel={tString(language, 'tags')}
|
||||
clearLabel={tString(language, 'clear')}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'mb-3 ml-3 flex page-no-outline:hidden items-center justify-between max-lg:hidden',
|
||||
filterableTags.length > 0 && 'mt-4'
|
||||
)}
|
||||
>
|
||||
<ScrollToTopButton className="flex cursor-pointer items-center gap-1 font-semibold text-tint text-xs uppercase leading-wider">
|
||||
<Icon icon="block-quote" className="size-3" />{' '}
|
||||
{t(language, 'on_this_page')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
import { useUpdatesFilter } from '@/components/DocumentView/UpdatesFilter';
|
||||
import { useScrollActiveId } from '@/components/hooks';
|
||||
import type { DocumentSection } from '@/lib/document-sections';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -19,7 +20,21 @@ const SECTION_INTERSECTING_THRESHOLD = 0.9;
|
||||
const ACTIVE_ITEM_OFFSET = 100;
|
||||
|
||||
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
|
||||
const ids = React.useMemo(() => sections.map(({ id }) => id), [sections]);
|
||||
const { selectedTagSet } = useUpdatesFilter();
|
||||
const visibleSections = React.useMemo(() => {
|
||||
if (selectedTagSet.size === 0) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return sections.filter((section) => {
|
||||
if (section.tags === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug));
|
||||
});
|
||||
}, [sections, selectedTagSet]);
|
||||
const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]);
|
||||
|
||||
const enabled = useBodyLoaded();
|
||||
|
||||
@@ -46,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
className="relative flex flex-col border-tint-subtle sidebar-list-line:border-l pb-5"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
{visibleSections.map((section) => (
|
||||
<li
|
||||
key={section.id}
|
||||
className={tcls(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } f
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document';
|
||||
import { getLLMsTxtURL, getPageMarkdownURL } from '@/lib/llms-directive';
|
||||
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
|
||||
@@ -21,7 +22,7 @@ import { PreservePageLayout } from './PreservePageLayout';
|
||||
|
||||
const LINK_PREVIEW_MAX_COUNT = 500;
|
||||
|
||||
export function PageBody(props: {
|
||||
export async function PageBody(props: {
|
||||
context: GitBookSiteContext;
|
||||
page: RevisionPageDocument;
|
||||
ancestors: AncestorRevisionPage[];
|
||||
@@ -59,7 +60,7 @@ export function PageBody(props: {
|
||||
// This happens when: (1) document has full-width blocks, OR (2) page layout is explicitly set to 'wide'
|
||||
const wideContent = document ? hasFullWidthBlock(document) : false;
|
||||
const wideLayout = wideContent || page.layout.width === 'wide';
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
const updatedAt = page.updatedAt ?? page.createdAt;
|
||||
|
||||
const hasVisibleTOCItems =
|
||||
@@ -88,6 +89,7 @@ export function PageBody(props: {
|
||||
)}
|
||||
>
|
||||
<PreservePageLayout wideLayout={wideLayout} pageHasToc={pageHasToc} />
|
||||
<LLMsTxtPageDirective context={context} page={page} />
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
@@ -160,3 +162,17 @@ export function PageBody(props: {
|
||||
</CurrentPageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LLMsTxtPageDirective(props: {
|
||||
context: GitBookSiteContext;
|
||||
page: RevisionPageDocument;
|
||||
}) {
|
||||
const { context, page } = props;
|
||||
|
||||
return (
|
||||
<div className="sr-only">
|
||||
For the complete documentation index, see <a href={getLLMsTxtURL(context)}>llms.txt</a>.
|
||||
This page is also available as <a href={getPageMarkdownURL(context, page)}>Markdown</a>.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function PageFooterNavigation(props: {
|
||||
const { context, page } = props;
|
||||
const { revision, linker } = context;
|
||||
const { previous, next } = resolvePrevNextPages(revision.pages, page);
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
const previousHref = previous
|
||||
? linker.toPathForPage({ pages: revision.pages, page: previous })
|
||||
: '';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user