mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 06:35:17 +00:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d25cc924e4 | |||
| 68e50d68e5 | |||
| 46b43c4372 | |||
| f8fd5e2bb4 | |||
| 9ab6cfb878 | |||
| c9a637e0d9 | |||
| c16ab0a2ce | |||
| fe20392d21 | |||
| 8a81d77151 | |||
| 55ac341cc9 | |||
| 2f5b0a7f8a | |||
| 99f751c060 | |||
| c41cf9c12a | |||
| 3d376845d7 | |||
| bcd4d4e980 | |||
| 88b1919a45 | |||
| cf94386c16 | |||
| e77bf32d51 | |||
| a69a307de2 | |||
| 39156ee9c3 | |||
| 47ac3e2c81 | |||
| e73b182925 | |||
| a9a52fee72 | |||
| cb92754575 | |||
| 49d35aa026 | |||
| 98b2df42ef | |||
| 6cf4278f14 | |||
| 86c44299eb | |||
| 89c4a0f808 | |||
| 8e9a49de1a | |||
| 4e9071d1b5 | |||
| 597fe34d3b | |||
| e14609cb3e | |||
| 57f3077006 | |||
| e24632c80b | |||
| ad3399b42e | |||
| f3408ed495 | |||
| 8676ad19e2 | |||
| 3a31100efe | |||
| 3fedaaa08c | |||
| bcea23e886 | |||
| fdea8f1c47 | |||
| 139b897521 | |||
| 37c2dd8819 | |||
| 62ca6d3ce5 | |||
| 6102413d6b | |||
| eaef259a80 | |||
| 578a3a829b | |||
| c923c654bd | |||
| 65fd3cc101 | |||
| bb30db105d | |||
| 6146f8e183 | |||
| 7e55cd5e4c | |||
| cb2dfa76af | |||
| 77efd449ae | |||
| af259fbe86 | |||
| 2b31b0213c | |||
| f60afe1b25 | |||
| b7c3b847e8 | |||
| 2fa65e6949 | |||
| c76b4fdccc | |||
| 9f651339f0 | |||
| 5ce3b1e578 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the docs embed `navigateToPage` API on multi-space sites. Deep-linking to a page in a different space/section (e.g. `navigateToPage('/help-center/integrations')`) previously 404'd because the section base was not placed before `~gitbook/embed/page`. The target is now resolved to its space server-side, so pages in any space resolve correctly. The input accepts the page path, an absolute path, or the full published URL.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/colors": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
A near-white tint color (e.g. a warm `#F5F3EF`) is now taken as the exact page background, mirroring the existing behavior for near-black tints. The tint's exact lightness, hue and chroma are preserved, and the color is anchored to whichever scale step the active theme renders as the background — so it matches exactly on `muted` (which uses the second step) as well as `clean`. This applies only to near-neutral tints that are light enough to read as a background; saturated or merely light-ish colors keep their normal accent scale. The `bold` theme is unaffected: it already uses the tint for the header and stays intentionally two-tone.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add cover image background mode and masks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Button blocks now respect the `size` option, so you can render small, medium, or large buttons.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Reduce the JavaScript and CSS loaded on published site pages: the search index and its UI now load only when search is opened, and the admin toolbar and OpenAPI/ContentKit styles are no longer shipped to every visitor.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Remove GBO's redundant re-selection of the best-scoring search section. The search API now returns a single highest-scoring section per page (and orders sections highest-score-first), so GBO no longer needs its own `getBestScoredResult` helper to pick the best section for the search and MCP previews. No user-visible change.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix a light/dark flash on published sites configured to respect the system default (no theme toggle). Such sites forced the `system` theme, but `next-themes`' pre-paint script applies a forced value verbatim without resolving `prefers-color-scheme`, so the page painted light and only switched to dark after hydration. We now leave the theme unforced when the default is `system` (only concrete light/dark themes are forced), letting `next-themes`' existing pre-paint script resolve the system preference before first paint.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Separate the prompt block actions into a primary "Open in" dropdown and a secondary "Copy prompt" button, instead of a single combined button group, and align the block's design with the expandable block (bordered frame, left disclosure chevron, and subtle elevation when expanded).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the docs embed widget shipping a stale script: declare the embed package's `standalone/` bundle as a Turbo build output. Because it wasn't declared, changes confined to the standalone widget (which compiles to `standalone/` but not `dist/`) didn't invalidate the downstream `generate` cache that copies it into the app, so the deployed widget could lag the source — e.g. the `clipboard-write` permission on the widget iframe never reached production, breaking the copy button in the Assistant embed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Refine the per-paragraph AI ask button: shorten its tooltip to "Ask" (from "Ask <assistant> about this"), and hide it inside cards where it would otherwise be clipped by the card's overflow.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep the "On this page" and "Ask" buttons pinned below the header while scrolling on desktop API reference pages, so the page outline stays reachable throughout long operations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Hide unfocusable unlabelled button from screen readers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Only show the "Back to [space]" shortcut for cross-space links in the table of contents, not for in-content text links or other ways of reaching another space.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
a11y screen reader fixes
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix an issue where certain keywords could cause an exception when rendering emojis
|
||||
@@ -57,7 +57,8 @@ runs:
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
|
||||
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
|
||||
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
|
||||
@@ -66,6 +67,7 @@ runs:
|
||||
env:
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
shell: bash
|
||||
|
||||
- name: Upload the DO worker
|
||||
|
||||
@@ -59,7 +59,8 @@ runs:
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
|
||||
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
|
||||
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
|
||||
@@ -72,6 +73,7 @@ runs:
|
||||
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 "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local
|
||||
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
|
||||
cat .vercel/.env.${{ inputs.environment }}.local
|
||||
- name: Build Project Artifacts
|
||||
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
name: Visual Testing Customers v2
|
||||
needs: deploy-v2-vercel
|
||||
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -75,3 +75,7 @@ Save as `.changeset/<name>.md`, then commit it separately with message: `changes
|
||||
## Formatting
|
||||
|
||||
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
|
||||
|
||||
## Comments
|
||||
|
||||
Comment to explain *why*, not *what* — the code already shows what it does. Keep comments short, ideally a single line. Avoid multi-line block comments that narrate mechanics a reader can follow from the code; they add noise and go stale. Reserve longer comments for genuinely non-obvious rationale: a subtle invariant, or a workaround and the reason it exists.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="./assets/gitbook_icon_dark.svg" alt="GitBook" width="48" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">GitBook</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="65" height="65" viewBox="0 0 65 65" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.3963 34.2195C30.5255 36.0255 32.09 36.9285 33.8082 36.93C35.5265 36.9315 37.0926 36.0313 40.2249 34.2308L60.1913 22.7534C61.0927 22.2353 61.6484 21.2749 61.6484 20.2352C61.6484 19.1955 61.0927 18.2351 60.1913 17.717L40.2177 6.23554C37.0888 4.43695 35.5243 3.53766 33.8078 3.53833C32.0912 3.539 30.5275 4.43951 27.4 6.24053L10.2292 16.1286C10.102 16.2019 10.0383 16.2385 9.97905 16.2732C4.11368 19.7068 0.489862 25.9754 0.441408 32.7717C0.440918 32.8404 0.440918 32.9138 0.440918 33.0607C0.440918 33.2074 0.440918 33.2807 0.441407 33.3494C0.489754 40.138 4.10549 46.4008 9.96041 49.837C10.0196 49.8718 10.0831 49.9085 10.2101 49.9818L20.9658 56.1918C27.2331 59.8104 30.3668 61.6197 33.808 61.6208C37.2493 61.622 40.3842 59.8148 46.6539 56.2005L58.008 49.6551C61.1474 47.8454 62.7171 46.9405 63.579 45.4487C64.4409 43.957 64.4409 42.1451 64.4409 38.5215V31.5212C64.4409 30.5159 63.8965 29.5895 63.0182 29.1004C62.1683 28.627 61.1325 28.6341 60.2891 29.1189L37.0074 42.5019C35.4453 43.3998 34.6643 43.8487 33.8072 43.849C32.9501 43.8493 32.1688 43.4008 30.6062 42.5038L14.8487 33.4586C14.0593 33.0055 13.6647 32.7789 13.3477 32.738C12.625 32.6448 11.9301 33.0497 11.6548 33.7244C11.534 34.0203 11.5365 34.4753 11.5414 35.3855C11.545 36.0555 11.5468 36.3905 11.6094 36.6987C11.7496 37.3887 12.1127 38.0136 12.6428 38.4771C12.8795 38.6842 13.1696 38.8516 13.7499 39.1866L30.5974 48.9103C32.164 49.8145 32.9473 50.2666 33.8075 50.2668C34.6677 50.267 35.4512 49.8154 37.0184 48.912L57.6684 37.0085C58.2037 36.7 58.4713 36.5457 58.672 36.6616C58.8727 36.7776 58.8727 37.0865 58.8727 37.7044V40.8796C58.8727 41.7855 58.8727 42.2385 58.6572 42.6114C58.4417 42.9844 58.0493 43.2106 57.2644 43.663L40.2322 53.4811C37.0966 55.2885 35.5288 56.1923 33.8078 56.1915C32.0869 56.1907 30.5199 55.2855 27.386 53.4752L11.4509 44.2701C11.4003 44.2409 11.375 44.2262 11.3514 44.2125C8.0102 42.26 5.94856 38.6882 5.92922 34.8185C5.92909 34.7911 5.92909 34.7619 5.92909 34.7035V31.7889C5.92909 29.6526 7.06686 27.678 8.9151 26.6067C10.5483 25.66 12.5628 25.6582 14.1977 26.6018L27.3963 34.2195Z" fill="#181C1F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -7,7 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.18",
|
||||
"turbo": "^2.10.3",
|
||||
"vercel": "50.37.3",
|
||||
},
|
||||
},
|
||||
@@ -74,14 +74,14 @@
|
||||
},
|
||||
"packages/expr": {
|
||||
"name": "@gitbook/expr",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-loose": "^8.5.2",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a",
|
||||
"eval-estree-expression": "^3.0.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "catalog:",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.27.1",
|
||||
"version": "0.27.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
@@ -201,8 +201,8 @@
|
||||
"zustand": "^5.0.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^6.4.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@argos-ci/playwright": "^7.2.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/openapi-parser": {
|
||||
"name": "@gitbook/openapi-parser",
|
||||
"version": "3.0.11",
|
||||
"version": "3.0.12",
|
||||
"dependencies": {
|
||||
"@scalar/json-magic": "^0.9.4",
|
||||
"@scalar/openapi-parser": "^0.24.5",
|
||||
@@ -312,7 +312,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.5.14",
|
||||
"version": "1.5.15",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -360,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.185.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -392,15 +392,15 @@
|
||||
|
||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||
|
||||
"@argos-ci/api-client": ["@argos-ci/api-client@0.16.0", "", { "dependencies": { "debug": "^4.4.3", "openapi-fetch": "^0.15.0" } }, "sha512-BG6g+AZABKN8W2syzfPWKhPxxrAB9Wjp2iNS11R4IskHDV6T41+qeQDpT88GOq6eUZ64Sjzoh/nXG+9EpNxtfw=="],
|
||||
"@argos-ci/api-client": ["@argos-ci/api-client@0.24.0", "", { "dependencies": { "debug": "^4.4.3", "openapi-fetch": "^0.17.0", "p-retry": "^8.0.0" } }, "sha512-pWEkmqVyD2CbGGG9KsTQgufWIYRzMsZVWyLY4d3lI2pUbuTlv0W0pvvmJybmKkIk3Tcn1T5vMBjT2DYuUiZbqQ=="],
|
||||
|
||||
"@argos-ci/browser": ["@argos-ci/browser@5.1.2", "", {}, "sha512-eQqtM54Vh83++Dac5+7ml4YXKW/KnUHRQWjTZ+VGeXfOJdjnZa4JjfVL6fnFG6CKrXjCK5dd9gcZRmbVpbt8rA=="],
|
||||
"@argos-ci/browser": ["@argos-ci/browser@6.3.0", "", {}, "sha512-quTww/Yhm2jv+Y6q6dD4xEpm9Bb/SrZNFJaXgncAnCvW8jNnCrmDE2+8zAuGUh41Wkq1JQNDYKlBCO/RpY6A8Q=="],
|
||||
|
||||
"@argos-ci/core": ["@argos-ci/core@5.1.1", "", { "dependencies": { "@argos-ci/api-client": "0.16.0", "@argos-ci/util": "3.2.0", "convict": "^6.2.4", "debug": "^4.4.3", "fast-glob": "^3.3.3", "mime-types": "^3.0.2", "sharp": "^0.34.5", "tmp": "^0.2.5" } }, "sha512-RI5pYb5wmWUoXzWefNCjKSqGA3BroUB2ac9awlYV3McJjSAW8nCGJUt2Eds90Shkp+i4S6KbMLnLG56slhTCsg=="],
|
||||
"@argos-ci/core": ["@argos-ci/core@6.4.0", "", { "dependencies": { "@argos-ci/api-client": "0.24.0", "@argos-ci/util": "4.0.0", "convict": "^6.2.5", "debug": "^4.4.3", "fast-glob": "^3.3.3", "mime-types": "^3.0.2", "p-retry": "^8.0.0", "sharp": "^0.35.2", "tmp": "^0.2.7" } }, "sha512-YUySBUbjHpI8GZKe96n4VzgPPXl90hjw/GCHLQ3U6xbbIEZczCBCHeHB/MDjyXCHyk+9T7FUJSE0ddWnpDRI8Q=="],
|
||||
|
||||
"@argos-ci/playwright": ["@argos-ci/playwright@6.4.2", "", { "dependencies": { "@argos-ci/browser": "5.1.2", "@argos-ci/core": "5.1.1", "@argos-ci/util": "3.2.0", "chalk": "^5.6.2", "debug": "^4.4.3" } }, "sha512-0HE4rmgx2/znlMUi3z0Bp8OD7u7zNls9SlljL+qTVoWQRW22sDvpmRdZpjzb4pEjoe6onqPTDOqm5/f8QYHMwg=="],
|
||||
"@argos-ci/playwright": ["@argos-ci/playwright@7.2.0", "", { "dependencies": { "@argos-ci/browser": "6.3.0", "@argos-ci/core": "6.4.0", "@argos-ci/util": "4.0.0", "chalk": "^5.6.2", "debug": "^4.4.3" } }, "sha512-YkZswuc+hZLC8sUUp6QPzZJSfZsxnbt+sfyZr5DWSHuz5g2XRxJ8oEsXJwFraDMYku5jwfAtqXvza1J3WUo6qA=="],
|
||||
|
||||
"@argos-ci/util": ["@argos-ci/util@3.2.0", "", {}, "sha512-/Bn0qCH8VsdPv5WB9TUEf3oTgsIqsTMUEjPVDopHLzKK+j7nQYGOF3MnN7VhBow82BXeStBfJCS3UiZ6cgxRlw=="],
|
||||
"@argos-ci/util": ["@argos-ci/util@4.0.0", "", {}, "sha512-2yrJr81OVpa2TkvSjzGZlGv6SVqZcxF62AVk6M79aZ7nXkwWFg3tjA8awbFwn2mODI67gkP5FarAD29bzx3P3w=="],
|
||||
|
||||
"@ast-grep/napi": ["@ast-grep/napi@0.40.5", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.5", "@ast-grep/napi-darwin-x64": "0.40.5", "@ast-grep/napi-linux-arm64-gnu": "0.40.5", "@ast-grep/napi-linux-arm64-musl": "0.40.5", "@ast-grep/napi-linux-x64-gnu": "0.40.5", "@ast-grep/napi-linux-x64-musl": "0.40.5", "@ast-grep/napi-win32-arm64-msvc": "0.40.5", "@ast-grep/napi-win32-ia32-msvc": "0.40.5", "@ast-grep/napi-win32-x64-msvc": "0.40.5" } }, "sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A=="],
|
||||
|
||||
@@ -756,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.189.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-QezuW8dMScSTJ+2IL/GGixkhGEsMRFRoO/W2r80pKRjoWyI9Ebai9oZsoN/pbRZz3cTnwa7hpRDtzaqHeeOAdQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -812,6 +812,8 @@
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
@@ -850,6 +852,8 @@
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
@@ -1026,7 +1030,7 @@
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
|
||||
|
||||
@@ -1590,17 +1594,17 @@
|
||||
|
||||
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="],
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-guuiO2kKc7yUQFU2jhWyM/BrYGD/brqb0+JvJIXTQ/QI1NlWfHZ1id50kkkOWqxmBiDc8DgW2orfY85pQIc7gw=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="],
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UglEDl/r1/h5Vw6oS6cEE29Jugz2sDLxHCSupNznKatj1fDMRXFqYKFcbvm8RTFhtYPI45NxkrPNo9BqNychBg=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="],
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.10.3", "", { "os": "linux", "cpu": "x64" }, "sha512-KuQxaPWD7OBmwEZqO0sgijwcuXR5eMWbo7BEt2m1D2Q3RylJDj7WMcJ0Btv2VJA0QC+khzAaTAm1yWowJ0CWAw=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="],
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-DPkIKQ+6p0sho3Cx/e/A3F+AKgXQJA+z//cHsTcyyM1YWRGSYA0UTP8NUpb4iS2tVBSniGwmjRUr73hpq8kcDg=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="],
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.10.3", "", { "os": "win32", "cpu": "x64" }, "sha512-8NvFAze9D4PsosZAnK3aGqHz2vLzREz9lNIxLgfE0jRchq2sZQE190cwFm7WX17yg3ftPvwCvWH3rhLbG1UHCQ=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="],
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AqjqV5cHbFa0YRaQ+Dyw6lSm6as4h/Uf8LcZ7VN8i+Odr23ShFD5SUvVAR1d3rZqibFVs9c0VdtXdfQfkdcs3A=="],
|
||||
|
||||
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
|
||||
|
||||
@@ -2024,7 +2028,7 @@
|
||||
|
||||
"convert-hrtime": ["convert-hrtime@3.0.0", "", {}, "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA=="],
|
||||
|
||||
"convict": ["convict@6.2.4", "", { "dependencies": { "lodash.clonedeep": "^4.5.0", "yargs-parser": "^20.2.7" } }, "sha512-qN60BAwdMVdofckX7AlohVJ2x9UvjTNoKVXCL2LxFk1l7757EJqf1nySdMkPQer0bt8kQ5lQiyZ9/2NvrFBuwQ=="],
|
||||
"convict": ["convict@6.2.5", "", { "dependencies": { "lodash.clonedeep": "^4.5.0", "yargs-parser": "^20.2.7" } }, "sha512-JtXpxqDqJ8P0UwEHwhxLzCIXQy97vlYBZR222Sbzb1q1Erex9ASrztJ29SyhWFQjod1AeFBaPzEEC8YvtZMIYg=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
@@ -2270,7 +2274,7 @@
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eval-estree-expression": ["eval-estree-expression@github:jonschlinkert/eval-estree-expression#fb0246a", {}, "jonschlinkert-eval-estree-expression-fb0246a"],
|
||||
"eval-estree-expression": ["eval-estree-expression@3.0.1", "", {}, "sha512-zTLKGbiVdQYp4rQkSoXPibrFf5ZoPn6jzExegRLEQ13F+FSxu5iLgaRH6hlDs2kWSUa6vp8yD20cdJi0me6pEw=="],
|
||||
|
||||
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
|
||||
|
||||
@@ -2908,11 +2912,11 @@
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
|
||||
|
||||
"openapi-fetch": ["openapi-fetch@0.15.2", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg=="],
|
||||
"openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="],
|
||||
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="],
|
||||
|
||||
"os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="],
|
||||
|
||||
@@ -3322,7 +3326,7 @@
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
@@ -3356,7 +3360,7 @@
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
|
||||
"turbo": ["turbo@2.10.3", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.3", "@turbo/darwin-arm64": "2.10.3", "@turbo/linux-64": "2.10.3", "@turbo/linux-arm64": "2.10.3", "@turbo/windows-64": "2.10.3", "@turbo/windows-arm64": "2.10.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-uZIqzfgtWbyqu1Tqwdd0giRnPGgL0ejbcdF8eZLJhtTTVSrOgArZGzYeXTD6XNcc7ANgdhqex11Y9bHBeyiQLQ=="],
|
||||
|
||||
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||
|
||||
@@ -3538,6 +3542,8 @@
|
||||
|
||||
"@argos-ci/core/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"@argos-ci/core/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.734.0", "", { "dependencies": { "@smithy/types": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg=="],
|
||||
@@ -3630,6 +3636,10 @@
|
||||
|
||||
"@hyperjump/json-schema/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
@@ -3682,7 +3692,7 @@
|
||||
|
||||
"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@playwright/test/playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
"@playwright/test/playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
@@ -4288,6 +4298,58 @@
|
||||
|
||||
"@argos-ci/core/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
|
||||
|
||||
"@argos-ci/core/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
|
||||
|
||||
"@argos-ci/core/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@argos-ci/core/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-enhjdwp4D7CXmwLtD6zbcDMbo6/T6WtuuKCY49Xxc6OMOmUWlBEBDREsxxgV2LIdeQPW756+f97GzcgAwp3iLw=="],
|
||||
@@ -4332,6 +4394,10 @@
|
||||
|
||||
"@headlessui/react/@floating-ui/react/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
@@ -4370,7 +4436,7 @@
|
||||
|
||||
"@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"@playwright/test/playwright/playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
"@playwright/test/playwright/playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.18",
|
||||
"turbo": "^2.10.3",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.185.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { colorScale } from './transformations';
|
||||
|
||||
describe('colorScale exact base', () => {
|
||||
it('takes a very light tint as the exact background on step 1 (clean/bold)', () => {
|
||||
const scale = colorScale('#F5F3EF', { baseStep: 1 });
|
||||
expect(scale[0]).toBe('#F5F3EF');
|
||||
});
|
||||
|
||||
it('anchors a very light tint to step 2 when the theme uses the subtle step (muted)', () => {
|
||||
const scale = colorScale('#F5F3EF', { baseStep: 2 });
|
||||
expect(scale[1]).toBe('#F5F3EF');
|
||||
// Step 1 sits just above the exact base, toward white.
|
||||
expect(scale[0]).not.toBe('#F5F3EF');
|
||||
expect(scale[0]).not.toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('takes a darker-than-dark tint as the exact background, preserving hue and chroma', () => {
|
||||
const scale = colorScale('#0B0F19', { darkMode: true, baseStep: 1 });
|
||||
expect(scale[0]).toBe('#0B0F19');
|
||||
});
|
||||
|
||||
it('does not trigger for a normal mid-lightness tint', () => {
|
||||
const scale = colorScale('#787878', { baseStep: 2 });
|
||||
// The default white background is kept; the tint only colors the scale.
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[1]).not.toBe('#787878');
|
||||
});
|
||||
|
||||
it('does not trigger for a saturated light color, keeping the normal accent ramp', () => {
|
||||
// Light enough (L≈0.93) to pass the lightness bound, but too chromatic to read as a
|
||||
// background — emitting it verbatim would leave a vivid step 1 above a near-gray scale.
|
||||
const scale = colorScale('#FFEB3B', { baseStep: 1 });
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[0]).not.toBe('#FFEB3B');
|
||||
});
|
||||
|
||||
it('respects a custom light background instead of overriding it with the tint', () => {
|
||||
// The color is darker than the requested background, so it is not the extreme end and the
|
||||
// supplied base must be preserved rather than overwritten.
|
||||
const scale = colorScale('#eeeeee', { baseStep: 1, background: '#f8f8f8' });
|
||||
expect(scale[0]).not.toBe('#eeeeee');
|
||||
});
|
||||
|
||||
it('anchors an exact base even when a neutral mix is supplied (tint === primary)', () => {
|
||||
// getTintMixColor blends neutral into the tint when it equals the primary color; that must
|
||||
// not darken a near-white tint out of the exact-base path.
|
||||
const scale = colorScale('#F5F3EF', {
|
||||
baseStep: 1,
|
||||
mix: { color: '#787878', ratio: 0.4 },
|
||||
});
|
||||
expect(scale[0]).toBe('#F5F3EF');
|
||||
});
|
||||
|
||||
it('does not trigger for a light accent color below the near-white threshold', () => {
|
||||
// #D8DEEC (light blue-gray, L≈0.90) is a UI accent, not a background, so it must not anchor.
|
||||
const scale = colorScale('#D8DEEC', { baseStep: 1 });
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[0]).not.toBe('#D8DEEC');
|
||||
});
|
||||
|
||||
it('never anchors when no baseStep is given (accent scales and the bold theme)', () => {
|
||||
// A scale that does not define the page background opts out of the exact base entirely.
|
||||
const scale = colorScale('#F5F3EF', {});
|
||||
expect(scale[0]).toBe('#ffffff');
|
||||
expect(scale[0]).not.toBe('#F5F3EF');
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,22 @@ export const colorMixMapping = {
|
||||
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
|
||||
};
|
||||
|
||||
/**
|
||||
* Light mode has no equivalent to the dark base bound (nothing is lighter than white), so a tint
|
||||
* at or above this lightness is treated as an explicit, near-white background (e.g. a warm `#F5F3EF`
|
||||
* at L≈0.96). Kept high so light UI accent colors (around L≈0.90) aren't mistaken for backgrounds.
|
||||
*/
|
||||
const EXACT_BASE_LIGHT_THRESHOLD = 0.95;
|
||||
|
||||
/**
|
||||
* Only a near-neutral tint reads as a background. A saturated color would keep the exact hue at the
|
||||
* anchored step while the rest of the low scale stays ~gray, so those keep the normal accent ramp.
|
||||
*/
|
||||
const EXACT_BASE_NEUTRAL_CHROMA = 0.05;
|
||||
|
||||
/** Lightness of the default white light background (≈0.99999, not exactly 1). */
|
||||
const LIGHT_BASE_L = rgbToOklch(hexToRgbArray(LIGHT_BASE)).L;
|
||||
|
||||
/**
|
||||
* Convert a hex color to an RGB color.
|
||||
*/
|
||||
@@ -159,6 +175,14 @@ export type ColorScaleOptions = {
|
||||
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
|
||||
foreground?: string;
|
||||
|
||||
/**
|
||||
* The 1-indexed scale step this scale renders as the page background (1 = `tint-base` for
|
||||
* `clean`, 2 = `tint-subtle` for `muted`). When set, an extreme near-neutral tint is taken as
|
||||
* the exact background, anchored to this step so it matches exactly. Omit for scales that don't
|
||||
* define the page background (accents, or the two-tone `bold` theme) — they never anchor.
|
||||
*/
|
||||
baseStep?: number;
|
||||
|
||||
mix?: {
|
||||
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
|
||||
color: string;
|
||||
@@ -179,6 +203,7 @@ export function colorScale(
|
||||
darkMode = false,
|
||||
background = darkMode ? DARK_BASE : LIGHT_BASE,
|
||||
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
|
||||
baseStep,
|
||||
mix,
|
||||
}: ColorScaleOptions = {}
|
||||
) {
|
||||
@@ -188,31 +213,51 @@ export function colorScale(
|
||||
const backgroundColor = rgbToOklch(hexToRgbArray(background));
|
||||
let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
|
||||
|
||||
if (mixColor && mix?.ratio && mix.ratio > 0) {
|
||||
// If defined, we mix in a (tiny) bit of the mix color with the base color.
|
||||
// A near-neutral tint at the extreme end of the scale is taken as the exact page background
|
||||
// rather than tinting pure black/white with it — letting brands set an exact background such as
|
||||
// a warm `#F5F3EF`. Only scales that define the page background opt in (via `baseStep`). In light
|
||||
// mode the base is pure white by default, so a near-white tint also qualifies (nothing is lighter
|
||||
// than white); a custom, lower background is respected instead. Decided on the raw color so a
|
||||
// neutral mix (below) can't darken a tint out of the exact base.
|
||||
const isExtremeBase = darkMode
|
||||
? baseColor.L < backgroundColor.L
|
||||
: backgroundColor.L >= LIGHT_BASE_L
|
||||
? baseColor.L > EXACT_BASE_LIGHT_THRESHOLD
|
||||
: baseColor.L > backgroundColor.L;
|
||||
const isExactBase =
|
||||
baseStep !== undefined && isExtremeBase && baseColor.C < EXACT_BASE_NEUTRAL_CHROMA;
|
||||
const exactBaseIndex = (baseStep ?? 1) - 1;
|
||||
|
||||
if (mixColor && mix?.ratio && mix.ratio > 0 && !isExactBase) {
|
||||
// Mix a little of the mix color into the base — but not when the tint is the exact base,
|
||||
// where it must stay true to the supplied color (and match `--header-background`).
|
||||
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
|
||||
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
|
||||
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
|
||||
}
|
||||
|
||||
if (
|
||||
(darkMode && baseColor.L < backgroundColor.L) ||
|
||||
(!darkMode && baseColor.L > backgroundColor.L)
|
||||
) {
|
||||
// If the supplied color is outside of our lightness bounds, use the supplied color's lightness.
|
||||
// This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look.
|
||||
if (isExactBase) {
|
||||
const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L;
|
||||
backgroundColor.L = baseColor.L;
|
||||
// At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out.
|
||||
const amplifier = 1;
|
||||
mapping = mapping.map((step, index) =>
|
||||
index < 9 ? step + step * amplifier * difference : step
|
||||
);
|
||||
|
||||
// Anchor the supplied color to the step the theme renders as the background, solving the
|
||||
// background lightness so neighbouring steps stay continuous with it.
|
||||
const baseMix = mapping[exactBaseIndex]!;
|
||||
backgroundColor.L = (baseColor.L - foregroundColor.L * baseMix) / (1 - baseMix);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
for (let index = 0; index < mapping.length; index++) {
|
||||
if (isExactBase && index === exactBaseIndex) {
|
||||
result.push(hex);
|
||||
continue;
|
||||
}
|
||||
|
||||
const step = mapping[index]!;
|
||||
const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step);
|
||||
|
||||
@@ -238,7 +283,10 @@ export function colorScale(
|
||||
case 11:
|
||||
return 0.1;
|
||||
default:
|
||||
return index * 0.05;
|
||||
// When the tint is the exact base, hold the steps from the base toward the
|
||||
// accents at its chroma so the background stays tinted; steps lighter than the
|
||||
// base (e.g. cards in `muted`) keep desaturating toward white.
|
||||
return isExactBase && index >= exactBaseIndex ? 1 : index * 0.05;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ function MyComponent() {
|
||||
- `GitBook('open')` - Open widget window
|
||||
- `GitBook('close')` - Close widget window
|
||||
- `GitBook('toggle')` - Toggle widget window
|
||||
- `GitBook('navigateToPage', path: string)` - Navigate to page
|
||||
- `GitBook('navigateToPage', path: string)` - Navigate to page (accepts the page path within the site, an absolute path, or the full published URL; resolves pages in any space/section)
|
||||
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
|
||||
- `GitBook('postUserMessage', message: string)` - Post message to chat
|
||||
- `GitBook('clearChat')` - Clear chat history
|
||||
@@ -202,7 +202,7 @@ function MyComponent() {
|
||||
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
|
||||
|
||||
**Frame Client:**
|
||||
- `frame.navigateToPage(path: string)` → `void`
|
||||
- `frame.navigateToPage(path: string)` → `void` (accepts the page path within the site, an absolute path, or the full published URL; resolves pages in any space/section)
|
||||
- `frame.navigateToAssistant()` → `void`
|
||||
- `frame.postUserMessage(message: string)` → `void`
|
||||
- `frame.clearChat()` → `void`
|
||||
|
||||
@@ -7,7 +7,12 @@ import type {
|
||||
|
||||
export type GitBookFrameClient = {
|
||||
/**
|
||||
* Navigate to a page by its path.
|
||||
* Navigate to a page in the docs tab.
|
||||
*
|
||||
* Accepts the page's path within the site (e.g. `getting-started/quickstart`),
|
||||
* an absolute path (e.g. `/help-center/integrations`), or its full published URL.
|
||||
* The target page may live in any space/section of the site — including one other
|
||||
* than the embed is currently showing — and is resolved to the right space.
|
||||
*/
|
||||
navigateToPage: (path: string) => void;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**", "standalone/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3fedaaa: Depend on `eval-estree-expression` from the npm registry (`^3.0.1`) instead of a pinned GitHub commit. The published `3.0.1` release is built from the exact commit the package was pinned to, so the code is unchanged — this only removes the fragile git/tarball dependency so consumers install it from npm like any other package.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
|
||||
"eval-estree-expression": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/strictest": "catalog:",
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# gitbook
|
||||
|
||||
## 0.27.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bb30db1: Add a hover affordance in the document margin to ask the AI Assistant about a paragraph. On devices with a fine pointer, hovering a top-level paragraph reveals a small button that stages the paragraph's text as context and opens the assistant — making the existing text-selection "Ask" flow more discoverable.
|
||||
- 62ca6d3: Show a "Back to [space]" shortcut at the top of the table of contents when a reader follows a link into a different space, so they don't lose track of where they were browsing from.
|
||||
- fdea8f1: Show where a page lives in the site at the start of the page breadcrumbs: its section (including any enclosing section groups) and variant. Hovering any breadcrumb item reveals a dropdown to switch to its siblings at the same level — other section groups, sections, variants, page groups or pages — computed on the server from already-available data (no extra request). Language variants are left to the dedicated language picker rather than shown as a crumb. Also restyles the breadcrumbs to normal casing (matching search results).
|
||||
- 37c2dd8: Navigate customer visual tests with `domcontentloaded` to avoid `load`-event hangs on external sites.
|
||||
- c923c65: Fix center- and end-aligned paragraphs shifting left on wide/no-TOC pages when the AI Assistant is enabled, caused by the per-paragraph ask-button wrapper not inheriting the block alignment.
|
||||
- 37c2dd8: Fix flaky customer e2e screenshots by waiting for the built-in cookie banner before capturing.
|
||||
- 37c2dd8: Add a `data-testid` to the admin toolbar so e2e tests can assert its presence while hiding it from visual screenshots (it animates open, causing flaky diffs).
|
||||
- 36dbdb4: Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it.
|
||||
- 7e55cd5: Add an "Available in MCP" badge on OpenAPI operations marked with `x-gitbook-mcp: true`. When `x-gitbook-mcp-url` is set (on the operation, path, or root — most specific wins), the badge becomes a button that copies the MCP server URL to the clipboard.
|
||||
- 6146f8e: Reduce the size of `searchDocumentation` MCP responses by returning only the best-matching section per page instead of concatenating every section body.
|
||||
- 37c2dd8: Disable the content max-width transition under reduced motion, matching the surrounding layout transitions.
|
||||
- 37c2dd8: Reset cross-space navigation state between e2e navigations so the "Back to <space>" shortcut can't leak in and cause flaky screenshots.
|
||||
- bcea23e: Render `anyOf`/`oneOf` with a `null` member as a nullable schema instead of a `null` union branch.
|
||||
- 77efd44: Show changed pages in preview toolbars for change requests and revisions.
|
||||
- 578a3a8: Fix site section dropdowns not being bounded by their container during the open/resize animation, causing the contents to visually clip.
|
||||
- 2dc76a2: Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped.
|
||||
- 9f65133: Improve the 404 page: remove the table of contents sidebar and center the content, suggest related pages (ranked from the already-cached search index, so no extra request per 404), show a search or assistant input depending on the site's AI configuration, and move "Go to homepage" to the bottom as a last resort.
|
||||
- Updated dependencies [3fedaaa]
|
||||
- Updated dependencies [7e55cd5]
|
||||
- @gitbook/expr@1.3.1
|
||||
- @gitbook/openapi-parser@3.0.12
|
||||
- @gitbook/react-openapi@1.5.15
|
||||
|
||||
## 0.27.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -40,11 +40,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'jasons-tutorials.gitbook.io',
|
||||
contentBaseURL: 'https://jasons-tutorials.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'faq.deltaemulator.com',
|
||||
contentBaseURL: 'https://faq.deltaemulator.com',
|
||||
@@ -70,16 +65,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.gmgn.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// Removed, an AI widget, makes it flaky.
|
||||
// {
|
||||
// name: 'docs.spicychat.ai',
|
||||
// contentBaseURL: 'https://docs.spicychat.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// name: 'docs.portainer.io',
|
||||
// contentBaseURL: 'https://docs.portainer.io',
|
||||
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.portainer.io',
|
||||
contentBaseURL: 'https://docs.portainer.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.chirptoken.io',
|
||||
contentBaseURL: 'https://docs.chirptoken.io',
|
||||
@@ -110,27 +101,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// 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',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.gradient.network',
|
||||
// contentBaseURL: 'https://docs.gradient.network',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
// {
|
||||
// name: 'mygate-network.gitbook.io',
|
||||
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'treasurenft.gitbook.io',
|
||||
contentBaseURL: 'https://treasurenft.gitbook.io',
|
||||
@@ -159,23 +134,13 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.tickettool.xyz',
|
||||
contentBaseURL: 'https://docs.tickettool.xyz',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.redmodding.org',
|
||||
contentBaseURL: 'https://wiki.redmodding.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.cherry-ai.com',
|
||||
// contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.snyk.io',
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.realapp.link',
|
||||
contentBaseURL: 'https://docs.realapp.link',
|
||||
@@ -203,16 +168,6 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sosovalue-white-paper.gitbook.io',
|
||||
contentBaseURL: 'https://sosovalue-white-paper.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.revrobotics.com',
|
||||
// contentBaseURL: 'https://docs.revrobotics.com',
|
||||
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'chartschool.stockcharts.com',
|
||||
contentBaseURL: 'https://chartschool.stockcharts.com',
|
||||
@@ -221,24 +176,13 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'docs.soniclabs.com',
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// 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',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.raydium.io',
|
||||
contentBaseURL: 'https://docs.raydium.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.fluentbit.io',
|
||||
contentBaseURL: 'https://docs.fluentbit.io',
|
||||
@@ -256,6 +200,417 @@ const testCases: TestsCase[] = [
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
],
|
||||
},
|
||||
|
||||
// Additional customer docs sites.
|
||||
{
|
||||
name: 'unsloth.ai/docs',
|
||||
contentBaseURL: 'https://unsloth.ai',
|
||||
tests: [{ name: 'Home', url: '/docs', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'mariadb.com/docs',
|
||||
contentBaseURL: 'https://mariadb.com',
|
||||
tests: [{ name: 'Home', url: '/docs', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.n8n.io',
|
||||
contentBaseURL: 'https://docs.n8n.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cherryai.com.cn',
|
||||
contentBaseURL: 'https://docs.cherryai.com.cn',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'library.zoom.com',
|
||||
contentBaseURL: 'https://library.zoom.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.verkada.com',
|
||||
contentBaseURL: 'https://help.verkada.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.overleaf.com',
|
||||
contentBaseURL: 'https://docs.overleaf.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.tiltedphoques.com/tilted-online',
|
||||
contentBaseURL: 'https://wiki.tiltedphoques.com',
|
||||
tests: [{ name: 'Home', url: '/tilted-online' }],
|
||||
},
|
||||
{
|
||||
name: 'handbook.musescore.org',
|
||||
contentBaseURL: 'https://handbook.musescore.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'kakaobusiness.gitbook.io/main',
|
||||
contentBaseURL: 'https://kakaobusiness.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/main', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.maestro.dev',
|
||||
contentBaseURL: 'https://docs.maestro.dev',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'developers.oxylabs.io',
|
||||
contentBaseURL: 'https://developers.oxylabs.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.parallels.com/landing',
|
||||
contentBaseURL: 'https://docs.parallels.com',
|
||||
tests: [{ name: 'Home', url: '/landing', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.impact.com',
|
||||
contentBaseURL: 'https://help.impact.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.9proxy.com',
|
||||
contentBaseURL: 'https://docs.9proxy.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'vimeo.com/legal',
|
||||
contentBaseURL: 'https://vimeo.com',
|
||||
tests: [{ name: 'Home', url: '/legal' }],
|
||||
},
|
||||
{
|
||||
name: 'help.platipomiru.com',
|
||||
contentBaseURL: 'https://help.platipomiru.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'help.aikido.dev',
|
||||
contentBaseURL: 'https://help.aikido.dev',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'doc.demarche.numerique.gouv.fr',
|
||||
contentBaseURL: 'https://doc.demarche.numerique.gouv.fr',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.adapta.org',
|
||||
contentBaseURL: 'https://docs.adapta.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'www.xabuxa.com',
|
||||
contentBaseURL: 'https://www.xabuxa.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.triumpharcade.com',
|
||||
contentBaseURL: 'https://docs.triumpharcade.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.nats.io',
|
||||
contentBaseURL: 'https://docs.nats.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.glpi-project.org',
|
||||
contentBaseURL: 'https://help.glpi-project.org',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.waydro.id',
|
||||
contentBaseURL: 'https://docs.waydro.id',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'bellingcat.gitbook.io/toolkit',
|
||||
contentBaseURL: 'https://bellingcat.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/toolkit' }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.project-fika.com',
|
||||
contentBaseURL: 'https://wiki.project-fika.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.pinot.apache.org',
|
||||
contentBaseURL: 'https://docs.pinot.apache.org',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.devolutions.net',
|
||||
contentBaseURL: 'https://docs.devolutions.net',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'guides.gresb.com',
|
||||
contentBaseURL: 'https://guides.gresb.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.prestashop-project.org/welcome',
|
||||
contentBaseURL: 'https://docs.prestashop-project.org',
|
||||
tests: [{ name: 'Home', url: '/welcome', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.researchgate.net',
|
||||
contentBaseURL: 'https://help.researchgate.net',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.verifone.com',
|
||||
contentBaseURL: 'https://docs.verifone.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// Deactivate it because of a custom Ask AI that causes flakiness.
|
||||
// {
|
||||
// name: 'docs.roboflow.com',
|
||||
// contentBaseURL: 'https://docs.roboflow.com',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'www.netexec.wiki',
|
||||
contentBaseURL: 'https://www.netexec.wiki',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'guide.strikepack.com',
|
||||
contentBaseURL: 'https://guide.strikepack.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'gitbook.com/docs',
|
||||
contentBaseURL: 'https://gitbook.com',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/docs', run: waitForCookiesDialog },
|
||||
{
|
||||
name: 'OpenAPI',
|
||||
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'documentation.gravitee.io',
|
||||
contentBaseURL: 'https://documentation.gravitee.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'faq.wanttopay.net/wanttopay-app',
|
||||
contentBaseURL: 'https://faq.wanttopay.net',
|
||||
tests: [{ name: 'Home', url: '/wanttopay-app', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'guide.prismlive.com',
|
||||
contentBaseURL: 'https://guide.prismlive.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.ionos.com/cloud',
|
||||
contentBaseURL: 'https://docs.ionos.com',
|
||||
tests: [{ name: 'Home', url: '/cloud', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'support.evite.com',
|
||||
contentBaseURL: 'https://support.evite.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'knowledge.illumina.com',
|
||||
contentBaseURL: 'https://knowledge.illumina.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.retrobat.org',
|
||||
contentBaseURL: 'https://wiki.retrobat.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.polymaker.com',
|
||||
contentBaseURL: 'https://wiki.polymaker.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.ducks-services.com',
|
||||
contentBaseURL: 'https://docs.ducks-services.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.hex-rays.com',
|
||||
contentBaseURL: 'https://docs.hex-rays.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'whitepaper.interlinklabs.ai',
|
||||
contentBaseURL: 'https://whitepaper.interlinklabs.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'help.openloyalty.io',
|
||||
contentBaseURL: 'https://help.openloyalty.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'retrozia.gitbook.io/retrozia',
|
||||
contentBaseURL: 'https://retrozia.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/retrozia' }],
|
||||
},
|
||||
{
|
||||
name: 'helpcenter.channable.com',
|
||||
contentBaseURL: 'https://helpcenter.channable.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'developerdocs.instructure.com',
|
||||
contentBaseURL: 'https://developerdocs.instructure.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'legal.jagex.com',
|
||||
contentBaseURL: 'https://legal.jagex.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'manual.edgetx.org',
|
||||
contentBaseURL: 'https://manual.edgetx.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cortex.io',
|
||||
contentBaseURL: 'https://docs.cortex.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// Flaky because of the GIF
|
||||
// {
|
||||
// name: 'docs.mufy.ai',
|
||||
// contentBaseURL: 'https://docs.mufy.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.ndi.video/all',
|
||||
contentBaseURL: 'https://docs.ndi.video',
|
||||
tests: [{ name: 'Home', url: '/all', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.sevenpens.com/drawtab',
|
||||
contentBaseURL: 'https://docs.sevenpens.com',
|
||||
tests: [{ name: 'Home', url: '/drawtab' }],
|
||||
},
|
||||
{
|
||||
name: 'manuals.i-reporter.jp',
|
||||
contentBaseURL: 'https://manuals.i-reporter.jp',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.holybro.com',
|
||||
contentBaseURL: 'https://docs.holybro.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.tokenpocket.pro/en',
|
||||
contentBaseURL: 'https://help.tokenpocket.pro',
|
||||
tests: [{ name: 'Home', url: '/en' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.bullmq.io',
|
||||
contentBaseURL: 'https://docs.bullmq.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'tools.osintnewsletter.com',
|
||||
contentBaseURL: 'https://tools.osintnewsletter.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.mmorealms.gg',
|
||||
contentBaseURL: 'https://wiki.mmorealms.gg',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.vectra.ai',
|
||||
contentBaseURL: 'https://docs.vectra.ai',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cipp.app',
|
||||
contentBaseURL: 'https://docs.cipp.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'sinfa-com-co.gitbook.io/manual-de-usuario',
|
||||
contentBaseURL: 'https://sinfa-com-co.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/manual-de-usuario' }],
|
||||
},
|
||||
{
|
||||
name: 'support.skylum.com',
|
||||
contentBaseURL: 'https://support.skylum.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.jgscripts.com',
|
||||
contentBaseURL: 'https://docs.jgscripts.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.patchmypc.com',
|
||||
contentBaseURL: 'https://docs.patchmypc.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'guide.cryosparc.com',
|
||||
contentBaseURL: 'https://guide.cryosparc.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'guides.stellaraio.com/stellar',
|
||||
contentBaseURL: 'https://guides.stellaraio.com',
|
||||
tests: [{ name: 'Home', url: '/stellar', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.iyzico.com',
|
||||
contentBaseURL: 'https://docs.iyzico.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.wotnot.io',
|
||||
contentBaseURL: 'https://help.wotnot.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.sportmonks.com/v3',
|
||||
contentBaseURL: 'https://docs.sportmonks.com',
|
||||
tests: [{ name: 'Home', url: '/v3', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.payments.thalescloud.io',
|
||||
contentBaseURL: 'https://docs.payments.thalescloud.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'doc.anytype.io/anytype-docs',
|
||||
contentBaseURL: 'https://doc.anytype.io',
|
||||
tests: [{ name: 'Home', url: '/anytype-docs', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'help.blotato.com',
|
||||
contentBaseURL: 'https://help.blotato.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cartographer3d.com',
|
||||
contentBaseURL: 'https://docs.cartographer3d.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.acestudio.ai',
|
||||
contentBaseURL: 'https://docs.acestudio.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
];
|
||||
|
||||
runTestCases(testCases);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForAIChatResponse,
|
||||
waitForAdminToolbar,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
@@ -185,17 +186,8 @@ const searchTestCases: Test[] = [
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.focus();
|
||||
await searchInput.fill(AI_PROMPT);
|
||||
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||
await expect(aiSearchResult).toBeVisible();
|
||||
@@ -621,21 +613,19 @@ const testCases: TestsCase[] = [
|
||||
name: 'Site with sections and section groups',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
name: 'Section group dropdown',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.getByRole('button', { name: 'Test Section Group 1' }).hover();
|
||||
await expect(page.getByRole('link', { name: /Section B/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Section group link',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const sectionGroupDropdown = await page.getByText('Test Section Group 1');
|
||||
await sectionGroupDropdown.hover();
|
||||
const trigger = page.getByRole('button', { name: 'Test Section Group 1' });
|
||||
// Radix NavigationMenu opens the dropdown on a `pointermove`. A single
|
||||
// synthetic hover can land before hydration and be lost, so re-hover
|
||||
// until the dropdown content actually appears.
|
||||
await expect(async () => {
|
||||
await trigger.hover();
|
||||
await expect(page.getByText('Section B')).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await page.getByText('Section B').click();
|
||||
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
|
||||
},
|
||||
@@ -665,7 +655,12 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Revision',
|
||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
// Viewing a past revision shows the admin toolbar; assert it is
|
||||
// present (it is hidden from the screenshot as it animates open).
|
||||
await waitForAdminToolbar(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Invalid revision',
|
||||
@@ -1364,8 +1359,6 @@ const testCases: TestsCase[] = [
|
||||
name: 'Without page actions',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -172,6 +172,19 @@ export async function waitForCookiesDialog(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the GitBook admin toolbar to be present.
|
||||
*
|
||||
* The toolbar only renders when signed in to GitBook. It is hidden from
|
||||
* screenshots (see `argosCSS`) because it auto-expands with an animation, so
|
||||
* use this to assert it is rendered without capturing its flaky visual state.
|
||||
*/
|
||||
export async function waitForAdminToolbar(page: Page) {
|
||||
await expect(page.getByTestId('admin-toolbar')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.status()).toBe(404);
|
||||
@@ -248,6 +261,19 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Reset the cross-space navigation state on every document load so the
|
||||
// "Back to <space>" shortcut never leaks between navigations/tests. It is
|
||||
// detected client-side from this sessionStorage, and a stale value (e.g.
|
||||
// after a retry or a cross-space redirect) makes it appear or not
|
||||
// non-deterministically, causing flaky screenshots.
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
sessionStorage.removeItem('gitbook-space-navigation:last');
|
||||
sessionStorage.removeItem('gitbook-space-navigation:back');
|
||||
sessionStorage.removeItem('gitbook-space-navigation:from-picker');
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Set the header to disable the Vercel toolbar
|
||||
// But only on the main document as it'd cause CORS issues on other resources
|
||||
await page.route('**/*', async (route, request) => {
|
||||
@@ -263,7 +289,11 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
}
|
||||
});
|
||||
|
||||
const response = await page.goto(url);
|
||||
// Wait only for `domcontentloaded` rather than the default `load`: these
|
||||
// are real customer sites whose third-party subresources can hang and
|
||||
// never fire `load`, aborting the navigation. Argos stabilization (run in
|
||||
// `beforeScreenshot`) still waits for images/fonts before capturing.
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
if (testEntry.run) {
|
||||
await testEntry.run(page, response);
|
||||
}
|
||||
@@ -283,6 +313,12 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
/* Hide the GitBook admin toolbar: it auto-expands with an
|
||||
animation, so its state at capture time is non-deterministic.
|
||||
Its presence is asserted separately via waitForAdminToolbar. */
|
||||
[data-testid="admin-toolbar"] {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
@@ -390,9 +426,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
default: CustomizationDefaultThemeMode.System,
|
||||
toggeable: true,
|
||||
},
|
||||
pdf: {
|
||||
enabled: true,
|
||||
},
|
||||
feedback: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -405,20 +438,16 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
advancedCustomization: {
|
||||
enabled: true,
|
||||
},
|
||||
git: {
|
||||
showEditLink: false,
|
||||
},
|
||||
pagination: {
|
||||
enabled: true,
|
||||
},
|
||||
pageActions: {
|
||||
externalAI: true,
|
||||
markdown: true,
|
||||
mcp: true,
|
||||
items: [
|
||||
CustomizationPageActionType.Assistant,
|
||||
CustomizationPageActionType.Markdown,
|
||||
CustomizationPageActionType.ExternalAi,
|
||||
CustomizationPageActionType.Mcp,
|
||||
CustomizationPageActionType.Pdf,
|
||||
],
|
||||
},
|
||||
trademark: {
|
||||
|
||||
@@ -37,6 +37,16 @@ const nextConfig = {
|
||||
optimisticClientCache: false,
|
||||
// Disable splitting the RSC in like 5 chunks
|
||||
prefetchInlining: true,
|
||||
|
||||
// Tree-shake barrel imports from these packages so only the used entrypoints ship
|
||||
// in the client bundle (notably `motion`, which is otherwise pulled in wholesale).
|
||||
optimizePackageImports: [
|
||||
'motion',
|
||||
'@gitbook/icons',
|
||||
'react-aria',
|
||||
'react-aria-components',
|
||||
'react-stately',
|
||||
],
|
||||
},
|
||||
|
||||
env: {
|
||||
@@ -50,6 +60,7 @@ const nextConfig = {
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SALT: process.env.GITBOOK_IMAGE_RESIZE_SALT,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
GITBOOK_URL: process.env.GITBOOK_URL,
|
||||
@@ -61,6 +72,7 @@ const nextConfig = {
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
|
||||
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE,
|
||||
|
||||
// Next.js envs
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.27.1",
|
||||
"version": "0.27.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -93,8 +93,8 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^6.4.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@argos-ci/playwright": "^7.2.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -138,9 +138,7 @@
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
||||
],
|
||||
"browserslist": ["chrome >= 93, edge >= 93, firefox >= 92, safari >= 15.4, not dead"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
type PageProps = {
|
||||
@@ -15,7 +15,7 @@ export default async function Page(props: PageProps) {
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
|
||||
// If the assistant is not enabled, redirect to the docs
|
||||
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
|
||||
if (!isAIChatEnabled(context.customization.ai.mode)) {
|
||||
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
type PageProps = {
|
||||
@@ -13,7 +13,7 @@ export default async function Page(props: PageProps) {
|
||||
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
|
||||
|
||||
// If assistant is enabled, redirect to assistant, otherwise to docs
|
||||
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
|
||||
if (isAIChatEnabled(context.customization.ai.mode)) {
|
||||
redirect(`${baseURL}/assistant`);
|
||||
} else {
|
||||
redirect(`${baseURL}/page/`);
|
||||
|
||||
+294
-49
@@ -1,8 +1,14 @@
|
||||
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationPageActionType,
|
||||
SiteFindingType,
|
||||
SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
|
||||
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
|
||||
import { resolvePagePath } from '@/lib/pages';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
|
||||
@@ -12,6 +18,18 @@ import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
|
||||
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
|
||||
*/
|
||||
function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
|
||||
waitUntil(
|
||||
trackServerInsightsEvents(args).catch((error) => {
|
||||
console.error('Failed to track MCP insights event:', error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleMcpRequest(
|
||||
rawRequest: NextRequest,
|
||||
params: RouteLayoutParams,
|
||||
@@ -20,13 +38,8 @@ export async function handleMcpRequest(
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
// Use the configured `items` list when the API provides it, and fall back to the deprecated
|
||||
// `mcp` flag otherwise (legacy mode), since this endpoint is called directly and cannot rely on
|
||||
// any page-rendering fallback.
|
||||
const { pageActions } = context.customization;
|
||||
const isMcpEnabled = pageActions.items
|
||||
? pageActions.items.includes(CustomizationPageActionType.Mcp)
|
||||
: pageActions.mcp;
|
||||
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
|
||||
if (!isMcpEnabled) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
@@ -36,21 +49,19 @@ export async function handleMcpRequest(
|
||||
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,
|
||||
},
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: context.site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'mcp_request',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
@@ -60,6 +71,13 @@ export async function handleMcpRequest(
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
{
|
||||
title: 'Search documentation',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
@@ -70,22 +88,20 @@ export async function handleMcpRequest(
|
||||
})
|
||||
);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: results.flatMap((result) => {
|
||||
@@ -118,9 +134,11 @@ export async function handleMcpRequest(
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
// The search API returns sections ordered highest-score-first, so
|
||||
// the first section with a body is the best-scoring preview.
|
||||
const body = (pageResult.sections ?? []).find(
|
||||
(section) => section.body
|
||||
)?.body;
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
@@ -160,6 +178,13 @@ export async function handleMcpRequest(
|
||||
return z.NEVER;
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Get page content',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
const match = findSiteSpaceByUrl(context.structure, url);
|
||||
@@ -191,26 +216,246 @@ export async function handleMcpRequest(
|
||||
resolved.page
|
||||
);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
trackMcpEvent({
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Only expose the answer tool when the site has AI enabled, since it relies on
|
||||
// the same AI search backend that powers the site's "ask a question" experience.
|
||||
if (isAIEnabled(context.customization.ai.mode)) {
|
||||
server.tool(
|
||||
'askQuestion',
|
||||
`Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`,
|
||||
{
|
||||
question: z
|
||||
.string()
|
||||
.describe(
|
||||
`The natural-language question to answer about ${site.title}.`
|
||||
),
|
||||
goal: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Ask a question',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ question, goal }) => {
|
||||
try {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Please provide a question to answer.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedGoal = goal?.trim() || undefined;
|
||||
|
||||
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
|
||||
goal: trimmedGoal,
|
||||
});
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'page_view',
|
||||
type: 'ask_question',
|
||||
query: trimmedQuestion,
|
||||
...(trimmedGoal ? { goal: trimmedGoal } : {}),
|
||||
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 }] };
|
||||
if (!answer || !answer.answer || !('markdown' in answer.answer)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "We couldn't answer this question.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const answerMarkdown = toPageMarkdown(
|
||||
await fromPageMarkdown(context, {
|
||||
markdown: answer.answer.markdown,
|
||||
pagePath: '',
|
||||
})
|
||||
);
|
||||
const sourcesMarkdown = await renderAskSourcesMarkdown(
|
||||
context,
|
||||
answer.sources ?? []
|
||||
);
|
||||
|
||||
let text = answerMarkdown.trim();
|
||||
if (sourcesMarkdown) {
|
||||
text += `\n\n# Sources\n\n${sourcesMarkdown}`;
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
server.tool(
|
||||
'sendFeedback',
|
||||
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
|
||||
{
|
||||
category: z
|
||||
.nativeEnum(SiteFindingType)
|
||||
.describe(
|
||||
'The kind of issue. "content-outdated": the content was correct at some point but no longer matches the current product or reality. "incoherence": the content contradicts itself or another page. "content-gap": information the reader needs is missing entirely, whether or not it was ever documented. "other": only as a last resort when none of the above fits.'
|
||||
),
|
||||
content: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.describe(
|
||||
'Explain the issue in full, as if writing to a documentation maintainer who never saw this conversation. Describe what is wrong, where on the page it appears (quote the exact sentence or section title when possible), what the user was trying to do, and, when relevant, what the correct or expected information should be. Write a few clear, specific sentences in English. Never include personal or confidential information from the conversation. Up to 2048 characters.'
|
||||
),
|
||||
pageUrl: z
|
||||
.string()
|
||||
.describe(
|
||||
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started). Provide it whenever you can so the finding is linked to the exact page.`
|
||||
)
|
||||
.transform((value, ctx) => {
|
||||
const candidate = URL.canParse(value)
|
||||
? new URL(value)
|
||||
: URL.canParse(value, siteUrl)
|
||||
? new URL(value, siteUrl)
|
||||
: null;
|
||||
|
||||
if (
|
||||
!candidate ||
|
||||
(candidate.protocol !== 'https:' && candidate.protocol !== 'http:')
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return candidate.toString();
|
||||
})
|
||||
.optional(),
|
||||
},
|
||||
{
|
||||
title: 'Send feedback',
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ category, content, pageUrl }) => {
|
||||
try {
|
||||
let pageLocation:
|
||||
| { page: string; space: string; revision: string }
|
||||
| undefined;
|
||||
|
||||
if (pageUrl) {
|
||||
const match = findSiteSpaceByUrl(context.structure, pageUrl);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Page not found: "${pageUrl}"` },
|
||||
],
|
||||
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: "${pageUrl}"` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
pageLocation = {
|
||||
page: resolved.page.id,
|
||||
space: match.siteSpace.space.id,
|
||||
revision: match.siteSpace.space.revision,
|
||||
};
|
||||
}
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'agent_feedback',
|
||||
feedback: { content, category },
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
...pageLocation,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
|
||||
};
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
|
||||
+3
-5
@@ -186,11 +186,9 @@ function transformSitePageResult(args: {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// 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
|
||||
);
|
||||
// The search API returns each page's sections ordered highest-score-first and caps them at one
|
||||
// per page, so the first section is the best-scoring one to use as a body preview.
|
||||
const bestSection = pageSections[0];
|
||||
if (bestSection) {
|
||||
page.bestSection = {
|
||||
href: bestSection.href,
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { RouteParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
@@ -15,7 +15,7 @@ export default async function Page(props: PageProps) {
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
|
||||
// If the assistant is not enabled, redirect to the docs
|
||||
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
|
||||
if (!isAIChatEnabled(context.customization.ai.mode)) {
|
||||
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
@@ -15,7 +15,7 @@ export default async function Page(props: PageProps) {
|
||||
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
|
||||
|
||||
// If assistant is enabled, redirect to assistant, otherwise to docs
|
||||
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
|
||||
if (isAIChatEnabled(context.customization.ai.mode)) {
|
||||
redirect(`${baseURL}/assistant`);
|
||||
} else {
|
||||
redirect(`${baseURL}/page/`);
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
'use client';
|
||||
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AIChatReference } from './references';
|
||||
import { serializeReferences } from './references';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import {
|
||||
AIChatControllerContext,
|
||||
type AIChatEvent,
|
||||
getDefaultAIChatMessageActivity,
|
||||
globalAIChatState as globalState,
|
||||
updateAIChatMessageActivity,
|
||||
} from './useAIChat';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
|
||||
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
|
||||
|
||||
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||
Extract<AIChatEvent, { type: T }>,
|
||||
'type'
|
||||
>;
|
||||
|
||||
// The assistant's tools and controls pull in zod (~270KB chunk); load them on demand so the
|
||||
// provider itself stays light and the chunk is only fetched on AI-enabled sites.
|
||||
function importAITooling() {
|
||||
return Promise.all([import('./tools'), import('./controls/ConfirmControl')]);
|
||||
}
|
||||
|
||||
function notify(
|
||||
listeners: AIChatEventListener[] | undefined,
|
||||
input: Omit<AIChatEvent, 'type'>
|
||||
): void {
|
||||
if (!listeners) return;
|
||||
// Defer event listeners to next tick so React can process state updates first
|
||||
setTimeout(() => {
|
||||
listeners.forEach((listener) => listener(input));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the controller to interact with the AI chat.
|
||||
*/
|
||||
export function AIChatProvider(props: {
|
||||
renderMessageOptions?: RenderAIMessageOptions;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { renderMessageOptions, children } = props;
|
||||
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
const setSearchState = useSetSearchState();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
|
||||
// identity, so it can be referenced directly from the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
|
||||
// Warm the tools/controls chunk in the background so the first message doesn't pay its
|
||||
// download latency. Only runs on AI-enabled sites since the provider is gated.
|
||||
React.useEffect(() => {
|
||||
void importAITooling();
|
||||
}, []);
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(() => {
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('open'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
const onClose = React.useCallback(() => {
|
||||
globalState.setState((state) => ({ ...state, opened: false }));
|
||||
|
||||
// Clear ask parameter but keep other search state
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('close'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
|
||||
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** User-typed prompt; compared against state.query to abort stale streams */
|
||||
userQuery?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const [{ getTools }] = await importAITooling();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const [{ getTools }, { ConfirmControlDef, ConfirmControlOutputSchema }] =
|
||||
await importAITooling();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
|
||||
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
...event.suggestions,
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
if ('createControl' in toolDef) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: toolDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: event.toolCall.input as any,
|
||||
language,
|
||||
send: async (result) => {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: {
|
||||
label: confirmation.label,
|
||||
icon: confirmation.icon,
|
||||
},
|
||||
language,
|
||||
send: async (result) => {
|
||||
const output = ConfirmControlOutputSchema.parse(
|
||||
result.output
|
||||
);
|
||||
switch (output.result) {
|
||||
case 'cancelled': {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: { cancelled: true },
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'confirmed':
|
||||
await executeToolCall(event);
|
||||
break;
|
||||
default:
|
||||
assertNever(output.result);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
|
||||
// Turn settled: send the next queued follow-up (oldest first). Held back while a
|
||||
// control is pending, since posting would throw; it flushes after that resolves.
|
||||
const { queuedMessages, control: activeControl } = globalState.getState();
|
||||
const [next, ...rest] = queuedMessages;
|
||||
if (next !== undefined && !activeControl) {
|
||||
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
|
||||
postMessageRef.current?.({ message: next });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
messageContextRef.current,
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
navigateToPageTool,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, control, references, responding } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
|
||||
if (responding) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
queuedMessages: [...state.queuedMessages, input.message],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const wireMessage = `${serializeReferences(references)}${input.message}`;
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
if (siteSpaceId) {
|
||||
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
|
||||
}
|
||||
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||
|
||||
if (query === input.message && references.length === 0) {
|
||||
// Return early if the message is the same as the previous message
|
||||
// (unless new references are staged, which change the payload)
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
opened: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
references,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
responding: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
streamResponse({ message: wireMessage, userQuery: input.message });
|
||||
},
|
||||
[setSearchState, siteSpaceId, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
|
||||
postMessageRef.current = onPostMessage;
|
||||
|
||||
// Remove a follow-up queued while the assistant is still answering (the × on the affordance).
|
||||
const onCancelQueuedMessage = React.useCallback((index: number) => {
|
||||
globalState.setState((state) =>
|
||||
index < 0 || index >= state.queuedMessages.length
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
queuedMessages: state.queuedMessages.filter((_, i) => i !== index),
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
responding: false,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
queuedMessages: [],
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onAddReference = React.useCallback((ref: AIChatReference) => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: [...state.references, ref],
|
||||
};
|
||||
});
|
||||
return ref.id;
|
||||
}, []);
|
||||
|
||||
const onRemoveReference = React.useCallback((id: string) => {
|
||||
globalState.setState((state) => {
|
||||
if (!state.references.some((ref) => ref.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: state.references.filter((ref) => ref.id !== id),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClearReferences = React.useCallback(() => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, references: [] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFocus = React.useCallback(() => {
|
||||
notify(eventsRef.current.get('focus'), {});
|
||||
}, []);
|
||||
|
||||
const onSetDraft = React.useCallback((draft: string) => {
|
||||
globalState.setState({ draft });
|
||||
}, []);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
listener: (input?: AIChatEventData<T>) => void
|
||||
) => {
|
||||
const listeners = eventsRef.current.get(event) || [];
|
||||
listeners.push(listener as AIChatEventListener);
|
||||
eventsRef.current.set(event, listeners);
|
||||
return () => {
|
||||
const currentListeners = eventsRef.current.get(event) || [];
|
||||
eventsRef.current.set(
|
||||
event,
|
||||
currentListeners.filter((l) => l !== listener)
|
||||
);
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const controller = React.useMemo(() => {
|
||||
return {
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
addReference: onAddReference,
|
||||
removeReference: onRemoveReference,
|
||||
clearReferences: onClearReferences,
|
||||
focus: onFocus,
|
||||
setDraft: onSetDraft,
|
||||
cancelQueuedMessage: onCancelQueuedMessage,
|
||||
on: onEvent,
|
||||
};
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
onClear,
|
||||
onPostMessage,
|
||||
onAddReference,
|
||||
onRemoveReference,
|
||||
onClearReferences,
|
||||
onFocus,
|
||||
onSetDraft,
|
||||
onCancelQueuedMessage,
|
||||
onEvent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
</AIChatControllerContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessageContext,
|
||||
@@ -39,6 +40,11 @@ export async function* streamAIChatResponse({
|
||||
isEmbeddable: options?.asEmbeddable,
|
||||
});
|
||||
|
||||
const siteContext = await fetchServerActionSiteContext(context);
|
||||
if (!isAIChatEnabled(siteContext.customization.ai.mode)) {
|
||||
throw new Error('The AI Assistant is not enabled for this site.');
|
||||
}
|
||||
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import type { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { isAIChatEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
@@ -89,7 +90,7 @@ export function useAI(): AIContext {
|
||||
|
||||
const assistants: Assistant[] = [];
|
||||
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
if (isAIChatEnabled(config.aiMode)) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
@@ -110,7 +111,7 @@ export function useAI(): AIContext {
|
||||
ui: true,
|
||||
mode: 'sidebar',
|
||||
});
|
||||
} else if (config.aiMode === CustomizationAIMode.Search) {
|
||||
} else if (isAISearchEnabled(config.aiMode)) {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
|
||||
@@ -2,28 +2,10 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
AIMessageStepPhase,
|
||||
type AIStreamResponse,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { AIMessageRole, AIMessageStepPhase, type AIStreamResponse } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
import type { AIChatReference } from './references';
|
||||
|
||||
export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
@@ -117,6 +99,20 @@ export type AIChatState = {
|
||||
* References staged on the next user message.
|
||||
*/
|
||||
references: AIChatReference[];
|
||||
|
||||
/**
|
||||
* Draft text to pre-fill the chat input with, without sending it. The chat input consumes
|
||||
* this value (seeding its editable content) and then clears it back to an empty string.
|
||||
*/
|
||||
draft: string;
|
||||
|
||||
/**
|
||||
* Follow-ups the visitor submitted while a previous turn was still streaming. They are held
|
||||
* here and sent automatically, one at a time in submission order, as each answer finishes
|
||||
* (see the flush in `streamResponse`), so submitting mid-stream isn't lost. Empty when
|
||||
* nothing is queued.
|
||||
*/
|
||||
queuedMessages: string[];
|
||||
};
|
||||
|
||||
export type AIChatEvent =
|
||||
@@ -131,8 +127,6 @@ type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||
'type'
|
||||
>;
|
||||
|
||||
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
|
||||
|
||||
export type AIChatController = {
|
||||
/** Open the dialog */
|
||||
open: () => void;
|
||||
@@ -150,6 +144,10 @@ export type AIChatController = {
|
||||
clearReferences: () => void;
|
||||
/** Focus the chat input */
|
||||
focus: () => void;
|
||||
/** Pre-fill the chat input with draft text, without sending it. */
|
||||
setDraft: (draft: string) => void;
|
||||
/** Remove a follow-up queued to send after the current answer finishes, by its queue index. */
|
||||
cancelQueuedMessage: (index: number) => void;
|
||||
/** Register an event listener */
|
||||
on: <T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -157,10 +155,10 @@ export type AIChatController = {
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||
export const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||
|
||||
// Global state store for AI chat
|
||||
const globalState = zustand.create<AIChatState>(() => {
|
||||
export const globalAIChatState = zustand.create<AIChatState>(() => {
|
||||
return {
|
||||
opened: false,
|
||||
responseId: null,
|
||||
@@ -173,6 +171,8 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
draft: '',
|
||||
queuedMessages: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -180,536 +180,28 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
* Get the current state of the AI chat.
|
||||
*/
|
||||
export function useAIChatState(): AIChatState {
|
||||
const state = zustand.useStore(globalState);
|
||||
const state = zustand.useStore(globalAIChatState);
|
||||
return state;
|
||||
}
|
||||
|
||||
function notify(
|
||||
listeners: AIChatEventListener[] | undefined,
|
||||
input: Omit<AIChatEvent, 'type'>
|
||||
): void {
|
||||
if (!listeners) return;
|
||||
// Defer event listeners to next tick so React can process state updates first
|
||||
setTimeout(() => {
|
||||
listeners.forEach((listener) => listener(input));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the controller to interact with the AI chat.
|
||||
* Inert controller returned when no AIChatProvider is mounted (AI chat disabled for the site).
|
||||
* Lets always-mounted consumers (search, page actions, …) call the hook unconditionally without
|
||||
* pulling the chat runtime into their bundle or throwing at render time.
|
||||
*/
|
||||
export function AIChatProvider(props: {
|
||||
renderMessageOptions?: RenderAIMessageOptions;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { renderMessageOptions, children } = props;
|
||||
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
const setSearchState = useSetSearchState();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
|
||||
// identity, so it can be referenced directly from the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(() => {
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('open'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
const onClose = React.useCallback(() => {
|
||||
globalState.setState((state) => ({ ...state, opened: false }));
|
||||
|
||||
// Clear ask parameter but keep other search state
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('close'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** User-typed prompt; compared against state.query to abort stale streams */
|
||||
userQuery?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
|
||||
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
...event.suggestions,
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
if ('createControl' in toolDef) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: toolDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: event.toolCall.input as any,
|
||||
language,
|
||||
send: async (result) => {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: {
|
||||
label: confirmation.label,
|
||||
icon: confirmation.icon,
|
||||
},
|
||||
language,
|
||||
send: async (result) => {
|
||||
const output = ConfirmControlOutputSchema.parse(
|
||||
result.output
|
||||
);
|
||||
switch (output.result) {
|
||||
case 'cancelled': {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: { cancelled: true },
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'confirmed':
|
||||
await executeToolCall(event);
|
||||
break;
|
||||
default:
|
||||
assertNever(output.result);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
messageContextRef.current,
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
navigateToPageTool,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, control, references, responding } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// Ignore duplicates while a previous turn is still streaming
|
||||
if (responding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wireMessage = `${serializeReferences(references)}${input.message}`;
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
if (siteSpaceId) {
|
||||
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
|
||||
}
|
||||
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||
|
||||
if (query === input.message && references.length === 0) {
|
||||
// Return early if the message is the same as the previous message
|
||||
// (unless new references are staged, which change the payload)
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
opened: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
references,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
responding: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
streamResponse({ message: wireMessage, userQuery: input.message });
|
||||
},
|
||||
[setSearchState, siteSpaceId, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
responding: false,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onAddReference = React.useCallback((ref: AIChatReference) => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: [...state.references, ref],
|
||||
};
|
||||
});
|
||||
return ref.id;
|
||||
}, []);
|
||||
|
||||
const onRemoveReference = React.useCallback((id: string) => {
|
||||
globalState.setState((state) => {
|
||||
if (!state.references.some((ref) => ref.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: state.references.filter((ref) => ref.id !== id),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClearReferences = React.useCallback(() => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, references: [] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFocus = React.useCallback(() => {
|
||||
notify(eventsRef.current.get('focus'), {});
|
||||
}, []);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
listener: (input?: AIChatEventData<T>) => void
|
||||
) => {
|
||||
const listeners = eventsRef.current.get(event) || [];
|
||||
listeners.push(listener as AIChatEventListener);
|
||||
eventsRef.current.set(event, listeners);
|
||||
return () => {
|
||||
const currentListeners = eventsRef.current.get(event) || [];
|
||||
eventsRef.current.set(
|
||||
event,
|
||||
currentListeners.filter((l) => l !== listener)
|
||||
);
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const controller = React.useMemo(() => {
|
||||
return {
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
addReference: onAddReference,
|
||||
removeReference: onRemoveReference,
|
||||
clearReferences: onClearReferences,
|
||||
focus: onFocus,
|
||||
on: onEvent,
|
||||
};
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
onClear,
|
||||
onPostMessage,
|
||||
onAddReference,
|
||||
onRemoveReference,
|
||||
onClearReferences,
|
||||
onFocus,
|
||||
onEvent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
</AIChatControllerContext.Provider>
|
||||
);
|
||||
}
|
||||
const NOOP_AI_CHAT_CONTROLLER: AIChatController = {
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
postMessage: () => {},
|
||||
clear: () => {},
|
||||
addReference: (ref) => ref.id,
|
||||
removeReference: () => {},
|
||||
clearReferences: () => {},
|
||||
focus: () => {},
|
||||
setDraft: () => {},
|
||||
cancelQueuedMessage: () => {},
|
||||
on: () => () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the controller to interact with the AI chat.
|
||||
@@ -717,10 +209,7 @@ export function AIChatProvider(props: {
|
||||
*/
|
||||
export function useAIChatController(): AIChatController {
|
||||
const controller = React.useContext(AIChatControllerContext);
|
||||
if (!controller) {
|
||||
throw new Error('useAIChatController must be used within an AIChatProvider');
|
||||
}
|
||||
return controller;
|
||||
return controller ?? NOOP_AI_CHAT_CONTROLLER;
|
||||
}
|
||||
|
||||
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
|
||||
@@ -763,7 +252,7 @@ function getLatestAssistantMessage(messages: AIChatMessage[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateAIChatMessageActivity(
|
||||
export function updateAIChatMessageActivity(
|
||||
activity: AIChatMessageActivity,
|
||||
event: AIStreamResponse
|
||||
): AIChatMessageActivity {
|
||||
@@ -789,7 +278,7 @@ function updateAIChatMessageActivity(
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
|
||||
export function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
|
||||
return {
|
||||
currentPhase: undefined,
|
||||
toolCount: 0,
|
||||
|
||||
@@ -6,19 +6,32 @@ import type { AIToolDefinition } from '@gitbook/api';
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { NavigationStatusContext } from '../hooks';
|
||||
import { normalizePathname, resolveNavigationTarget } from './navigation';
|
||||
import { resolveAINavigationLink } from './server-actions';
|
||||
|
||||
const NavigateToPageInputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
|
||||
),
|
||||
});
|
||||
// Hand-written JSON Schema: this hook runs in the always-mounted provider, so pulling zod +
|
||||
// zod-to-json-schema here would keep the entire zod chunk eager for every visitor.
|
||||
const NAVIGATE_TO_PAGE_INPUT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
additionalProperties: false,
|
||||
} as AIToolDefinition['inputSchema'];
|
||||
|
||||
function parseNavigateToPageInput(input: unknown): { url: string } {
|
||||
const url = (input as { url?: unknown } | null | undefined)?.url;
|
||||
if (typeof url !== 'string') {
|
||||
throw new Error('Invalid input for navigateToPage: expected { url: string }');
|
||||
}
|
||||
return { url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
|
||||
@@ -70,12 +83,10 @@ export function useNavigateToPageTool(): GitBookIntegrationTool {
|
||||
name: 'navigateToPage',
|
||||
description:
|
||||
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
|
||||
inputSchema: zodToJsonSchema(
|
||||
NavigateToPageInputSchema as any
|
||||
) as AIToolDefinition['inputSchema'],
|
||||
inputSchema: NAVIGATE_TO_PAGE_INPUT_SCHEMA,
|
||||
execute: async (input) => {
|
||||
const { router, language, onNavigationClick } = ref.current;
|
||||
const { url } = NavigateToPageInputSchema.parse(input);
|
||||
const { url } = parseNavigateToPageInput(input);
|
||||
|
||||
// The assistant references pages using the stable content-ref scheme
|
||||
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
|
||||
|
||||
@@ -35,6 +35,7 @@ import { AIChatExpandButton } from './AIChatExpandButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
import { AIChatQueuedMessage } from './AIChatQueuedMessage';
|
||||
import { AIChatResizeHandle } from './AIChatResizeHandle';
|
||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
|
||||
@@ -119,6 +120,7 @@ export function AIChat() {
|
||||
chat={chat}
|
||||
suggestions={config.suggestions}
|
||||
trademark={config.trademark}
|
||||
assistantName={config.assistantName}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
@@ -207,14 +209,17 @@ export function AIChatBody(props: {
|
||||
welcomeMessage?: string;
|
||||
suggestions?: string[];
|
||||
trademark?: boolean;
|
||||
/** Custom assistant name override; falls back to the branded/unbranded default name. */
|
||||
assistantName?: string;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
}) {
|
||||
const { chatController, chat, suggestions, greeting, trademark } = props;
|
||||
const { chatController, chat, suggestions, greeting, trademark, assistantName } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const resolvedAssistantName = assistantName ?? getAIChatName(language, trademark ?? true);
|
||||
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
||||
|
||||
const isEmpty = !chat.messages.length;
|
||||
@@ -280,13 +285,24 @@ export function AIChatBody(props: {
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
|
||||
{!chat.error &&
|
||||
chat.queuedMessages.map((message, index) => (
|
||||
<AIChatQueuedMessage
|
||||
// Queue order is stable and items carry no local state, so the index is a
|
||||
// safe key here.
|
||||
key={index}
|
||||
message={message}
|
||||
assistantName={resolvedAssistantName}
|
||||
onRemove={() => chatController.cancelQueuedMessage(index)}
|
||||
/>
|
||||
))}
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
{chat.control ? <AIChatControl control={chat.control} /> : null}
|
||||
<AIChatInput
|
||||
responding={chat.responding}
|
||||
disabled={chat.responding || chat.error}
|
||||
disabled={chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
@@ -11,7 +11,8 @@ import { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
export function AIChatInput(props: {
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When true, the input is disabled
|
||||
* When true, the assistant is streaming an answer. The field stays editable and submitting a
|
||||
* follow-up queues it (sent automatically once the answer finishes) rather than being blocked.
|
||||
*/
|
||||
responding: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -24,6 +25,29 @@ export function AIChatInput(props: {
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Controlled value so a pre-filled draft can be injected without sending it.
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// Consume a draft staged via the controller (e.g. the per-paragraph "Ask" button): seed the
|
||||
// input, focus it with the cursor at the end, then clear the pending draft so it is applied
|
||||
// once and not re-injected on a later mount.
|
||||
useEffect(() => {
|
||||
if (!chat.draft) {
|
||||
return;
|
||||
}
|
||||
setValue(chat.draft);
|
||||
chatController.setDraft('');
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
el.setSelectionRange(end, end);
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [chat.draft, chatController]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.opened && !disabled && !responding) {
|
||||
// Add a small delay to ensure the input is rendered before focusing
|
||||
@@ -66,6 +90,8 @@ export function AIChatInput(props: {
|
||||
sizing="large"
|
||||
label="Assistant chat input"
|
||||
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSubmit={(val) => onSubmit(val as string)}
|
||||
submitButton={{
|
||||
size: 'small',
|
||||
@@ -85,7 +111,7 @@ export function AIChatInput(props: {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || responding || chat.control !== null}
|
||||
disabled={disabled || chat.control !== null}
|
||||
aria-busy={responding}
|
||||
ref={inputRef}
|
||||
header={
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Button, Tooltip } from '../primitives';
|
||||
|
||||
/**
|
||||
* Shows a follow-up the visitor submitted while the assistant was still answering. It mirrors a
|
||||
* sent user message — a right-aligned, tinted bubble — so it reads as "theirs", with a clock icon
|
||||
* marking it as pending and a tooltip (on the whole bubble) explaining it will be sent once the
|
||||
* current answer finishes. The × beside the bubble drops it from the queue.
|
||||
*/
|
||||
export function AIChatQueuedMessage(props: {
|
||||
message: string;
|
||||
assistantName: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { message, assistantName, onRemove } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[80%] origin-top-right animate-scale-in items-center gap-1 self-end">
|
||||
<Tooltip label={t(language, 'ai_chat_queued_message', assistantName)}>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex min-w-0 items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md',
|
||||
'bg-primary-solid/1 px-4 py-2 text-tint'
|
||||
)}
|
||||
>
|
||||
<Icon icon="clock" className="size-3.5 shrink-0 text-tint" />
|
||||
<span className="min-w-0 break-words">{message}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="small"
|
||||
iconOnly
|
||||
icon="xmark"
|
||||
label={t(language, 'clear')}
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-tint"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AIChatIcon } from '../AIChatIcon';
|
||||
|
||||
/**
|
||||
* A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on
|
||||
* devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens
|
||||
* the AI chat — a more discoverable variant of the text-selection "Ask" affordance.
|
||||
*
|
||||
* The button is absolutely positioned so it never affects the document flow, and its visibility is
|
||||
* driven purely by the `group/ask-ai` hover state of its paragraph wrapper.
|
||||
*/
|
||||
export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) {
|
||||
const { content, className } = props;
|
||||
const config = useAIConfig();
|
||||
const language = useLanguage();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
if (!isAIChatEnabled(config.aiMode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
const text = content.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatController.addReference({
|
||||
type: 'text',
|
||||
id: `text-${fnv1a(text, { size: 32 })}`,
|
||||
content: text,
|
||||
});
|
||||
chatController.open();
|
||||
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
|
||||
chatController.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
// Sit in the left margin, flush against the paragraph so there is no hover gap.
|
||||
'absolute top-0 right-full z-10 pr-1',
|
||||
// Per-block nudges: `in-[…]` matches an ancestor (tag or class) with no markup
|
||||
// changes elsewhere — add a self-contained rule per block type to clear its gutter.
|
||||
'in-[.hint]:-top-0.5 in-[.hint]:pr-2',
|
||||
'in-[li]:-left-14',
|
||||
'in-[blockquote]:pr-0',
|
||||
// Hover affordance only: hidden until the paragraph (or the button) is hovered.
|
||||
'invisible opacity-0 transition-opacity duration-150',
|
||||
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
|
||||
// Never shown on touch / hover-less contexts.
|
||||
'not-pointer-fine:hidden',
|
||||
// Hidden where an overflow-clipped ancestor would cut it off (tables, record cards).
|
||||
'in-[[role=table]]:hidden',
|
||||
'in-[[data-card]]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
icon={<AIChatIcon state="default" trademark={config.trademark} />}
|
||||
label={t(language, 'ask')}
|
||||
onClick={onClick}
|
||||
// Don't steal focus (and shift the scroll position) when clicked with the mouse.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className={tcls(
|
||||
'bg-tint-base',
|
||||
'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AskAIParagraphButton';
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import * as React from 'react';
|
||||
@@ -10,6 +9,7 @@ import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { useIsMobile } from '@/components/hooks/useIsMobile';
|
||||
import { useIsMounted } from '@/components/hooks/useIsMounted';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
|
||||
import { AIChatIcon } from '../AIChatIcon';
|
||||
@@ -31,7 +31,7 @@ export function AskAITextSelection() {
|
||||
const isMobile = useIsMobile();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
|
||||
const enabled = isAIChatEnabled(config.aiMode) && !isMobile;
|
||||
|
||||
const toolbarRef = React.useRef<HTMLDivElement>(null);
|
||||
const { selection, clear } = useStableTextSelection({
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
export * from './AIChatControlButton';
|
||||
export * from './AskAITextSelection';
|
||||
export * from './AskAIParagraphButton';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { AdminToolbarClient } from './AdminToolbarClient';
|
||||
import { getToolbarChangedPages } from './changedPages';
|
||||
import type { AdminToolbarContext } from './types';
|
||||
|
||||
export interface AdminToolbarProps {
|
||||
@@ -11,6 +12,7 @@ export interface AdminToolbarProps {
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { context } = props;
|
||||
const changedPages = await getToolbarChangedPages(context);
|
||||
|
||||
// Create a minimal context to avoid serializing and passing too many data to the client
|
||||
const minimalContext: AdminToolbarContext = {
|
||||
@@ -57,6 +59,7 @@ export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
published: context.site.urls.published,
|
||||
},
|
||||
},
|
||||
changedPages,
|
||||
};
|
||||
|
||||
return <AdminToolbarClient context={minimalContext} />;
|
||||
|
||||
@@ -1,371 +1,32 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { MotionConfig, motion } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useVisitor } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarBody,
|
||||
ToolbarButton,
|
||||
ToolbarButtonGroup,
|
||||
type ToolbarButtonProps,
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import { ToolbarDate } from './ToolbarDate';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
import type { AdminToolbarClientProps } from './types';
|
||||
|
||||
// Loaded on demand so its Framer Motion + toolbar UI never ship in the main client chunk.
|
||||
// Anonymous public visitors — who can never see the toolbar — pay nothing.
|
||||
const AdminToolbarFull = dynamic(
|
||||
() => import('./AdminToolbarFull').then((mod) => mod.AdminToolbarFull),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
/**
|
||||
* Lightweight gate deciding whether the admin toolbar can appear for this viewer, before
|
||||
* loading any of its heavy UI. It renders for editor contexts (change request / prior revision)
|
||||
* and for authenticated members of the organization owning the site; for everyone else it renders
|
||||
* nothing and the full toolbar bundle is never requested.
|
||||
*/
|
||||
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
|
||||
const {
|
||||
minified,
|
||||
setMinified,
|
||||
shouldAutoExpand,
|
||||
hidden,
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
} = useToolbarVisibility({
|
||||
onPersistentClose,
|
||||
onSessionClose,
|
||||
onToggleMinify,
|
||||
});
|
||||
|
||||
const { context } = props;
|
||||
const visitor = useVisitor();
|
||||
|
||||
const toolbarControls: ToolbarControlsContextValue = {
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
shouldAutoExpand,
|
||||
};
|
||||
const isEditorContext =
|
||||
Boolean(context.changeRequest) || context.revisionId !== context.space.revision;
|
||||
const isOrgMember = visitor?.organizationId === context.organizationId;
|
||||
|
||||
if (hidden) {
|
||||
if (!isEditorContext && !isOrgMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there is a change request, show the change request toolbar
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<ChangeRequestToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<RevisionToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitor?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<AuthenticatedUserToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
|
||||
*/
|
||||
export function ToolbarControlsWrapper(
|
||||
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
|
||||
) {
|
||||
const { children, value } = props;
|
||||
return (
|
||||
<ToolbarControlsProvider value={value}>
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">{children}</MotionConfig>
|
||||
</IframeWrapper>
|
||||
</ToolbarControlsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarViewProps {
|
||||
context: AdminToolbarContext;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { changeRequest, site } = context;
|
||||
if (!changeRequest) {
|
||||
throw new Error('Change request is not set');
|
||||
}
|
||||
|
||||
const author = changeRequest.createdBy.displayName;
|
||||
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: changeRequest.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
suffix={`${changeRequest.subject || 'Untitled'}`}
|
||||
/>
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<ToolbarDate value={changeRequest.updatedAt} />{' '}
|
||||
<motion.span layout="position">by {author}</motion.span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Comment in app */}
|
||||
<ToolbarButton
|
||||
title="Comment in a GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${changeRequest.urls.app}~/comments`,
|
||||
siteId: site.id,
|
||||
buttonId: 'comment',
|
||||
})}
|
||||
icon="comment"
|
||||
/>
|
||||
|
||||
{/* Open published/live site */}
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Open CR in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View change request in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: changeRequest.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'change-request',
|
||||
})}
|
||||
icon="code-pull-request"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, site } = context;
|
||||
if (!revision) {
|
||||
throw new Error('Revision is not set');
|
||||
}
|
||||
|
||||
const gitURL = revision.git?.url;
|
||||
const isGitHub = gitURL?.includes('github.com');
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarActions>
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
title={
|
||||
gitURL ? (
|
||||
`Open commit in ${gitProvider}`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
Setup GitSync to edit using Git{' '}
|
||||
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
|
||||
<a
|
||||
href="https://gitbook.com/docs/getting-started/git-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
href={gitURL}
|
||||
disabled={!gitURL}
|
||||
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
|
||||
/>
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
title="View this revision in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: revision.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'revision',
|
||||
})}
|
||||
icon="code-commit"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, space, site } = context;
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View site configuration"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="folder-gear"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Customize site"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/customization/general`,
|
||||
siteId: site.id,
|
||||
buttonId: 'customize',
|
||||
})}
|
||||
icon="palette"
|
||||
/>
|
||||
|
||||
{/* Open insights in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open insights"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/insights`,
|
||||
siteId: site.id,
|
||||
buttonId: 'insights',
|
||||
})}
|
||||
icon="chart-simple"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarActions(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<ToolbarButtonGroup>
|
||||
{children}
|
||||
<HideToolbarButton />
|
||||
</ToolbarButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageButton(props: {
|
||||
href: string;
|
||||
siteId: string;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { href, motionValues, siteId } = props;
|
||||
const pagePath = useCurrentPagePath();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit this page"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pen-to-square"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append utm parameters to a URL to track usage of the toolbar.
|
||||
*/
|
||||
function getToolbarHref({
|
||||
href,
|
||||
siteId,
|
||||
buttonId,
|
||||
}: { href: string; siteId: string; buttonId: string }) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('utm_source', 'content');
|
||||
url.searchParams.set('utm_medium', 'toolbar');
|
||||
url.searchParams.set('utm_campaign', siteId);
|
||||
url.searchParams.set('utm_content', buttonId);
|
||||
|
||||
return url.toString();
|
||||
return <AdminToolbarFull {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { MotionConfig, motion } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitor } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { ChangedPagesButton } from './ChangedPagesButton';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarBody,
|
||||
ToolbarButton,
|
||||
ToolbarButtonGroup,
|
||||
type ToolbarButtonProps,
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import { ToolbarDate } from './ToolbarDate';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
|
||||
/**
|
||||
* The full toolbar UI. Pulls in Framer Motion and every toolbar variant, so it is only
|
||||
* loaded (via a dynamic import in AdminToolbarClient) for viewers who can actually see it —
|
||||
* never for anonymous public visitors.
|
||||
*/
|
||||
export function AdminToolbarFull(props: AdminToolbarClientProps) {
|
||||
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
|
||||
const {
|
||||
minified,
|
||||
setMinified,
|
||||
shouldAutoExpand,
|
||||
hidden,
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
} = useToolbarVisibility({
|
||||
onPersistentClose,
|
||||
onSessionClose,
|
||||
onToggleMinify,
|
||||
});
|
||||
|
||||
const visitor = useVisitor();
|
||||
|
||||
const toolbarControls: ToolbarControlsContextValue = {
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
shouldAutoExpand,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there is a change request, show the change request toolbar
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<ChangeRequestToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<RevisionToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitor?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<AuthenticatedUserToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
|
||||
*/
|
||||
export function ToolbarControlsWrapper(
|
||||
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
|
||||
) {
|
||||
const { children, value } = props;
|
||||
return (
|
||||
<ToolbarControlsProvider value={value}>
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">{children}</MotionConfig>
|
||||
</IframeWrapper>
|
||||
</ToolbarControlsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarViewProps {
|
||||
context: AdminToolbarContext;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { changeRequest, site } = context;
|
||||
if (!changeRequest) {
|
||||
throw new Error('Change request is not set');
|
||||
}
|
||||
|
||||
const author = changeRequest.createdBy.displayName;
|
||||
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: changeRequest.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
suffix={`${changeRequest.subject || 'Untitled'}`}
|
||||
/>
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<ToolbarDate value={changeRequest.updatedAt} />{' '}
|
||||
<motion.span layout="position">by {author}</motion.span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
{/* View a popover with quick links to the changed pages */}
|
||||
<ChangedPagesButton changedPages={context.changedPages} />
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Comment in app */}
|
||||
<ToolbarButton
|
||||
title="Comment in a GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${changeRequest.urls.app}~/comments`,
|
||||
siteId: site.id,
|
||||
buttonId: 'comment',
|
||||
})}
|
||||
icon="comment"
|
||||
/>
|
||||
|
||||
{/* Open published/live site */}
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Open CR in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View change request in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: changeRequest.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'change-request',
|
||||
})}
|
||||
icon="code-pull-request"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, site } = context;
|
||||
if (!revision) {
|
||||
throw new Error('Revision is not set');
|
||||
}
|
||||
|
||||
const gitURL = revision.git?.url;
|
||||
const isGitHub = gitURL?.includes('github.com');
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarActions>
|
||||
{/* View a popover with quick links to the changed pages */}
|
||||
<ChangedPagesButton changedPages={context.changedPages} />
|
||||
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
title={
|
||||
gitURL ? (
|
||||
`Open commit in ${gitProvider}`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
Setup GitSync to edit using Git{' '}
|
||||
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
|
||||
<a
|
||||
href="https://gitbook.com/docs/getting-started/git-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
href={gitURL}
|
||||
disabled={!gitURL}
|
||||
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
|
||||
/>
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
title="View this revision in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: revision.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'revision',
|
||||
})}
|
||||
icon="code-commit"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, space, site } = context;
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View site configuration"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="folder-gear"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Customize site"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/customization/general`,
|
||||
siteId: site.id,
|
||||
buttonId: 'customize',
|
||||
})}
|
||||
icon="palette"
|
||||
/>
|
||||
|
||||
{/* Open insights in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open insights"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/insights`,
|
||||
siteId: site.id,
|
||||
buttonId: 'insights',
|
||||
})}
|
||||
icon="chart-simple"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarActions(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<ToolbarButtonGroup>
|
||||
{children}
|
||||
<HideToolbarButton />
|
||||
</ToolbarButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageButton(props: {
|
||||
href: string;
|
||||
siteId: string;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { href, motionValues, siteId } = props;
|
||||
const pagePath = useCurrentPagePath();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit this page"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pen-to-square"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append utm parameters to a URL to track usage of the toolbar.
|
||||
*/
|
||||
function getToolbarHref({
|
||||
href,
|
||||
siteId,
|
||||
buttonId,
|
||||
}: { href: string; siteId: string; buttonId: string }) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('utm_source', 'content');
|
||||
url.searchParams.set('utm_medium', 'toolbar');
|
||||
url.searchParams.set('utm_campaign', siteId);
|
||||
url.searchParams.set('utm_content', buttonId);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import type { SVGProps } from 'react';
|
||||
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
|
||||
import type { ToolbarButtonProps } from './Toolbar';
|
||||
import type { MinimalChangedPage, MinimalChangedPages } from './types';
|
||||
|
||||
const STATUS_ICON_STYLES: Record<
|
||||
MinimalChangedPage['status'],
|
||||
{ chip: string; icon: IconName; iconClassName: string; iconStyle?: IconStyle }
|
||||
> = {
|
||||
created: {
|
||||
chip: 'border-green-200 bg-green-50',
|
||||
icon: 'plus',
|
||||
iconClassName: 'size-3.5 text-green-600',
|
||||
},
|
||||
edited: {
|
||||
chip: 'border-blue-200 bg-blue-50',
|
||||
icon: 'pencil',
|
||||
iconClassName: 'size-3 text-blue-600',
|
||||
iconStyle: IconStyle.Regular,
|
||||
},
|
||||
moved: {
|
||||
chip: 'border-blue-200 bg-blue-50',
|
||||
icon: 'arrow-right',
|
||||
iconClassName: 'size-3 text-blue-600',
|
||||
},
|
||||
deleted: {
|
||||
chip: 'border-red-200 bg-red-50',
|
||||
icon: 'minus',
|
||||
iconClassName: 'size-3 text-red-600',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center rounded-md border';
|
||||
|
||||
/**
|
||||
* A button that opens a popover with quick links to the _changed_ pages.
|
||||
*/
|
||||
export function ChangedPagesButton(props: {
|
||||
changedPages: MinimalChangedPages | null;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { changedPages, motionValues } = props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
if (!changedPages?.pages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changedPagesCount = changedPages.pages.length + changedPages.more;
|
||||
const changedPagesLabel = `${changedPagesCount} changed ${
|
||||
changedPagesCount === 1 ? 'page' : 'pages'
|
||||
}`;
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View changed pages"
|
||||
className="relative size-8 cursor-pointer border-0 bg-transparent p-0"
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'text-tint-7 hover:text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'bg-[var(--toolbar-bg)]',
|
||||
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<DiffIcon className="size-4 shrink-0" />
|
||||
</motion.span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={trigger}
|
||||
buttonTooltip="View changed pages"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="w-80 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<div className="px-3 py-2 font-medium text-sm text-tint">{changedPagesLabel}</div>
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<div className="flex max-h-80 flex-col overflow-y-auto">
|
||||
{changedPages.pages.map((page) => (
|
||||
<ChangedPageMenuItem key={page.id} page={page} />
|
||||
))}
|
||||
</div>
|
||||
{changedPages.more > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-3 py-1 text-tint-subtle text-xs">
|
||||
{changedPages.more} more changes not shown
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedPageMenuItem(props: { page: MinimalChangedPage }) {
|
||||
const { page } = props;
|
||||
const actionLabel = page.action === 'editor' ? 'Open in editor' : 'Open';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
href={page.href}
|
||||
className="group/changed-page relative w-full min-w-0 items-center rounded-none px-3 py-1.5"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ChangedPageStatusIcon status={page.status} />
|
||||
<span className="min-w-0 flex-1 transition-[padding] group-hover/changed-page:pr-24 group-focus-visible/changed-page:pr-24 group-data-[highlighted]/changed-page:pr-24">
|
||||
<span className="block truncate font-medium text-sm leading-4">
|
||||
{page.title}
|
||||
</span>
|
||||
<span className="block truncate text-tint-subtle text-xs leading-4">
|
||||
/{page.path || ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute right-2 flex items-center gap-0.5 bg-tint-hover pl-3 text-tint-subtle text-xs opacity-0 transition-opacity group-hover/changed-page:opacity-100 group-focus-visible/changed-page:opacity-100 group-data-[highlighted]/changed-page:opacity-100">
|
||||
{actionLabel}
|
||||
<Icon icon="chevron-right" className="size-3 shrink-0" />
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedPageStatusIcon(props: { status: MinimalChangedPage['status'] }) {
|
||||
const { status } = props;
|
||||
const style = STATUS_ICON_STYLES[status];
|
||||
|
||||
return (
|
||||
<span className={tcls(STATUS_ICON_CHIP_CLASS, style.chip)}>
|
||||
<Icon icon={style.icon} iconStyle={style.iconStyle} className={style.iconClassName} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 2.4a.6.6 0 0 1 .6.6v2.9h2.9a.6.6 0 1 1 0 1.2H8.6V10a.6.6 0 1 1-1.2 0V7.1H4.5a.6.6 0 0 1 0-1.2h2.9V3a.6.6 0 0 1 .6-.6M4.5 12.4a.6.6 0 1 0 0 1.2h7a.6.6 0 1 0 0-1.2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -105,7 +105,10 @@ export function Toolbar(props: ToolbarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<motion.div
|
||||
data-testid="admin-toolbar"
|
||||
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
|
||||
>
|
||||
<ToolbarVisibilityHint show={showHint} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getDataOrNull, ignoreAllThrownError } from '@/lib/data';
|
||||
import { getToolbarChangedPagesFromChanges } from './changedPagesMapper';
|
||||
import type { MinimalChangedPages } from './types';
|
||||
|
||||
export const TOOLBAR_CHANGED_PAGES_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Fetch and reduce semantic changes to the minimal list needed by the toolbar client.
|
||||
*/
|
||||
export async function getToolbarChangedPages(
|
||||
context: GitBookSiteContext
|
||||
): Promise<MinimalChangedPages | null> {
|
||||
// The changed-pages list is a non-critical toolbar enhancement. Any failure to fetch or
|
||||
// reduce the changes should simply hide the button, never break rendering of the site page.
|
||||
return ignoreAllThrownError(fetchToolbarChangedPages(context));
|
||||
}
|
||||
|
||||
async function fetchToolbarChangedPages(
|
||||
context: GitBookSiteContext
|
||||
): Promise<MinimalChangedPages | null> {
|
||||
const changes = context.changeRequest
|
||||
? await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequestChanges({
|
||||
spaceId: context.space.id,
|
||||
changeRequestId: context.changeRequest.id,
|
||||
limit: TOOLBAR_CHANGED_PAGES_LIMIT,
|
||||
})
|
||||
)
|
||||
: context.revisionId !== context.space.revision
|
||||
? await getDataOrNull(
|
||||
context.dataFetcher.getRevisionSemanticChanges({
|
||||
spaceId: context.space.id,
|
||||
revisionId: context.revisionId,
|
||||
limit: TOOLBAR_CHANGED_PAGES_LIMIT,
|
||||
})
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!changes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pages = getToolbarChangedPagesFromChanges({
|
||||
changes: changes.changes,
|
||||
editorBaseURL: context.changeRequest?.urls.app ?? context.revision.urls.app,
|
||||
linker: context.linker,
|
||||
pages: context.revision.pages,
|
||||
});
|
||||
|
||||
return pages.length > 0
|
||||
? {
|
||||
pages,
|
||||
more: changes.more ?? 0,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { GitBookLinker } from '@/lib/links';
|
||||
import { getPagePath, resolvePageId } from '@/lib/pages';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import {
|
||||
type ChangedRevisionPage,
|
||||
type Revision,
|
||||
RevisionPageType,
|
||||
type RevisionSemanticChange,
|
||||
} from '@gitbook/api';
|
||||
import type { MinimalChangedPage } from './types';
|
||||
|
||||
// When one page has multiple semantic changes, show the label that best summarizes its final state.
|
||||
const CHANGE_STATUS_SUMMARY_ORDER: Array<MinimalChangedPage['status']> = [
|
||||
'edited',
|
||||
'moved',
|
||||
'created',
|
||||
'deleted',
|
||||
];
|
||||
|
||||
export function getToolbarChangedPagesFromChanges(input: {
|
||||
changes: RevisionSemanticChange[];
|
||||
linker: Pick<GitBookLinker, 'toPathForPage'>;
|
||||
pages: Revision['pages'];
|
||||
editorBaseURL: string;
|
||||
}): MinimalChangedPage[] {
|
||||
const changedPages = new Map<string, MinimalChangedPage>();
|
||||
|
||||
for (const change of input.changes) {
|
||||
// Only real document page changes can become toolbar rows; computed/link/group pages are ignored.
|
||||
const pageChange = getChangedPageChange(change);
|
||||
if (!pageChange || pageChange.page.type !== RevisionPageType.Document) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve against the current revision so preview links use the current title and path.
|
||||
const resolved = resolvePageId(input.pages, pageChange.page.id);
|
||||
const isDeleted = pageChange.status === 'deleted';
|
||||
const currentPage = resolved?.page;
|
||||
const path = currentPage ? getPagePath(input.pages, currentPage) : pageChange.page.path;
|
||||
|
||||
if (!path && !currentPage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Missing non-deleted pages cannot be linked reliably from the preview.
|
||||
if (!currentPage && !isDeleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted pages may no longer exist in the preview, so link to their editor change.
|
||||
if (!currentPage) {
|
||||
changedPages.set(pageChange.page.id, {
|
||||
id: pageChange.page.id,
|
||||
title: pageChange.page.title,
|
||||
path: path ?? '',
|
||||
href: joinPathWithBaseURL(input.editorBaseURL, path ?? ''),
|
||||
status: pageChange.status,
|
||||
action: 'editor',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageId = currentPage.id;
|
||||
const existing = changedPages.get(pageId);
|
||||
// The API can return multiple changes for one page; keep the clearest one-row summary.
|
||||
if (existing && shouldKeepExistingPageChange(existing.status, pageChange.status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changedPages.set(pageId, {
|
||||
id: pageId,
|
||||
title: currentPage.title,
|
||||
path: path ?? '',
|
||||
href: isDeleted
|
||||
? joinPathWithBaseURL(input.editorBaseURL, path ?? '')
|
||||
: input.linker.toPathForPage({
|
||||
pages: input.pages,
|
||||
page: currentPage,
|
||||
}),
|
||||
status: pageChange.status,
|
||||
action: isDeleted ? 'editor' : 'preview',
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(changedPages.values());
|
||||
}
|
||||
|
||||
function getChangedPageChange(
|
||||
change: RevisionSemanticChange
|
||||
): { page: ChangedRevisionPage; status: MinimalChangedPage['status'] } | null {
|
||||
switch (change.type) {
|
||||
case 'page_created':
|
||||
return { page: change.page, status: 'created' };
|
||||
case 'page_edited':
|
||||
return { page: change.page, status: 'edited' };
|
||||
case 'page_moved':
|
||||
return { page: change.page, status: 'moved' };
|
||||
case 'page_deleted':
|
||||
return { page: change.page, status: 'deleted' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldKeepExistingPageChange(
|
||||
existing: MinimalChangedPage['status'],
|
||||
next: MinimalChangedPage['status']
|
||||
) {
|
||||
return getChangeStatusSummaryIndex(existing) >= getChangeStatusSummaryIndex(next);
|
||||
}
|
||||
|
||||
function getChangeStatusSummaryIndex(status: MinimalChangedPage['status']) {
|
||||
return CHANGE_STATUS_SUMMARY_ORDER.indexOf(status);
|
||||
}
|
||||
@@ -43,6 +43,20 @@ export type MinimalSite = {
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalChangedPage = {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
href: string;
|
||||
status: 'created' | 'edited' | 'moved' | 'deleted';
|
||||
action: 'preview' | 'editor';
|
||||
};
|
||||
|
||||
export type MinimalChangedPages = {
|
||||
pages: MinimalChangedPage[];
|
||||
more: number;
|
||||
};
|
||||
|
||||
export type AdminToolbarContext = {
|
||||
organizationId: string;
|
||||
revisionId: string;
|
||||
@@ -50,6 +64,7 @@ export type AdminToolbarContext = {
|
||||
changeRequest: MinimalChangeRequest | null;
|
||||
revision: MinimalRevision;
|
||||
site: MinimalSite;
|
||||
changedPages: MinimalChangedPages | null;
|
||||
};
|
||||
|
||||
export interface AdminToolbarClientProps {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { AIChatIcon } from '@/components/AIChat';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -22,7 +21,7 @@ export function AskAICodeButton(props: {
|
||||
const config = useAIConfig();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
if (config.aiMode !== CustomizationAIMode.Assistant) {
|
||||
if (!isAIChatEnabled(config.aiMode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface DocumentContext {
|
||||
* @default false
|
||||
*/
|
||||
withLinkPreviews?: boolean;
|
||||
|
||||
/**
|
||||
* True when this document is the main page body — the only place a background page cover is
|
||||
* shown behind the content. Cover-aware text styling (contrast over the cover) is scoped to it
|
||||
* so auxiliary documents (search answers, AI chat, PDF export) don't inherit the cover colors.
|
||||
* @default false
|
||||
*/
|
||||
isPageBody?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentContextProps {
|
||||
|
||||
@@ -59,6 +59,9 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
'justify-self-start',
|
||||
'max-w-full',
|
||||
'break-words',
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody && 'page-cover-background:text-contrast-cover',
|
||||
getTextAlignment(block.data.align),
|
||||
textStyle.lineHeight
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { useAI, useAIChatController, useAIChatState } from '../AI';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { Button, type ButtonProps, Input } from '../primitives';
|
||||
|
||||
// The Input primitive has no `xsmall`; otherwise it shares the button's size scale.
|
||||
const INPUT_SIZE_MAP: Record<NonNullable<ButtonProps['size']>, 'small' | 'medium' | 'large'> = {
|
||||
xsmall: 'small',
|
||||
small: 'small',
|
||||
medium: 'medium',
|
||||
large: 'large',
|
||||
};
|
||||
|
||||
export function InlineActionButton(
|
||||
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
|
||||
) {
|
||||
@@ -42,7 +50,7 @@ export function InlineActionButton(
|
||||
<Input
|
||||
inline
|
||||
label={buttonProps.label as string}
|
||||
sizing="medium"
|
||||
sizing={INPUT_SIZE_MAP[buttonProps.size ?? 'medium']}
|
||||
className="inline-flex max-w-full grow"
|
||||
submitButton={{
|
||||
label: tString(language, action === 'ask' ? 'send' : 'search'),
|
||||
|
||||
@@ -9,6 +9,16 @@ import type { InlineProps } from './Inline';
|
||||
import { InlineActionButton } from './InlineActionButton';
|
||||
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
|
||||
|
||||
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
|
||||
const BUTTON_SIZE_MAP: Record<
|
||||
NonNullable<api.DocumentInlineButton['data']['size']>,
|
||||
ButtonProps['size']
|
||||
> = {
|
||||
small: 'xsmall',
|
||||
medium: 'small',
|
||||
large: 'medium',
|
||||
};
|
||||
|
||||
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
const { inline, context } = props;
|
||||
|
||||
@@ -16,7 +26,7 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
label: inline.data.label,
|
||||
variant: inline.data.kind,
|
||||
icon: inline.data.icon as IconName | undefined,
|
||||
size: 'medium',
|
||||
size: BUTTON_SIZE_MAP[inline.data.size ?? 'large'],
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { createLazyStylesheet } from '../createLazyStylesheet';
|
||||
|
||||
/**
|
||||
* Lazy-loads the ContentKit stylesheet so it only downloads on pages that actually render
|
||||
* an integration block, instead of shipping in every page's CSS chunk.
|
||||
*/
|
||||
export default createLazyStylesheet(() => import('./contentkit.css'));
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import React from 'react';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that need client-only adaptive context.
|
||||
*/
|
||||
export function ContentKitWithAdaptiveVisitorContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext>
|
||||
) {
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: () => ({
|
||||
visitor: visitorClaims?.visitor ?? null,
|
||||
}),
|
||||
}),
|
||||
[visitorClaims]
|
||||
);
|
||||
|
||||
return <ContentKit {...props} clientContext={clientContext} />;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { NavigationStatusContext } from '@/components/hooks';
|
||||
import { type GitBookLinker, createLinker } from '@/lib/links';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */
|
||||
export type WebframeLinkerData = Pick<
|
||||
Parameters<typeof createLinker>[0],
|
||||
'host' | 'protocol' | 'siteBasePath' | 'spaceBasePath'
|
||||
>;
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
|
||||
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
|
||||
* access them).
|
||||
*/
|
||||
export function ContentKitWithClientContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext> & {
|
||||
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */
|
||||
canAccessVisitorClaims: boolean;
|
||||
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */
|
||||
linkerData: WebframeLinkerData;
|
||||
}
|
||||
) {
|
||||
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
|
||||
// Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest
|
||||
// of the app, instead of duplicating the join logic here.
|
||||
const linker = React.useMemo<GitBookLinker>(() => createLinker(linkerData), [linkerData]);
|
||||
|
||||
// Navigate to an in-site href, driving the same navigation progress bar as a regular link so
|
||||
// the reader gets feedback while the destination page loads.
|
||||
const navigateTo = React.useCallback(
|
||||
(href: string) => {
|
||||
onNavigationClick(href);
|
||||
router.push(href);
|
||||
},
|
||||
[onNavigationClick, router]
|
||||
);
|
||||
// Read during render (Suspense) only when the integration is allowed visitor claims, so that
|
||||
// webframes that don't use visitor claims don't suspend on the visitor-claims fetch.
|
||||
const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null;
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: canAccessVisitorClaims
|
||||
? () => ({ visitor: visitorClaims?.visitor ?? null })
|
||||
: undefined,
|
||||
navigate: ({ path, anchor }) => {
|
||||
// Resolve the requested path relative to the site root so a webframe can navigate
|
||||
// to any section or space within the site (and nowhere outside it).
|
||||
const suffix = anchor ? `#${anchor}` : '';
|
||||
navigateTo(linker.toPathInSite(path) + suffix);
|
||||
},
|
||||
}),
|
||||
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
|
||||
);
|
||||
|
||||
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
|
||||
}
|
||||
@@ -2,15 +2,22 @@ import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
import React from 'react';
|
||||
|
||||
import type { GitBookLinker } from '@/lib/links';
|
||||
import type { BlockProps } from '../Block';
|
||||
import './contentkit.css';
|
||||
import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext';
|
||||
import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive';
|
||||
import {
|
||||
ContentKitWithClientContext,
|
||||
type WebframeLinkerData,
|
||||
} from './ContentKitWithClientContext';
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import { fetchSafeIntegrationUI } from './render';
|
||||
import { renderIntegrationUi } from './server-actions';
|
||||
|
||||
// Lazy so the ContentKit CSS is only fetched on pages that render an integration block.
|
||||
const ContentKitStyles = React.lazy(() => import('./ContentKitStyles'));
|
||||
|
||||
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
@@ -70,34 +77,71 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
return null;
|
||||
}
|
||||
|
||||
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
initialOutput
|
||||
)
|
||||
? ContentKitWithAdaptiveVisitorContext
|
||||
: ContentKit;
|
||||
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
|
||||
// visitor claims when the integration is allowed them.
|
||||
const useClientContext = containsWebframe;
|
||||
|
||||
const contentKitProps = {
|
||||
renderContext: {
|
||||
integrationName: block.data.integration,
|
||||
},
|
||||
security: {
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
},
|
||||
initialInput,
|
||||
initialOutput,
|
||||
render: renderIntegrationUi,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKitComponent
|
||||
renderContext={{
|
||||
integrationName: block.data.integration,
|
||||
}}
|
||||
security={{
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
}}
|
||||
initialInput={initialInput}
|
||||
initialOutput={initialOutput}
|
||||
render={renderIntegrationUi}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitComponent>
|
||||
<ContentKitStyles />
|
||||
{useClientContext ? (
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitWithClientContext>
|
||||
) : (
|
||||
<ContentKit {...contentKitProps}>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKit>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the serializable data needed to rebuild the site linker on the client, so webframe
|
||||
* navigation resolves paths through the same (tested) linker as the rest of the app.
|
||||
*/
|
||||
function getWebframeLinkerData(linker: GitBookLinker): WebframeLinkerData {
|
||||
const data: WebframeLinkerData = {
|
||||
siteBasePath: linker.siteBasePath,
|
||||
spaceBasePath: linker.spaceBasePath,
|
||||
};
|
||||
|
||||
// `host`/`protocol` are only used to build absolute URLs, which webframe navigation never does.
|
||||
// Carry them along when available so the rebuilt linker is complete (and avoids a dev warning).
|
||||
try {
|
||||
const url = new URL(linker.toAbsoluteURL('/'));
|
||||
data.host = url.host;
|
||||
data.protocol = url.protocol;
|
||||
} catch {
|
||||
// No usable host (e.g. tests): the linker still resolves in-site paths without it.
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
|
||||
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
|
||||
const webframe: ContentKitWebFrame = {
|
||||
type: 'webframe',
|
||||
source: { url: 'https://integrations.gitbook.com/frame' },
|
||||
};
|
||||
|
||||
function elementOutput(element: unknown): ContentKitRenderOutput {
|
||||
return {
|
||||
type: 'element',
|
||||
element,
|
||||
state: {},
|
||||
props: {},
|
||||
} as ContentKitRenderOutput;
|
||||
}
|
||||
|
||||
describe('integrationBlockContainsWebframe', () => {
|
||||
it('returns false for a completed output', () => {
|
||||
expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there is no webframe in the tree', () => {
|
||||
const output = elementOutput({
|
||||
type: 'block',
|
||||
children: [{ type: 'text', text: 'hello' }],
|
||||
} as never);
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a webframe is nested in the tree', () => {
|
||||
const output = elementOutput({
|
||||
type: 'block',
|
||||
children: [{ type: 'vstack', children: [webframe] }],
|
||||
} as never);
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,19 +8,15 @@ import type {
|
||||
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
|
||||
|
||||
/**
|
||||
* Decide whether an integration block should expose Adaptive visitor context to webframes.
|
||||
* Whether an integration block's output contains a webframe that can consume client-only context
|
||||
* (navigation and/or visitor claims).
|
||||
*/
|
||||
export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
output: ContentKitRenderOutput
|
||||
) {
|
||||
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
|
||||
if (output.type === 'complete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
output.canAccessVisitorClaims === true &&
|
||||
doesContentKitElementContainWebframe(output.element)
|
||||
);
|
||||
return doesContentKitElementContainWebframe(output.element);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi block or an openapi-operation block.
|
||||
@@ -14,6 +14,7 @@ export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlo
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||
<OpenAPIStyles />
|
||||
<OpenAPIOperationBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
|
||||
|
||||
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi-schemas block.
|
||||
@@ -13,6 +13,7 @@ export async function OpenAPISchemas(props: BlockProps<OpenAPISchemasBlock>) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full', style, 'max-w-full')}>
|
||||
<OpenAPIStyles />
|
||||
<OpenAPISchemasBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { createLazyStylesheet } from '../createLazyStylesheet';
|
||||
|
||||
/**
|
||||
* Lazy-loads the OpenAPI/Scalar stylesheet. Kept out of the static import graph so the
|
||||
* ~148KB Scalar CSS only downloads on pages that actually render an OpenAPI block.
|
||||
*/
|
||||
export default createLazyStylesheet(() => import('./style.css'));
|
||||
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi block or an openapi-webhook block.
|
||||
@@ -14,6 +14,7 @@ export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||
<OpenAPIStyles />
|
||||
<OpenAPIWebhookBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { JSONDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||
import React from 'react';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { Heading } from '../Heading';
|
||||
|
||||
import './style.css';
|
||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
||||
@@ -17,6 +17,12 @@ import type {
|
||||
OpenAPIWebhookBlock,
|
||||
} from '@/lib/openapi/types';
|
||||
|
||||
/**
|
||||
* Lazy loader for the OpenAPI/Scalar stylesheet, rendered by each OpenAPI block so the CSS
|
||||
* is only fetched on pages that use one.
|
||||
*/
|
||||
export const OpenAPIStyles = React.lazy(() => import('./OpenAPIStyles'));
|
||||
|
||||
/**
|
||||
* Get the OpenAPI context to render a block.
|
||||
*/
|
||||
@@ -51,6 +57,7 @@ export function getOpenAPIContext(args: {
|
||||
copy: <Icon icon="copy" />,
|
||||
check: <Icon icon="check" />,
|
||||
lock: <Icon icon="lock" />,
|
||||
mcp: <Icon icon="mcp" />,
|
||||
},
|
||||
renderCodeBlock: (codeProps) => (
|
||||
<PlainCodeBlock
|
||||
|
||||
@@ -38,11 +38,29 @@
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* In headless mode the tags row sits directly above the path; match the operation-level rhythm. */
|
||||
.openapi-summary-tags + .openapi-path {
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.openapi-deprecated,
|
||||
.openapi-stability {
|
||||
.openapi-stability,
|
||||
.openapi-mcp {
|
||||
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
.openapi-mcp {
|
||||
@apply inline-flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/6 ring-emerald-500/5;
|
||||
}
|
||||
|
||||
button.openapi-mcp {
|
||||
@apply cursor-pointer transition-colors hover:bg-emerald-100 dark:hover:bg-emerald-900/12;
|
||||
}
|
||||
|
||||
.openapi-mcp .gb-icon {
|
||||
@apply size-3;
|
||||
}
|
||||
|
||||
.openapi-stability-alpha {
|
||||
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
|
||||
}
|
||||
@@ -98,52 +116,8 @@
|
||||
@apply !mb-0;
|
||||
}
|
||||
|
||||
/* Method Tags */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-small {}
|
||||
|
||||
.openapi-method-medium {
|
||||
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
|
||||
}
|
||||
|
||||
.toclink .openapi-method {
|
||||
@apply text-[0.625rem] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
.openapi-statuscode-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
|
||||
.openapi-method-post,
|
||||
.openapi-statuscode-redirection {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||
}
|
||||
|
||||
.openapi-method-put,
|
||||
.openapi-statuscode-informational {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.openapi-method-patch {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.openapi-method-delete,
|
||||
.openapi-statuscode-error {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
/* Method / status-code tag styles moved to `./tags.css`, loaded globally so the sidebar method
|
||||
* badges are styled even before an OpenAPI block mounts this deferred stylesheet. */
|
||||
|
||||
/* URL */
|
||||
.openapi-url {
|
||||
@@ -160,16 +134,23 @@
|
||||
}
|
||||
|
||||
.openapi-column-preview {
|
||||
@apply flex flex-col flex-1 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] scroll-mt-4 top-[calc(var(--toc-top-offset)+1.5rem)];
|
||||
/* Only on the 2-column layout is the preview a sticky pane capped to the viewport so both panels fit.
|
||||
The top offset clears the site header (--toc-top-offset) plus the sticky page-actions bar pinned just
|
||||
below it on desktop API pages (~3rem: its 1rem gap under the header + the button row), landing 4rem
|
||||
down with a small gap beneath the bar. The max-height subtracts that same 4rem top offset plus a
|
||||
1.5rem bottom margin. On mobile (single column) it flows normally, each block bounded by max-h-96, so
|
||||
the response isn't crammed into the viewport. */
|
||||
@apply flex flex-col flex-1 scroll-mt-4 lg:sticky lg:self-start lg:max-h-[calc(100vh-var(--toc-top-offset)-5.5rem)] lg:top-[calc(var(--toc-top-offset)+4rem)];
|
||||
}
|
||||
|
||||
.openapi-column-preview-body {
|
||||
/* overflow-clip + clip-margin so panel box-shadows aren't cut while overflow is still clipped */
|
||||
@apply flex flex-col shrink overflow-clip [overflow-clip-margin:0.75rem] gap-4 print-mode:static;
|
||||
@apply flex flex-col shrink min-h-0 overflow-clip [overflow-clip-margin:0.75rem] gap-4 print-mode:static;
|
||||
}
|
||||
|
||||
.openapi-column-preview pre {
|
||||
@apply max-h-96 print-mode:max-h-none;
|
||||
/* On the 2-column sticky layout, let the code block grow to fill the available preview height
|
||||
(bounded by the sticky max-height) instead of a fixed cap, so it doesn't scroll when there's room. */
|
||||
@apply max-h-96 lg:max-h-none print-mode:max-h-none;
|
||||
}
|
||||
|
||||
/* Schema Structure */
|
||||
@@ -539,6 +520,11 @@
|
||||
@apply border shrink min-h-32 overflow-hidden rounded-corners:rounded-xl circular-corners:rounded-2xl straight-corners:rounded-xs bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
|
||||
}
|
||||
|
||||
/* The code sample stays at full height; only the response example shrinks/scrolls to fit the viewport */
|
||||
.openapi-codesample {
|
||||
@apply shrink-0 lg:max-h-96;
|
||||
}
|
||||
|
||||
.openapi-response-examples-panel {
|
||||
@apply flex flex-col shrink overflow-hidden;
|
||||
}
|
||||
@@ -565,7 +551,7 @@
|
||||
|
||||
.openapi-panel-footer,
|
||||
.openapi-codesample-footer {
|
||||
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint empty:hidden;
|
||||
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint empty:hidden shrink-0;
|
||||
}
|
||||
|
||||
.openapi-panel-footer .openapi-markdown {
|
||||
@@ -669,6 +655,11 @@ body:has(.openapi-select-popover) {
|
||||
@apply flex flex-col gap-1 justify-start items-start;
|
||||
}
|
||||
|
||||
.openapi-select > button > span.react-aria-SelectValue:has(.openapi-statuscode),
|
||||
.openapi-select-item:has(.openapi-statuscode) {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.openapi-select-item [slot="description"] {
|
||||
@apply text-xs text-tint-subtle;
|
||||
}
|
||||
@@ -695,16 +686,18 @@ body:has(.openapi-select-popover) {
|
||||
|
||||
/* Section Components */
|
||||
.openapi-section {
|
||||
/* overflow-clip + clip-margin so the centered AND/OR separator badge isn't cut at the outermost alternatives level */
|
||||
@apply flex flex-col overflow-clip [overflow-clip-margin:0.75rem];
|
||||
/* min-h-0 so nested sections (e.g. the media-types panel inside a response example) can shrink
|
||||
inside the height-capped preview; otherwise the code/JSON body keeps full height and pushes
|
||||
the footer out of the panel. Headers/footers stay put via their own shrink-0. */
|
||||
@apply flex flex-col min-h-0 overflow-clip [overflow-clip-margin:0.75rem];
|
||||
}
|
||||
|
||||
.openapi-section-body {
|
||||
@apply flex flex-col shrink overflow-clip [overflow-clip-margin:0.75rem];
|
||||
@apply flex flex-col shrink min-h-0 overflow-clip [overflow-clip-margin:0.75rem];
|
||||
}
|
||||
|
||||
.openapi-section-header {
|
||||
@apply flex flex-row items-center;
|
||||
@apply flex flex-row items-center shrink-0;
|
||||
}
|
||||
|
||||
.openapi-section-header-content {
|
||||
@@ -724,7 +717,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-section-footer {
|
||||
@apply flex flex-row items-center p-2.5 gap-2.5 text-sm text-tint-strong border-t border-tint-subtle;
|
||||
@apply flex flex-row items-center p-2.5 gap-2.5 text-sm text-tint-strong border-t border-tint-subtle shrink-0;
|
||||
}
|
||||
|
||||
.openapi-section-footer-content {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* OpenAPI method / status-code tag styles.
|
||||
*
|
||||
* These live outside the deferred `style.css` and are loaded from the global stylesheet because
|
||||
* the HTTP method badge (`OpenAPIMethodBadge`) renders in the always-present sidebar (table of
|
||||
* contents) on every page — not only on pages that mount an OpenAPI block. If they stayed in the
|
||||
* lazily-loaded stylesheet, the sidebar badges would be unstyled until an OpenAPI page pulled in
|
||||
* the heavy Scalar CSS.
|
||||
*/
|
||||
|
||||
/* Method Tags */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-small {}
|
||||
|
||||
.openapi-method-medium {
|
||||
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
|
||||
}
|
||||
|
||||
.toclink .openapi-method {
|
||||
@apply text-[0.625rem] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
.openapi-statuscode-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
|
||||
.openapi-method-post,
|
||||
.openapi-statuscode-redirection {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||
}
|
||||
|
||||
.openapi-method-put,
|
||||
.openapi-statuscode-informational {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.openapi-method-patch {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.openapi-method-delete,
|
||||
.openapi-statuscode-error {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { DocumentBlockParagraph } from '@gitbook/api';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
|
||||
import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton';
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from './Block';
|
||||
@@ -8,14 +11,53 @@ import { getTextAlignment } from './utils';
|
||||
|
||||
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
|
||||
const { block, style, ...contextProps } = props;
|
||||
const { context } = contextProps;
|
||||
|
||||
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
|
||||
const inlineButtonStyle =
|
||||
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
|
||||
|
||||
return (
|
||||
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
|
||||
const paragraph = (
|
||||
<p
|
||||
className={tcls(
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody &&
|
||||
'page-cover-background:[&:not(:has(.button,input))]:text-contrast-cover',
|
||||
inlineButtonStyle,
|
||||
style,
|
||||
getTextAlignment(block.data?.align)
|
||||
)}
|
||||
>
|
||||
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
|
||||
</p>
|
||||
);
|
||||
|
||||
// Offer to ask the assistant about any paragraph, in Assistant mode, on screen.
|
||||
const contentContext = context.contentContext;
|
||||
const aiAssistantEnabled =
|
||||
context.mode !== 'print' &&
|
||||
contentContext != null &&
|
||||
'customization' in contentContext &&
|
||||
contentContext.customization.ai.mode === CustomizationAIMode.Assistant;
|
||||
|
||||
const text = aiAssistantEnabled ? getNodeText(block) : '';
|
||||
if (aiAssistantEnabled && text.trim()) {
|
||||
// The wrapper is now the flex child, so it must carry the block alignment (notably
|
||||
// `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages.
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'group/ask-ai relative',
|
||||
style,
|
||||
getTextAlignment(block.data?.align)
|
||||
)}
|
||||
>
|
||||
{paragraph}
|
||||
<AskAIParagraphButton content={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import {
|
||||
CustomizationPageActionType,
|
||||
type DocumentBlockPrompt,
|
||||
@@ -15,21 +14,12 @@ export function Prompt(props: BlockProps<DocumentBlockPrompt>) {
|
||||
block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative flex w-full flex-col overflow-hidden',
|
||||
'border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
|
||||
'depth-subtle:shadow-xs'
|
||||
)}
|
||||
>
|
||||
<PromptClient
|
||||
contentIcon={contentIcon}
|
||||
description={block.data.description}
|
||||
prompt={getPromptText(block)}
|
||||
openInAIProviders={getOpenInAIProviders(props)}
|
||||
/>
|
||||
</div>
|
||||
<PromptClient
|
||||
contentIcon={contentIcon}
|
||||
description={block.data.description}
|
||||
prompt={getPromptText(block)}
|
||||
openInAIProviders={getOpenInAIProviders(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,11 +43,7 @@ function getOpenInAIProviders(props: BlockProps<DocumentBlockPrompt>): boolean {
|
||||
function isExternalAIPageActionEnabled(
|
||||
pageActions: SiteCustomizationSettings['pageActions']
|
||||
): boolean {
|
||||
// Use the configured `items` list when the API provides it, and fall back to the deprecated
|
||||
// `externalAI` flag otherwise (legacy mode).
|
||||
return pageActions.items
|
||||
? pageActions.items.includes(CustomizationPageActionType.ExternalAi)
|
||||
: pageActions.externalAI;
|
||||
return pageActions.items.includes(CustomizationPageActionType.ExternalAi);
|
||||
}
|
||||
|
||||
function getPromptText(block: DocumentBlockPrompt): string {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
ToggleChevron,
|
||||
} from '@/components/primitives';
|
||||
import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/components/primitives';
|
||||
import { getURLForLLM } from '@/components/utils';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -26,10 +20,21 @@ export function PromptClient(props: {
|
||||
const language = useLanguage();
|
||||
const promptId = React.useId();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [headerHasFocus, setHeaderHasFocus] = React.useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="group/prompt-header relative flex min-h-9 flex-row items-center justify-between gap-4 px-3 py-2">
|
||||
<div
|
||||
className={tcls(
|
||||
'relative flex w-full flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs text-tint-strong',
|
||||
'border border-tint-subtle contrast-more:border-tint',
|
||||
'transition',
|
||||
open ? 'bg-tint depth-subtle:shadow-xs' : 'bg-tint-base'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'group/prompt-header relative flex min-h-9 flex-row items-center justify-between gap-4 p-3 transition-colors',
|
||||
open ? 'hover:bg-tint-hover' : 'hover:bg-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls={promptId}
|
||||
@@ -40,16 +45,15 @@ export function PromptClient(props: {
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-hover'
|
||||
)}
|
||||
disabled={!prompt}
|
||||
onBlur={() => setHeaderHasFocus(false)}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onFocus={() => setHeaderHasFocus(true)}
|
||||
/>
|
||||
<div className="pointer-events-none relative z-0 flex min-w-0 flex-row items-center gap-2 text-tint-strong">
|
||||
<PromptDisclosureIcon
|
||||
contentIcon={contentIcon}
|
||||
headerHasFocus={headerHasFocus}
|
||||
<ToggleChevron
|
||||
open={open}
|
||||
orientation="right-to-down"
|
||||
className="size-3 shrink-0 text-tint-subtle transition-colors group-hover/prompt-header:text-tint-strong"
|
||||
/>
|
||||
{contentIcon ? <Icon icon={contentIcon} className="size-4 shrink-0" /> : null}
|
||||
<span className="min-w-0 truncate">{description}</span>
|
||||
</div>
|
||||
<PromptActions prompt={prompt} openInAIProviders={openInAIProviders} />
|
||||
@@ -63,45 +67,7 @@ export function PromptClient(props: {
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDisclosureIcon(props: {
|
||||
contentIcon: IconName | null;
|
||||
headerHasFocus: boolean;
|
||||
open: boolean;
|
||||
}) {
|
||||
const { contentIcon, headerHasFocus, open } = props;
|
||||
return (
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center">
|
||||
{contentIcon ? (
|
||||
<>
|
||||
<span
|
||||
className={tcls(
|
||||
'flex items-center transition-opacity duration-150 group-hover/prompt-header:opacity-0',
|
||||
headerHasFocus && 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<Icon icon={contentIcon} className="size-4 shrink-0" />
|
||||
</span>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute inset-0 flex items-center justify-center text-tint-subtle opacity-0 transition-opacity duration-150 group-hover/prompt-header:opacity-100',
|
||||
headerHasFocus && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<ToggleChevron open={open} orientation="right-to-down" className="size-3" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<ToggleChevron
|
||||
open={open}
|
||||
orientation="right-to-down"
|
||||
className="size-3 text-tint-subtle"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,10 +75,10 @@ function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
|
||||
const { prompt, openInAIProviders } = props;
|
||||
|
||||
return (
|
||||
<ButtonGroup className="relative z-20 shrink-0 overflow-visible">
|
||||
<div className="relative z-20 flex shrink-0 items-center gap-2">
|
||||
<CopyPromptButton prompt={prompt} />
|
||||
{openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,11 +106,9 @@ function CopyPromptButton(props: { prompt: string }) {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
size="xsmall"
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
|
||||
className="bg-tint-base"
|
||||
disabled={!prompt}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
@@ -164,12 +128,11 @@ function OpenPromptDropdown(props: { prompt: string }) {
|
||||
className="!min-w-48 max-w-max"
|
||||
button={
|
||||
<Button
|
||||
icon={<ToggleChevron className="size-text-sm" />}
|
||||
label={tString(language, 'open')}
|
||||
iconOnly
|
||||
label={tString(language, 'open_in_ai')}
|
||||
trailing={<ToggleChevron className="size-text-sm" />}
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
className="bg-tint-base"
|
||||
className="max-sm:hidden"
|
||||
disabled={!prompt}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ export async function RecordCard(
|
||||
|
||||
const body = (
|
||||
<div
|
||||
// Marks the card body so overflow-clipped affordances (e.g. the paragraph "Ask"
|
||||
// button) can opt out of rendering inside it.
|
||||
data-card=""
|
||||
className={tcls(
|
||||
'grid-area-1-1',
|
||||
'relative',
|
||||
|
||||
@@ -28,7 +28,10 @@ function DefaultHeaderScrollGrid({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle">
|
||||
<div
|
||||
role="table"
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle"
|
||||
>
|
||||
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
|
||||
<div className={tcls('flex', 'flex-col', resolvedTableClassName)}>
|
||||
{header}
|
||||
@@ -137,6 +140,7 @@ function StickyHeaderOverlayScrollGrid({
|
||||
ref={rootRef}
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
|
||||
data-scrollable="false"
|
||||
role="table"
|
||||
>
|
||||
{header ? (
|
||||
<div
|
||||
|
||||
@@ -16,14 +16,10 @@ import {
|
||||
getTableRecordSearchData,
|
||||
getTableSelectColumns,
|
||||
} from './search';
|
||||
import { shouldShowTableSearch } from './shouldShowSearch';
|
||||
|
||||
export type { TableRecordKV };
|
||||
|
||||
/**
|
||||
* Only show the table search once there are enough records that searching is useful.
|
||||
*/
|
||||
const MIN_RECORDS_FOR_SEARCH = 7;
|
||||
|
||||
export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
|
||||
view: View;
|
||||
records: TableRecordKV[];
|
||||
@@ -38,7 +34,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
a[1].orderIndex.localeCompare(b[1].orderIndex)
|
||||
);
|
||||
|
||||
const showSearch = context.mode !== 'print' && records.length >= MIN_RECORDS_FOR_SEARCH;
|
||||
// Authors can override the smart default per block with `search: true | false`; `undefined`
|
||||
// keeps the default (search on grid tables with enough rows, off on cards). See the helper.
|
||||
const showSearch = shouldShowTableSearch({
|
||||
recordCount: records.length,
|
||||
viewType: block.data.view.type,
|
||||
searchOverride: block.data.search,
|
||||
isPrint: context.mode === 'print',
|
||||
});
|
||||
const searchRecords = showSearch
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
@@ -137,7 +140,10 @@ function TableView({
|
||||
'w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle '
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-col', tableContainerClassName)}>
|
||||
<div
|
||||
className={tcls('flex', 'flex-col', tableContainerClassName)}
|
||||
role="table"
|
||||
>
|
||||
{withHeader ? (
|
||||
<ViewGridHeader
|
||||
{...gridProps}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DocumentTableViewCards } from '@gitbook/api';
|
||||
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RecordCard } from './RecordCard';
|
||||
@@ -7,6 +8,20 @@ import type { TableViewProps } from './Table';
|
||||
import { TableSearchRecord } from './TableSearch';
|
||||
|
||||
export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
// `wrap` defaults to `true` (a wrapping grid); only an explicit `false` opts into the
|
||||
// horizontally-scrolling carousel row. Fall back to the grid in print mode: a PDF can't
|
||||
// scroll, so carousel overflow would be silently clipped.
|
||||
if (props.view.wrap === false && props.context.mode !== 'print') {
|
||||
return <CardsCarousel {...props} />;
|
||||
}
|
||||
|
||||
return <CardsGrid {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default layout: cards wrap into a responsive grid.
|
||||
*/
|
||||
function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { block, view, records, style } = props;
|
||||
|
||||
return (
|
||||
@@ -35,3 +50,130 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The carousel layout: cards lay out in a single horizontally-scrolling row that
|
||||
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
|
||||
*
|
||||
* Rather than fading the edges, the row breaks out of the content column so it can
|
||||
* scroll to the page edges. Negative margins on the outer wrapper pull it out; matching
|
||||
* padding + scroll-padding on the scroller keep the first/last cards aligned with the
|
||||
* body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll.
|
||||
* See `bleedVars` below for how far each side reaches.
|
||||
*/
|
||||
function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { view, records } = props;
|
||||
|
||||
// Cards need a fixed width so the row overflows and scrolls; mirror the grid's
|
||||
// medium/large sizing.
|
||||
const cardWidth =
|
||||
view.cardSize === 'large'
|
||||
? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]'
|
||||
: 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]';
|
||||
|
||||
// Break the row out of the content column so it bleeds to the page edges instead of
|
||||
// fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the
|
||||
// negative margins (on the wrapper) and the matching padding + scroll-padding (on the
|
||||
// scroller), so the first/last cards stay aligned with the body text at rest while cards
|
||||
// bleed to the edge mid-scroll. Kept as literals here since it's a single block.
|
||||
//
|
||||
// The bleed only makes sense on the default layout, where the 48rem column leaves wide
|
||||
// empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width,
|
||||
// so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the
|
||||
// row past where every other block ends, jutting into the window frame.
|
||||
//
|
||||
// On the default layout:
|
||||
// - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC.
|
||||
// - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space
|
||||
// beside the TOC, so the gap to the viewport edge is `50vw` minus half the column
|
||||
// (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown.
|
||||
// `html` clips horizontal overflow, so a small overshoot is harmless.
|
||||
// - Right collapses to 0 once an outline occupies that column (shown from `xl`), so
|
||||
// cards never slide under it.
|
||||
const bleedVars = tcls(
|
||||
'[--cards-bleed-l:1rem]',
|
||||
'sm:[--cards-bleed-l:1.5rem]',
|
||||
'md:[--cards-bleed-l:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]',
|
||||
'xl:[--cards-bleed-l:3rem]',
|
||||
|
||||
'[--cards-bleed-r:1rem]',
|
||||
'sm:[--cards-bleed-r:1.5rem]',
|
||||
'md:[--cards-bleed-r:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]',
|
||||
'layout-default:xl:[--cards-bleed-r:3rem]',
|
||||
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
|
||||
// Default centered
|
||||
'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]',
|
||||
'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]',
|
||||
|
||||
// Full width, no outline
|
||||
'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]',
|
||||
|
||||
// Full width centered
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]',
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]'
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls(
|
||||
bleedVars,
|
||||
'ml-[calc(var(--cards-bleed-l)*-1)]',
|
||||
'mr-[calc(var(--cards-bleed-r)*-1)]',
|
||||
'xl:transition-[margin]',
|
||||
'hover:z-11'
|
||||
)}
|
||||
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
|
||||
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
|
||||
contentClassName={tcls(
|
||||
'gap-4',
|
||||
'pt-px',
|
||||
'-mt-px',
|
||||
'pb-6',
|
||||
'-mb-6',
|
||||
'pl-[var(--cards-bleed-l)]',
|
||||
'pr-[var(--cards-bleed-r)]',
|
||||
'scroll-pl-[var(--cards-bleed-l)]',
|
||||
'scroll-pr-[var(--cards-bleed-r)]',
|
||||
'snap-x',
|
||||
'snap-mandatory',
|
||||
'xl:transition-[padding]'
|
||||
)}
|
||||
leading={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' },
|
||||
}}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' },
|
||||
}}
|
||||
>
|
||||
{records.map((record) => {
|
||||
return (
|
||||
<TableSearchRecord
|
||||
key={record[0]}
|
||||
recordId={record[0]}
|
||||
// `grid grid-cols-1` stretches the card to fill the fixed-width,
|
||||
// equal-height track; `snap-start` aligns it to the left edge.
|
||||
visibleClassName={tcls(
|
||||
'grid',
|
||||
'grid-cols-1',
|
||||
'shrink-0',
|
||||
'snap-start',
|
||||
cardWidth
|
||||
)}
|
||||
>
|
||||
<RecordCard {...props} record={record} />
|
||||
</TableSearchRecord>
|
||||
);
|
||||
})}
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,9 +108,5 @@ export function ViewGrid(props: ViewGridProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="table" className={tcls('flex', 'flex-col', tableClassName ?? tableWidth)}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
return <div className={tcls('flex', 'flex-col', tableClassName ?? tableWidth)}>{body}</div>;
|
||||
}
|
||||
|
||||
@@ -18,11 +18,24 @@ export interface TableSelectColumn {
|
||||
}
|
||||
|
||||
/**
|
||||
* List the visible "select" columns of a table along with their options.
|
||||
* All column ids of a table: visible columns first (in view order), then any hidden ones (defined
|
||||
* but not shown). The filter input offers every column, so hidden select/checkbox fields are
|
||||
* filterable too even though they aren't displayed.
|
||||
*/
|
||||
function getTableColumnIds(block: DocumentBlockTable): string[] {
|
||||
const visible = block.data.view.columns;
|
||||
return [
|
||||
...visible,
|
||||
...Object.keys(block.data.definition).filter((id) => !visible.includes(id)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List the "select" columns of a table along with their options, including hidden fields.
|
||||
* Used to render the per-column filter dropdowns next to the search input.
|
||||
*/
|
||||
export function getTableSelectColumns(block: DocumentBlockTable): TableSelectColumn[] {
|
||||
return block.data.view.columns.flatMap((column) => {
|
||||
return getTableColumnIds(block).flatMap((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
if (definition?.type !== 'select') {
|
||||
return [];
|
||||
@@ -40,11 +53,11 @@ export interface TableCheckboxColumn {
|
||||
}
|
||||
|
||||
/**
|
||||
* List the visible "checkbox" columns of a table.
|
||||
* List the "checkbox" columns of a table, including hidden fields.
|
||||
* Used to render a filter checkbox per column next to the search input.
|
||||
*/
|
||||
export function getTableCheckboxColumns(block: DocumentBlockTable): TableCheckboxColumn[] {
|
||||
return block.data.view.columns.flatMap((column) => {
|
||||
return getTableColumnIds(block).flatMap((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
if (definition?.type !== 'checkbox') {
|
||||
return [];
|
||||
@@ -63,10 +76,15 @@ export function getTableRecordSearchData(block: DocumentBlockTable, record: Docu
|
||||
const selectValues: Record<string, string[]> = {};
|
||||
const checkboxValues: Record<string, boolean> = {};
|
||||
|
||||
for (const column of block.data.view.columns) {
|
||||
const text = getTableCellSearchText(block, record, column);
|
||||
if (text) {
|
||||
searchText.push(text);
|
||||
// Free-text search only covers visible columns; select/checkbox filters also cover hidden
|
||||
// columns, since those are offered in the filter input.
|
||||
const visibleColumns = new Set(block.data.view.columns);
|
||||
for (const column of getTableColumnIds(block)) {
|
||||
if (visibleColumns.has(column)) {
|
||||
const text = getTableCellSearchText(block, record, column);
|
||||
if (text) {
|
||||
searchText.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
const value = record.values[column];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { MIN_RECORDS_FOR_SEARCH, shouldShowTableSearch } from './shouldShowSearch';
|
||||
|
||||
describe('shouldShowTableSearch', () => {
|
||||
describe('default (no override)', () => {
|
||||
it('shows search on a grid table once it reaches the row threshold', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: MIN_RECORDS_FOR_SEARCH,
|
||||
viewType: 'grid',
|
||||
searchOverride: undefined,
|
||||
isPrint: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides search on a grid table below the row threshold', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: MIN_RECORDS_FOR_SEARCH - 1,
|
||||
viewType: 'grid',
|
||||
searchOverride: undefined,
|
||||
isPrint: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides search on cards regardless of row count', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: 100,
|
||||
viewType: 'cards',
|
||||
searchOverride: undefined,
|
||||
isPrint: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit override', () => {
|
||||
it('forces search on for a small grid table', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: 1,
|
||||
viewType: 'grid',
|
||||
searchOverride: true,
|
||||
isPrint: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('forces search on for cards', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: 1,
|
||||
viewType: 'cards',
|
||||
searchOverride: true,
|
||||
isPrint: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('forces search off for a large grid table', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: 1000,
|
||||
viewType: 'grid',
|
||||
searchOverride: false,
|
||||
isPrint: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('never shows search when printing, even when forced on', () => {
|
||||
expect(
|
||||
shouldShowTableSearch({
|
||||
recordCount: 1000,
|
||||
viewType: 'grid',
|
||||
searchOverride: true,
|
||||
isPrint: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DocumentBlockTable } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Number of records a grid table needs before the search bar is shown by default.
|
||||
*
|
||||
* Duplicated in `@gitbook/doc-core` (`table/search.ts`) so the editor preview matches this default.
|
||||
* There's no shared module for it — GBO consumes the published `@gitbook/api`, not `doc-core` — so
|
||||
* the two copies must be kept in sync by hand when changing the default behaviour.
|
||||
*/
|
||||
export const MIN_RECORDS_FOR_SEARCH = 7;
|
||||
|
||||
/**
|
||||
* Decide whether to render the search bar for a table block.
|
||||
*
|
||||
* Authors can force search on or off per block via `block.data.search`. When that override is left
|
||||
* unset, we fall back to a smart default: search appears on grid tables once they have enough rows,
|
||||
* and stays off on cards. Search is never shown when printing, regardless of the override.
|
||||
*/
|
||||
export function shouldShowTableSearch(args: {
|
||||
/** Number of records in the table. */
|
||||
recordCount: number;
|
||||
/** The table view type (`grid` or `cards`). */
|
||||
viewType: DocumentBlockTable['data']['view']['type'];
|
||||
/** Explicit per-block override, from `block.data.search`; `undefined` defers to the default. */
|
||||
searchOverride: boolean | undefined;
|
||||
/** Whether the document is being rendered for print/PDF, where search is never shown. */
|
||||
isPrint: boolean;
|
||||
}): boolean {
|
||||
if (args.isPrint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const showByDefault = args.recordCount >= MIN_RECORDS_FOR_SEARCH && args.viewType === 'grid';
|
||||
|
||||
return args.searchOverride ?? showByDefault;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user