mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 20:03:16 +00:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aac9a2e935 | |||
| bf6a7af72b | |||
| 703e654a37 | |||
| 1b571aeaa9 | |||
| 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 | |||
| d172684b88 | |||
| 937c8b8eb2 | |||
| 36dbdb48cc | |||
| 2dc76a2c3c | |||
| 0edc378c57 | |||
| 2eb313194a | |||
| 882dfcda83 | |||
| 3ff88ba22f | |||
| 51bd768042 | |||
| daadd91eba | |||
| f4469b2df3 | |||
| 91b325ca08 | |||
| 1616028426 | |||
| 6c15d8314a | |||
| 6c967c9bcc | |||
| c44d822fe1 | |||
| af3cda1c53 | |||
| a0ad403a91 | |||
| 0ca49523a2 | |||
| 76550415ac | |||
| 509389c7e5 | |||
| 88b94b5d36 | |||
| 251630c49f | |||
| acf2719d06 | |||
| ddba8031ec | |||
| 72a1596ae8 | |||
| 5b36c59061 | |||
| 77c717c251 | |||
| 60310b4c83 | |||
| 6eabaecbad | |||
| 423bbd06d9 | |||
| 36f19c8018 | |||
| 2ecbdd8f62 | |||
| c727cc8b9d | |||
| 2c729d461e | |||
| d83ea4ce14 | |||
| bac8608e78 | |||
| 7a1236a3a5 | |||
| ed80bd9e7a | |||
| 7036b67307 | |||
| 6bd522b322 | |||
| 74fc3f74d7 | |||
| 3e384c679e | |||
| f1fa1061db | |||
| 4f754a73a3 | |||
| 62d6d28888 | |||
| fd73a9912b | |||
| 595435ac4a | |||
| aa9d41a1b6 | |||
| 30089b404c | |||
| 92b68560e1 | |||
| f158064817 | |||
| 2885a137f8 | |||
| 0ffb74a6f8 | |||
| b116a0a2a4 | |||
| b9bb535d52 | |||
| 257ca09a5e | |||
| f405fffa06 | |||
| 16a54b6bb9 | |||
| 526a928420 | |||
| c201abab5f | |||
| 3697dfefed | |||
| 287203d197 | |||
| 0b38f6c93b | |||
| 8ce64f4862 | |||
| 127ef54b0f | |||
| 67f31aaa7a | |||
| 51fef84d39 | |||
| 0c577a00de | |||
| 8b6dc6dbbd | |||
| 2e7ba40a25 | |||
| b70b8fd2ca | |||
| a9ba996953 | |||
| 47c8ccba6f | |||
| e9842feeef | |||
| f4b4fe15c1 | |||
| 06b9fd542a | |||
| 76077f9f6d | |||
| 7b65f7c0d5 | |||
| 37a9453917 | |||
| d994875bea | |||
| a9b5521f3a | |||
| 91fadb08ed | |||
| b55605f25e | |||
| 6ba5e54d19 | |||
| 56aeb19e49 | |||
| ebab8188f6 | |||
| 8064c95267 | |||
| 2cef2e02c8 | |||
| 8bfced2e0d | |||
| 61ee4e3637 | |||
| 927aa89213 | |||
| f29d1e2f16 | |||
| 3a9efb8aed | |||
| f71ab8426c | |||
| a3d1558f99 | |||
| ca53869e2d | |||
| 33598e665b | |||
| 9045ba5248 | |||
| 7061fc2223 | |||
| 0c9e3952b7 | |||
| 4b78672135 | |||
| 10289e4881 | |||
| 8b6a6df1d6 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add "Content-Signal" to "robots.txt"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support variant selector in hidden section
|
||||
@@ -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 embeddable search links
|
||||
@@ -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
|
||||
---
|
||||
|
||||
Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Pass visitor session to Ask AI.
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "🚀 Dev server",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "bun install && bun dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -55,8 +55,10 @@ runs:
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_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
|
||||
@@ -65,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
|
||||
|
||||
@@ -57,8 +57,10 @@ runs:
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_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
|
||||
@@ -71,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
|
||||
|
||||
@@ -15,6 +15,10 @@ jobs:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
env:
|
||||
# The repo is managed by Bun, but publishing uses npm for provenance.
|
||||
# Keep Corepack from rejecting packageManager: bun@... when npm runs.
|
||||
COREPACK_ENABLE_PROJECT_SPEC: 0
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -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 |
@@ -6,8 +6,8 @@
|
||||
"name": "gitbook",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.6",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.10.3",
|
||||
"vercel": "50.37.3",
|
||||
},
|
||||
},
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"packages/embed": {
|
||||
"name": "@gitbook/embed",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
@@ -74,14 +74,14 @@
|
||||
},
|
||||
"packages/expr": {
|
||||
"name": "@gitbook/expr",
|
||||
"version": "1.2.5",
|
||||
"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.25.1",
|
||||
"version": "0.27.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
@@ -127,8 +127,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "3.10.1",
|
||||
"@opennextjs/cloudflare": "1.19.1",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -164,15 +164,18 @@
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "^16.2.3",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"p-retry": "^8.0.0",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -186,6 +189,7 @@
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"tldts": "^7.0.30",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
@@ -197,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",
|
||||
@@ -228,7 +232,7 @@
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@gitbook/icons",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.0",
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||
},
|
||||
@@ -249,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",
|
||||
@@ -266,7 +270,7 @@
|
||||
},
|
||||
"packages/react-contentkit": {
|
||||
"name": "@gitbook/react-contentkit",
|
||||
"version": "0.7.15",
|
||||
"version": "0.7.16",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -308,7 +312,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.5.12",
|
||||
"version": "1.5.15",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -343,7 +347,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"next@16.2.3": "patches/next@16.2.3.patch",
|
||||
"next@16.2.6": "patches/next@16.2.6.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
@@ -356,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.179.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -388,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=="],
|
||||
|
||||
@@ -552,21 +556,21 @@
|
||||
|
||||
"@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="],
|
||||
|
||||
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="],
|
||||
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="],
|
||||
|
||||
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="],
|
||||
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="],
|
||||
|
||||
"@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
|
||||
|
||||
"@changesets/cli": ["@changesets/cli@2.30.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.0", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.3", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.15", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA=="],
|
||||
"@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="],
|
||||
|
||||
"@changesets/config": ["@changesets/config@3.1.3", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw=="],
|
||||
"@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="],
|
||||
|
||||
"@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
|
||||
|
||||
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="],
|
||||
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="],
|
||||
|
||||
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.15", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.3", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g=="],
|
||||
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="],
|
||||
|
||||
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
|
||||
|
||||
@@ -752,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.179.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-DrD/Pdfkdv6WWQb1nE0o91O4+gzIhRh+v8l9fhL5Wq57yt0Tk0kTCUycVpI1+H9qUt39bopN4O+KTns4mR2eLQ=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.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"],
|
||||
|
||||
@@ -808,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=="],
|
||||
@@ -846,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=="],
|
||||
@@ -916,23 +924,23 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
|
||||
|
||||
@@ -968,9 +976,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.10.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.15 || >=16.2.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-Pn8hLuP3M9EWlsZnS/O7Bm41Nt+lIOEsCaW/QJ3KlmIbtAeixnSj1CS80Z9PlQe/LQCe0GnQ8vGJeOdg5a4iWg=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.0.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.10.1", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.15 || >=16.2.3", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-qW6ivDkwDzomX4uBTwIHiSFMvsXjZ2GuVf/GITxGByr8sA3eyx3IMfFjWyaqBHVqcqtJaCiFo8IPxpLHv0jWbg=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.1", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -1022,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=="],
|
||||
|
||||
@@ -1586,17 +1594,17 @@
|
||||
|
||||
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-guuiO2kKc7yUQFU2jhWyM/BrYGD/brqb0+JvJIXTQ/QI1NlWfHZ1id50kkkOWqxmBiDc8DgW2orfY85pQIc7gw=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UglEDl/r1/h5Vw6oS6cEE29Jugz2sDLxHCSupNznKatj1fDMRXFqYKFcbvm8RTFhtYPI45NxkrPNo9BqNychBg=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.10.3", "", { "os": "linux", "cpu": "x64" }, "sha512-KuQxaPWD7OBmwEZqO0sgijwcuXR5eMWbo7BEt2m1D2Q3RylJDj7WMcJ0Btv2VJA0QC+khzAaTAm1yWowJ0CWAw=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
|
||||
"@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.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.10.3", "", { "os": "win32", "cpu": "x64" }, "sha512-8NvFAze9D4PsosZAnK3aGqHz2vLzREz9lNIxLgfE0jRchq2sZQE190cwFm7WX17yg3ftPvwCvWH3rhLbG1UHCQ=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -1970,6 +1978,8 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
@@ -2018,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=="],
|
||||
|
||||
@@ -2264,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=="],
|
||||
|
||||
@@ -2532,6 +2542,8 @@
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
|
||||
|
||||
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
@@ -2854,7 +2866,7 @@
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
@@ -2900,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=="],
|
||||
|
||||
@@ -2922,6 +2934,8 @@
|
||||
|
||||
"p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="],
|
||||
|
||||
"p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
||||
@@ -2994,7 +3008,7 @@
|
||||
|
||||
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
@@ -3308,7 +3322,11 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"tmp": ["tmp@0.2.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=="],
|
||||
|
||||
@@ -3342,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.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
|
||||
"turbo": ["turbo@2.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=="],
|
||||
|
||||
@@ -3524,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=="],
|
||||
@@ -3554,10 +3574,6 @@
|
||||
|
||||
"@babel/highlight/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
|
||||
|
||||
"@changesets/assemble-release-plan/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@changesets/get-dependents-graph/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@changesets/logger/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
|
||||
|
||||
"@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
|
||||
@@ -3620,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=="],
|
||||
@@ -3672,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=="],
|
||||
|
||||
@@ -4022,6 +4042,8 @@
|
||||
|
||||
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
|
||||
|
||||
"@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@zenuml/core/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
|
||||
|
||||
"@zenuml/core/tailwind-merge": ["tailwind-merge@3.4.1", "", {}, "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q=="],
|
||||
@@ -4276,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=="],
|
||||
@@ -4320,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=="],
|
||||
@@ -4358,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=="],
|
||||
|
||||
@@ -4594,6 +4672,8 @@
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -4602,6 +4682,8 @@
|
||||
|
||||
"@zenuml/core/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
|
||||
@@ -4774,6 +4856,8 @@
|
||||
|
||||
"@zenuml/core/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"@zenuml/core/tailwindcss/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"micro/raw-body/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
|
||||
|
||||
"micro/raw-body/http-errors/setprototypeof": ["setprototypeof@1.1.1", "", {}, "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="],
|
||||
|
||||
+4
-4
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.6",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.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.179.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -62,6 +62,6 @@
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.2.3": "patches/next@16.2.3.patch"
|
||||
"next@16.2.6": "patches/next@16.2.6.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 37a9453: Allow copying in embed
|
||||
- Updated dependencies [47c8ccb]
|
||||
- @gitbook/icons@0.5.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,6 +49,16 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
if (!iframe.contentWindow) {
|
||||
throw new Error('Iframe must have a content window');
|
||||
}
|
||||
|
||||
const allowTokens = iframe.allow
|
||||
.split(';')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!allowTokens.includes('clipboard-write')) {
|
||||
iframe.allow = [...allowTokens, 'clipboard-write'].join('; ');
|
||||
}
|
||||
|
||||
const channel = createChannel(iframe.contentWindow);
|
||||
|
||||
channel.receive((message: FrameToParentMessage) => {
|
||||
|
||||
@@ -76,6 +76,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
src={frameURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
allow="clipboard-write"
|
||||
className={className}
|
||||
style={colorScheme ? { colorScheme } : undefined}
|
||||
/>
|
||||
|
||||
@@ -101,6 +101,7 @@ function getIframe() {
|
||||
widgetIframe?.remove();
|
||||
widgetIframe = document.createElement('iframe');
|
||||
widgetIframe.id = 'gitbook-widget-iframe';
|
||||
widgetIframe.allow = 'clipboard-write';
|
||||
if (frameOptions?.colorScheme) {
|
||||
widgetIframe.style.colorScheme = frameOptions.colorScheme;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**", "standalone/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# @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
|
||||
|
||||
- 6eabaec: Implement a getVariables function for ExpressionRuntime
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.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:",
|
||||
|
||||
@@ -195,6 +195,49 @@ describe('ExpressionRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('getVariables', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'single variable',
|
||||
condition: 'isBetaUser === true',
|
||||
expectedVariables: ['isBetaUser'],
|
||||
},
|
||||
{
|
||||
scenario: 'multiple variables',
|
||||
condition: 'useProductA && !isBetaUser',
|
||||
expectedVariables: ['useProductA', 'isBetaUser'],
|
||||
},
|
||||
{
|
||||
scenario: 'member expression',
|
||||
condition: 'user.role === "admin"',
|
||||
expectedVariables: ['user.role'],
|
||||
},
|
||||
{
|
||||
scenario: 'nested member expression with method call',
|
||||
condition: 'products.includes("productA") && userSegments.alpha',
|
||||
expectedVariables: ['products.includes', 'userSegments.alpha'],
|
||||
},
|
||||
])(
|
||||
'should return variables used in expression: $scenario',
|
||||
({ condition, expectedVariables }) => {
|
||||
expect(runtime.getVariables(condition)).toEqual(expectedVariables);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'invalid syntax',
|
||||
condition: 't}=d',
|
||||
},
|
||||
{
|
||||
scenario: 'non conditional expression',
|
||||
condition: 'const a = 1;',
|
||||
},
|
||||
])('should return an empty array for invalid expressions: $scenario', ({ condition }) => {
|
||||
expect(runtime.getVariables(condition)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('generate', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { parse as parseLoose } from 'acorn-loose';
|
||||
import escodegen from 'escodegen';
|
||||
import evalESTreeExpr from 'eval-estree-expression';
|
||||
const { evaluate } = evalESTreeExpr;
|
||||
const { evaluate, variables } = evalESTreeExpr;
|
||||
|
||||
import { AutoComplete } from './autocomplete';
|
||||
import { ExpressionError } from './errors';
|
||||
@@ -162,6 +162,28 @@ export class ExpressionRuntime {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an expression, returns a list of variables used in the expression.
|
||||
*/
|
||||
public getVariables(expr: string): string[] {
|
||||
try {
|
||||
const parsed = this.parse(expr);
|
||||
|
||||
if (parsed.invalidNodes.length > 0) {
|
||||
throw new ExpressionError('Invalid nodes found when parsing');
|
||||
}
|
||||
|
||||
return variables(parsed.result, {
|
||||
functions: true,
|
||||
withMembers: true,
|
||||
generate: escodegen.generate,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.error(`Error while parsing expression ${expr} to get variables`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||
*/
|
||||
|
||||
+10
@@ -41,6 +41,16 @@ declare module 'eval-estree-expression' {
|
||||
options?: EvalESTreeExpressionOptions
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Given an ESTree-compliant AST node, returns a list of variables used in the expression.
|
||||
* @param ast An object representing an ESTree-compliant AST node.
|
||||
* @param options Options for evaluation and compilation.
|
||||
*/
|
||||
export function variables<ASTNode>(
|
||||
ast: ASTNode,
|
||||
options?: EvalESTreeExpressionOptions
|
||||
): string[];
|
||||
|
||||
/**
|
||||
* Evaluates an ESTree expression synchronously against a given context.
|
||||
* @param expression - An object representing an ESTree-compliant AST node.
|
||||
|
||||
@@ -1,5 +1,117 @@
|
||||
# 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
|
||||
|
||||
- acf2719: Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
|
||||
- 72a1596: Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
|
||||
- 77c717c: Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
|
||||
- c44d822: Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured.
|
||||
- 7655041: Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts).
|
||||
- af3cda1: Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
|
||||
- 0ca4952: Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive.
|
||||
- 3ff88ba: Add a Prompt block
|
||||
- 251630c: Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
- ddba803: Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
|
||||
- f4469b2: Support OpenAPI one-operation-per-page mode
|
||||
- 6c967c9: Add a client-side search field to table blocks, with per-column filters for select and checkbox columns
|
||||
- 882dfcd: Tidy prompt and add e2e test
|
||||
- Updated dependencies [f4469b2]
|
||||
- Updated dependencies [88b94b5]
|
||||
- @gitbook/react-openapi@1.5.14
|
||||
|
||||
## 0.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- aa9d41a: Update llms.txt footer for GitBook sites
|
||||
- 4f754a7: Add GitBook information to footer of markdown pages
|
||||
- 2ecbdd8: Add a `navigateToPage` assistant tool that lets the AI open a documentation page on the reader's behalf. The page opens instantly (no confirmation) and is pushed to the browser history so the reader can navigate back.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7036b67: Fix custom assistants (e.g. Kapa, Inkeep) being opened twice when clicking "Ask with …" in the search bar.
|
||||
- 2885a13: Fix heading anchor alignment with heading text and translate its accessibility label.
|
||||
- b9bb535: Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view.
|
||||
- b116a0a: Fix OpenAPI CodeBlock scroll
|
||||
- d83ea4c: Improve a11y of list item collapse/extend toggle
|
||||
- 92b6856: Add Ask AI in CodeBlock
|
||||
- 7a1236a: Fix search results linking to the page instead of the section anchor when a section snippet is displayed.
|
||||
- b9bb535: Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger.
|
||||
- Updated dependencies [6eabaec]
|
||||
- Updated dependencies [f158064]
|
||||
- @gitbook/expr@1.3.0
|
||||
- @gitbook/react-openapi@1.5.13
|
||||
|
||||
## 0.26.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 67f31aa: Allow user-triggered AI assistants (ChatGPT-User, Claude-User, Perplexity-User) to read pages on non-indexable sites, so end-users can pull content into an LLM without an MCP connection. Search engines and training crawlers remain blocked.
|
||||
- 0c577a0: Allow CORS for the same root domain
|
||||
- 51fef84: Remove the date-gated header logo height logic and always apply the compact logo size to prevent size shifts between sections and translations.
|
||||
- f405fff: Add icon support in tab items
|
||||
- 3697dfe: Add support for hiding page actions from page layout options.
|
||||
- 127ef54: Add tag filtering controls for Updates blocks.
|
||||
|
||||
## 0.26.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b70b8fd: Fix llms-full.txt pagination to include pages from all site sections.
|
||||
- 6ba5e54: Support isComposing for search
|
||||
- 47c8ccb: Render icons as svg markup
|
||||
- a9ba996: Fix left/right aligment of images with caption
|
||||
- 56aeb19: Fix events tracked from the middleware missing some properties.
|
||||
- Updated dependencies [47c8ccb]
|
||||
- Updated dependencies [37a9453]
|
||||
- @gitbook/icons@0.5.0
|
||||
- @gitbook/embed@0.5.1
|
||||
- @gitbook/react-contentkit@0.7.16
|
||||
|
||||
## 0.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d3d5150: Add "Content-Signal" to "robots.txt"
|
||||
- 7595706: Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
- a17ab65: Support variant selector in hidden section
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d1a2958: Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
- e3aca9e: Fix embeddable search links
|
||||
- 4b78672: Fix links to other spaces/sections in the llms.txt.
|
||||
- d1a2958: Pass visitor session to Ask AI.
|
||||
|
||||
## 0.25.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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,412 @@ 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.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);
|
||||
|
||||
@@ -31,12 +31,25 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForAIChatResponse,
|
||||
waitForAdminToolbar,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
|
||||
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
|
||||
// single fixed search, a concise answer, and a fixed number of follow-ups. The
|
||||
// model is never perfectly deterministic, so `overrideAIResponse` still
|
||||
// normalizes the rendered content below — this prompt just narrows the variance.
|
||||
const AI_PROMPT = [
|
||||
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
|
||||
'Follow these instructions exactly and do not deviate:',
|
||||
'1. Do not write any preamble, commentary, or reasoning before acting.',
|
||||
'2. Perform a single search for exactly "Lorem ipsum".',
|
||||
'3. Reply with only the first sentence of the first page you find, and nothing else.',
|
||||
'4. Always end by proposing exactly 3 follow-up suggestions.',
|
||||
].join('\n');
|
||||
|
||||
const overrideAIInitialState = () => {
|
||||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||
@@ -44,21 +57,45 @@ const overrideAIInitialState = () => {
|
||||
greeting.textContent = 'Good morning';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize the non-deterministic content of an AI response before screenshotting,
|
||||
* while preserving the surrounding structure (message bubbles, tool/activity
|
||||
* summary, response container, suggestion buttons) so visual regressions in the
|
||||
* chat chrome are still caught. The actual answer formatting is covered separately
|
||||
* by the deterministic page tests, since the AI response renders through the same
|
||||
* `DocumentView`.
|
||||
*
|
||||
* Must run only once the chat is no longer `aria-busy` (the response has fully
|
||||
* settled), otherwise React re-renders from late stream events will clobber these
|
||||
* mutations. See `waitForAIChatResponse`.
|
||||
*/
|
||||
const overrideAIResponse = () => {
|
||||
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
|
||||
if (userMessage) {
|
||||
// The user's prompt varies in length; pin it to a fixed string.
|
||||
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
|
||||
userMessage.textContent = '[Replaced message] Chat message sent by the user';
|
||||
}
|
||||
const assistantMessage = document.querySelectorAll(
|
||||
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
|
||||
);
|
||||
assistantMessage.forEach((message) => {
|
||||
message.innerHTML = '[Replaced message] AI chat response';
|
||||
});
|
||||
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
|
||||
suggestions.forEach((suggestion) => {
|
||||
suggestion.textContent = 'Follow-up suggestion';
|
||||
|
||||
// The assistant's answer text is non-deterministic; replace the rendered
|
||||
// document body while keeping the `.ai-response-document` container.
|
||||
document
|
||||
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
|
||||
.forEach((message) => {
|
||||
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
|
||||
});
|
||||
|
||||
// The "Explored with N tools" activity label varies with the number of tool
|
||||
// calls; pin it (the chevron sibling is left intact).
|
||||
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
|
||||
summary.textContent = 'Explored';
|
||||
});
|
||||
|
||||
// Follow-up suggestion text varies; pin each label.
|
||||
document
|
||||
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
|
||||
.forEach((suggestion) => {
|
||||
suggestion.textContent = 'Follow-up suggestion';
|
||||
});
|
||||
};
|
||||
|
||||
const searchTestCases: Test[] = [
|
||||
@@ -149,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();
|
||||
@@ -167,12 +195,13 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||||
await waitForAIChatResponse(page);
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
|
||||
@@ -186,9 +215,9 @@ const searchTestCases: Test[] = [
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Button',
|
||||
@@ -202,9 +231,9 @@ const searchTestCases: Test[] = [
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||||
@@ -219,9 +248,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-input')).toBeEmpty();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Results)',
|
||||
@@ -237,12 +266,13 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||||
await waitForAIChatResponse(page);
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -583,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'));
|
||||
},
|
||||
@@ -627,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',
|
||||
@@ -1056,8 +1089,8 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
await page.waitForSelector('[data-annotation]');
|
||||
await page.click('[data-annotation]');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1075,6 +1108,11 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/mermaid',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Prompt',
|
||||
url: 'blocks/prompt',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1321,8 +1359,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'Without page actions',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
@@ -2221,9 +2258,13 @@ const testCases: TestsCase[] = [
|
||||
|
||||
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
|
||||
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
|
||||
|
||||
await iframe.owner().evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'API - navigateToPage',
|
||||
@@ -2257,8 +2298,15 @@ const testCases: TestsCase[] = [
|
||||
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
|
||||
AI_PROMPT
|
||||
);
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// Wait for the full response to settle before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Suggested questions',
|
||||
@@ -2284,8 +2332,13 @@ const testCases: TestsCase[] = [
|
||||
await expect(
|
||||
iframe.getByTestId('ai-chat-suggested-question').nth(2)
|
||||
).toHaveText('What can you do?');
|
||||
await iframe.owner().evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Custom action buttons',
|
||||
@@ -2367,8 +2420,15 @@ const testCases: TestsCase[] = [
|
||||
await actions.nth(3).click();
|
||||
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
|
||||
await page.locator('#gitbook-widget-button').click();
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// Wait for the response posted above to settle before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Custom tools',
|
||||
@@ -2412,8 +2472,16 @@ const testCases: TestsCase[] = [
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// The turn settles (aria-busy clears) once the stream pauses on the
|
||||
// confirmation control; wait for that before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+102
-15
@@ -13,6 +13,7 @@ import {
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLinksStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationPageActionType,
|
||||
CustomizationSearchStyle,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
@@ -21,7 +22,14 @@ import {
|
||||
type SiteCustomizationSettings,
|
||||
SiteExternalLinksTarget,
|
||||
} from '@gitbook/api';
|
||||
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
|
||||
import {
|
||||
type BrowserContext,
|
||||
type FrameLocator,
|
||||
type Page,
|
||||
type Response,
|
||||
expect,
|
||||
test,
|
||||
} from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
@@ -39,6 +47,16 @@ export interface Test {
|
||||
* Test to run
|
||||
*/
|
||||
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||
/**
|
||||
* Re-applied right before every viewport screenshot (after Argos
|
||||
* stabilization), so it survives re-renders triggered by viewport resizing.
|
||||
*
|
||||
* Use this — rather than mutating the DOM once in `run` — to normalize
|
||||
* non-deterministic content (e.g. AI responses). A one-time mutation in `run`
|
||||
* is clobbered when React re-renders on resize (e.g. crossing the mobile
|
||||
* breakpoint), so only the first viewport ends up normalized.
|
||||
*/
|
||||
normalizeBeforeScreenshot?: (page: Page) => Promise<void> | void;
|
||||
/**
|
||||
* Mode for the test.
|
||||
*/
|
||||
@@ -154,11 +172,46 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an AI chat response to be fully settled before asserting or
|
||||
* screenshotting it.
|
||||
*
|
||||
* The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`),
|
||||
* which stays true from the moment a message is sent until the stream — including
|
||||
* the follow-up suggestion phase — completes. Gating on it avoids the two main
|
||||
* sources of flakiness: capturing a "thinking" placeholder or a half-streamed
|
||||
* answer, and running the content normalization while React is still re-rendering
|
||||
* (which would clobber the replacements).
|
||||
*
|
||||
* Argos also waits for `aria-busy` to clear during its own stabilization
|
||||
* (`waitForAriaBusy`), so this is both an explicit gate and a backstop.
|
||||
*
|
||||
* Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe).
|
||||
*/
|
||||
export async function waitForAIChatResponse(scope: Page | FrameLocator) {
|
||||
await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTimeToMorning(page: Page) {
|
||||
const now = new Date();
|
||||
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
|
||||
@@ -208,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) => {
|
||||
@@ -223,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);
|
||||
}
|
||||
@@ -243,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,
|
||||
@@ -253,6 +329,9 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
await waitForIcons(page);
|
||||
// Re-apply per viewport, last — after any resize-driven
|
||||
// re-render — so normalized content survives to capture.
|
||||
await testEntry.normalizeBeforeScreenshot?.(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -347,9 +426,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
default: CustomizationDefaultThemeMode.System,
|
||||
toggeable: true,
|
||||
},
|
||||
pdf: {
|
||||
enabled: true,
|
||||
},
|
||||
feedback: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -362,16 +438,17 @@ 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: {
|
||||
enabled: true,
|
||||
@@ -396,11 +473,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
export async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urlStates: Record<
|
||||
type IconURLStates = Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
>;
|
||||
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
|
||||
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
|
||||
iconStatesWindow.__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
@@ -445,7 +524,15 @@ export async function waitForIcons(page: Page) {
|
||||
|
||||
const maskImage = icon.querySelector('[data-testid="mask-image"]');
|
||||
if (!maskImage) {
|
||||
throw new Error('No mask-image element');
|
||||
const inlineContent = icon.querySelector(
|
||||
'path, circle, ellipse, line, polygon, polyline, rect, g, use'
|
||||
);
|
||||
if (inlineContent) {
|
||||
icon.setAttribute('data-argos-state', 'loaded');
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('Icon has no inline SVG content or mask-image element');
|
||||
}
|
||||
|
||||
const url = maskImage.getAttribute('href');
|
||||
|
||||
@@ -48,7 +48,9 @@ const nextConfig = {
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_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,
|
||||
@@ -60,6 +62,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,
|
||||
|
||||
@@ -5,7 +5,8 @@ export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
//TODO: bump it again once I figured out the race condition
|
||||
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
|
||||
// and cause race conditions. This will be fixed in a future release of OpenNext
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
bypassTagCacheOnCacheHit: false,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
//TODO: remove, reducing cache ttl of regional cache to help debugging
|
||||
defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.25.1",
|
||||
"version": "0.27.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -19,16 +19,16 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "3.10.1",
|
||||
"@opennextjs/cloudflare": "1.19.1",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
@@ -56,15 +56,18 @@
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "^16.2.3",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"p-retry": "^8.0.0",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -78,6 +81,7 @@
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"tldts": "^7.0.30",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
@@ -89,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",
|
||||
|
||||
@@ -18,6 +18,16 @@ export default defineConfig({
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
launchOptions: {
|
||||
args: [
|
||||
// Disable subpixel (LCD) text so glyphs always render with
|
||||
// grayscale antialiasing — removes the red/blue edge fringing
|
||||
// that varies between macOS (local) and Linux (CI) runs.
|
||||
'--disable-lcd-text',
|
||||
// Disable font hinting so glyph rasterization is platform-independent.
|
||||
'--font-render-hinting=none',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+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/`);
|
||||
|
||||
+296
-44
@@ -1,8 +1,14 @@
|
||||
import { 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,7 +38,9 @@ export async function handleMcpRequest(
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
if (!context.customization.pageActions.mcp) {
|
||||
const { pageActions } = context.customization;
|
||||
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
|
||||
if (!isMcpEnabled) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
@@ -29,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) => {
|
||||
@@ -53,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({
|
||||
@@ -63,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) => {
|
||||
@@ -111,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',
|
||||
@@ -153,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);
|
||||
@@ -184,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 {
|
||||
|
||||
+4
-6
@@ -158,7 +158,7 @@ function transformSitePageResult(args: {
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
let sectionHref = linker.toPathInSpace(pageItem.path);
|
||||
let sectionHref = linker.toPathInSpace(section.path);
|
||||
|
||||
if (spaceURL) {
|
||||
if (asEmbeddable) {
|
||||
@@ -186,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/`);
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
/**
|
||||
* Serve an AI answer as markdown for a page, steered by the end goal ("goal")
|
||||
* the calling agent provided via the `?goal=` search parameter.
|
||||
*
|
||||
* The goal is encoded as a path segment (rather than read from the query) because
|
||||
* this route is statically rendered, mirroring how the question itself is handled.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { question: string; goal: string }> }
|
||||
) {
|
||||
const { question: encodedQuestion, goal: encodedGoal } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
const goal = decodeURIComponent(encodedGoal);
|
||||
|
||||
return serveAskMarkdown(context, question, { goal });
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_SECRET,
|
||||
@@ -32,6 +33,7 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
|
||||
// Secret envs
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { normalizePathname, resolveNavigationTarget, toInSiteHref } from './navigation';
|
||||
|
||||
const location = {
|
||||
href: 'https://docs.example.com/guides/intro',
|
||||
origin: 'https://docs.example.com',
|
||||
};
|
||||
|
||||
describe('resolveNavigationTarget', () => {
|
||||
it('resolves an absolute same-origin URL to a relative href and pathname', () => {
|
||||
expect(
|
||||
resolveNavigationTarget('https://docs.example.com/reference/models', location)
|
||||
).toEqual({ href: '/reference/models', pathname: '/reference/models' });
|
||||
});
|
||||
|
||||
it('keeps the query string and section anchor in href but not in pathname', () => {
|
||||
expect(
|
||||
resolveNavigationTarget(
|
||||
'https://docs.example.com/reference/models?tab=api#usage',
|
||||
location
|
||||
)
|
||||
).toEqual({ href: '/reference/models?tab=api#usage', pathname: '/reference/models' });
|
||||
});
|
||||
|
||||
it('resolves a relative path against the current location', () => {
|
||||
expect(resolveNavigationTarget('/reference/models', location)).toEqual({
|
||||
href: '/reference/models',
|
||||
pathname: '/reference/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a URL pointing to an external site', () => {
|
||||
const result = resolveNavigationTarget('https://evil.example.org/phishing', location);
|
||||
expect('error' in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toInSiteHref', () => {
|
||||
// A docs site served under `/docs/` on a host shared with other content/sites.
|
||||
const linker = {
|
||||
siteBasePath: '/docs/',
|
||||
toLinkForContent: (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
// Mirrors the real linker: in-site iff same host AND under the site base path.
|
||||
if (parsed.hostname === 'gitbook.com' && parsed.pathname.startsWith('/docs/')) {
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts an in-site absolute URL and returns a relative path', () => {
|
||||
expect(toInSiteHref('https://gitbook.com/docs/guides/intro', linker)).toBe(
|
||||
'/docs/guides/intro'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an in-site relative path under the site base path', () => {
|
||||
expect(toInSiteHref('/docs/guides/intro?x=1#y', linker)).toBe('/docs/guides/intro?x=1#y');
|
||||
});
|
||||
|
||||
it('rejects another page on the same host but outside the site base path', () => {
|
||||
// The reviewer's case: same host, different site/section.
|
||||
expect(toInSiteHref('https://gitbook.com/pricing', linker)).toBeNull();
|
||||
expect(toInSiteHref('/pricing', linker)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an external host', () => {
|
||||
expect(toInSiteHref('https://evil.example.org/docs/guides', linker)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePathname', () => {
|
||||
it('strips a trailing slash', () => {
|
||||
expect(normalizePathname('/guides/intro/')).toBe('/guides/intro');
|
||||
});
|
||||
|
||||
it('keeps the root slash', () => {
|
||||
expect(normalizePathname('/')).toBe('/');
|
||||
});
|
||||
|
||||
it('decodes percent-encoding so encoded and decoded paths compare equal', () => {
|
||||
expect(normalizePathname('/h%C3%A9llo')).toBe(normalizePathname('/héllo'));
|
||||
});
|
||||
|
||||
it('treats encoded and decoded paths with a trailing slash as equal', () => {
|
||||
expect(normalizePathname('/caf%C3%A9/')).toBe(normalizePathname('/café'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { withTrailingSlash } from '@/lib/paths';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
|
||||
/**
|
||||
* Resolve a link into a same-site target to navigate to.
|
||||
*
|
||||
* Returns an `error` when the URL is malformed or points outside of the documentation site,
|
||||
* so the assistant can be told it could not navigate.
|
||||
*/
|
||||
export function resolveNavigationTarget(
|
||||
url: string,
|
||||
location: { href: string; origin: string }
|
||||
): { href: string; pathname: string } | { error: string } {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url, location.href);
|
||||
} catch {
|
||||
return { error: `Invalid URL: ${url}` };
|
||||
}
|
||||
|
||||
// Only allow navigating within the current documentation site to avoid sending the user to
|
||||
// an external website without their consent.
|
||||
if (isExternalLink(target.href, location.origin)) {
|
||||
return { error: 'Cannot navigate to a page outside of this documentation site.' };
|
||||
}
|
||||
|
||||
return { href: `${target.pathname}${target.search}${target.hash}`, pathname: target.pathname };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a site-relative href if `url` points within the current site, otherwise `null`.
|
||||
*
|
||||
* Unlike a bare same-origin check, this enforces the site base path, so an assistant cannot
|
||||
* navigate the reader to another page on the same host (e.g. a marketing page, or a different
|
||||
* docs site sharing the host such as `gitbook.com/other` or another `/url/...` proxied site).
|
||||
*/
|
||||
export function toInSiteHref(
|
||||
url: string,
|
||||
linker: { toLinkForContent: (url: string) => string; siteBasePath: string }
|
||||
): string | null {
|
||||
if (URL.canParse(url)) {
|
||||
// toLinkForContent returns a site-relative path for in-site URLs (matching host AND site
|
||||
// base path), or the raw absolute URL otherwise.
|
||||
const link = linker.toLinkForContent(url);
|
||||
return URL.canParse(link) ? null : link;
|
||||
}
|
||||
|
||||
// Relative path: it must live under the site base path.
|
||||
let pathname: string;
|
||||
let rest = '';
|
||||
try {
|
||||
const parsed = new URL(url, 'https://navigation.invalid');
|
||||
pathname = parsed.pathname;
|
||||
rest = `${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return withTrailingSlash(pathname).startsWith(linker.siteBasePath)
|
||||
? `${pathname}${rest}`
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pathname so two equivalent paths compare equal regardless of percent-encoding or a
|
||||
* trailing slash (e.g. `/h%C3%A9llo/` and `/héllo`). Used to detect when an SPA navigation has
|
||||
* committed by comparing against `window.location.pathname`.
|
||||
*/
|
||||
export function normalizePathname(pathname: string): string {
|
||||
let decoded = pathname;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
// Keep the raw value if it isn't valid percent-encoding.
|
||||
}
|
||||
return decoded.length > 1 && decoded.endsWith('/') ? decoded.slice(0, -1) : decoded;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
|
||||
describe('serializeReferences', () => {
|
||||
it('returns an empty string when there are no references', () => {
|
||||
expect(serializeReferences([])).toBe('');
|
||||
});
|
||||
|
||||
it('serializes a single page reference as a markdown link', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('The user is referring to the following page they are reading');
|
||||
expect(result).toContain('- [Getting started](/getting-started)');
|
||||
expect(result.endsWith('\n\n---\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the path when no href is provided', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('- [Overview](getting-started)');
|
||||
});
|
||||
|
||||
it('renders a quoted label when neither href nor path is provided', () => {
|
||||
const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('- "Overview"');
|
||||
expect(result).not.toContain('](');
|
||||
});
|
||||
|
||||
it('uses the plural form for multiple pages', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'One' },
|
||||
{ type: 'page', id: 'page-2', label: 'Two' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('the following pages they are reading');
|
||||
expect(result).toContain('in the context of them');
|
||||
});
|
||||
|
||||
it('serializes a single code block reference', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{
|
||||
type: 'code-block',
|
||||
id: 'code-1',
|
||||
label: 'main.ts',
|
||||
content: 'const a = 1;',
|
||||
syntax: 'ts',
|
||||
},
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('The user is referring to the following code block');
|
||||
expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('escapes backtick fences inside the code content', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('````md\na ``` b\n````');
|
||||
});
|
||||
|
||||
it('groups page and code references into distinct sections', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Quickstart' },
|
||||
{ type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
const pageIndex = result.indexOf('referring to the following page');
|
||||
const codeIndex = result.indexOf('referring to the following code block');
|
||||
expect(pageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(codeIndex).toBeGreaterThan(pageIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
type BaseAIChatReference = {
|
||||
id: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type CodeBlockReference = BaseAIChatReference & {
|
||||
type: 'code-block';
|
||||
content: string;
|
||||
syntax?: string;
|
||||
};
|
||||
|
||||
export type PageReference = BaseAIChatReference & {
|
||||
type: 'page';
|
||||
/** Title of the page being referenced. */
|
||||
label: string;
|
||||
/** Path of the page, used to help the assistant identify it. */
|
||||
path?: string;
|
||||
/** Site-relative href of the page, used to navigate back to it from the chip. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type TextReference = BaseAIChatReference & {
|
||||
type: 'text';
|
||||
/** The selected text content. */
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AIChatReference = CodeBlockReference | PageReference | TextReference;
|
||||
|
||||
/**
|
||||
* Serialize the staged references into a preamble prepended to the user's message,
|
||||
* so the assistant is informed about the context the user is referring to.
|
||||
*/
|
||||
export function serializeReferences(refs: AIChatReference[]): string {
|
||||
if (refs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page');
|
||||
if (pageRefs.length > 0) {
|
||||
sections.push(serializePageReferences(pageRefs));
|
||||
}
|
||||
|
||||
const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block');
|
||||
if (codeRefs.length > 0) {
|
||||
sections.push(serializeCodeBlockReferences(codeRefs));
|
||||
}
|
||||
|
||||
const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
|
||||
if (textRefs.length > 0) {
|
||||
sections.push(serializeTextReferences(textRefs));
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${sections.join('\n\n')}\n\n---\n\n`;
|
||||
}
|
||||
|
||||
function serializePageReferences(refs: PageReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const list = refs
|
||||
.map((ref) => {
|
||||
const url = ref.href ?? ref.path;
|
||||
return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`;
|
||||
})
|
||||
.join('\n');
|
||||
return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`;
|
||||
}
|
||||
|
||||
function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const blocks = refs.map(buildCodeBlockFence).join('\n\n');
|
||||
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function serializeTextReferences(refs: TextReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
|
||||
return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function quoteText(content: string): string {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildCodeBlockFence(ref: CodeBlockReference): string {
|
||||
const { label, content, syntax } = ref;
|
||||
let max = 2;
|
||||
for (const match of content.matchAll(/`+/g)) {
|
||||
max = Math.max(max, match[0].length);
|
||||
}
|
||||
const fence = '`'.repeat(max + 1);
|
||||
const heading = label ? `${label}\n` : '';
|
||||
return `${heading}${fence}${syntax ?? ''}\n${content}\n${fence}`;
|
||||
}
|
||||
@@ -71,13 +71,13 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
}
|
||||
}
|
||||
|
||||
function DescriptionForPageContentToolCall(props: {
|
||||
async function DescriptionForPageContentToolCall(props: {
|
||||
toolCall: AIToolCallGetPageContent;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -101,13 +101,13 @@ function DescriptionForPageContentToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForMCPToolCall(props: {
|
||||
async function DescriptionForMCPToolCall(props: {
|
||||
toolCall: AIToolCallMCP;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -135,7 +135,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
// Resolve all hrefs for search results in parallel
|
||||
const searchResultsWithHrefs = await Promise.all(
|
||||
@@ -287,13 +287,13 @@ async function DescriptionForSearchToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForGetPagesToolCall(props: {
|
||||
async function DescriptionForGetPagesToolCall(props: {
|
||||
toolCall: AIToolCallGetPages;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
|
||||
@@ -1,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,2 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './chat';
|
||||
export * from './navigate';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import { toInSiteHref } from '../navigation';
|
||||
|
||||
/**
|
||||
* Resolve a link provided by the assistant into a path that can be navigated to within the site.
|
||||
*
|
||||
* The assistant references pages using the stable content-ref scheme (e.g.
|
||||
* `/spaces/<spaceId>/pages/<pageId>`). Those URLs are not directly navigable in the published
|
||||
* site, so we resolve them to the real site link using the site context. Any other URL is only
|
||||
* accepted if it points within the current site, so the assistant cannot navigate the reader off
|
||||
* the documentation site.
|
||||
*/
|
||||
export async function resolveAINavigationLink(
|
||||
url: string
|
||||
): Promise<{ href: string } | { error: string }> {
|
||||
return traceErrorOnly('AI.resolveAINavigationLink', async () => {
|
||||
const baseContext = await getServerActionBaseContext();
|
||||
const context = await fetchServerActionSiteContext(baseContext);
|
||||
|
||||
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
|
||||
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
|
||||
let path = url;
|
||||
if (URL.canParse(url)) {
|
||||
const parsed = new URL(url);
|
||||
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
|
||||
const contentRef = resolveStringContentRef(path);
|
||||
if (contentRef) {
|
||||
const resolved = await resolveContentRef(contentRef, context);
|
||||
if (!resolved) {
|
||||
return { error: `Could not resolve page for ${url}` };
|
||||
}
|
||||
return { href: resolved.href };
|
||||
}
|
||||
|
||||
// Not a content reference: only navigate to it if it points within the current site.
|
||||
const inSiteHref = toInSiteHref(url, context.linker);
|
||||
if (!inSiteHref) {
|
||||
return { error: 'Cannot navigate to a page outside of this documentation site.' };
|
||||
}
|
||||
return { href: inSiteHref };
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { type AnyAIControlTool, getControlTools } from './controls';
|
||||
|
||||
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
export function getTools(
|
||||
builtInTools: GitBookIntegrationTool[] = []
|
||||
): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
return [...getControlTools(), ...integrationTools];
|
||||
return [...getControlTools(), ...builtInTools, ...integrationTools];
|
||||
}
|
||||
|
||||
@@ -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 '.';
|
||||
@@ -63,7 +64,7 @@ export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): Rea
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
|
||||
function useAIConfig(): AIConfig {
|
||||
export function useAIConfig(): AIConfig {
|
||||
const ctx = React.useContext(AIContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAI must be used within AIContextProvider');
|
||||
@@ -89,13 +90,13 @@ 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),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
state={chat.responding ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
className="size-text-lg"
|
||||
/>
|
||||
@@ -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,6 +2,7 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
@@ -15,16 +16,20 @@ import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
|
||||
export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
content: React.ReactNode;
|
||||
query?: string;
|
||||
references?: AIChatReference[];
|
||||
activity?: AIChatMessageActivity;
|
||||
};
|
||||
|
||||
@@ -81,7 +86,23 @@ export type AIChatState = {
|
||||
control: AnyAIControl | null;
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
* If true, the assistant is actively producing its answer — from the moment a
|
||||
* message is sent until the `response_finish` event. It is cleared at that point
|
||||
* (even though follow-up suggestions may still trickle in) so the input can be
|
||||
* re-enabled. Drives the local "are we still answering" UI: the disabled input,
|
||||
* the loading shim, and the thinking/exploring/working status.
|
||||
*/
|
||||
responding: boolean;
|
||||
|
||||
/**
|
||||
* If true, the turn is still in progress overall: from the moment a message is
|
||||
* sent until the stream fully completes, including the follow-up suggestion
|
||||
* phase.
|
||||
*
|
||||
* Unlike `responding` — which clears on `response_finish` — this stays true
|
||||
* until the response is truly settled. It is the global busyness indicator,
|
||||
* surfaced as `aria-busy` on the chat so that assistive tech (and visual tests)
|
||||
* can wait for a complete, stable response.
|
||||
*/
|
||||
loading: boolean;
|
||||
|
||||
@@ -91,13 +112,33 @@ export type AIChatState = {
|
||||
* display an error alert. Clearing the conversation will reset this flag.
|
||||
*/
|
||||
error: boolean;
|
||||
|
||||
/**
|
||||
* References staged on the next user message.
|
||||
*/
|
||||
references: AIChatReference[];
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
| { type: 'open' }
|
||||
| { type: 'postMessage'; message: string }
|
||||
| { type: 'clear' }
|
||||
| { type: 'close' };
|
||||
| { type: 'close' }
|
||||
| { type: 'focus' };
|
||||
|
||||
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||
Extract<AIChatEvent, { type: T }>,
|
||||
@@ -115,6 +156,18 @@ export type AIChatController = {
|
||||
postMessage: (input: { message: string }) => void;
|
||||
/** Clear the conversation */
|
||||
clear: () => void;
|
||||
/** Stage a reference on the next message */
|
||||
addReference: (ref: AIChatReference) => string;
|
||||
/** Remove a staged reference */
|
||||
removeReference: (id: string) => void;
|
||||
/** Clear all staged references */
|
||||
clearReferences: () => void;
|
||||
/** Focus the chat input */
|
||||
focus: () => void;
|
||||
/** 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,
|
||||
@@ -133,9 +186,13 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
draft: '',
|
||||
queuedMessages: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -170,8 +227,13 @@ export function AIChatProvider(props: {
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
const setSearchState = useSetSearchState();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
|
||||
// identity, so it can be referenced directly from the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
@@ -206,11 +268,16 @@ export function AIChatProvider(props: {
|
||||
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;
|
||||
}) => {
|
||||
@@ -219,6 +286,7 @@ export function AIChatProvider(props: {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -232,9 +300,19 @@ export function AIChatProvider(props: {
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
@@ -270,7 +348,7 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
@@ -294,8 +372,8 @@ export function AIChatProvider(props: {
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (input.message && globalState.getState().query !== input.message) {
|
||||
// Chat was cleared, stop processing the stream
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -306,9 +384,9 @@ export function AIChatProvider(props: {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not loading when the response is finished
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
loading: false,
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
@@ -426,23 +504,49 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
|
||||
// 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);
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -451,20 +555,36 @@ export function AIChatProvider(props: {
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
navigateToPageTool,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, control } = globalState.getState();
|
||||
const { query, messages, control, references, responding } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -475,8 +595,9 @@ export function AIChatProvider(props: {
|
||||
|
||||
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||
|
||||
if (query === input.message) {
|
||||
if (query === input.message && references.length === 0) {
|
||||
// Return early if the message is the same as the previous message
|
||||
// (unless new references are staged, which change the payload)
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
opened: true,
|
||||
@@ -496,25 +617,43 @@ export function AIChatProvider(props: {
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
references,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
responding: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
streamResponse({ message: input.message });
|
||||
streamResponse({ message: wireMessage, userQuery: input.message });
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse, language]
|
||||
[setSearchState, siteSpaceId, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// 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,
|
||||
@@ -523,6 +662,8 @@ export function AIChatProvider(props: {
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
queuedMessages: [],
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
@@ -534,6 +675,48 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onAddReference = React.useCallback((ref: AIChatReference) => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: [...state.references, ref],
|
||||
};
|
||||
});
|
||||
return ref.id;
|
||||
}, []);
|
||||
|
||||
const onRemoveReference = React.useCallback((id: string) => {
|
||||
globalState.setState((state) => {
|
||||
if (!state.references.some((ref) => ref.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: state.references.filter((ref) => ref.id !== id),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClearReferences = React.useCallback(() => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, references: [] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFocus = React.useCallback(() => {
|
||||
notify(eventsRef.current.get('focus'), {});
|
||||
}, []);
|
||||
|
||||
const onSetDraft = React.useCallback((draft: string) => {
|
||||
globalState.setState({ draft });
|
||||
}, []);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -559,9 +742,27 @@ export function AIChatProvider(props: {
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
addReference: onAddReference,
|
||||
removeReference: onRemoveReference,
|
||||
clearReferences: onClearReferences,
|
||||
focus: onFocus,
|
||||
setDraft: onSetDraft,
|
||||
cancelQueuedMessage: onCancelQueuedMessage,
|
||||
on: onEvent,
|
||||
};
|
||||
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
onClear,
|
||||
onPostMessage,
|
||||
onAddReference,
|
||||
onRemoveReference,
|
||||
onClearReferences,
|
||||
onFocus,
|
||||
onSetDraft,
|
||||
onCancelQueuedMessage,
|
||||
onEvent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
@@ -591,7 +792,7 @@ export function getAIChatStatus(chat: AIChatState): AIChatStatus {
|
||||
return 'confirm';
|
||||
}
|
||||
|
||||
if (chat.loading) {
|
||||
if (chat.responding) {
|
||||
const latestMessage = getLatestAssistantMessage(chat.messages);
|
||||
const phase = latestMessage?.activity?.currentPhase;
|
||||
switch (phase) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import type { AIToolDefinition } from '@gitbook/api';
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { NavigationStatusContext } from '../hooks';
|
||||
import { normalizePathname, resolveNavigationTarget } from './navigation';
|
||||
import { resolveAINavigationLink } from './server-actions';
|
||||
|
||||
const NavigateToPageInputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
|
||||
* after a timeout. App Router updates `window.location` only when the navigation commits, so this
|
||||
* lets the tool hold its turn until the user is actually on the new page — after which the
|
||||
* tool-result server action's router refresh can no longer cancel the navigation.
|
||||
*/
|
||||
function waitForNavigationCommit(pathname: string): Promise<boolean> {
|
||||
const target = normalizePathname(pathname);
|
||||
if (normalizePathname(window.location.pathname) === target) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
const check = () => {
|
||||
if (normalizePathname(window.location.pathname) === target) {
|
||||
resolve(true);
|
||||
} else if (Date.now() - startedAt > 3000) {
|
||||
resolve(false);
|
||||
} else {
|
||||
requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the built-in `navigateToPage` tool exposed to the assistant.
|
||||
*
|
||||
* The tool opens a page within the current documentation site without confirmation. It navigates
|
||||
* instantly with the Next.js router (adding a browser history entry, so the user can navigate
|
||||
* back) and waits for the navigation to commit before reporting back, so the assistant's
|
||||
* follow-up does not cancel the navigation.
|
||||
*/
|
||||
export function useNavigateToPageTool(): GitBookIntegrationTool {
|
||||
const router = useRouter();
|
||||
const language = useLanguage();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
|
||||
// The tool object is memoized once, so read the latest values from a ref at call time.
|
||||
const ref = React.useRef({ router, language, onNavigationClick });
|
||||
React.useEffect(() => {
|
||||
ref.current = { router, language, onNavigationClick };
|
||||
});
|
||||
|
||||
return React.useMemo<GitBookIntegrationTool>(
|
||||
() => ({
|
||||
name: 'navigateToPage',
|
||||
description:
|
||||
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
|
||||
inputSchema: zodToJsonSchema(
|
||||
NavigateToPageInputSchema as any
|
||||
) as AIToolDefinition['inputSchema'],
|
||||
execute: async (input) => {
|
||||
const { router, language, onNavigationClick } = ref.current;
|
||||
const { url } = NavigateToPageInputSchema.parse(input);
|
||||
|
||||
// The assistant references pages using the stable content-ref scheme
|
||||
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
|
||||
const resolved = await resolveAINavigationLink(url);
|
||||
const target =
|
||||
'error' in resolved
|
||||
? resolved
|
||||
: resolveNavigationTarget(resolved.href, window.location);
|
||||
|
||||
if ('error' in target) {
|
||||
return {
|
||||
output: { error: target.error },
|
||||
summary: {
|
||||
icon: 'triangle-exclamation',
|
||||
text: tString(language, 'ai_chat_tools_navigate_failed'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onNavigationClick(target.href);
|
||||
router.push(target.href);
|
||||
const committed = await waitForNavigationCommit(target.pathname);
|
||||
if (!committed) {
|
||||
// biome-ignore lint/suspicious/noConsole: surfaces a navigation that never committed
|
||||
console.warn(`navigateToPage: navigation to ${target.href} did not commit`);
|
||||
}
|
||||
|
||||
return {
|
||||
output: { navigated: true, url: target.href },
|
||||
summary: {
|
||||
icon: 'book-open',
|
||||
text: tString(language, 'ai_chat_tools_navigated_to_page'),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,12 @@ import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { AIChatControl } from './AIChatControl';
|
||||
import { AIChatControlButton } from './AIChatControlButton';
|
||||
import { AIChatExpandButton } from './AIChatExpandButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
import { AIChatQueuedMessage } from './AIChatQueuedMessage';
|
||||
import { AIChatResizeHandle } from './AIChatResizeHandle';
|
||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
|
||||
export function AIChat() {
|
||||
@@ -83,12 +86,14 @@ export function AIChat() {
|
||||
}
|
||||
}}
|
||||
withOverlay={true}
|
||||
data-ai-chat
|
||||
className={tcls(
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:w-(--ai-chat-width)'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<AIChatResizeHandle />
|
||||
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
|
||||
<EmbeddableFrameHeader className="not-embed:px-4">
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
@@ -99,6 +104,7 @@ export function AIChat() {
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
<AIChatExpandButton />
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
@@ -114,6 +120,7 @@ export function AIChat() {
|
||||
chat={chat}
|
||||
suggestions={config.suggestions}
|
||||
trademark={config.trademark}
|
||||
assistantName={config.assistantName}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
@@ -202,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;
|
||||
@@ -225,7 +235,7 @@ export function AIChatBody(props: {
|
||||
return (
|
||||
<>
|
||||
<ScrollContainer
|
||||
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
|
||||
className="min-h-[20%] max-w-full shrink grow animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
|
||||
orientation="vertical"
|
||||
trailing={{ fade: false, button: true }}
|
||||
@@ -275,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
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
responding={chat.responding}
|
||||
disabled={chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import type { Assistant } from '../AI';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from '../primitives';
|
||||
@@ -38,6 +38,7 @@ export function AIChatButton(props: {
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Button } from '../primitives';
|
||||
import { useAIChatWidthStore, useIsAIChatMaxWidth } from './useAIChatWidthStore';
|
||||
|
||||
export function AIChatExpandButton() {
|
||||
const language = useLanguage();
|
||||
const toggleWidth = useAIChatWidthStore((state) => state.toggleWidth);
|
||||
const isMaxWidth = useIsAIChatMaxWidth();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggleWidth}
|
||||
iconOnly
|
||||
icon={
|
||||
<Icon
|
||||
icon={
|
||||
isMaxWidth
|
||||
? 'arrow-down-left-and-arrow-up-right-to-center'
|
||||
: 'arrow-up-right-and-arrow-down-left-from-center'
|
||||
}
|
||||
className="scale-90"
|
||||
/>
|
||||
}
|
||||
label={tString(
|
||||
language,
|
||||
isMaxWidth ? 'ai_chat_collapse_panel' : 'ai_chat_expand_panel'
|
||||
)}
|
||||
variant="blank"
|
||||
className="max-lg:hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,55 @@
|
||||
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 { useAIChatState } from '../AI/useAIChat';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
import { Input } from '../primitives/Input';
|
||||
import { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
|
||||
export function AIChatInput(props: {
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When true, the input is disabled
|
||||
* 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.
|
||||
*/
|
||||
loading: boolean;
|
||||
responding: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
}) {
|
||||
const { onSubmit, disabled, loading } = props;
|
||||
const { onSubmit, disabled, responding } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 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.opened && !disabled && !loading) {
|
||||
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
|
||||
// This fixes inconsistent focus behaviour across browsers
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -32,7 +58,14 @@ export function AIChatInput(props: {
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [disabled, loading, chat.opened]);
|
||||
}, [disabled, responding, chat.opened]);
|
||||
|
||||
// Explicit focus requests (e.g. clicking "Ask" while the chat is already open).
|
||||
useEffect(() => {
|
||||
return chatController.on('focus', () => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
}, [chatController]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
@@ -42,6 +75,9 @@ export function AIChatInput(props: {
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
|
||||
// instead of focusing the Assistant input. RND-11340.
|
||||
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -54,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',
|
||||
@@ -66,16 +104,23 @@ export function AIChatInput(props: {
|
||||
rows={1}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={
|
||||
!disabled && !loading
|
||||
!disabled && !responding
|
||||
? {
|
||||
keys: ['mod', 'i'],
|
||||
className: 'bg-tint-base group-focus-within/input:hidden',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading || chat.control !== null}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || chat.control !== null}
|
||||
aria-busy={responding}
|
||||
ref={inputRef}
|
||||
header={
|
||||
<AIChatReferenceChips
|
||||
references={chat.references}
|
||||
onRemove={chatController.removeReference}
|
||||
disabled={responding || disabled}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<HoverCardRoot openDelay={500}>
|
||||
<HoverCard
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { Fragment } from 'react';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatMessage,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { ToggleChevron } from '../primitives';
|
||||
import { Button } from '../primitives/Button';
|
||||
import { Collapsible, CollapsibleTrigger } from '../primitives/Collapsible';
|
||||
import { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
@@ -20,7 +22,7 @@ export function AIChatMessages(props: {
|
||||
}) {
|
||||
const { chat, chatController } = props;
|
||||
const status = getAIChatStatus(chat);
|
||||
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
|
||||
const showLoadingShim = chat.responding && status !== 'working' && status !== 'done';
|
||||
|
||||
// Group messages: user messages start a new group, all following messages until next user message belong to that group
|
||||
type MessageGroup = { message: AIChatMessage; originalIndex: number };
|
||||
@@ -49,6 +51,29 @@ export function AIChatMessages(props: {
|
||||
|
||||
return messageGroups.map((group, groupIndex) => {
|
||||
const isLastGroup = group === messageGroups[messageGroups.length - 1];
|
||||
|
||||
const userItems = group.filter(({ message }) => message.role === AIMessageRole.User);
|
||||
const assistantItems = group.filter(
|
||||
({ message }) => message.role === AIMessageRole.Assistant
|
||||
);
|
||||
|
||||
// Each client-side tool call splits a turn into a separate assistant message, so without
|
||||
// aggregating here every tool would render its own "Explored with 1 tool" expandable.
|
||||
// We collapse the whole assistant run behind a single expandable instead, reusing the
|
||||
// commentary (hidden) vs final-answer (visible) distinction from AIMessageView.
|
||||
const toolCount = assistantItems.reduce(
|
||||
(acc, { message }) => acc + (message.activity?.toolCount ?? 0),
|
||||
0
|
||||
);
|
||||
const hasCommentary =
|
||||
assistantItems.some(({ message }) => message.activity?.hasCommentary) || toolCount > 0;
|
||||
const hasFinalAnswer =
|
||||
assistantItems.some(({ message }) => message.activity?.hasFinalAnswer) || !isLastGroup;
|
||||
|
||||
const firstAssistantIndex = assistantItems.at(0)?.originalIndex ?? -1;
|
||||
const lastAssistantIndex = assistantItems.at(-1)?.originalIndex ?? -1;
|
||||
const isLastMessage = lastAssistantIndex === chat.messages.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupIndex}
|
||||
@@ -61,52 +86,60 @@ export function AIChatMessages(props: {
|
||||
)}
|
||||
style={{ animationDelay: '.2s' }}
|
||||
>
|
||||
{group.map(({ message, originalIndex }) => {
|
||||
const isLastMessage = originalIndex === chat.messages.length - 1;
|
||||
const toolCount = message.activity?.toolCount ?? 0;
|
||||
const hasCommentary =
|
||||
(message.activity?.hasCommentary ?? false) || toolCount > 0;
|
||||
const hasFinalAnswer =
|
||||
(message.activity?.hasFinalAnswer ?? false) || !isLastMessage;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={!hasFinalAnswer}
|
||||
disabled={!hasFinalAnswer}
|
||||
key={originalIndex}
|
||||
data-testid={
|
||||
message.role === AIMessageRole.User
|
||||
? 'ai-chat-message-user'
|
||||
: 'ai-chat-message-assistant'
|
||||
}
|
||||
{userItems.map(({ message, originalIndex }) => (
|
||||
<Fragment key={originalIndex}>
|
||||
{message.references?.length ? (
|
||||
<div className="flex max-w-[80%] origin-top-right justify-end self-end">
|
||||
<AIChatReferenceChips references={message.references} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="ai-chat-message-user"
|
||||
id={`message-${originalIndex}`}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
message.role === AIMessageRole.User
|
||||
? 'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'origin-top-left text-tint-strong',
|
||||
isLastMessage && message.role === AIMessageRole.Assistant
|
||||
? 'grow'
|
||||
: ''
|
||||
'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{message.role === AIMessageRole.Assistant &&
|
||||
hasCommentary &&
|
||||
hasFinalAnswer ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="small"
|
||||
label={tString(language, 'ai_chat_view_activity')}
|
||||
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{message.content}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{assistantItems.length > 0 ? (
|
||||
<Collapsible
|
||||
open={!hasFinalAnswer}
|
||||
disabled={!hasFinalAnswer}
|
||||
data-testid="ai-chat-message-assistant"
|
||||
id={`message-${firstAssistantIndex}`}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
'origin-top-left text-tint-strong',
|
||||
isLastMessage ? 'grow' : ''
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(firstAssistantIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{hasCommentary && hasFinalAnswer ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="small"
|
||||
label={tString(language, 'ai_chat_view_activity')}
|
||||
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span data-testid="ai-chat-activity-summary">
|
||||
{toolCount > 0
|
||||
? t(
|
||||
language,
|
||||
@@ -120,50 +153,58 @@ export function AIChatMessages(props: {
|
||||
)
|
||||
)
|
||||
: t(language, 'ai_chat_explored')}
|
||||
<ToggleChevron orientation="right-to-down" />
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
</span>
|
||||
<ToggleChevron orientation="right-to-down" />
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
|
||||
{message.content}
|
||||
{assistantItems.map(({ message, originalIndex }) => (
|
||||
<Fragment key={originalIndex}>{message.content}</Fragment>
|
||||
))}
|
||||
|
||||
{isLastMessage && message.role === AIMessageRole.Assistant ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
|
||||
showLoadingShim
|
||||
? 'max-h-48 opacity-11'
|
||||
: 'pointer-events-none max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage className={message.content ? 'hidden' : ''} />
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
{isLastMessage ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
|
||||
showLoadingShim
|
||||
? 'max-h-48 opacity-11'
|
||||
: 'pointer-events-none max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage
|
||||
className={
|
||||
assistantItems.some(({ message }) => message.content)
|
||||
? 'hidden'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.responding &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,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,126 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIChatReference } from '../AI/references';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
const triggerClassName =
|
||||
'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint';
|
||||
|
||||
export function AIChatReferenceChips(props: {
|
||||
references: AIChatReference[];
|
||||
onRemove?: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { references, onRemove, disabled } = props;
|
||||
|
||||
if (references.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5">
|
||||
{references.map((ref) => {
|
||||
const content = (
|
||||
<>
|
||||
<Icon icon={getReferenceIcon(ref)} className="size-3 shrink-0 opacity-7" />
|
||||
<span
|
||||
className={tcls(
|
||||
'min-w-0 truncate',
|
||||
ref.type === 'code-block' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{ref.type === 'text' ? ref.content : ref.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
|
||||
>
|
||||
{ref.type === 'page' && ref.href ? (
|
||||
// A page reference may be clicked from anywhere: render a link so it
|
||||
// navigates back to the page (and supports cmd/ctrl-click to open in a
|
||||
// new tab).
|
||||
<Link
|
||||
href={ref.href}
|
||||
prefetch={false}
|
||||
className={triggerClassName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : ref.type === 'text' ? (
|
||||
// A text selection has no persistent DOM anchor to navigate to, so the
|
||||
// excerpt is shown as plain (non-interactive) content.
|
||||
<span className={triggerClassName}>{content}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
focusReference(ref);
|
||||
}}
|
||||
className={triggerClassName}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(ref.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
|
||||
>
|
||||
<Icon icon="xmark" className="size-2.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReferenceIcon(ref: AIChatReference): IconName {
|
||||
switch (ref.type) {
|
||||
case 'code-block':
|
||||
return 'code';
|
||||
case 'page':
|
||||
return 'memo';
|
||||
case 'text':
|
||||
return 'quote-left';
|
||||
default:
|
||||
assertNever(ref);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the content a reference points at, for chips that aren't rendered as links:
|
||||
* - a code block: scroll it into view on the current page and focus it;
|
||||
* - a page without a known href: scroll back to the top (the reader is most likely on it).
|
||||
*/
|
||||
function focusReference(ref: AIChatReference) {
|
||||
if (ref.type === 'page') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = document.querySelectorAll<HTMLElement>(`#${CSS.escape(ref.id)}`);
|
||||
const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]'));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.querySelector<HTMLElement>('[data-codeblock-focus]')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import React from 'react';
|
||||
import { useAIChatWidthStore } from './useAIChatWidthStore';
|
||||
|
||||
function setResizing(active: boolean) {
|
||||
document.documentElement.dataset.aiChatResizing = String(active);
|
||||
}
|
||||
|
||||
export function AIChatResizeHandle() {
|
||||
const setWidth = useAIChatWidthStore((state) => state.setWidth);
|
||||
const frameRef = React.useRef<number | null>(null);
|
||||
const widthRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onResize = () => useAIChatWidthStore.getState().syncWidth();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
setResizing(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopResizing = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
setResizing(false);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
widthRef.current = useAIChatWidthStore.getState().width;
|
||||
setResizing(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
// Panel is right-anchored, so its width is the distance from the cursor to the right edge.
|
||||
widthRef.current = window.innerWidth - event.clientX;
|
||||
if (frameRef.current === null) {
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
widthRef.current = setWidth(widthRef.current);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
setWidth(widthRef.current);
|
||||
stopResizing(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={stopResizing}
|
||||
className={tcls(
|
||||
'group -translate-x-1/2 absolute inset-y-0 left-0 z-10 hidden w-3 cursor-col-resize touch-none lg:flex',
|
||||
'items-stretch justify-center'
|
||||
)}
|
||||
>
|
||||
<span className="h-full w-px rounded-full bg-transparent transition-all duration-150 ease-out group-hover:w-0.5 group-hover:bg-primary-solid/40 group-active:w-0.5 group-active:bg-primary-solid" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { AIChatController } from '../AI';
|
||||
import { useRecentSearchQueries } from '../Search/recent-queries';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export default function AIChatSuggestedQuestions(props: {
|
||||
@@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const language = useLanguage();
|
||||
const { chatController, suggestions: _suggestions } = props;
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
|
||||
const { chatController, suggestions: configuredSuggestions } = props;
|
||||
|
||||
const suggestions =
|
||||
_suggestions && _suggestions.length > 0
|
||||
? _suggestions
|
||||
: [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const defaultSuggestions = [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const baseSuggestions =
|
||||
configuredSuggestions && configuredSuggestions.length > 0
|
||||
? configuredSuggestions
|
||||
: defaultSuggestions;
|
||||
|
||||
const suggestions = [
|
||||
...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query),
|
||||
...baseSuggestions,
|
||||
].reduce<string[]>((acc, suggestion) => {
|
||||
if (acc.includes(suggestion)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(suggestion);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { Button } from '@/components/primitives';
|
||||
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();
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { useIsMobile } from '@/components/hooks/useIsMobile';
|
||||
import { useIsMounted } from '@/components/hooks/useIsMounted';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
|
||||
import { AIChatIcon } from '../AIChatIcon';
|
||||
import { useStableTextSelection } from './useStableTextSelection';
|
||||
|
||||
/** Gap between the selection and the button. */
|
||||
const GAP = 8;
|
||||
/** Minimum distance to the viewport edges. */
|
||||
const MARGIN = 8;
|
||||
|
||||
/**
|
||||
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
|
||||
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
|
||||
*/
|
||||
export function AskAITextSelection() {
|
||||
const config = useAIConfig();
|
||||
const language = useLanguage();
|
||||
const chatController = useAIChatController();
|
||||
const isMobile = useIsMobile();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const enabled = isAIChatEnabled(config.aiMode) && !isMobile;
|
||||
|
||||
const toolbarRef = React.useRef<HTMLDivElement>(null);
|
||||
const { selection, clear } = useStableTextSelection({
|
||||
rootSelector: '[data-content-ref-root]',
|
||||
enabled,
|
||||
ignoreRef: toolbarRef,
|
||||
});
|
||||
|
||||
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Position once the button has been measured, so it can be centered and clamped to the viewport.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const el = toolbarRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = el.offsetWidth;
|
||||
const height = el.offsetHeight;
|
||||
const { anchor } = selection;
|
||||
|
||||
let top = anchor.top - GAP - height;
|
||||
if (top < MARGIN) {
|
||||
// Not enough room above the selection: drop below it.
|
||||
top = anchor.bottom + GAP;
|
||||
}
|
||||
top = Math.min(top, window.innerHeight - height - MARGIN);
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(anchor.centerX - width / 2, MARGIN),
|
||||
window.innerWidth - width - MARGIN
|
||||
);
|
||||
|
||||
setCoords({ top, left });
|
||||
}, [selection]);
|
||||
|
||||
const onClick = () => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const content = selection.text;
|
||||
if (!content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatController.addReference({
|
||||
type: 'text',
|
||||
id: `text-${fnv1a(content, { size: 32 })}`,
|
||||
content,
|
||||
});
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
clear();
|
||||
};
|
||||
|
||||
if (!enabled || !isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{selection ? (
|
||||
<motion.div
|
||||
ref={toolbarRef}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.12, ease: 'easeOut' }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? 0,
|
||||
left: coords?.left ?? 0,
|
||||
zIndex: 40,
|
||||
}}
|
||||
// Keep the selection alive: prevent the button from stealing focus on click.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
icon={<AIChatIcon state="default" trademark={config.trademark} />}
|
||||
onClick={onClick}
|
||||
className="shadow-sm"
|
||||
>
|
||||
{t(language, 'ask')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AskAITextSelection';
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
type SelectionAnchor = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
centerX: number;
|
||||
};
|
||||
|
||||
export type StableTextSelection = {
|
||||
anchor: SelectionAnchor;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
/** Selector of the content root; both selection endpoints must be inside it. */
|
||||
rootSelector: string;
|
||||
enabled: boolean;
|
||||
/** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
|
||||
ignoreRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
const KEYBOARD_DEBOUNCE_MS = 250;
|
||||
const SCROLL_SETTLE_MS = 200;
|
||||
// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
|
||||
const POINTER_SETTLE_MS = 120;
|
||||
|
||||
/**
|
||||
* Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
|
||||
* doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
|
||||
* when a new gesture starts.
|
||||
*/
|
||||
export function useStableTextSelection(options: Options): {
|
||||
selection: StableTextSelection | null;
|
||||
clear: () => void;
|
||||
} {
|
||||
const { rootSelector, enabled, ignoreRef } = options;
|
||||
const [selection, setSelection] = React.useState<StableTextSelection | null>(null);
|
||||
|
||||
const clear = React.useCallback(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelection(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let pointerDown = false;
|
||||
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearKeyboardTimer = () => {
|
||||
if (keyboardTimer) {
|
||||
clearTimeout(keyboardTimer);
|
||||
keyboardTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearFinalizeTimer = () => {
|
||||
if (finalizeTimer) {
|
||||
clearTimeout(finalizeTimer);
|
||||
finalizeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
// A new gesture started; it will reschedule its own finalize.
|
||||
if (pointerDown) {
|
||||
return;
|
||||
}
|
||||
setSelection(readStableSelection(rootSelector));
|
||||
};
|
||||
|
||||
const schedulePointerFinalize = () => {
|
||||
clearKeyboardTimer();
|
||||
clearFinalizeTimer();
|
||||
finalizeTimer = setTimeout(() => {
|
||||
finalizeTimer = null;
|
||||
finalize();
|
||||
}, POINTER_SETTLE_MS);
|
||||
};
|
||||
|
||||
const isIgnored = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!ignoreRef.current?.contains(target);
|
||||
|
||||
const onSelectionChange = () => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
|
||||
clearKeyboardTimer();
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
// Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
|
||||
if (pointerDown || finalizeTimer) {
|
||||
return;
|
||||
}
|
||||
clearKeyboardTimer();
|
||||
keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (isIgnored(event.target)) {
|
||||
return;
|
||||
}
|
||||
// New gesture: hide and cancel a pending show so it can't fire mid double-click.
|
||||
pointerDown = true;
|
||||
clearFinalizeTimer();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (isIgnored(event.target)) {
|
||||
return;
|
||||
}
|
||||
pointerDown = false;
|
||||
schedulePointerFinalize();
|
||||
};
|
||||
|
||||
// pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
|
||||
const onPointerCancel = () => {
|
||||
pointerDown = false;
|
||||
schedulePointerFinalize();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
setSelection(null);
|
||||
if (scrollTimer) {
|
||||
clearTimeout(scrollTimer);
|
||||
}
|
||||
scrollTimer = setTimeout(() => {
|
||||
if (!pointerDown) {
|
||||
finalize();
|
||||
}
|
||||
}, SCROLL_SETTLE_MS);
|
||||
};
|
||||
|
||||
const onWindowBlur = () => {
|
||||
clearKeyboardTimer();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', onPointerCancel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', onPointerCancel, true);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
clearKeyboardTimer();
|
||||
clearFinalizeTimer();
|
||||
if (scrollTimer) {
|
||||
clearTimeout(scrollTimer);
|
||||
}
|
||||
};
|
||||
}, [enabled, rootSelector, ignoreRef]);
|
||||
|
||||
return { selection, clear };
|
||||
}
|
||||
|
||||
function readStableSelection(rootSelector: string): StableTextSelection | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = sel.toString().trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = document.querySelector(rootSelector);
|
||||
if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = getSelectionAnchor(sel);
|
||||
return anchor ? { anchor, text } : null;
|
||||
}
|
||||
|
||||
/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
|
||||
function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) {
|
||||
return null;
|
||||
}
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
return null;
|
||||
}
|
||||
return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export * from './AIChatButton';
|
||||
export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
export * from './AIChatControlButton';
|
||||
export * from './AskAITextSelection';
|
||||
export * from './AskAIParagraphButton';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from '@/lib/browser/local-storage';
|
||||
import { create } from 'zustand';
|
||||
import { type StorageValue, persist } from 'zustand/middleware';
|
||||
|
||||
const AI_CHAT_MIN_WIDTH = 384;
|
||||
const AI_CHAT_MAX_WIDTH = 640;
|
||||
const MIN_CONTENT_WIDTH = 720;
|
||||
|
||||
type AIChatWidthStore = {
|
||||
width: number;
|
||||
toggleWidth: () => void;
|
||||
setWidth: (width: number) => number;
|
||||
syncWidth: () => void;
|
||||
};
|
||||
|
||||
type PersistedState = Pick<AIChatWidthStore, 'width'>;
|
||||
|
||||
export const useAIChatWidthStore = create<AIChatWidthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
width: AI_CHAT_MIN_WIDTH,
|
||||
toggleWidth: () =>
|
||||
get().setWidth(
|
||||
get().width >= AI_CHAT_MAX_WIDTH ? AI_CHAT_MIN_WIDTH : AI_CHAT_MAX_WIDTH
|
||||
),
|
||||
setWidth: (width) => {
|
||||
const clamped = clampWidth(width);
|
||||
if (get().width !== clamped) {
|
||||
set({ width: clamped });
|
||||
}
|
||||
setWidthOnViewport(clamped);
|
||||
return clamped;
|
||||
},
|
||||
syncWidth: () => setWidthOnViewport(get().width),
|
||||
}),
|
||||
{
|
||||
name: '@gitbook/ai-chat-width',
|
||||
storage: {
|
||||
getItem: (name) =>
|
||||
getLocalStorageItem<StorageValue<PersistedState> | null>(name, null),
|
||||
setItem: (name, value) => setLocalStorageItem(name, value),
|
||||
removeItem: (name) => removeLocalStorageItem(name),
|
||||
},
|
||||
partialize: (state) => ({ width: state.width }),
|
||||
onRehydrateStorage: () => (state) => state?.syncWidth(),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the panel is at its maximum width.
|
||||
*/
|
||||
export const useIsAIChatMaxWidth = () =>
|
||||
useAIChatWidthStore((state) => state.width >= AI_CHAT_MAX_WIDTH);
|
||||
|
||||
// Hoisted so the synchronous persist rehydrate (during create() above) can call them before this point.
|
||||
function setWidthOnViewport(width: number) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--ai-chat-width', `${capToViewport(width)}px`);
|
||||
}
|
||||
}
|
||||
|
||||
function clampWidth(width: number) {
|
||||
return Math.min(AI_CHAT_MAX_WIDTH, Math.max(AI_CHAT_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
// Cap a width so the remaining content keeps a usable minimum at the current viewport.
|
||||
function capToViewport(width: number) {
|
||||
return typeof window === 'undefined'
|
||||
? width
|
||||
: Math.min(width, Math.max(AI_CHAT_MIN_WIDTH, window.innerWidth - MIN_CONTENT_WIDTH));
|
||||
}
|
||||
@@ -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} />;
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -150,6 +151,8 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
<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} />
|
||||
@@ -211,6 +214,9 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
||||
<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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user