mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 07:35:16 +00:00
Compare commits
334 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39150d5a8f | |||
| 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 | |||
| 83df84ac28 | |||
| 5e5075a137 | |||
| 90c1f68e19 | |||
| 0e10124626 | |||
| 01e1c2ae6a | |||
| 65558cfbb1 | |||
| 10d894ce19 | |||
| d1a2958212 | |||
| 894b6adc4c | |||
| b3b819f8e5 | |||
| 2b98af0cc0 | |||
| 54a56ed772 | |||
| 071627d4f0 | |||
| 7595706b92 | |||
| be080248b1 | |||
| d3d51507e5 | |||
| a17ab65a6d | |||
| e3aca9e4f6 | |||
| 045f603849 | |||
| b2854500a1 | |||
| ead88591c2 | |||
| aa1fc246e2 | |||
| e792cffeb3 | |||
| 119e3bc369 | |||
| 823e7d5c4b | |||
| f5f659e7fc | |||
| 723e579958 | |||
| f27901c453 | |||
| 789c2fab60 | |||
| 3fa727c80b | |||
| 88c38fa505 | |||
| bda9fe2d76 | |||
| 2bdade3b57 | |||
| 8d2a95b168 | |||
| 68c842bd4c | |||
| ecb4292551 | |||
| 7fd381c0b1 | |||
| 709964fe3e | |||
| afc7a38bef | |||
| bbcfd72a6d | |||
| 8242f18b8a | |||
| 4f7fba08df | |||
| f17e292ba4 | |||
| 6e1074ec4d | |||
| c5e5857623 | |||
| 11df1fd395 | |||
| 569d4046be | |||
| 50653abd08 | |||
| 84082225af | |||
| 7d882cef5f | |||
| 126d4b78b6 | |||
| e8224bad6c | |||
| 6063437513 | |||
| ab17ff47b0 | |||
| d97b404c7a | |||
| 3c141f0231 | |||
| e3d391e01b | |||
| 62cfec874a | |||
| ccd21e4943 | |||
| 696d9d0d01 | |||
| b77c4fc5b9 | |||
| 7e1ea48080 | |||
| b5d6f2e908 | |||
| 832b44f0ac | |||
| 4909044831 | |||
| 4ed84c209a | |||
| ca01888fe2 | |||
| 6855c05781 | |||
| ab867eb69c | |||
| deabce98ae | |||
| f884477a65 | |||
| c56b569a05 | |||
| 1ba412ff6c | |||
| 7c2823cd26 | |||
| 16bfafe32b | |||
| da648cabd8 | |||
| a36e437580 | |||
| d5dd5e7ba1 | |||
| c4a2604b4d | |||
| 7f4e644733 | |||
| 5d3f78b112 | |||
| 60bcf43304 | |||
| b40465e7b7 | |||
| 3151864f81 | |||
| 80803c92f2 | |||
| 31e77d4e17 | |||
| 8e5a2aa6e6 | |||
| fe6f6420e3 | |||
| aff6285eb9 | |||
| 5f132bc979 | |||
| 4297de3571 | |||
| 6455f2e3ff | |||
| dee52fa80a | |||
| 4ac29817a2 | |||
| f09ca60930 | |||
| a7c0f7251b | |||
| efc6117665 | |||
| 6c613d0df3 | |||
| 45d32b5084 | |||
| 0f62a629b4 | |||
| 7ab63516bd | |||
| 9dd2db6678 | |||
| 4787b7e910 | |||
| be02e33ada | |||
| 3ce7e529f7 | |||
| 0f162a45a7 | |||
| 02ba0ef601 | |||
| bdc6cd9aba | |||
| b3875a19d7 | |||
| 884d900e38 | |||
| f68755695e | |||
| 9ffde72ced | |||
| 9e3629b12b | |||
| 48f81c80f0 | |||
| 0baa8e5022 | |||
| 59b9663ce3 | |||
| 9be66e3e8a | |||
| 9a0f35c26d | |||
| f1826807a7 | |||
| 887c3f8eac | |||
| d666212674 | |||
| 8cb9131d83 | |||
| 98b36616cb | |||
| eceec6f641 | |||
| 9f4d93502e | |||
| 8ab419a47e | |||
| 56ce5dfd74 | |||
| 370b731972 | |||
| b7fb9e57ca | |||
| 5b9f5fa991 | |||
| bf1f11794e | |||
| 8621c7f571 | |||
| 4a6f91a19d | |||
| 38a1c85fee | |||
| cb71511cda | |||
| afa476c51f | |||
| 1e9ed753a1 | |||
| 22da7961d1 | |||
| e074283bbf | |||
| afa868e0e1 | |||
| c5bdde4401 | |||
| 5efe13e622 | |||
| 41ef3435bc | |||
| 157d5fb9e7 | |||
| ec28a7b686 | |||
| b3e9ff9839 | |||
| aa2292036c | |||
| f7c4538b16 | |||
| b9b444c24e | |||
| 6f1b795d80 | |||
| 0803db07ef | |||
| a9adbc6370 | |||
| f49b0af3fe | |||
| 3d8e89cbe9 | |||
| f0a54bcb27 | |||
| c348583a18 | |||
| a4b238dc0c | |||
| 529afa3fac | |||
| 577bf26d1e | |||
| 4808f36718 | |||
| a100c5a6c8 | |||
| 4962662fb1 | |||
| f7c251e2d2 | |||
| 40524d4d4a | |||
| 1a7ef78fe2 | |||
| 5f3f4da2d2 | |||
| 93eea0b6d9 | |||
| 9dfa9c2db0 | |||
| d9ff25d28e | |||
| ae30a576e8 | |||
| 2e495cb28a | |||
| f517035e38 | |||
| 83bd0d74cd | |||
| 3cfefa40fd | |||
| 59ffb95417 | |||
| 2ebb18dc5a | |||
| 1deb897de1 | |||
| 11d9b80e77 | |||
| e73d9afd86 | |||
| 729501c977 | |||
| 414866cbec | |||
| 9d5e397574 | |||
| 92c9b8b056 | |||
| a73caf69ff | |||
| f72f250b16 | |||
| bd330ac133 | |||
| e31a8f4275 | |||
| cc38ec2098 | |||
| eab11e2d6f | |||
| 152490cd67 | |||
| 833b07b284 | |||
| 5f668605c2 | |||
| 9c762065f2 | |||
| 4af7df8902 | |||
| a3f854e1a8 | |||
| 4c54820055 | |||
| 22f574da9c | |||
| 426aabe39a | |||
| 64de8d2cf6 | |||
| b4b471fab6 | |||
| 17b32994f6 | |||
| bcc0ef8666 | |||
| 6ede6ee7c6 | |||
| 7a11861d36 | |||
| 10754fbaf4 | |||
| 8f32f4fb1e | |||
| 717ad0b300 | |||
| a3eed52f24 | |||
| 27a34b7ee2 | |||
| bf3ad28c7f | |||
| 70c2e90e4b | |||
| d0a378cdb6 | |||
| 81f8ddcf27 | |||
| 9d6457f814 | |||
| 07c02ebcd2 | |||
| 4d3172d13f | |||
| 0aa34cc9a8 | |||
| 551d30cfa4 | |||
| 844a0a8ea7 | |||
| eb8ba66a66 | |||
| d738e118a0 | |||
| c16d08f5d5 | |||
| 61ec9b25b9 | |||
| b57561d395 | |||
| e00d1c5bbd | |||
| 9063b56c89 | |||
| 020d3668d7 | |||
| 4d30cbf058 | |||
| d3f6c38f05 | |||
| 6f1db32682 | |||
| 4ad6b2c067 | |||
| 07449443a0 | |||
| b09cc64fec | |||
| 1eed2004f6 | |||
| 91539b8187 | |||
| 16cfde87ee | |||
| 58b4a978ff | |||
| e34be0e89b | |||
| e60c754343 | |||
| 48ca21e471 | |||
| 3ba9e46f2b | |||
| 58f0cc8287 | |||
| 29e2b22b63 | |||
| 3c7cb09c1b | |||
| 0f1cd6745d | |||
| e90f57b138 | |||
| 7bd5939247 | |||
| 89de966e7a | |||
| 4e2d86318e |
@@ -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
|
||||
---
|
||||
|
||||
Add sidesheet component, use it for TOC and AIChat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Set <html lang> based on translated language
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix signed JWT token usage
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Align styling of PageLinkItem and PageDocumentItem
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Better conditional display of page actions
|
||||
@@ -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
|
||||
---
|
||||
|
||||
Standardise toggle chevrons across the app
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Grow InlineActionButton to take available width on line
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "🚀 Dev server",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "bun install && bun dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,7 @@ inputs:
|
||||
description: 'Commit message to associate with the deployment'
|
||||
required: true
|
||||
outputs:
|
||||
deployment-url:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
runs:
|
||||
@@ -52,6 +52,8 @@ runs:
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
@@ -62,6 +64,7 @@ runs:
|
||||
run: bun run turbo build:cloudflare
|
||||
env:
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
shell: bash
|
||||
|
||||
- name: Upload the DO worker
|
||||
@@ -107,7 +110,7 @@ runs:
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||
|
||||
|
||||
- name: Extract middleware version worker ID
|
||||
shell: bash
|
||||
id: extract_middleware_version_id
|
||||
|
||||
@@ -19,8 +19,11 @@ inputs:
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
headSha:
|
||||
description: 'Git ref to deploy, used for the deploymentId'
|
||||
required: false
|
||||
outputs:
|
||||
deployment-url:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||
runs:
|
||||
@@ -51,12 +54,25 @@ runs:
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Inject build env vars
|
||||
if: ${{ inputs.environment == 'preview' }}
|
||||
shell: bash
|
||||
run: |
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "resolved HEAD_SHA: $HEAD_SHA"
|
||||
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
|
||||
cat .vercel/.env.${{ inputs.environment }}.local
|
||||
- name: Build Project Artifacts
|
||||
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
@@ -64,6 +80,7 @@ runs:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
GITBOOK_RUNTIME: vercel
|
||||
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
shell: bash
|
||||
|
||||
@@ -5,8 +5,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- changeset-release/main
|
||||
env:
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -36,13 +34,10 @@ jobs:
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- run: bun unit
|
||||
build-oss:
|
||||
# CI to check that the repository builds correctly on a machine without the credentials
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build (Open Source)
|
||||
name: Build
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
BUN_NPM_TOKEN: ''
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
name: Preview
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
approval:
|
||||
# Ensure that external contributors' PRs are manually approved before deployment
|
||||
name: Approve Preview Deployment
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
|
||||
environment:
|
||||
name: preview-approval
|
||||
steps:
|
||||
- name: Approval gate
|
||||
run: echo "Preview deployment approved."
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
runs-on: ubuntu-latest
|
||||
needs: approval
|
||||
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
environment:
|
||||
name: 2v-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
@@ -18,6 +28,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Deploy to Vercel
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
@@ -28,9 +40,12 @@ jobs:
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-preview
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
headSha: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (preview)
|
||||
runs-on: ubuntu-latest
|
||||
needs: approval
|
||||
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
|
||||
environment:
|
||||
name: 2c-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
@@ -39,6 +54,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Deploy to Cloudflare
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-cloudflare
|
||||
@@ -65,7 +82,7 @@ jobs:
|
||||
comment-deployments:
|
||||
runs-on: ubuntu-latest
|
||||
name: Comment Deployments (preview)
|
||||
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
||||
if: always() && github.event_name != 'push'
|
||||
needs:
|
||||
- deploy-v2-vercel
|
||||
- deploy-v2-cloudflare
|
||||
@@ -102,10 +119,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -118,15 +138,18 @@ jobs:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-vercel'
|
||||
ARGOS_BUILD_NAME: v2-vercel
|
||||
visual-testing-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -139,15 +162,18 @@ jobs:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
||||
ARGOS_BUILD_NAME: v2-cloudflare
|
||||
visual-testing-customers-v2:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -160,15 +186,18 @@ jobs:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
ARGOS_BUILD_NAME: customers-v2-vercel
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -181,14 +210,17 @@ jobs:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
ARGOS_BUILD_NAME: customers-v2-cloudflare
|
||||
browserless-testing-v2-vercel:
|
||||
runs-on: ubuntu-latest
|
||||
name: Browserless Testing v2 (Vercel)
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -217,4 +249,4 @@ jobs:
|
||||
# run: cd ./packages/gitbook && bun e2e-browserless
|
||||
# env:
|
||||
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
|
||||
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
|
||||
@@ -3,8 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (production)
|
||||
|
||||
@@ -3,8 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (staging)
|
||||
|
||||
@@ -4,9 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -18,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
|
||||
@@ -29,9 +30,10 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
run: |
|
||||
corepack enable npm
|
||||
corepack install -g npm@latest
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -48,4 +50,3 @@ jobs:
|
||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun dev # Start dev server (all packages)
|
||||
bun run build # Build all packages
|
||||
bun run format # Format with Biome (run after every change)
|
||||
bun run typecheck # Type-check all packages
|
||||
bun run unit # Run unit tests
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The dev server proxies published GitBook sites locally. After `bun dev`, access any published site at:
|
||||
|
||||
```
|
||||
http://localhost:3000/url/<published-gitbook-url>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `http://localhost:3000/url/gitbook.com/docs`
|
||||
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
packages/
|
||||
gitbook/ # Main Next.js app
|
||||
src/
|
||||
app/ # Next.js App Router (sites/)
|
||||
components/ # React components
|
||||
lib/ # Server utilities, data fetching
|
||||
intl/ # Internationalization (translations/)
|
||||
openapi-parser/ # OpenAPI 3.0/3.1/Swagger parser
|
||||
react-openapi/ # OpenAPI rendering components
|
||||
react-contentkit/ # ContentKit component rendering
|
||||
embed/ # Embeddable GitBook components
|
||||
shared/ # Shared utilities
|
||||
icons/ # Icon assets
|
||||
fonts/ # Font assets
|
||||
colors/ # Color tokens
|
||||
expr/ # GitBook expression evaluator
|
||||
cache-do/ # Cloudflare DO cache
|
||||
cache-tags/ # Cache tag utilities
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun run unit # Unit tests via bun test (not vitest)
|
||||
bun run e2e # Playwright e2e tests (requires built app)
|
||||
```
|
||||
|
||||
Run a specific test file:
|
||||
```bash
|
||||
cd packages/gitbook && bun test src/lib/cache.test.ts
|
||||
```
|
||||
|
||||
## Changesets
|
||||
|
||||
After committing code changes, create a changeset for the affected package:
|
||||
|
||||
```md
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Provide a short description of the change.
|
||||
```
|
||||
|
||||
Save as `.changeset/<name>.md`, then commit it separately with message: `changeset`
|
||||
|
||||
## Formatting
|
||||
|
||||
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
|
||||
@@ -77,23 +77,7 @@ All pull-requests will be tested against both visual and performances testing to
|
||||
|
||||
## Fonts and Icons
|
||||
|
||||
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
|
||||
|
||||
```
|
||||
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
|
||||
```
|
||||
|
||||
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
|
||||
|
||||
If you are GitBook staff, you'll need our NPM token in your local environment.
|
||||
|
||||
```
|
||||
.env.local
|
||||
|
||||
BUN_NPM_TOKEN=xxx
|
||||
```
|
||||
|
||||
and then reinstall dependencies.
|
||||
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"gitbook/tsconfig.json",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[install.scopes]
|
||||
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
|
||||
+12
-8
@@ -6,18 +6,20 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"turbo": "^2.7.4",
|
||||
"vercel": "^39.4.2"
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.18",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.2",
|
||||
"packageManager": "bun@1.3.7",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2"
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -41,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.158.0",
|
||||
"@gitbook/api": "0.184.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -53,11 +55,13 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.1"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.2.6": "patches/next@16.2.6.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# @gitbook/browser-types
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
|
||||
- 2e495cb: Add Global Privacy Control (GPC) support
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7a11861: Add support for custom cookie banner registration
|
||||
- Updated dependencies [6f1db32]
|
||||
- @gitbook/icons@0.4.3
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
|
||||
@@ -46,6 +46,11 @@ export type GitBookAssistant = {
|
||||
ui?: boolean;
|
||||
};
|
||||
|
||||
export type GitBookCookieBannerHandler = (options: {
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}) => void;
|
||||
|
||||
export type GitBookGlobal = {
|
||||
/**
|
||||
* Register an event listener.
|
||||
@@ -72,6 +77,25 @@ export type GitBookGlobal = {
|
||||
* Register a custom assistant to be available on the site.
|
||||
*/
|
||||
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||
|
||||
/**
|
||||
* Register a custom cookie banner handler.
|
||||
* When registered, the built-in cookie banner will not be displayed.
|
||||
*/
|
||||
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
|
||||
|
||||
/**
|
||||
* Indicates whether cookies tracking is disabled for the current user.
|
||||
* Returns `true` when tracking is disabled, `false` when tracking is enabled,
|
||||
* and `undefined` when the tracking preference is unknown or not yet determined.
|
||||
*/
|
||||
isCookiesTrackingDisabled: () => boolean | undefined;
|
||||
|
||||
/**
|
||||
* Indicates whether global privacy control is enabled for the current user.
|
||||
* Returns `true` when global privacy control is enabled, `false` when it is disabled.
|
||||
*/
|
||||
isGlobalPrivacyControlEnabled: () => boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# @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
|
||||
|
||||
- 88c38fa: Add Search tab to Docs Embed, refactor search
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b3875a1: Support `assistantName` property to override Assistant name
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 370b731: Docs Embed: Make trademark optional
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b4b471f: Fix unexpected error thrown when using the embed in standalone mode
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 96e24a1: Fix signed JWT token usage
|
||||
- 0aa34cc: Fix Embed options in React
|
||||
- Updated dependencies [6f1db32]
|
||||
- @gitbook/icons@0.4.3
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+101
-15
@@ -2,8 +2,9 @@
|
||||
|
||||
Embed your GitBook docs in your product or website.
|
||||
|
||||
The Docs Embed can contain two tabs:
|
||||
The Docs Embed can contain three tabs:
|
||||
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
|
||||
- **Search**: A search-focused surface for quickly finding pages and asking scoped questions
|
||||
- **Docs**: A browser for navigating your documentation site
|
||||
|
||||
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
|
||||
@@ -40,7 +41,7 @@ GitBook('configure', {
|
||||
label: 'Ask',
|
||||
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
||||
},
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
@@ -49,8 +50,10 @@ GitBook('configure', {
|
||||
}
|
||||
],
|
||||
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||
assistantName: 'Support Assistant',
|
||||
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||
tools: [/* ... */]
|
||||
tools: [/* ... */],
|
||||
closeButton: true,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -70,6 +73,7 @@ const gitbook = createGitBook({
|
||||
// Create an iframe and get its URL
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = gitbook.getFrameURL({
|
||||
colorScheme: 'dark', // Optional: force the embed to render in dark mode
|
||||
visitor: {
|
||||
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||
unsignedClaims: { // Optional: custom claims for dynamic expressions
|
||||
@@ -90,7 +94,7 @@ frame.clearChat();
|
||||
|
||||
// Configure the embed (see Configuration section for all options)
|
||||
frame.configure({
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
@@ -99,8 +103,10 @@ frame.configure({
|
||||
}
|
||||
],
|
||||
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||
assistantName: 'Support Assistant',
|
||||
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||
tools: [/* ... */]
|
||||
tools: [/* ... */],
|
||||
closeButton: true
|
||||
});
|
||||
|
||||
// Listen to events
|
||||
@@ -118,12 +124,14 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||
|
||||
<GitBookProvider siteURL="https://docs.company.com">
|
||||
<GitBookFrame
|
||||
colorScheme="dark"
|
||||
visitor={{
|
||||
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
|
||||
}}
|
||||
tabs={['assistant', 'docs']}
|
||||
tabs={['assistant', 'search', 'docs']}
|
||||
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||
assistantName="Support Assistant"
|
||||
suggestions={['What is GitBook?', 'How do I get started?']}
|
||||
actions={[
|
||||
{
|
||||
@@ -133,6 +141,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||
}
|
||||
]}
|
||||
tools={[/* ... */]}
|
||||
closeButton
|
||||
/>
|
||||
</GitBookProvider>
|
||||
```
|
||||
@@ -144,7 +153,7 @@ import { useGitBook } from '@gitbook/embed/react';
|
||||
|
||||
function MyComponent() {
|
||||
const gitbook = useGitBook();
|
||||
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
|
||||
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -172,7 +181,7 @@ function MyComponent() {
|
||||
|
||||
### Standalone Script
|
||||
|
||||
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
|
||||
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
|
||||
- `GitBook('show')` - Show widget button
|
||||
- `GitBook('hide')` - Hide widget button
|
||||
- `GitBook('open')` - Open widget window
|
||||
@@ -189,7 +198,7 @@ function MyComponent() {
|
||||
|
||||
**Client Factory:**
|
||||
- `createGitBook(options: { siteURL: string })` → `GitBookClient`
|
||||
- `client.getFrameURL(options?: { visitor?: {...} })` → `string`
|
||||
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` → `string`
|
||||
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
|
||||
|
||||
**Frame Client:**
|
||||
@@ -222,14 +231,61 @@ Available in: Standalone script, NPM package, React components
|
||||
|
||||
Override which tabs are displayed. Defaults to your site's configuration.
|
||||
|
||||
- **Type**: `('assistant' | 'docs')[]`
|
||||
- **Options**:
|
||||
- `['assistant', 'docs']` - Show both tabs
|
||||
- `['assistant']` - Show only the assistant tab
|
||||
- `['docs']` - Show only the docs tab
|
||||
- **Type**: `('assistant' | 'search' | 'docs')[]`
|
||||
|
||||
```javascript
|
||||
tabs: ['assistant', 'docs']
|
||||
tabs: ['assistant', 'search', 'docs']
|
||||
```
|
||||
|
||||
### `closeButton`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Display a close (`x`) button in the embed sidebar.
|
||||
|
||||
- **Type**: `boolean`
|
||||
- **Default**: `false`
|
||||
|
||||
```javascript
|
||||
closeButton: true
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- When clicked, the embed sends a `close` event to the parent page.
|
||||
- In the standalone script, this event is handled automatically and the widget closes.
|
||||
- In custom iframe integrations (NPM package), you must listen for the `close` event and decide how to hide/collapse your UI.
|
||||
- In React, this works automatically when using the standard widget flow. If you build custom frame wiring, handle `close` the same way as the NPM package.
|
||||
|
||||
NPM package example:
|
||||
|
||||
```typescript
|
||||
const frame = gitbook.createFrame(iframe);
|
||||
|
||||
frame.configure({
|
||||
closeButton: true
|
||||
});
|
||||
|
||||
const unsubscribe = frame.on('close', () => {
|
||||
// Hide your modal/drawer/container
|
||||
container.classList.add('hidden');
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The close button is rendered in the same sidebar area as tabs/actions.
|
||||
- If your UI hides that sidebar or doesn't render it, the button will not be visible.
|
||||
|
||||
### `trademark`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Display GitBook branding in the embed. Defaults to true.
|
||||
|
||||
- **Type**: `boolean`
|
||||
- **Default**: `true`
|
||||
|
||||
```javascript
|
||||
trademark: true
|
||||
```
|
||||
|
||||
### `actions`
|
||||
@@ -278,6 +334,18 @@ greeting: {
|
||||
}
|
||||
```
|
||||
|
||||
### `assistantName`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Override the assistant name displayed in the chat header and assistant entry points (for example, sidebar tabs and action labels). This name will be limited to 32 characters to prevent text overflow.
|
||||
|
||||
- **Type**: `string`
|
||||
|
||||
```javascript
|
||||
assistantName: 'Support Assistant'
|
||||
```
|
||||
|
||||
### `suggestions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
@@ -392,6 +460,24 @@ visitor: {
|
||||
}
|
||||
```
|
||||
|
||||
### `colorScheme`
|
||||
|
||||
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
|
||||
|
||||
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
|
||||
|
||||
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
|
||||
|
||||
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
|
||||
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
|
||||
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
|
||||
|
||||
- **Type**: `'light' | 'dark'`
|
||||
|
||||
```javascript
|
||||
colorScheme: 'dark'
|
||||
```
|
||||
|
||||
### `button`
|
||||
|
||||
Available in: Standalone script only
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -35,4 +35,17 @@ describe('createGitBook.getFrameURL', () => {
|
||||
expect(url.searchParams.get('visitor.count')).toBe('3');
|
||||
expect(url.searchParams.get('visitor.enabled')).toBe('false');
|
||||
});
|
||||
|
||||
it('adds an explicit color scheme override when requested', () => {
|
||||
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
|
||||
|
||||
const url = new URL(
|
||||
client.getFrameURL({
|
||||
colorScheme: 'dark',
|
||||
})
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe('/docs/~gitbook/embed');
|
||||
expect(url.searchParams.get('theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ export type CreateGitBookOptions = {
|
||||
};
|
||||
|
||||
export type GetFrameURLOptions = {
|
||||
/**
|
||||
* Override the color scheme used by the embedded docs.
|
||||
* When omitted, the embed follows the iframe's CSS `color-scheme`.
|
||||
*/
|
||||
colorScheme?: 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* Authentication to use for the frame.
|
||||
*/
|
||||
@@ -42,6 +48,10 @@ export function createGitBook(options: CreateGitBookOptions) {
|
||||
const url = new URL(options.siteURL);
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
|
||||
|
||||
if (frameOptions.colorScheme) {
|
||||
url.searchParams.set('theme', frameOptions.colorScheme);
|
||||
}
|
||||
|
||||
if (frameOptions.visitor?.token) {
|
||||
url.searchParams.set('jwt_token', frameOptions.visitor.token);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
@@ -64,11 +74,12 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
|
||||
*/
|
||||
export type GitBookEmbeddableConfiguration = {
|
||||
/** Tabs to display in the embed (if enabled on the site). */
|
||||
tabs: ('assistant' | 'docs')[];
|
||||
tabs: ('assistant' | 'docs' | 'search')[];
|
||||
|
||||
/** Additional buttons to be displayed in the header of the GitBook embed. */
|
||||
actions: GitBookEmbeddableActionDefinition[];
|
||||
@@ -62,11 +62,27 @@ export type GitBookEmbeddableConfiguration = {
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Override the assistant name displayed in the UI.
|
||||
* Limited to 32 characters.
|
||||
*/
|
||||
assistantName?: string;
|
||||
|
||||
/** Suggestions of questions to be displayed in the welcome page. */
|
||||
suggestions: string[];
|
||||
|
||||
/** Tools to be provided to the assistant. */
|
||||
tools: GitBookToolDefinition[];
|
||||
|
||||
/**
|
||||
* Display GitBook branding in the embed.
|
||||
*/
|
||||
trademark?: boolean;
|
||||
|
||||
/**
|
||||
* Display a close button inside the assistant.
|
||||
*/
|
||||
closeButton?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,19 +11,34 @@ import { useGitBook } from './GitBookProvider';
|
||||
export type GitBookFrameProps = {
|
||||
className?: string;
|
||||
} & GetFrameURLOptions &
|
||||
GitBookEmbeddableConfiguration;
|
||||
Partial<GitBookEmbeddableConfiguration>;
|
||||
|
||||
/**
|
||||
* Render a frame with the GitBook Assistant in it.
|
||||
*/
|
||||
export function GitBookFrame(props: GitBookFrameProps) {
|
||||
const { className, visitor, actions, greeting, suggestions, tools } = props;
|
||||
const {
|
||||
className,
|
||||
colorScheme,
|
||||
visitor,
|
||||
actions = [],
|
||||
greeting,
|
||||
suggestions = [],
|
||||
tools = [],
|
||||
tabs = ['assistant', 'search', 'docs'],
|
||||
trademark = true,
|
||||
closeButton = false,
|
||||
assistantName,
|
||||
} = props;
|
||||
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const gitbook = useGitBook();
|
||||
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
|
||||
|
||||
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||
const frameURL = useMemo(
|
||||
() => gitbook.getFrameURL({ visitor, colorScheme }),
|
||||
[gitbook, visitor, colorScheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (frameRef.current) {
|
||||
@@ -33,13 +48,26 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
|
||||
useEffect(() => {
|
||||
gitbookFrame?.configure({
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs,
|
||||
actions,
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
});
|
||||
}, [gitbookFrame, actions, greeting, suggestions, tools]);
|
||||
}, [
|
||||
gitbookFrame,
|
||||
actions,
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
tabs,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
@@ -48,7 +76,9 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
src={frameURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
allow="clipboard-write"
|
||||
className={className}
|
||||
style={colorScheme ? { colorScheme } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
@@ -100,12 +101,20 @@ 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;
|
||||
}
|
||||
widgetIframe.src = client.getFrameURL({
|
||||
...frameOptions,
|
||||
});
|
||||
widgetWindow.appendChild(widgetIframe);
|
||||
|
||||
_frame = client.createFrame(widgetIframe);
|
||||
_frame.on('close', () => {
|
||||
widgetWindow.classList.add('hidden');
|
||||
widgetButton.classList.remove('open');
|
||||
});
|
||||
}
|
||||
return { iframe: widgetIframe, frame: _frame };
|
||||
}
|
||||
@@ -152,6 +161,19 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
break;
|
||||
case 'configure': {
|
||||
const settings = args[1];
|
||||
|
||||
// If trademark is disabled, change the (branded) icon to the sparkle icon
|
||||
if (
|
||||
settings.trademark === false &&
|
||||
!settings.button?.icon &&
|
||||
frameConfiguration.button.icon === 'assistant'
|
||||
) {
|
||||
settings.button = {
|
||||
label: frameConfiguration.button.label,
|
||||
icon: 'sparkle',
|
||||
};
|
||||
}
|
||||
|
||||
frameConfiguration = {
|
||||
...frameConfiguration,
|
||||
...settings,
|
||||
@@ -193,5 +215,3 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||
// @ts-expect-error - GitBook is not defined in the global scope
|
||||
window.GitBook = GitBook;
|
||||
precalls.forEach((call) => GitBook(...call));
|
||||
|
||||
GitBook('configure', {});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6eabaec: Implement a getVariables function for ExpressionRuntime
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8242f18: Extend gitbook/expr std lib with some additional methods
|
||||
|
||||
## 1.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-loose": "^8.5.2",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('autocomplete', () => {
|
||||
description: 'An array of string',
|
||||
items: SymbolString(),
|
||||
});
|
||||
const visitorClaimsKeyPropStringSymbol = SymbolString({ name: 'key' });
|
||||
const symbols = {
|
||||
visitor: SymbolObject({
|
||||
name: 'visitor',
|
||||
@@ -33,7 +34,7 @@ describe('autocomplete', () => {
|
||||
name: 'claims',
|
||||
description: 'The claims contained in the visitor JWT token',
|
||||
properties: {
|
||||
key: SymbolString({ name: 'key' }),
|
||||
key: visitorClaimsKeyPropStringSymbol,
|
||||
flags: SymbolObject({
|
||||
name: 'flags',
|
||||
properties: {
|
||||
@@ -168,8 +169,12 @@ describe('autocomplete', () => {
|
||||
childrenRefs: [
|
||||
'visitor.claims.key.length',
|
||||
'visitor.claims.key.at',
|
||||
'visitor.claims.key.startsWith',
|
||||
'visitor.claims.key.endsWith',
|
||||
'visitor.claims.key.includes',
|
||||
'visitor.claims.key.toLowerCase',
|
||||
'visitor.claims.key.toUpperCase',
|
||||
'visitor.claims.key.trim',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -227,8 +232,12 @@ describe('autocomplete', () => {
|
||||
childrenRefs: [
|
||||
'visitor.claims.role.length',
|
||||
'visitor.claims.role.at',
|
||||
'visitor.claims.role.startsWith',
|
||||
'visitor.claims.role.endsWith',
|
||||
'visitor.claims.role.includes',
|
||||
'visitor.claims.role.toLowerCase',
|
||||
'visitor.claims.role.toUpperCase',
|
||||
'visitor.claims.role.trim',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -246,13 +255,47 @@ describe('autocomplete', () => {
|
||||
childrenRefs: [
|
||||
'visitor.claims.key.length',
|
||||
'visitor.claims.key.at',
|
||||
'visitor.claims.key.startsWith',
|
||||
'visitor.claims.key.endsWith',
|
||||
'visitor.claims.key.includes',
|
||||
'visitor.claims.key.toLowerCase',
|
||||
'visitor.claims.key.toUpperCase',
|
||||
'visitor.claims.key.trim',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
expressionWithCursor: 'visitor.claims.key.<cur>',
|
||||
expectedSuggestions: [
|
||||
{
|
||||
type: 'symbol',
|
||||
symbol: {
|
||||
definition: SymbolNumber({
|
||||
name: 'length',
|
||||
description:
|
||||
'The length data property of a String value contains the length of the string in UTF-16 code units.',
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
|
||||
}),
|
||||
ref: 'visitor.claims.key.length',
|
||||
parentRef: 'visitor.claims.key',
|
||||
childrenRefs: [],
|
||||
},
|
||||
},
|
||||
...visitorClaimsKeyPropStringSymbol.methods.map<AutocompleteSymbolSuggestion>(
|
||||
(method) => ({
|
||||
type: 'symbol',
|
||||
symbol: {
|
||||
definition: method,
|
||||
ref: `visitor.claims.key.${method.name}`,
|
||||
parentRef: 'visitor.claims.key',
|
||||
childrenRefs: [],
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
expressionWithCursor: 'visitor.claims.h<cur>',
|
||||
expectedSuggestions: [
|
||||
|
||||
@@ -54,12 +54,54 @@ describe('ExpressionRuntime', () => {
|
||||
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'array includes',
|
||||
condition: 'reviews.includes("approved")',
|
||||
inputs: { reviews: ['pending', 'approved'] },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'array map',
|
||||
condition: '[1, 2, 3].map(n => n * x)',
|
||||
inputs: { x: 2 },
|
||||
expectedResult: [2, 4, 6],
|
||||
},
|
||||
{
|
||||
scenario: 'string startsWith',
|
||||
condition: 'user.role.startsWith("ad")',
|
||||
inputs: { user: { role: 'admin' } },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'string endsWith',
|
||||
condition: 'user.role.endsWith("min")',
|
||||
inputs: { user: { role: 'admin' } },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'string includes',
|
||||
condition: 'user.role.includes("dm")',
|
||||
inputs: { user: { role: 'admin' } },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'string toLowerCase',
|
||||
condition: 'user.role.toLowerCase() === "admin"',
|
||||
inputs: { user: { role: 'ADMIN' } },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'string toUpperCase',
|
||||
condition: 'user.role.toUpperCase() === "ADMIN"',
|
||||
inputs: { user: { role: 'admin' } },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
scenario: 'string trim',
|
||||
condition: 'user.role.trim() === "admin"',
|
||||
inputs: { user: { role: ' admin ' } },
|
||||
expectedResult: true,
|
||||
},
|
||||
])(
|
||||
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
|
||||
({ condition, inputs, expectedResult }) => {
|
||||
@@ -153,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.
|
||||
*/
|
||||
|
||||
@@ -255,8 +255,12 @@ describe('ExpressionRuntime', () => {
|
||||
childrenRefs: [
|
||||
'visitor.claims.key.length',
|
||||
'visitor.claims.key.at',
|
||||
'visitor.claims.key.startsWith',
|
||||
'visitor.claims.key.endsWith',
|
||||
'visitor.claims.key.includes',
|
||||
'visitor.claims.key.toLowerCase',
|
||||
'visitor.claims.key.toUpperCase',
|
||||
'visitor.claims.key.trim',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -301,8 +305,12 @@ describe('ExpressionRuntime', () => {
|
||||
childrenRefs: [
|
||||
'visitor.claims.flags.FLAG1.length',
|
||||
'visitor.claims.flags.FLAG1.at',
|
||||
'visitor.claims.flags.FLAG1.startsWith',
|
||||
'visitor.claims.flags.FLAG1.endsWith',
|
||||
'visitor.claims.flags.FLAG1.includes',
|
||||
'visitor.claims.flags.FLAG1.toLowerCase',
|
||||
'visitor.claims.flags.FLAG1.toUpperCase',
|
||||
'visitor.claims.flags.FLAG1.trim',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -318,8 +326,12 @@ describe('ExpressionRuntime', () => {
|
||||
childrenRefs: [
|
||||
'visitor.claims.flags.FLAG2.length',
|
||||
'visitor.claims.flags.FLAG2.at',
|
||||
'visitor.claims.flags.FLAG2.startsWith',
|
||||
'visitor.claims.flags.FLAG2.endsWith',
|
||||
'visitor.claims.flags.FLAG2.includes',
|
||||
'visitor.claims.flags.FLAG2.toLowerCase',
|
||||
'visitor.claims.flags.FLAG2.toUpperCase',
|
||||
'visitor.claims.flags.FLAG2.trim',
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -156,6 +156,31 @@ const StandardLibrary: Partial<
|
||||
members: [SymbolString(), SymbolUndefined()],
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'startsWith',
|
||||
description: `Returns true if the given characters are found at the beginning of the string, including when searchString
|
||||
is an empty string. Otherwise returns false.`,
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
|
||||
args: [
|
||||
SymbolString({
|
||||
name: 'searchString',
|
||||
description: `The characters to be searched for at the start of this string. Cannot be a regex.
|
||||
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes startsWith() to search for
|
||||
the string "undefined", which is rarely what you want.`,
|
||||
}),
|
||||
OptionalFunctionArg(
|
||||
SymbolNumber({
|
||||
name: 'position',
|
||||
description: `The start position at which searchString is expected to be found
|
||||
(the index of searchString's first character). Defaults to 0.`,
|
||||
})
|
||||
),
|
||||
],
|
||||
returns: SymbolBoolean({
|
||||
description: `true if the given characters are found at the beginning of the string, including when searchString is an empty string;
|
||||
otherwise, false.`,
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'endsWith',
|
||||
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
|
||||
@@ -205,6 +230,39 @@ const StandardLibrary: Partial<
|
||||
otherwise, false.`,
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'toLowerCase',
|
||||
description:
|
||||
'Returns the value of the string converted to lower case. toLowerCase() does not affect the value of the string str itself.',
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
|
||||
args: [],
|
||||
returns: SymbolString({
|
||||
description:
|
||||
'A new string representing the calling string converted to lower case.',
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'toUpperCase',
|
||||
description:
|
||||
'Returns the value of the string converted to uppercase. toUpperCase() does not affect the value of the string str itself.',
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
|
||||
args: [],
|
||||
returns: SymbolString({
|
||||
description:
|
||||
'A new string representing the calling string converted to upper case.',
|
||||
}),
|
||||
}),
|
||||
SymbolFunction({
|
||||
name: 'trim',
|
||||
description:
|
||||
'Returns new string representing str stripped of whitespace from both its beginning and end.',
|
||||
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim',
|
||||
args: [],
|
||||
returns: SymbolString({
|
||||
description:
|
||||
'A new string representing str stripped of whitespace from both its beginning and end.',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
|
||||
|
||||
+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,260 @@
|
||||
# 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
|
||||
|
||||
- aa1fc24: Add support for sticky first column in tables
|
||||
|
||||
## 0.25.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 88c38fa: Add Search tab to Docs Embed, refactor search
|
||||
- 709964f: Rework page layout
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 789c2fa: Adjust empty TOC logic and layout
|
||||
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
|
||||
- 7fd381c: Hide empty sidebar on OpenAPI pages
|
||||
- 723e579: Fix rendering multi-expandable block in a column
|
||||
- 68c842b: Translations dropdown should show language spaces that best matches the current space title
|
||||
- Updated dependencies [8d2a95b]
|
||||
- Updated dependencies [88c38fa]
|
||||
- @gitbook/embed@0.5.0
|
||||
|
||||
## 0.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 50653ab: Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
|
||||
- 50653ab: Improve AI agent detection and readability by outputting 200 for page not found.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 126d4b7: Improve Assistant form controls
|
||||
- Updated dependencies [8242f18]
|
||||
- @gitbook/expr@1.2.5
|
||||
- @gitbook/react-openapi@1.5.12
|
||||
|
||||
## 0.23.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ab17ff4]
|
||||
- @gitbook/icons@0.4.6
|
||||
- @gitbook/react-contentkit@0.7.15
|
||||
|
||||
## 0.23.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3c141f0]
|
||||
- @gitbook/icons@0.4.5
|
||||
- @gitbook/react-contentkit@0.7.14
|
||||
|
||||
## 0.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8e5a2aa: Pan and zoom controls for mermaid diagrams
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b77c4fc: Add getPage MCP tool
|
||||
- 31e77d4: Track new events for site analytics.
|
||||
- f884477: Associate questions asked in the embeddable assistant to the proper channel.
|
||||
- deabce9: Sort results by score to mix records and pages.
|
||||
- 4909044: Add Claude Code and Codex connect actions
|
||||
- da648ca: Fix regressions in Assistant UX
|
||||
- 3ce7e52: Fix Variant dropdown not showing in GitBook embed
|
||||
- 7f4e644: Small OpenAPI Improvements
|
||||
- 4787b7e: Use localized titles/descriptions for site entities
|
||||
- 7ab6351: Import site redirect patterns from @gitbook/api
|
||||
- b40465e: Fix normalization of URL causing invalid redirections.
|
||||
- b5d6f2e: Fix translation quality across 11 locales
|
||||
- 3151864: Track events from the proper server URL.
|
||||
- 16bfafe: Add option to sticky header row in tables
|
||||
- 7c2823c: Fix full width header alignment
|
||||
- c4a2604: Fix PDF export crash when page contains an OpenAPI block
|
||||
- c56b569: Support expand-all options for OpenAPI
|
||||
- Updated dependencies [7f4e644]
|
||||
- Updated dependencies [b5d6f2e]
|
||||
- Updated dependencies [9dd2db6]
|
||||
- Updated dependencies [c56b569]
|
||||
- Updated dependencies [ccd21e4]
|
||||
- @gitbook/react-openapi@1.5.11
|
||||
- @gitbook/openapi-parser@3.0.11
|
||||
- @gitbook/icons@0.4.4
|
||||
- @gitbook/react-contentkit@0.7.13
|
||||
|
||||
## 0.22.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f182680: Add backward compatibility for OpenAPI spec content URL
|
||||
- bdc6cd9: Use OpenAPI publicURL as Scalar specUrl
|
||||
- b3875a1: Refactor embeddable context to merge local & site properties in one unified way
|
||||
- eceec6f: Move ~scalar/proxy route to site route group
|
||||
- 887c3f8: Add token verification on OpenAPI proxy
|
||||
- d666212: Add support for localized site section titles
|
||||
- Updated dependencies [b3875a1]
|
||||
- Updated dependencies [bdc6cd9]
|
||||
- Updated dependencies [8ab419a]
|
||||
- Updated dependencies [eceec6f]
|
||||
- Updated dependencies [887c3f8]
|
||||
- @gitbook/embed@0.4.0
|
||||
- @gitbook/react-openapi@1.5.10
|
||||
- @gitbook/openapi-parser@3.0.10
|
||||
|
||||
## 0.22.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e074283: Add server-side proxy for Scalar API client to bypass CORS
|
||||
- afa868e: Show "Ask Assistant" search result immediately
|
||||
- 0803db0: Track opening of search results for records and in the assistant.
|
||||
- cb71511: Wrap tracking call with waitUntil
|
||||
- 4a6f91a: Show social accounts in header
|
||||
- 370b731: Docs Embed: Make trademark optional
|
||||
- afa476c: Add server-side insight event tracking for MCP route
|
||||
- Updated dependencies [e074283]
|
||||
- Updated dependencies [370b731]
|
||||
- @gitbook/openapi-parser@3.0.9
|
||||
- @gitbook/react-openapi@1.5.9
|
||||
- @gitbook/embed@0.3.0
|
||||
|
||||
## 0.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9d5e397: Remove code syntax highlighting patch for Safari
|
||||
- 3d8e89c: Show favicon for record search results in the assistant.
|
||||
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
|
||||
- 426aabe: Fix TOC header logo on bold themes
|
||||
- ae30a57: Integrate Ask AI with upcoming insights by passing visitor session IDs.
|
||||
- f7c251e: Update Reddit social account rendering
|
||||
- e31a8f4: Hide cookie banner for AI user agents
|
||||
- 2e495cb: Add Global Privacy Control (GPC) support
|
||||
- Updated dependencies [1a7ef78]
|
||||
- Updated dependencies [93eea0b]
|
||||
- Updated dependencies [b4b471f]
|
||||
- Updated dependencies [5f66860]
|
||||
- Updated dependencies [2ebb18d]
|
||||
- Updated dependencies [11d9b80]
|
||||
- Updated dependencies [2e495cb]
|
||||
- Updated dependencies [f517035]
|
||||
- @gitbook/react-openapi@1.5.8
|
||||
- @gitbook/embed@0.2.2
|
||||
- @gitbook/browser-types@0.1.5
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e00d1c5: Support code themes customization
|
||||
- f294818: Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9d6457f: OpenAPI styling tweaks
|
||||
- d738e11: Prevent form submission during IME composition
|
||||
- 1e53376: Add sidesheet component, use it for TOC and AIChat
|
||||
- f7f6f9d: Set <html lang> based on translated language
|
||||
- 89de966: Support social links
|
||||
- b53cb1d: Align styling of PageLinkItem and PageDocumentItem
|
||||
- 27a34b7: Support `defaultExpanded` in Expandable block
|
||||
- a5ef44a: Better conditional display of page actions
|
||||
- 07c02eb: Header & TOC tweaks
|
||||
- 29e2b22: Fix ordered list item index calculation
|
||||
- f6cf62a: Standardise toggle chevrons across the app
|
||||
- b57561d: Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
|
||||
- e172b25: Grow InlineActionButton to take available width on line
|
||||
- 7a11861: Add support for custom cookie banner registration
|
||||
- 8f32f4f: Better handle huge section groups
|
||||
- 0aa34cc: Fix Embed options in React
|
||||
- Updated dependencies [e00d1c5]
|
||||
- Updated dependencies [96e24a1]
|
||||
- Updated dependencies [4e2d863]
|
||||
- Updated dependencies [d3f6c38]
|
||||
- Updated dependencies [3ba9e46]
|
||||
- Updated dependencies [717ad0b]
|
||||
- Updated dependencies [6f1db32]
|
||||
- Updated dependencies [7a11861]
|
||||
- Updated dependencies [0aa34cc]
|
||||
- Updated dependencies [48ca21e]
|
||||
- @gitbook/react-openapi@1.5.7
|
||||
- @gitbook/embed@0.2.1
|
||||
- @gitbook/openapi-parser@3.0.8
|
||||
- @gitbook/icons@0.4.3
|
||||
- @gitbook/browser-types@0.1.4
|
||||
- @gitbook/react-contentkit@0.7.12
|
||||
|
||||
## 0.21.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { type TestsCase, getCustomizationURL, runTestCases } from './util';
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Cookie Banner',
|
||||
contentBaseURL: 'https://gitbook.com/docs/',
|
||||
tests: [
|
||||
{
|
||||
name: 'should show built-in banner when no custom banner is registered',
|
||||
url: getCustomizationURL({
|
||||
privacyPolicy: {
|
||||
url: 'https://policies.gitbook.com/privacy/cookies',
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
// Check that built-in banner is visible
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'should not show built-in banner when UserAgent is AI',
|
||||
url: getCustomizationURL({
|
||||
privacyPolicy: {
|
||||
url: 'https://policies.gitbook.com/privacy/cookies',
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
// Override navigator.userAgent to simulate AI crawler (affects isAIUserAgent())
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
get: () =>
|
||||
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
// Check that built-in banner is not visible for AI UserAgent
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'should not show built-in banner when custom banner is registered',
|
||||
url: getCustomizationURL({
|
||||
privacyPolicy: {
|
||||
url: 'https://policies.gitbook.com/privacy/cookies',
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
// Register a custom cookie banner handler
|
||||
await page.waitForFunction(() => {
|
||||
return typeof window !== 'undefined' && window.GitBook !== undefined;
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.GitBook?.registerCookieBanner(() => {
|
||||
// Custom cookie banner handler - no-op for testing to avoid reload
|
||||
});
|
||||
});
|
||||
|
||||
// Check that built-in banner is not visible
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
runTestCases(testCases);
|
||||
@@ -1,5 +1,18 @@
|
||||
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
|
||||
|
||||
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
|
||||
|
||||
const CLOSED_ONETRUST_COOKIE_BANNER = [
|
||||
{
|
||||
name: '__gitbook_cookie_granted',
|
||||
value: 'no',
|
||||
},
|
||||
{
|
||||
name: 'OptanonAlertBoxClosed',
|
||||
value: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
/** A list of test cases to run on the customers' docs sites. */
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
@@ -97,11 +110,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// Disabling for now, one of the image is making the tests timeout
|
||||
// {
|
||||
// name: 'docs.keeper.io',
|
||||
// contentBaseURL: 'https://docs.keeper.io',
|
||||
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'adiblar.gitbook.io',
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
@@ -209,11 +223,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.meshchain.ai',
|
||||
contentBaseURL: 'https://docs.meshchain.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// This one redirects to binance now
|
||||
// {
|
||||
// name: 'docs.meshchain.ai',
|
||||
// contentBaseURL: 'https://docs.meshchain.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.thousandeyes.com',
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
@@ -232,9 +247,12 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'run-ai-docs.nvidia.com',
|
||||
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
cookies: CLOSED_ONETRUST_COOKIE_BANNER,
|
||||
},
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,23 +3,20 @@ import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationDepth,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationThemeMode,
|
||||
SiteSocialAccountPlatform,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookStandalone } from '@gitbook/embed';
|
||||
import { expect } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
VISITOR_TOKEN_COOKIE,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
} from '@/lib/visitors';
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type Test,
|
||||
type TestsCase,
|
||||
@@ -33,12 +30,25 @@ import {
|
||||
getCustomizationURL,
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForAIChatResponse,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
|
||||
// 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"]');
|
||||
@@ -46,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[] = [
|
||||
@@ -71,8 +105,8 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
@@ -84,8 +118,6 @@ const searchTestCases: Test[] = [
|
||||
});
|
||||
const pageResults = await page.getByTestId('search-page-result').all();
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||
},
|
||||
},
|
||||
@@ -96,8 +128,8 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+K');
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
},
|
||||
@@ -110,6 +142,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
},
|
||||
},
|
||||
@@ -121,6 +154,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
@@ -134,6 +168,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
@@ -147,6 +182,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
@@ -157,7 +193,7 @@ const searchTestCases: Test[] = [
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill(AI_PROMPT);
|
||||
@@ -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',
|
||||
@@ -182,12 +219,13 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
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',
|
||||
@@ -196,14 +234,14 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
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)',
|
||||
@@ -213,13 +251,14 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&ask=`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
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)',
|
||||
@@ -229,17 +268,19 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||
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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -257,6 +298,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'No variants dropdown',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
|
||||
0
|
||||
);
|
||||
@@ -314,7 +356,6 @@ const testCases: TestsCase[] = [
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -506,6 +547,72 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Language Site (Navigation when switching language variant)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Should resolve to the same page in the new language variant when switching language variant (Source English -> Target Dutch)',
|
||||
url: 'ecosystem/connection-provider',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// Click the dutch language variant in the dropdown
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: 'Yjs (NL)',
|
||||
})
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the NL variant
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('nl/ecosysteem/connection-provider')
|
||||
);
|
||||
// Verify we are on the correct page by checking the h1
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Connectieprovider' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Should resolve to the same page in the new language variant when switching language variant (Source Dutch -> Target Finnish)',
|
||||
url: 'nl/ecosysteem/connection-provider',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// Click the finnish language variant in the dropdown
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: 'Yjs (FI)',
|
||||
})
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the FI variant
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('fi/ekosysteemi/connection-provider')
|
||||
);
|
||||
// Verify we are on the correct page by checking the h1
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Yhteysvälittäjä' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GitBook Site (Sections and Section Groups)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
@@ -544,7 +651,6 @@ const testCases: TestsCase[] = [
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -630,10 +736,30 @@ const testCases: TestsCase[] = [
|
||||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Show error when missing token',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
// Intentionally not setting the token to test error handling when the token is missing
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('limit', '10');
|
||||
|
||||
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page, response) => {
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.status()).toBe(400);
|
||||
await expect(page.getByText('Missing API token')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Preview',
|
||||
name: 'Site Previews',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
@@ -646,7 +772,7 @@ const testCases: TestsCase[] = [
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
@@ -661,7 +787,7 @@ const testCases: TestsCase[] = [
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
@@ -671,10 +797,74 @@ const testCases: TestsCase[] = [
|
||||
const sectionTabLinks = sectionTabs.getByRole('link');
|
||||
for (const link of await sectionTabLinks.all()) {
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
|
||||
expect(href?.includes('/preview/site_p4Xo4')).toBeTruthy();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With customization cookie',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
// Trademark exists by default
|
||||
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
|
||||
1
|
||||
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
|
||||
|
||||
// Go to another page with the customization query to disable the trademark
|
||||
const pageBlocks = new URL(page.url());
|
||||
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
|
||||
pageBlocks.search = getCustomizationURL({
|
||||
trademark: {
|
||||
enabled: false,
|
||||
},
|
||||
}).slice(1);
|
||||
await page.goto(pageBlocks.toString());
|
||||
// No trademark because customization is disabled
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Blocks' })
|
||||
).toBeVisible();
|
||||
|
||||
const pageBlocksCode = new URL(page.url());
|
||||
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
|
||||
pageBlocksCode.search = '';
|
||||
await page.goto(pageBlocksCode.toString());
|
||||
// The trademark should not be visible because the cookie is still set,
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Code' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Redirect to app for authentication when missing token',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
// Intentionally not setting the token to test redirection for authentication
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.host === 'app.gitbook.com' && url.pathname.includes('/preview/auth')
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -714,9 +904,15 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Search & AI',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: searchTestCases,
|
||||
},
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
fullPage: true,
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -838,26 +1034,31 @@ const testCases: TestsCase[] = [
|
||||
name: 'Lists',
|
||||
url: 'blocks/lists',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Code',
|
||||
url: 'blocks/code',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Cards',
|
||||
url: 'blocks/cards',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Updates',
|
||||
url: 'blocks/updates',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForFunction(() => {
|
||||
const fonts = Array.from(document.fonts.values());
|
||||
const mjxFonts = fonts.filter(
|
||||
@@ -874,30 +1075,44 @@ const testCases: TestsCase[] = [
|
||||
name: 'Files',
|
||||
url: 'blocks/files',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Embeds',
|
||||
url: 'blocks/embeds',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Page links',
|
||||
url: 'blocks/page-links',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Annotations',
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForSelector('[data-annotation]');
|
||||
await page.click('[data-annotation]');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Stepper',
|
||||
url: 'blocks/stepper',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Columns',
|
||||
url: 'blocks/columns',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Mermaid',
|
||||
url: 'blocks/mermaid',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{ name: 'Columns', url: 'blocks/columns' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -921,11 +1136,14 @@ const testCases: TestsCase[] = [
|
||||
name: 'With cover for dark mode',
|
||||
url: `page-options/page-with-dark-cover${getCustomizationURL({
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Dark,
|
||||
default: CustomizationDefaultThemeMode.Dark,
|
||||
toggeable: false,
|
||||
},
|
||||
})}`,
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page, { darkMode: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With hero cover',
|
||||
@@ -1123,6 +1341,16 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'Reusable contents',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/reusable-contents/',
|
||||
tests: [
|
||||
{
|
||||
name: 'All cases',
|
||||
url: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Page actions',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
@@ -1133,12 +1361,43 @@ const testCases: TestsCase[] = [
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Social links',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'With social links',
|
||||
url: getCustomizationURL({
|
||||
socialAccounts: [
|
||||
{
|
||||
platform: SiteSocialAccountPlatform.Github,
|
||||
display: { footer: true },
|
||||
handle: 'GitbookIO',
|
||||
},
|
||||
{
|
||||
platform: SiteSocialAccountPlatform.Linkedin,
|
||||
display: { footer: true },
|
||||
handle: 'gitbook',
|
||||
},
|
||||
{
|
||||
platform: SiteSocialAccountPlatform.Twitter,
|
||||
display: { footer: false },
|
||||
handle: 'GitBookIO',
|
||||
},
|
||||
],
|
||||
}),
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ads',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
@@ -1165,7 +1424,7 @@ const testCases: TestsCase[] = [
|
||||
).toBeVisible();
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
|
||||
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -1185,7 +1444,7 @@ const testCases: TestsCase[] = [
|
||||
).toBeVisible();
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
|
||||
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -1196,13 +1455,21 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to SSO page',
|
||||
name: 'Basic redirect',
|
||||
url: 'a/redirect/to/sso',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SSO');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Complex wildcard with special characters',
|
||||
url: 'foo/bar/baz/123456789-welcome-to-gitbook-%22%20target=%22_blank',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SEO');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1504,33 +1771,36 @@ const testCases: TestsCase[] = [
|
||||
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to fallback on invalid token pulled from cookie',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
cookies: (() => {
|
||||
const basePath = '/va-site-redirects-fallback/';
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
'invalidKey',
|
||||
{
|
||||
expiresIn: '24h',
|
||||
}
|
||||
);
|
||||
return [
|
||||
{
|
||||
name: getVisitorAuthCookieName(basePath),
|
||||
value: getVisitorAuthCookieValue(basePath, invalidToken),
|
||||
httpOnly: true,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
run: async (page) => {
|
||||
await expect(page).toHaveURL(/https:\/\/www.google.com/);
|
||||
},
|
||||
},
|
||||
// This test does not work on Playwright
|
||||
// Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
|
||||
// @see https://github.com/microsoft/playwright/issues/34889
|
||||
// {
|
||||
// name: 'Redirect to fallback on invalid token pulled from cookie',
|
||||
// url: '',
|
||||
// screenshot: false,
|
||||
// cookies: (() => {
|
||||
// const basePath = '/va-site-redirects-fallback/';
|
||||
// const invalidToken = jwt.sign(
|
||||
// {
|
||||
// name: 'gitbook-open-tests',
|
||||
// },
|
||||
// 'invalidKey',
|
||||
// {
|
||||
// expiresIn: '24h',
|
||||
// }
|
||||
// );
|
||||
// return [
|
||||
// {
|
||||
// name: getVisitorAuthCookieName(basePath),
|
||||
// value: getVisitorAuthCookieValue(basePath, invalidToken),
|
||||
// httpOnly: true,
|
||||
// },
|
||||
// ];
|
||||
// })(),
|
||||
// run: async (page) => {
|
||||
// await expect(page).toHaveURL(/https:\/\/www.google.com/);
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'Show error message when invalid token is passed to url',
|
||||
screenshot: false,
|
||||
@@ -1925,7 +2195,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Docs Embed - Basic',
|
||||
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Standalone UX',
|
||||
@@ -1972,13 +2242,15 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Docs Embed - Assistant + Docs',
|
||||
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
name: 'Switch between tabs',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await setTimeToMorning(page);
|
||||
await page.reload();
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await iframe.getByTestId('embed-tab-docs').click(); // Switch to docs tab
|
||||
@@ -1988,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',
|
||||
@@ -1998,7 +2274,7 @@ const testCases: TestsCase[] = [
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||
GitBook('navigateToPage', '/text-page');
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
@@ -2007,7 +2283,7 @@ const testCases: TestsCase[] = [
|
||||
});
|
||||
await expect(iframe.owner()).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('getting-started/quickstart')
|
||||
expect.stringContaining('text-page')
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -2024,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',
|
||||
@@ -2051,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',
|
||||
@@ -2068,7 +2356,7 @@ const testCases: TestsCase[] = [
|
||||
onClick: () => {
|
||||
const GitBook =
|
||||
window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||
GitBook('navigateToPage', '/text-page');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2109,7 +2397,7 @@ const testCases: TestsCase[] = [
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible();
|
||||
await expect(iframe.owner()).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('getting-started/quickstart')
|
||||
expect.stringContaining('text-page')
|
||||
);
|
||||
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Open external link');
|
||||
@@ -2134,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',
|
||||
@@ -2174,13 +2469,21 @@ const testCases: TestsCase[] = [
|
||||
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
|
||||
);
|
||||
const toolConfirmation = iframe
|
||||
.getByTestId('ai-chat-tool-confirmation')
|
||||
.getByTestId('ai-chat-tool-confirm-accept')
|
||||
.first();
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
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),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2193,6 +2496,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Docs only',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
@@ -2204,6 +2513,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Table of contents',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
@@ -2219,6 +2534,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Open in new tab',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - e2e - all pages', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - e2e - single-page', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
|
||||
@@ -2,25 +2,34 @@ import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
CustomizationAIMode,
|
||||
CustomizationBackground,
|
||||
CustomizationCodeTheme,
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultFont,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationDepth,
|
||||
type CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLinksStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationPageActionType,
|
||||
CustomizationSearchStyle,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationTheme,
|
||||
CustomizationThemeMode,
|
||||
type CustomizationThemedColor,
|
||||
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';
|
||||
@@ -38,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.
|
||||
*/
|
||||
@@ -74,6 +93,10 @@ export type TestsCase = {
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
contentBaseURL?: string;
|
||||
/**
|
||||
* Whether screenshots in this test case should capture the full scrollable page by default.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
export const allLocales: CustomizationLocale[] = [
|
||||
@@ -83,9 +106,9 @@ export const allLocales: CustomizationLocale[] = [
|
||||
CustomizationLocale.Zh,
|
||||
];
|
||||
|
||||
export const allThemeModes: CustomizationThemeMode[] = [
|
||||
CustomizationThemeMode.Light,
|
||||
CustomizationThemeMode.Dark,
|
||||
export const allThemeModes: CustomizationDefaultThemeMode[] = [
|
||||
CustomizationDefaultThemeMode.Light,
|
||||
CustomizationDefaultThemeMode.Dark,
|
||||
];
|
||||
|
||||
export const allTintColors: Array<{
|
||||
@@ -154,9 +177,41 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page) {
|
||||
/**
|
||||
* 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)
|
||||
|
||||
await page.clock.install({ time: now });
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page, options?: { darkMode?: boolean }) {
|
||||
const selector = options?.darkMode
|
||||
? 'img[alt="Page cover"].dark\\:inline'
|
||||
: 'img[alt="Page cover"]:not(.dark\\:inline)';
|
||||
// Wait for cover images to exist (not the shimmer placeholder)
|
||||
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
|
||||
await expect(page.locator(selector)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
@@ -229,15 +284,18 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
await waitForIcons(page);
|
||||
// Re-apply per viewport, last — after any resize-driven
|
||||
// re-render — so normalized content survives to capture.
|
||||
await testEntry.normalizeBeforeScreenshot?.(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -298,6 +356,16 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
background: CustomizationBackground.Plain,
|
||||
icons: CustomizationIconsStyle.Regular,
|
||||
links: CustomizationLinksStyle.Default,
|
||||
codeTheme: {
|
||||
default: {
|
||||
light: CustomizationCodeTheme.DefaultLight,
|
||||
dark: CustomizationCodeTheme.DefaultDark,
|
||||
},
|
||||
openapi: {
|
||||
light: CustomizationCodeTheme.DefaultLight,
|
||||
dark: CustomizationCodeTheme.DefaultDark,
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
background: CustomizationSidebarBackgroundStyle.Default,
|
||||
list: CustomizationSidebarListStyle.Default,
|
||||
@@ -319,7 +387,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
groups: [],
|
||||
},
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Light,
|
||||
default: CustomizationDefaultThemeMode.System,
|
||||
toggeable: true,
|
||||
},
|
||||
pdf: {
|
||||
@@ -347,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,
|
||||
@@ -371,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);
|
||||
@@ -420,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');
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
// @ts-check
|
||||
|
||||
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
|
||||
let deploymentId =
|
||||
process.env.GITBOOK_RUNTIME === 'cloudflare'
|
||||
? undefined
|
||||
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
|
||||
|
||||
const { VERCEL_TARGET_ENV } = process.env;
|
||||
|
||||
// Because preview, staging and prod shares the same SHA, the deployment will fail if we don't prefix it with the environment name.
|
||||
if (VERCEL_TARGET_ENV === 'preview') {
|
||||
deploymentId = `t-${deploymentId}`;
|
||||
} else if (VERCEL_TARGET_ENV === 'staging') {
|
||||
deploymentId = `s-${deploymentId}`;
|
||||
} else if (VERCEL_TARGET_ENV === 'production') {
|
||||
deploymentId = `p-${deploymentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
@@ -17,14 +35,18 @@ const nextConfig = {
|
||||
|
||||
// Since content is fully static, we don't want to fetch on hover again
|
||||
optimisticClientCache: false,
|
||||
// Disable splitting the RSC in like 5 chunks
|
||||
prefetchInlining: true,
|
||||
},
|
||||
|
||||
env: {
|
||||
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
|
||||
// GitBook envs
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
@@ -37,6 +59,7 @@ const nextConfig = {
|
||||
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
|
||||
|
||||
// Next.js envs
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
"main": "default.js",
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-02",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"vars": {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"main": "middleware.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-02",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"assets": {
|
||||
"directory": "../../.open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -6,12 +6,13 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
|
||||
const originalTagCache = doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
|
||||
// We can set a long TTL for the regional cache, as we invalidate it on update
|
||||
regionalCacheTtlSec: 24 * 60 * 60, // 1 day
|
||||
// Because we invalidate the Cache API on update, we can safely set this to true
|
||||
regionalCacheDangerouslyPersistMissingTags: true,
|
||||
shardReplication: {
|
||||
numberOfSoftReplicas: 2,
|
||||
numberOfHardReplicas: 1,
|
||||
numberOfHardReplicas: 2,
|
||||
regionalReplication: {
|
||||
defaultRegion: 'enam',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.21.3",
|
||||
"version": "0.27.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -17,10 +17,13 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "^3.8.5",
|
||||
"@opennextjs/cloudflare": "^1.14.4",
|
||||
"@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",
|
||||
@@ -29,6 +32,7 @@
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -36,30 +40,37 @@
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leven": "^4.1.0",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "15.4.10",
|
||||
"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": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"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",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -67,9 +78,10 @@
|
||||
"remark-rehype": "^11.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^3.2.0",
|
||||
"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",
|
||||
@@ -77,12 +89,12 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3",
|
||||
"negotiator": "^1.0.0"
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@argos-ci/playwright": "^6.4.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -92,8 +104,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/rison": "^0.0.9",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"bun-types": "catalog:",
|
||||
@@ -105,22 +117,22 @@
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^39.3.0",
|
||||
"wrangler": "^4.43.0",
|
||||
"vercel": "50.37.3",
|
||||
"wrangler": "^4.79.0",
|
||||
"rss-parser": "^3.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "./scripts/generate.sh",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next",
|
||||
"build": "next build",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
||||
"build:cloudflare": "opennextjs-cloudflare build",
|
||||
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
@@ -132,5 +144,9 @@
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
|
||||
|
||||
if (!context.site.urls.login) {
|
||||
return NextResponse.redirect(noLoginFallbackURL);
|
||||
}
|
||||
|
||||
try {
|
||||
const loginURL = new URL(context.site.urls.login);
|
||||
const location = request.nextUrl.searchParams.get('location');
|
||||
if (location) {
|
||||
loginURL.searchParams.set('location', location);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(loginURL);
|
||||
} catch (_error) {
|
||||
return NextResponse.redirect(noLoginFallbackURL);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getDynamicSiteContext,
|
||||
getSiteURLDataFromParams,
|
||||
} from '@/app/utils';
|
||||
import { getVisitorAuthBasePath } from '@/lib/data';
|
||||
import { getVisitorAuthCookieName } from '@/lib/visitors';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Clear the site-scoped auth session cookies and redirect to the site root.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
const [{ context }, siteURLData, cookieStore] = await Promise.all([
|
||||
getDynamicSiteContext(resolvedParams),
|
||||
getSiteURLDataFromParams(resolvedParams),
|
||||
cookies(),
|
||||
]);
|
||||
|
||||
cookieStore.delete(
|
||||
getVisitorAuthCookieName(
|
||||
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
|
||||
)
|
||||
);
|
||||
|
||||
// TODO: Redirect to the site root for now. Once the API supports it,
|
||||
// optionally redirect to a logoutURL (e.g when needing to logout from upstream auth too)
|
||||
// when defined in visitor auth settings.
|
||||
return NextResponse.redirect(context.linker.toAbsoluteURL(context.linker.toPathInSite('')));
|
||||
}
|
||||
+3
@@ -5,6 +5,7 @@ import {
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
@@ -18,12 +19,14 @@ export default async function RootLayout({
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
|
||||
return (
|
||||
<EmbeddableRootLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
{children}
|
||||
</EmbeddableRootLayout>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableSearchPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
|
||||
return <EmbeddableSearchPage context={context} />;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { handleMcpRequest } from '../handler';
|
||||
|
||||
async function handler(
|
||||
rawRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp/auth');
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
|
||||
import { resolvePagePath } from '@/lib/pages';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function handleMcpRequest(
|
||||
rawRequest: NextRequest,
|
||||
params: RouteLayoutParams,
|
||||
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
if (!context.customization.pageActions.mcp) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
|
||||
requestURL.search = rawRequest.nextUrl.search;
|
||||
const request = new Request(requestURL, rawRequest);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: context.site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'mcp_request',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((result) => {
|
||||
if (result.type === 'record') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${result.title}`,
|
||||
`Link: ${result.url}`,
|
||||
result.description ? `Content: ${result.description}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === result.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const siteUrl = context.siteSpace.urls.published;
|
||||
server.tool(
|
||||
'getPage',
|
||||
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
|
||||
{
|
||||
url: z
|
||||
.string()
|
||||
.describe('The URL of the page to fetch')
|
||||
.transform((value, ctx) => {
|
||||
if (URL.canParse(value)) {
|
||||
return value;
|
||||
}
|
||||
if (URL.canParse(`https://${value}`)) {
|
||||
return `https://${value}`;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}),
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
const match = findSiteSpaceByUrl(context.structure, url);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: match.siteSpace.space.id,
|
||||
revisionId: match.siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
|
||||
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
|
||||
if (!resolved) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const markdown = await getMarkdownForPageInSpace(
|
||||
context,
|
||||
match.siteSpace,
|
||||
resolved.page
|
||||
);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'page_view',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
page: resolved.page.id,
|
||||
space: match.siteSpace.space.id,
|
||||
revision: match.siteSpace.space.revision,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text', text: markdown }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
|
||||
maxDuration: 60,
|
||||
verboseLogs: true,
|
||||
disableSse: true,
|
||||
}
|
||||
);
|
||||
|
||||
return mcpHandler(request);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { handleMcpRequest } from './handler';
|
||||
|
||||
async function handler(
|
||||
rawRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp');
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
ComputedPageResult,
|
||||
ComputedSectionResult,
|
||||
OrderedComputedResult,
|
||||
SearchSiteContentRequest,
|
||||
} from '@/components/Search/search-types';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
|
||||
import type {
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
|
||||
const [context, siteURLData] = await Promise.all([
|
||||
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
|
||||
getSiteURLDataFromMiddleware(),
|
||||
]);
|
||||
|
||||
if (query.length <= 1) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const [searchResults, { structure }] = await Promise.all([
|
||||
throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
query,
|
||||
scope,
|
||||
})
|
||||
),
|
||||
throwIfDataError(
|
||||
context.dataFetcher.getPublishedContentSite({
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
siteShareKey: siteURLData.shareKey,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
const results = searchResults
|
||||
.flatMap((resultItem) => {
|
||||
if (resultItem.type === 'record') {
|
||||
const result: OrderedComputedResult = {
|
||||
type: 'record',
|
||||
id: resultItem.id,
|
||||
title: resultItem.title,
|
||||
description: resultItem.description,
|
||||
href: resultItem.url,
|
||||
score: resultItem.score,
|
||||
};
|
||||
|
||||
return [{ score: resultItem.score, items: [result] }];
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
|
||||
return resultItem.pages.map((pageItem) => ({
|
||||
score: pageItem.score,
|
||||
items: transformSitePageResult({
|
||||
asEmbeddable: Boolean(asEmbeddable),
|
||||
linker: context.linker,
|
||||
pageItem,
|
||||
spaceItem: resultItem,
|
||||
siteSpace: found?.siteSpace,
|
||||
siteSection: found?.siteSection ?? undefined,
|
||||
siteSectionGroup: found?.siteSectionGroup ?? undefined,
|
||||
}),
|
||||
}));
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.flatMap((group) => group.items);
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
|
||||
function transformSitePageResult(args: {
|
||||
asEmbeddable: boolean;
|
||||
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
siteSpace?: SiteSpace;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup | null;
|
||||
}): OrderedComputedResult[] {
|
||||
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
|
||||
args;
|
||||
const currentLanguage = siteSpace?.space.language;
|
||||
const spaceURL = siteSpace?.urls.published;
|
||||
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
|
||||
|
||||
if (siteSectionGroup) {
|
||||
breadcrumbs.push({
|
||||
icon: siteSectionGroup.icon as IconName,
|
||||
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
|
||||
});
|
||||
}
|
||||
|
||||
if (siteSection) {
|
||||
breadcrumbs.push({
|
||||
icon: siteSection.icon as IconName,
|
||||
label: getLocalizedTitle(siteSection, currentLanguage),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(siteSection?.siteSpaces?.filter(
|
||||
(space) =>
|
||||
siteSection.siteSpaces?.filter(
|
||||
(candidate) => candidate.space.language === space.space.language
|
||||
).length > 1
|
||||
).length ?? 0) > 1 &&
|
||||
siteSpace
|
||||
) {
|
||||
breadcrumbs.push({
|
||||
label: getLocalizedTitle(siteSpace, currentLanguage),
|
||||
});
|
||||
}
|
||||
|
||||
breadcrumbs.push(
|
||||
...pageItem.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
}))
|
||||
);
|
||||
|
||||
const pageHref = !spaceURL
|
||||
? linker.toPathInSpace(pageItem.path)
|
||||
: asEmbeddable
|
||||
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
|
||||
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
|
||||
|
||||
const page: ComputedPageResult = {
|
||||
type: 'page',
|
||||
id: `${spaceItem.id}/${pageItem.id}`,
|
||||
title: pageItem.title,
|
||||
href: pageHref,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: pageItem.score,
|
||||
breadcrumbs,
|
||||
};
|
||||
|
||||
const pageSections =
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
let sectionHref = linker.toPathInSpace(section.path);
|
||||
|
||||
if (spaceURL) {
|
||||
if (asEmbeddable) {
|
||||
sectionHref = toEmbeddableLinkForPublishedContent(
|
||||
linker,
|
||||
spaceURL,
|
||||
section.path
|
||||
);
|
||||
} else {
|
||||
sectionHref = linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, section.path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'section',
|
||||
id: `${page.id}/${section.id}`,
|
||||
title: section.title,
|
||||
href: sectionHref,
|
||||
body: section.body,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: section.score,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Find the best-scoring section to use as a body preview on the page result.
|
||||
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
|
||||
(best, section) => (!best || section.score > best.score ? section : best),
|
||||
undefined
|
||||
);
|
||||
if (bestSection) {
|
||||
page.bestSection = {
|
||||
href: bestSection.href,
|
||||
title: bestSection.title,
|
||||
body: bestSection.body,
|
||||
score: bestSection.score,
|
||||
};
|
||||
}
|
||||
|
||||
return [page];
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function HEAD(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleOpenAPIProxyOptions();
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export async function GET(
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
|
||||
+28
-8
@@ -18,6 +18,14 @@ export async function GET(
|
||||
<html>
|
||||
<head>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style type="text/css">
|
||||
.control {
|
||||
display: none;
|
||||
}
|
||||
.custom-trigger .control {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -105,17 +113,29 @@ export async function GET(
|
||||
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
|
||||
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
|
||||
</body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
<script>
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
});
|
||||
window.GitBook('open');
|
||||
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
closeButton: useCustomTrigger
|
||||
});
|
||||
|
||||
if (useCustomTrigger) {
|
||||
document.body.classList.add('custom-trigger');
|
||||
window.GitBook('hide');
|
||||
} else {
|
||||
window.GitBook('open');
|
||||
}
|
||||
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
|
||||
+14
-2
@@ -24,10 +24,22 @@ export async function GET(
|
||||
(function () {
|
||||
const w = window;
|
||||
const gb = w.GitBook;
|
||||
|
||||
function getScriptSearchParams() {
|
||||
const script = document.currentScript;
|
||||
if (!script) return new URLSearchParams();
|
||||
|
||||
const url = new URL(script.src);
|
||||
return url.searchParams;
|
||||
}
|
||||
|
||||
const searchParams = getScriptSearchParams()
|
||||
const token = searchParams.get('jwt_token');
|
||||
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
|
||||
const initFrameOptions = token ? { visitor: { token } } : undefined;
|
||||
|
||||
if (typeof gb === "function") {
|
||||
gb('init', initOptions);
|
||||
gb('init', initOptions, initFrameOptions);
|
||||
} else {
|
||||
var d = document;
|
||||
|
||||
@@ -40,7 +52,7 @@ export async function GET(
|
||||
};
|
||||
w.GitBook = g;
|
||||
|
||||
g('init', initOptions);
|
||||
g('init', initOptions, initFrameOptions);
|
||||
|
||||
const load = function () {
|
||||
const style = document.createElement('link');
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableSearchPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
|
||||
return <EmbeddableSearchPage context={context} />;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
|
||||
) {
|
||||
const { question: encodedQuestion } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
|
||||
return serveAskMarkdown(context, question);
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
async function handler(
|
||||
nextRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((spaceResult) => {
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceResult.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaceResult.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
basePath: context.linker.toPathInSite('~gitbook/'),
|
||||
streamableHttpEndpoint: '/mcp',
|
||||
maxDuration: 60,
|
||||
verboseLogs: true,
|
||||
disableSse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(
|
||||
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
|
||||
);
|
||||
requestURL.search = nextRequest.nextUrl.search;
|
||||
|
||||
const request = new Request(requestURL, nextRequest);
|
||||
return mcpHandler(request);
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type RevisionPage,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
SiteVisibility,
|
||||
} from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLocalizedTitle,
|
||||
listAllSiteSpaces,
|
||||
} from '@/lib/sites';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
|
||||
|
||||
interface IndexPageEntry {
|
||||
page: RevisionPageDocument;
|
||||
ancestors: AncestorPage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the page tree and return all indexable document pages together with
|
||||
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
|
||||
*/
|
||||
function getIndexablePagesWithAncestors(
|
||||
rootPages: RevisionPage[],
|
||||
ancestors: AncestorPage[] = []
|
||||
): IndexPageEntry[] {
|
||||
const results: IndexPageEntry[] = [];
|
||||
|
||||
for (const page of rootPages) {
|
||||
if (page.type === 'link' || page.type === 'computed') continue;
|
||||
if (page.hidden || !isPageIndexable([], page)) continue;
|
||||
|
||||
if (page.type === 'document') {
|
||||
results.push({ page, ancestors });
|
||||
// Recurse into children with this document as an ancestor
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
} else if (page.type === 'group') {
|
||||
// Groups themselves are not documents — push them only as ancestors
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const revalidate = 86400; // 1 day in seconds
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, structure } = context;
|
||||
|
||||
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
|
||||
|
||||
const revisions = await Promise.all(
|
||||
visibleSpaces.map((ss) =>
|
||||
throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: ss.space.id,
|
||||
revisionId: ss.space.revision,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const pages: RawIndexPage[] = [];
|
||||
|
||||
for (let i = 0; i < visibleSpaces.length; i++) {
|
||||
const siteSpace = visibleSpaces[i]!;
|
||||
const revision = revisions[i]!;
|
||||
const forkedLinker = linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
|
||||
});
|
||||
|
||||
const lang = siteSpace.space.language ?? undefined;
|
||||
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
|
||||
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
|
||||
|
||||
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
|
||||
const cacheKey = `${siteSpace.id}:${page.id}`;
|
||||
if (seen.has(cacheKey)) continue;
|
||||
seen.add(cacheKey);
|
||||
|
||||
const breadcrumbs: Breadcrumb[] = [
|
||||
siteSectionGroup
|
||||
? {
|
||||
label: getLocalizedTitle(siteSectionGroup, lang),
|
||||
icon: siteSectionGroup.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
siteSection
|
||||
? {
|
||||
label: getLocalizedTitle(siteSection, lang),
|
||||
icon: siteSection.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
...ancestors.map((a) => ({
|
||||
label: a.title,
|
||||
icon: a.icon ?? undefined,
|
||||
emoji: a.emoji ?? undefined,
|
||||
})),
|
||||
].filter((c) => c !== undefined);
|
||||
|
||||
pages.push({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
|
||||
siteSpaceId: siteSpace.id,
|
||||
lang,
|
||||
icon: page.icon ?? undefined,
|
||||
emoji: page.emoji ?? undefined,
|
||||
description: page.description ?? undefined,
|
||||
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
|
||||
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
|
||||
const shouldCacheOnClient =
|
||||
context.site.visibility === SiteVisibility.Public ||
|
||||
context.site.visibility === SiteVisibility.Unlisted;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
|
||||
version: 1,
|
||||
pages,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
|
||||
'Cache-Control': shouldCacheOnClient
|
||||
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
|
||||
: 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ function getModeFromParams(mode: string): RouteParamMode {
|
||||
/**
|
||||
* Get the decoded site data from the params.
|
||||
*/
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||
export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||
try {
|
||||
const decoded = decodeURIComponent(params.siteData);
|
||||
return rison.decode(decoded);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_SECRET,
|
||||
GITBOOK_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
@@ -25,6 +26,7 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
body.tags.forEach((tag) => {
|
||||
logger.log(`Revalidating tag: ${tag}`);
|
||||
revalidateTag(tag);
|
||||
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -21,16 +21,15 @@ export async function getSpacePDFContext(
|
||||
const apiToken = await getAPITokenFromMiddleware();
|
||||
|
||||
const basePath = getPDFRoutePath(params);
|
||||
const linker = createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
});
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: apiToken,
|
||||
});
|
||||
|
||||
const baseContext: GitBookBaseContext = {
|
||||
linker,
|
||||
linker: createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
}),
|
||||
dataFetcher,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
|
||||
export const ConfirmControlOutputSchema = z.object({
|
||||
result: z.enum(['confirmed', 'cancelled']),
|
||||
});
|
||||
|
||||
export const ConfirmControlDef = createAIControl({
|
||||
name: 'confirm',
|
||||
exposeAsTool: false,
|
||||
description:
|
||||
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the user’s click.',
|
||||
inputSchema: z.object({
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Icon to be shown in the confirm button (any Fontawesome icon name).'),
|
||||
label: z.string().describe('Label to be shown in the confirm button.'),
|
||||
}),
|
||||
outputSchema: ConfirmControlOutputSchema,
|
||||
render: (props) => {
|
||||
return <ConfirmControl {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
|
||||
const { label, icon, onSubmit } = props;
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-cancel"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'cancelled' });
|
||||
}}
|
||||
truncate={false}
|
||||
variant="blank"
|
||||
icon="xmark"
|
||||
label={tString(language, 'cancel')}
|
||||
/>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-accept"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'confirmed' });
|
||||
}}
|
||||
variant="primary"
|
||||
truncate={false}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Input } from '@/components/primitives';
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, IconStyle } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
|
||||
const OTHER_OPTION_ID = '$other';
|
||||
|
||||
export const MultiChoiceControlOutputSchema = z.object({
|
||||
ids: z.array(z.string()).describe('The identifiers of the options selected by the user.'),
|
||||
labels: z.array(z.string()).describe('The labels of the options selected by the user.'),
|
||||
input: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The custom text entered by the user when "Other" is enabled and selected among the choices.'
|
||||
),
|
||||
});
|
||||
|
||||
export const MultiChoiceControlDef = createAIControl({
|
||||
name: 'multi-choice',
|
||||
exposeAsTool: true,
|
||||
description:
|
||||
'Use this control whenever you need the user to choose one or more options from a predefined list. Never add an "Other" option to the `options` array, use the `allowOther` property instead.',
|
||||
inputSchema: z.object({
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide the question or instruction that tells the user what choices they need to make.'
|
||||
),
|
||||
options: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.describe('Provide the short label the user sees for this option.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optionally provide supporting details to help the user understand this option.'
|
||||
),
|
||||
})
|
||||
.describe('Define one selectable option the user can pick.')
|
||||
)
|
||||
.describe(
|
||||
'Provide the list of options the user can choose from. The user may select one or more.'
|
||||
),
|
||||
allowOther: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to let the user select an "Other" option and enter a custom text response.'
|
||||
),
|
||||
}),
|
||||
outputSchema: MultiChoiceControlOutputSchema,
|
||||
render: (props) => {
|
||||
return <MultiChoiceControl {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
function MultiChoiceControl(props: GetAIControlProps<typeof MultiChoiceControlDef>) {
|
||||
const { prompt, options, allowOther, onSubmit } = props;
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [otherInput, setOtherInput] = React.useState('');
|
||||
const orderedOptionIds = [
|
||||
...options.map((option) => option.id),
|
||||
...(allowOther ? [OTHER_OPTION_ID] : []),
|
||||
];
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
const toggleOption = (id: string) => {
|
||||
setSelectedIds((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((currentId) => currentId !== id)
|
||||
: orderedOptionIds.filter(
|
||||
(optionId) => optionId === id || current.includes(optionId)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hasOtherSelected = selectedIds.includes(OTHER_OPTION_ID);
|
||||
const canSubmit = selectedIds.length > 0 && (!hasOtherSelected || otherInput.trim().length > 0);
|
||||
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
|
||||
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={`ai-chat-tool-multi-choice-option-${option.id}`}
|
||||
onClick={() => {
|
||||
toggleOption(option.id);
|
||||
}}
|
||||
className={tcls(
|
||||
'text-left transition-colors',
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
|
||||
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original bg-primary-original text-contrast-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<Icon
|
||||
icon="check"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="size-3"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<p className="font-medium text-sm">{option.label}</p>
|
||||
{option.description ? (
|
||||
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||
{option.description}
|
||||
</p>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowOther ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ai-chat-tool-multi-choice-option-other"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
toggleOption(OTHER_OPTION_ID);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={tString(language, 'form_other_prompt')}
|
||||
value={otherInput}
|
||||
onValueChange={setOtherInput}
|
||||
data-testid="ai-chat-tool-multi-choice-other-input"
|
||||
placeholder={tString(
|
||||
language,
|
||||
hasOtherSelected ? 'form_other_prompt' : 'form_other_field'
|
||||
)}
|
||||
className={tcls(
|
||||
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
|
||||
hasOtherSelected
|
||||
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
|
||||
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
sizing="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!hasOtherSelected) {
|
||||
toggleOption(OTHER_OPTION_ID);
|
||||
}
|
||||
}}
|
||||
leading={
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
|
||||
hasOtherSelected
|
||||
? 'border-primary-original bg-primary-original text-contrast-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
{hasOtherSelected ? (
|
||||
<Icon
|
||||
icon="check"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="size-3"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</ScrollContainer>
|
||||
|
||||
<Button
|
||||
data-testid="ai-chat-tool-multi-choice-submit"
|
||||
variant="primary"
|
||||
label={tString(language, 'submit')}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
ids: selectedIds,
|
||||
labels: selectedIds.map((id) =>
|
||||
id === OTHER_OPTION_ID
|
||||
? 'Other'
|
||||
: options.find((option) => option.id === id)?.label || ''
|
||||
),
|
||||
input: hasOtherSelected ? otherInput.trim() : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Input } from '@/components/primitives';
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
|
||||
const OTHER_OPTION_ID = '$other';
|
||||
|
||||
export const SingleChoiceControlOutputSchema = z.object({
|
||||
id: z.string().describe('The identifier of the option selected by the user.'),
|
||||
label: z.string().describe('The label of the option selected by the user.'),
|
||||
input: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The custom text entered by the user when "Other" is enabled and selected.'),
|
||||
});
|
||||
|
||||
export const SingleChoiceControlDef = createAIControl({
|
||||
name: 'single-choice',
|
||||
exposeAsTool: true,
|
||||
description:
|
||||
'Use this control whenever you need the user to choose exactly one option from a predefined list. Important: NEVER write an "Other" choice yourself to the `options` array, pass the `allowOther` property as `true` to add it automatically instead.',
|
||||
inputSchema: z.object({
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide the question or instruction that tells the user what single choice they need to make.'
|
||||
),
|
||||
options: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.describe('Provide the short label the user sees for this option.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optionally provide supporting details to help the user understand this option. Keep it concise and do not repeat the label.'
|
||||
),
|
||||
})
|
||||
.describe('Define one selectable option the user can pick.')
|
||||
)
|
||||
.describe(
|
||||
'Provide the list of options the user can choose from. The user must select exactly one.'
|
||||
),
|
||||
allowOther: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to let the user select an "Other" option and enter a custom text response.'
|
||||
),
|
||||
}),
|
||||
outputSchema: SingleChoiceControlOutputSchema,
|
||||
render: (props) => {
|
||||
return <SingleChoiceControl {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControlDef>) {
|
||||
const { prompt, options, allowOther, onSubmit } = props;
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [otherInput, setOtherInput] = React.useState('');
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
const canSubmit =
|
||||
selectedId !== null &&
|
||||
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
|
||||
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
|
||||
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={`ai-chat-tool-single-choice-option-${option.id}`}
|
||||
onClick={() => {
|
||||
setSelectedId(option.id);
|
||||
}}
|
||||
className={tcls(
|
||||
'text-left transition-colors',
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
|
||||
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'size-2.5 rounded-full transition-colors',
|
||||
isSelected ? 'bg-primary-original' : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<p className="font-medium text-sm">{option.label}</p>
|
||||
{option.description ? (
|
||||
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||
{option.description}
|
||||
</p>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowOther ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ai-chat-tool-single-choice-option-other"
|
||||
tabIndex={-1} // The input is already focusable, so prevent focus on the wrapper button
|
||||
onClick={() => {
|
||||
setSelectedId(OTHER_OPTION_ID);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={tString(language, 'form_other_prompt')}
|
||||
value={otherInput}
|
||||
onValueChange={setOtherInput}
|
||||
data-testid="ai-chat-tool-single-choice-other-input"
|
||||
placeholder={tString(
|
||||
language,
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'form_other_prompt'
|
||||
: 'form_other_field'
|
||||
)}
|
||||
className={tcls(
|
||||
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
|
||||
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
sizing="small"
|
||||
leading={
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'size-2.5 rounded-full transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'bg-primary-original'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</ScrollContainer>
|
||||
|
||||
<Button
|
||||
data-testid="ai-chat-tool-single-choice-submit"
|
||||
variant="primary"
|
||||
label={tString(language, 'submit')}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
if (!canSubmit || !selectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId === OTHER_OPTION_ID) {
|
||||
onSubmit({
|
||||
id: OTHER_OPTION_ID,
|
||||
label: 'Other',
|
||||
input: otherInput.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
id: selectedId,
|
||||
label: options.find((option) => option.id === selectedId)?.label || '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { ComponentPropsWithRef } from 'react';
|
||||
|
||||
export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={tcls(
|
||||
'min-h-0 shrink grow-0 animate-blur-in circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base p-2',
|
||||
props.className
|
||||
)}
|
||||
style={{ animationDelay: '0.3s', ...props.style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { tString } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations/types';
|
||||
import type {
|
||||
AIStreamResponseToolCallPending,
|
||||
AIToolCallResult,
|
||||
AIToolDefinition,
|
||||
} from '@gitbook/api';
|
||||
import type { ZodType, z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
type AIUIToolContext = Pick<AIStreamResponseToolCallPending, 'toolCall' | 'toolCallId'>;
|
||||
|
||||
type AIControlDefinition<
|
||||
Name extends string = any,
|
||||
Input extends Record<string, unknown> = any,
|
||||
Output extends Record<string, unknown> = any,
|
||||
> = AIToolDefinition & {
|
||||
createControl: (args: {
|
||||
context: AIUIToolContext;
|
||||
input: Input;
|
||||
language: TranslationLanguage;
|
||||
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
|
||||
}) => AIControl<Name, Input, Output>;
|
||||
exposeAsTool: boolean;
|
||||
};
|
||||
|
||||
type AIControlProps<Input = Record<string, unknown>, Output = Record<string, unknown>> = Input & {
|
||||
onSubmit: (output: Output) => Promise<void>;
|
||||
};
|
||||
|
||||
type AIControl<Name = string, Input = Record<string, unknown>, Output = Record<string, unknown>> = {
|
||||
props: AIControlProps<Input, Output>;
|
||||
name: Name;
|
||||
context: AIUIToolContext;
|
||||
render: () => React.ReactNode;
|
||||
};
|
||||
|
||||
type AIControlFromDef<T extends AIControlDefinition> = ReturnType<T['createControl']>;
|
||||
|
||||
export type GetAIControlProps<T extends AIControlDefinition> = AIControlFromDef<T>['props'];
|
||||
|
||||
export function createAIControl<
|
||||
Name extends string,
|
||||
InputSchema extends ZodType<Record<string, unknown>>,
|
||||
OutputSchema extends ZodType<Record<string, unknown>>,
|
||||
>(def: {
|
||||
name: Name;
|
||||
description: string;
|
||||
inputSchema: InputSchema;
|
||||
outputSchema: OutputSchema;
|
||||
render: (props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>>) => React.ReactNode;
|
||||
/**
|
||||
* Indicates if the control should be exposed as a tool or not.
|
||||
*/
|
||||
exposeAsTool: boolean;
|
||||
}): AIControlDefinition<Name, z.infer<InputSchema>, z.infer<OutputSchema>> {
|
||||
return {
|
||||
name: `ui--${def.name}`,
|
||||
description: def.description,
|
||||
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
|
||||
createControl: ({ context, input, language, send }) => {
|
||||
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
|
||||
...input,
|
||||
onSubmit: async (output) => {
|
||||
await send({
|
||||
output,
|
||||
summary: {
|
||||
icon: 'comment-check',
|
||||
text: tString(language, 'ai_control_submitted_answer'),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
props,
|
||||
name: def.name,
|
||||
context,
|
||||
render: () => def.render(props),
|
||||
};
|
||||
},
|
||||
exposeAsTool: def.exposeAsTool,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ConfirmControlDef } from './ConfirmControl';
|
||||
import { MultiChoiceControlDef } from './MultiChoiceControl';
|
||||
import { SingleChoiceControlDef } from './SingleChoiceControl';
|
||||
|
||||
const CONTROLS = [SingleChoiceControlDef, MultiChoiceControlDef, ConfirmControlDef];
|
||||
|
||||
export type AnyAIControlTool = (typeof CONTROLS)[number];
|
||||
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
|
||||
|
||||
export function getControlTools(): AnyAIControlTool[] {
|
||||
return CONTROLS.filter((control) => control.exposeAsTool);
|
||||
}
|
||||
@@ -0,0 +1,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}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CollapsibleContent } from '@/components/primitives';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIMessage } from '@gitbook/api';
|
||||
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
|
||||
import { DocumentView } from '../../DocumentView';
|
||||
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
@@ -17,31 +18,41 @@ export function AIMessageView(
|
||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||
|
||||
return message.steps.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{message.steps.map((step, index) => {
|
||||
const hasContent = Boolean(step.content && step.content.nodes.length > 0);
|
||||
const Tag =
|
||||
step.phase === AIMessageStepPhase.Commentary ? CollapsibleContent : 'div';
|
||||
return (
|
||||
<div
|
||||
<Tag
|
||||
key={index}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||
'flex flex-col space-y-2 border-tint-subtle',
|
||||
hasContent ? 'has-content' : ''
|
||||
)}
|
||||
>
|
||||
<DocumentView
|
||||
document={step.content}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
wrapBlocksInSuspense: false,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
|
||||
/>
|
||||
{step.content ? (
|
||||
<DocumentView
|
||||
document={step.content}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
wrapBlocksInSuspense: false,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style={tcls(
|
||||
'ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden',
|
||||
step.phase === AIMessageStepPhase.Commentary
|
||||
? 'text-tint group-data-[disabled]/collapsible:text-inherit'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||
) : null}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HighlightQuery } from '@/components/Search/HighlightQuery';
|
||||
import { Link, StyledLink } from '@/components/primitives';
|
||||
import { Favicon } from '@/components/utils';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import type * as React from 'react';
|
||||
|
||||
/**
|
||||
@@ -69,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>
|
||||
@@ -99,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>
|
||||
@@ -133,30 +135,42 @@ 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(
|
||||
toolCall.results.map(async (result) => {
|
||||
const resolved = await resolveContentRef(
|
||||
result.anchor
|
||||
? {
|
||||
kind: 'anchor',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
anchor: result.anchor,
|
||||
}
|
||||
: {
|
||||
kind: 'page',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
},
|
||||
context
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
href: resolved?.href || '#',
|
||||
};
|
||||
switch (result.type) {
|
||||
case 'page': {
|
||||
const resolved = await resolveContentRef(
|
||||
result.anchor
|
||||
? {
|
||||
kind: 'anchor',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
anchor: result.anchor,
|
||||
}
|
||||
: {
|
||||
kind: 'page',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
href: resolved?.href || '#',
|
||||
};
|
||||
}
|
||||
case 'record':
|
||||
return {
|
||||
...result,
|
||||
href: result.url ?? '#',
|
||||
};
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -192,45 +206,80 @@ async function DescriptionForSearchToolCall(props: {
|
||||
{hasResults ? (
|
||||
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
key={`${result.pageId}-${index}`}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={result.href}
|
||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||
{searchResultsWithHrefs.map((result, index) => {
|
||||
const resultKey = (() => {
|
||||
switch (result.type) {
|
||||
case 'page':
|
||||
return `${result.spaceId}/${result.pageId}`;
|
||||
case 'record':
|
||||
return result.recordId;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
})();
|
||||
|
||||
const iconClassName = 'mt-1 size-3 shrink-0 text-tint-subtle';
|
||||
const icon = <Icon icon="memo" className={iconClassName} />;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${resultKey}-${index}`}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon="memo"
|
||||
className="mt-1 size-3 shrink-0 text-tint-subtle"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-tint">
|
||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.title}
|
||||
/>
|
||||
</h3>
|
||||
{result.description && (
|
||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||
<Link
|
||||
href={result.href}
|
||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||
insights={
|
||||
result.type === 'record'
|
||||
? {
|
||||
type: 'search_open_result',
|
||||
query: toolCall.query,
|
||||
result: {
|
||||
recordId: result.recordId,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'search_open_result',
|
||||
query: toolCall.query,
|
||||
result: {
|
||||
spaceId: result.spaceId,
|
||||
pageId: result.pageId,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{result.type === 'record' ? (
|
||||
<Favicon url={result.href} className={iconClassName} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<div className="flex flex-col gap-1 text-tint">
|
||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.description}
|
||||
text={result.title}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="ml-auto size-3 shrink-0 self-center"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</h3>
|
||||
{result.description && (
|
||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.description}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="ml-auto size-3 shrink-0 self-center"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -238,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>
|
||||
|
||||
@@ -39,9 +39,7 @@ export async function streamRenderAIMessage(
|
||||
|
||||
if (message.steps[stepIndex]) {
|
||||
message.steps = [...message.steps];
|
||||
// @ts-expect-error
|
||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||
// @ts-expect-error
|
||||
callback(message.steps[stepIndex]);
|
||||
} else {
|
||||
message.steps = [
|
||||
@@ -67,6 +65,16 @@ export async function streamRenderAIMessage(
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (event) => {
|
||||
switch (event.type) {
|
||||
/**
|
||||
* A new step started (phase update).
|
||||
*/
|
||||
case 'response_step_start': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
step.phase = event.phase;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent is processing a tool call in a new message.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use server';
|
||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
SiteCoreChannelType,
|
||||
type SiteInsightsSession,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
@@ -22,6 +23,7 @@ export async function* streamAIChatResponse({
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
session,
|
||||
options,
|
||||
}: {
|
||||
message?: string;
|
||||
@@ -29,13 +31,13 @@ export async function* streamAIChatResponse({
|
||||
previousResponseId?: string;
|
||||
toolCall?: AIToolCallResult;
|
||||
tools?: AIToolDefinition[];
|
||||
session: SiteInsightsSession;
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
let context = await getServerActionBaseContext();
|
||||
if (options?.asEmbeddable) {
|
||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||
}
|
||||
const context = await getServerActionBaseContext({
|
||||
isEmbeddable: options?.asEmbeddable,
|
||||
});
|
||||
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
@@ -57,6 +59,12 @@ export async function* streamAIChatResponse({
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
session,
|
||||
channel: {
|
||||
type: options?.asEmbeddable
|
||||
? SiteCoreChannelType.Embed
|
||||
: SiteCoreChannelType.Site,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { type AnyAIControlTool, getControlTools } from './controls';
|
||||
|
||||
export function getTools(
|
||||
builtInTools: GitBookIntegrationTool[] = []
|
||||
): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
return [...getControlTools(), ...builtInTools, ...integrationTools];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user