Compare commits

..

1 Commits

Author SHA1 Message Date
Kayssar Daher 2dca38796d Add test to cover longest URL match for nested site spaces 2026-11-02 15:59:01 +01:00
189 changed files with 1925 additions and 1902 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Bump Next.js to 16.4.3, keeping the dev-mode OOM patch.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix horizontal page scroll caused by card carousels bleeding past the viewport.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preconnect each asset origin once, skip same-origin icon URLs, and open the fonts preconnect in CORS mode so font requests can reuse it.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Stop caching failed searches, so a rate-limited or failing search is retried on the next query instead of being served from cache for 15 minutes.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Declare the Tailwind layer order in each component stylesheet, so a route whose first stylesheet is one of them still puts `base` before `components` and preflight does not strip their padding and borders.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Remove duplicated declarations from the global stylesheet.
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/icons": patch
---
Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Load the inline expression runtime only on pages whose code blocks or text use expressions, so other pages ship less JavaScript.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Load a webframe only once it is near the viewport. A page can hold hundreds of them inside collapsed expandables, and loading every one on mount held up the first paint.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pack the class lists that every block, heading anchor and table cell repeats into shared CSS classes, and carry a table cell's width once as a custom property instead of spelling it out as both width and min-width, so a large page carries far less markup.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Split llms-full.txt into parts by size so large sites no longer fail with "content too large"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Follow site and space redirects when the MCP getPage tool resolves a page URL.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the MCP `getPage` tool returning "Page not found" for the Markdown URL of a page (`<page>.md`).
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Limit the MCP `searchDocumentation` tool to one language: the one the client passes in the new optional `language` argument, or else the language of the site variant the MCP endpoint is served from.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a search input to filter models by name on OpenAPI models pages.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Let analytics tools like GA4 track outbound link clicks by using native link navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Flush insights events when the page is hidden, preload the search index at low priority and prioritize the dark page cover on dark-default sites.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Set `Partitioned` on visitor cookies set inside an iframe so authenticated embeds work cross-site.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix encoded or non-normalized links on proxied sites redirecting to the internal proxy path instead of the public URL.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the search button accessible name, hoverable link previews, IME-safe Enter on Safari, cookie banner semantics, active outline item announcement and textarea shrinking.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support a secondary brand colour for secondary buttons and `$secondary` text, background, icon and tag colours. Sites without one render as before.
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Respect reduced motion for enter and exit animations, keep the header separator and input focus visible in forced colors mode, and size mobile surfaces with dynamic viewport units.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send only the site origin as referrer to third parties, and only let the embedding page open the embed messaging channel.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Resolve the published content with the full URL in a single lookup and always send the visitor country.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Resolve dark-mode image URLs in published Markdown.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep lists, paragraphs and code blocks as HTML in Updates RSS feeds, instead of running all the text together
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/openapi-parser": patch
---
Bump @scalar/openapi-parser to 0.29 so valid OpenAPI 3.2 documents no longer report false validation errors.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Only apply a site preview customization override to the section or variant it was made for.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Show one server-ordered search list and open its top result on Enter.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render soft line breaks in paragraphs that contain buttons.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix visitors being logged out right after signing in on sites with a logout link in the header.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
-2
View File
@@ -87,5 +87,3 @@ The site must be [published publicly](https://gitbook.com/docs/published-documen
## License
The renderer is distributed under the [GNU GPLv3 License](https://github.com/GitbookIO/gitbook/blob/main/LICENSE). If you distribute the code, you must keep the source code public. To use it in a private repository, you need a [commercial license](https://www.gitbook.com/pricing).
The [`@gitbook/embed`](https://github.com/GitbookIO/gitbook/blob/main/packages/embed) package is distributed under the [Apache License 2.0](https://github.com/GitbookIO/gitbook/blob/main/packages/embed/LICENSE). Contributions to it are made under the same license.
+16 -42
View File
@@ -4,11 +4,6 @@ on:
push:
branches:
- main
# A newer run for the same PR supersedes the previous one. Each push to main gets a group of its
# own, so main runs are never queued behind or cancelled by another run.
concurrency:
group: ${{ github.event_name == 'pull_request_target' && format('preview-pr-{0}', github.event.pull_request.number) || format('preview-run-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
@@ -23,9 +18,8 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -50,9 +44,8 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -89,7 +82,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: ${{ !cancelled() && github.event_name != 'push' }}
if: always() && github.event_name != 'push'
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -102,25 +95,6 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
# A deploy that didn't succeed has no URL (e.g. a re-run hitting Vercel's "deploymentId
# already exists"), so keep the URL the comment already shows instead of blanking it.
- name: Resolve deployment URLs
id: urls
env:
PREVIOUS_COMMENT: ${{ steps.fc.outputs.comment-body }}
VERCEL_RESULT: ${{ needs.deploy-v2-vercel.result }}
VERCEL_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
CLOUDFLARE_RESULT: ${{ needs.deploy-v2-cloudflare.result }}
CLOUDFLARE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
run: |
previous_url() {
sed -n "s/^| $1 | \[\([^]]*\)\].*/\1/p" <<<"$PREVIOUS_COMMENT" | head -n 1
}
if [ "$VERCEL_RESULT" != success ]; then VERCEL_URL=$(previous_url Vercel); fi
if [ "$CLOUDFLARE_RESULT" != success ]; then CLOUDFLARE_URL=$(previous_url Cloudflare); fi
echo "vercel=$VERCEL_URL" >> "$GITHUB_OUTPUT"
echo "cloudflare=$CLOUDFLARE_URL" >> "$GITHUB_OUTPUT"
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
with:
@@ -131,21 +105,21 @@ jobs:
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ steps.urls.outputs.vercel }}](${{ steps.urls.outputs.vercel }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ steps.urls.outputs.cloudflare }}](${{ steps.urls.outputs.cloudflare }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | `2v` | `2c` |
| --- | --- | --- |
| GitBook | [${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs](${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs](${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs) |
| E2E | [${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open) |
| GitBook | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v2-vercel:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -169,7 +143,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -193,7 +167,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -217,7 +191,7 @@ jobs:
# Style recalc depends on the client bundle, not the host, so this runs here only and
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
- name: Run style invalidation tests
if: ${{ !cancelled() }}
if: always()
working-directory: packages/gitbook
run: bun run e2e-style-perf
env:
@@ -226,7 +200,7 @@ jobs:
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
- name: Build style invalidation report
if: ${{ !cancelled() && github.event_name != 'push' }}
if: always() && github.event_name != 'push'
id: style-perf
working-directory: packages/gitbook
run: |
@@ -245,7 +219,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Find style invalidation comment
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/find-comment@v3
id: fc-style-perf
with:
@@ -254,7 +228,7 @@ jobs:
body-includes: 'Style invalidation on a large API reference'
- name: Create or update style invalidation comment
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
@@ -265,7 +239,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -289,7 +263,7 @@ jobs:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
-2
View File
@@ -25,8 +25,6 @@ jobs:
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
# Otherwise git pushes with GITHUB_TOKEN instead of the PAT, and the release PR never triggers Preview
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
+1 -3
View File
@@ -124,8 +124,6 @@ If you plan to distribute the code, you must make the source code public to comp
See `LICENSE` for more information.
The [`@gitbook/embed`](packages/embed) package is an exception: it is distributed under the [Apache License 2.0](packages/embed/LICENSE), so it can be used in proprietary applications.
## Badges
<p align="left">
@@ -163,4 +161,4 @@ GitBook wouldn't be possible without these projects:
## Legacy GitBook (Deprecated)
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
+51 -51
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.93.0",
"oxlint": "^1.90.0",
"turbo": "^2.11.10",
"vercel": "50.37.3",
},
@@ -40,7 +40,7 @@
},
"packages/colors": {
"name": "@gitbook/colors",
"version": "0.4.5",
"version": "0.4.4",
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
@@ -49,9 +49,10 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.7.0",
"version": "0.6.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:",
},
"devDependencies": {
@@ -109,7 +110,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.28.2",
"version": "0.28.0",
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
@@ -128,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.15",
"@opennextjs/cloudflare": "1.20.16",
"@opennextjs/aws": "4.1.11",
"@opennextjs/cloudflare": "1.20.12",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -159,7 +160,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.4.5",
"next": "^16.4.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -248,7 +249,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "3.1.0",
"version": "3.0.13",
"dependencies": {
"@scalar/json-magic": "^0.17.0",
"@scalar/openapi-parser": "^0.29.19",
@@ -265,7 +266,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.18",
"version": "0.7.17",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -308,7 +309,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "2.0.1",
"version": "2.0.0",
"dependencies": {
"@base-ui/react": "catalog:",
"@gitbook/expr": "workspace:*",
@@ -341,9 +342,8 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.4.5": "patches/next@16.4.5.patch",
"@shikijs/langs@3.21.0": "patches/@shikijs%2Flangs@3.21.0.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
"next@16.4.3": "patches/next@16.4.3.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -888,23 +888,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.4.5", "", {}, "sha512-JwSPwpdkZL5VMl2jA/q+JI8MZdweAL3xmw5/tb9y/TLtmS0YXZnmYDzSihwk8xTj1OL+WM53gTqaj96yuLp6Xw=="],
"@next/env": ["@next/env@16.4.3", "", {}, "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W5TniBzWKASbNNwHZ/S/f1MxQUXU/LdjklVMWA0Ujbfl7SZnUzFls45hO0i/sF7Bx1jJBWp7WWmoQajXtX/O8A=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-uWq4qkHapBDS1oFltWS4YxTb9pLHAtLNcpbuK6SmRsebLYZqpBML/j/C0op+50JYGTpZgVL/DWNt/6oWlamrrw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-FdRcyOx5u5P51mlAqLB9EXdd8jZO5fsdHtk4BAQSkqOywCsbTNN/rmw+jO7pnmjNdAtu2AG46NzbCL2FPDrrgw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-cyvmmgtRXR8aGVT86wi9Py53IlfyCx7P2fZxuAXAoTummDnhKtUyjrMeXfapqp4qgD+gT5RqyGD/VfA16jEK7Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hPc+Fl+N22DAvqZHq559NmamXboOX5Lu8I5Wl0DezfrhTk1Zz21NQeYoZDmNI/NjTcrdsVxM72Fzn2lI+Q+vAw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-gbACYE8IP/jlprNTpiWXHb8N9CofOzpnEwH2SrIz1dUbiJf1FbDssDupCAPeP1Taxe8mgG20sGZamJI7KFOdZw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-+8gj++ZMo8H20Xh2ijjNqyFk5LPLw0AC4Fs4Tq/G94rjjQXhOjN4jgSWsWKPFsyY26hfIeemJIwKtMxOsp5GBA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-qNZj66pYXnaWLiF9R9GUbfBdrCv7kC6TaZfIwB2KH5WPc/8pEM3nVOj8hpQhWJU8S94rlH5MPDcX15+RrgUhiw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg=="],
"@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="],
@@ -934,9 +934,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.15", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@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": "^9.0.2", "@node-minify/terser": "^9.0.1", "@tsconfig/node18": "^18.2.7", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.1.1", "esbuild": "0.28.2", "express": "^5.2.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.9.1" }, "peerDependencies": { "diff": "^9.0.0", "next": ">=15.5.29 <16 || >=16.4.5" }, "optionalPeers": ["diff"], "bin": { "open-next": "dist/index.js" } }, "sha512-ulv7rFEAeieD0d/zDMnw4IFPU+zY/atVUhn7ezgFqIurLB5oBYeHCUGlcGk3Pg7uMPeI03SNRjHW1+xL9EwMPw=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.11", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@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": "^9.0.2", "@node-minify/terser": "^9.0.1", "@tsconfig/node18": "^18.2.7", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.1.1", "esbuild": "0.28.2", "express": "^5.2.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.9.1" }, "peerDependencies": { "next": ">=15.5.28 <16 || >=16.4.1" }, "bin": { "open-next": "dist/index.js" } }, "sha512-cew0cUgGZF/Kr/C+Z1qvmAdKj4Va5cRho+aB6Ci/5ILd+QDdrR2LVaC/6Qm12KVF37vSLDK7lPDd/KdvvCoNEw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.16", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@dotenvx/dotenvx": "^2.33.0", "@opennextjs/aws": "4.1.15", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^13.0.6", "ts-tqdm": "^0.8.6", "yargs": "^18.2.0" }, "peerDependencies": { "next": ">=15.5.29 <16 || >=16.4.5", "rclone.js": "^0.6.6", "wrangler": "^4.148.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-3c1Cwex5e2jXy4o4RZAusy8o9Uz4eowuPCjdVLYEZ/MnkAzN2jh9dOgsRjgu3wjigXuNe8rmPECmOTo+xKQHsw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.12", "", { "dependencies": { "@ast-grep/napi": "^0.45.3", "@dotenvx/dotenvx": "^2.33.0", "@opennextjs/aws": "4.1.11", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^13.0.6", "ts-tqdm": "^0.8.6", "yargs": "^18.2.0" }, "peerDependencies": { "next": ">=15.5.28 <16 || >=16.4.1", "rclone.js": "^0.6.6", "wrangler": "^4.148.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-ZU1j7hMuOFaa0RHT/wrPXQKEEqzkkoc3fHaHy0Xrx4K0zZdSXGQbwbSHCnbjPRGXblCuWo+ur6UUkl0o9kVhrg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1020,43 +1020,43 @@
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.93.0", "", { "os": "android", "cpu": "arm" }, "sha512-KKw70pbD5Pvq3G2lJ+WQ71XPMjCmg37rkSe2+mDqCRP3PoTSdDMuEi4UOnE8JBtyNM7bek61EC2s0Jt7S7u+Sg=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.90.0", "", { "os": "android", "cpu": "arm" }, "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.93.0", "", { "os": "android", "cpu": "arm64" }, "sha512-fvr1fTUZGelwRxKDt64pf28aL1WaWgviwz3X+E/FcRYvrlEahH0NECRz/c1SY+K+KWmZk5uAf6h1kQnyBkp/2A=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.90.0", "", { "os": "android", "cpu": "arm64" }, "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.93.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VdvSN9JRf4Es6ZzoAdvEeh8tgKdw8L4H82WmA8MMPVfPdZGCwE/nI27Z1HrRHYQhPxOh4kmnxQEioSUFrOvDaw=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.90.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.93.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-QR1V890Ml0YGRG0F/iV0K9igG+pOhe+VWd+aZnxY849rRHZf3F2gEoQks5+GVHf105X9Mh/6HdShTP5juekG2g=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.90.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.93.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jDfIbi30zZaKi+rTvig3sge+Xhi8pFFSgHxb0jzovc4qh8ETm/irKV9c7N/JsuwnYc5sbvf4IC8w1vQQ0IL+BA=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.90.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.93.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qtxZ714ND/koFgVH00QgxlzZiIbRPmguaPhnHRU5DFFtrtEdOrSnZD4U/H2AzXrXkZ59eQbMBnJwbum5XohrIQ=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.90.0", "", { "os": "linux", "cpu": "arm" }, "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.93.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5pJ9NjMvH7WMUqXB6bTTOSemBm39w2cod27Hu3+Yom/GhbTeNXlYcpDx1o4CgBP6fhp0YHG66TNdVaUm6sHjsA=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.90.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.93.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-pRf+7jtbt2fHE23SX7qBxRgnBZicUgsoCK1PVIjnOk+t3a6UBkpt/GrKI99WlxsKUxftz/5nv/kfKvScmYywkw=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.90.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.93.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zM8Fd/K8sqGvsBcJBZ3oVZ3ctGWbszA08IaGt5gFFF5RcunppAI/ZnAXbouABvnWeJ//S44KKlfPZnDDbeiphg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.90.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.93.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-7qKCnvhZKDXJG2pdbLazd2hw4FxjJHbZOFrrbzyy5PdHfPLH9vnNUa1le01A8uItZuYKPBPncH+HCoEiFT4SgA=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.90.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.93.0", "", { "os": "linux", "cpu": "none" }, "sha512-2pgulxC6G8V5wE750NJE/fRkf9aO6i6yoj10wyaR2EdiNtan4OemsOtsQ0ZTXGdY8Q3IoTmlND3GYvjJsvikYg=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.90.0", "", { "os": "linux", "cpu": "none" }, "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.93.0", "", { "os": "linux", "cpu": "none" }, "sha512-yWsn6Gg2PnGG5jlyDz5+pHGiMTMiy4adaa4WRMzDgzB8efaShk1JMmTHIBSVLQMh0tYXRs4CN5uJCO79r4Mi4g=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.90.0", "", { "os": "linux", "cpu": "none" }, "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.93.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-WCn5plwp/ezS2RDPQKp0/EXEVRr9/dDexUdRKmsMFqzNM5xBSZ1wbsi3PuMk1VfkMRnI5BuazCsziCWMXRDOlw=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.90.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.93.0", "", { "os": "linux", "cpu": "x64" }, "sha512-F2CGjb9LAkzH+IYTZSzd1klQ1vk5z6Utjax64WeLByCkYXbVDbsun+Oy2hLfW2MDSKCvW+jwy/9ld0EPgdeu+A=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.90.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.93.0", "", { "os": "linux", "cpu": "x64" }, "sha512-plf5DE2objcG8Tf7mXRuRa8tT8rzDkpjqXQtKoGsKHWkpgsialbON1+cCtoSEUTVbQUjXvuAm8vV9+3rR0yCBw=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.90.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.93.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Kp2yJrKvoTe9FfKdO/xwLNUbs2bYTMKx0+aoQD1s0h/Tx0kSgtHSdGmrk8tTKdluosjFbo88qYRIaNIX43AfFw=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.90.0", "", { "os": "none", "cpu": "arm64" }, "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.93.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vigjcHCFsR6KjnWTEVfaCxcMYapEuaK9FZa1MpewwQ1S+Xud3v0okfkj1RySsD6RF1NRHIjSlQSalTnKIaj3nA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.90.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.93.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0Ot3BEe/AAr2/uQhe55r8CmPlperYk2mQmxdutOru7oU0kir6IiEVHb1TG3GORRlfra97lPHVDLhgmihiKzLrg=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.90.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.93.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZzzIIVs/Qkm8trUWYKF0OYub5ALlv50/3gsEZbKAvWgz+yNJX6uRmQ8ySPXU2Q0/Y9m4JCOjhpmC8XUYbPHX5Q=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.90.0", "", { "os": "win32", "cpu": "x64" }, "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g=="],
"@panzoom/panzoom": ["@panzoom/panzoom@4.6.1", "", {}, "sha512-ogf/KhHHjj+DYAvHfaf3TXMQ8OE36pJtKpabLlx1OmpjcgtpCvkUoCiNONA8kuVRPGJdLiqMf0n8LRFXj1OyuA=="],
@@ -1362,17 +1362,17 @@
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.11.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-HMMeUIEAsmyIbSYn1ySvF/4gGMa0joiTMq4dQDuloIAnOpbtFT3JEiVsYnASdTWDXvplbRbZlqjU1EBeSR9G1A=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.11.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-By4idRap5+nLnMuRzLB4qkOPRlvNdyY1ne4VJYDyUoUB11H94k7uif9mbe3JT8JSNXs1D68uMcBj6hJ/jMQRvA=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+8iySNgqYWY6PZYUQzYu6zn0C04z2OEq1vQ6oMGsJ9sxN6wOpg1gzi1AJ5VKDHviWvMUjhqng51hTEQZOuiqYQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pyEYlVcuexOPa92p0Wlwl9YT1Is5D60lxN3ItWm3g30YLvFaUq86TF4BBWg5ZAc0uMTnrOzCvuCsOBTdzUWnRQ=="],
"@turbo/linux-64": ["@turbo/linux-64@2.11.11", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-uImTKFYCFKsoFxjaXeJXlMPv4uU3OIIDjyzoIwYVqOtu9LnCZ/5/fzu/WP6+G9YfwGAPRplTHkKFfcI5t97RGw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.11.10", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-1tYeR/IDZ5HEdsb0jNq2L2xam5nMQ+g80tlV2BZmv4OFw+pMcUM0mMmUcfc5wXPPNHVNPuDMjh9Y8G8HgXPLZA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.11", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-Y7xffSVfyyYRGzNy6PLdrFL4xXN6iTuR3W0SyJvH/yd3d4defA6dansZwJpPDVnIpD8a2Qn256DwY/Qin4Ip4g=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.10", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-HuekQxvVGhUd46g1Im/DKdOtglHr4/gguZAvzEpPUgtxNkztSa8lWhQEGQ+EmPQ0n7w19qjThFU+OfEEd0uvlw=="],
"@turbo/windows-64": ["@turbo/windows-64@2.11.11", "", { "os": "win32", "cpu": "x64" }, "sha512-e2WqNtkPGo3QDwNNwwBo89Dx3PtSJ9Wp9QuRNzn80rrRnSHB+1mfBMZTpdHjA8/G/sG9rl76TdWdpgVsimjRdg=="],
"@turbo/windows-64": ["@turbo/windows-64@2.11.10", "", { "os": "win32", "cpu": "x64" }, "sha512-UJOP3BkbreY3/JlMgJR6ZiimxYIBn+c+B4tnBIxD0E9c3Estmk+XoVNVBNBottZLNRXe3/ScgOU4+/k8tIWysg=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-LURivfmgv34Osm8NR1gZGx4vdlux9KAgRM+FoooXNG0838ff0+akWZ76iPSke0NS2rsf7Z4Gw+YSaygzNTZuRA=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-xOkEjF3ws/b/YNlc+bvJHMYBn+vL+QbEISZFVi0qS+ImmhA1X3/gmNmBrjjIG3dg6aeN+n6gQitzSkXQ286qJQ=="],
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
@@ -2636,7 +2636,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.4.5", "", { "dependencies": { "@next/env": "16.4.5", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.5", "@next/swc-darwin-x64": "16.4.5", "@next/swc-linux-arm64-gnu": "16.4.5", "@next/swc-linux-arm64-musl": "16.4.5", "@next/swc-linux-x64-gnu": "16.4.5", "@next/swc-linux-x64-musl": "16.4.5", "@next/swc-win32-arm64-msvc": "16.4.5", "@next/swc-win32-x64-msvc": "16.4.5", "sharp": "^0.35.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-rmdfsWosjTUO+6R0S3KokoIk6Kj0sWDRTe86tZHdqaWH/oXq/udX3/4xb+YQi7aWbyEFoglDU54kaifLCdXbBw=="],
"next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.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-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="],
"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=="],
@@ -2696,7 +2696,7 @@
"oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="],
"oxlint": ["oxlint@1.93.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.93.0", "@oxlint/binding-android-arm64": "1.93.0", "@oxlint/binding-darwin-arm64": "1.93.0", "@oxlint/binding-darwin-x64": "1.93.0", "@oxlint/binding-freebsd-x64": "1.93.0", "@oxlint/binding-linux-arm-gnueabihf": "1.93.0", "@oxlint/binding-linux-arm-musleabihf": "1.93.0", "@oxlint/binding-linux-arm64-gnu": "1.93.0", "@oxlint/binding-linux-arm64-musl": "1.93.0", "@oxlint/binding-linux-ppc64-gnu": "1.93.0", "@oxlint/binding-linux-riscv64-gnu": "1.93.0", "@oxlint/binding-linux-riscv64-musl": "1.93.0", "@oxlint/binding-linux-s390x-gnu": "1.93.0", "@oxlint/binding-linux-x64-gnu": "1.93.0", "@oxlint/binding-linux-x64-musl": "1.93.0", "@oxlint/binding-openharmony-arm64": "1.93.0", "@oxlint/binding-win32-arm64-msvc": "1.93.0", "@oxlint/binding-win32-ia32-msvc": "1.93.0", "@oxlint/binding-win32-x64-msvc": "1.93.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2005", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-19JMsQFX+YvEbcUTFQxjTwhvs0Ibg0D6CFneSLgpwnACWcY/6LjhpGpkPodh0T5aIT1pjMTLlYaeBFzaVgugTA=="],
"oxlint": ["oxlint@1.90.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.90.0", "@oxlint/binding-android-arm64": "1.90.0", "@oxlint/binding-darwin-arm64": "1.90.0", "@oxlint/binding-darwin-x64": "1.90.0", "@oxlint/binding-freebsd-x64": "1.90.0", "@oxlint/binding-linux-arm-gnueabihf": "1.90.0", "@oxlint/binding-linux-arm-musleabihf": "1.90.0", "@oxlint/binding-linux-arm64-gnu": "1.90.0", "@oxlint/binding-linux-arm64-musl": "1.90.0", "@oxlint/binding-linux-ppc64-gnu": "1.90.0", "@oxlint/binding-linux-riscv64-gnu": "1.90.0", "@oxlint/binding-linux-riscv64-musl": "1.90.0", "@oxlint/binding-linux-s390x-gnu": "1.90.0", "@oxlint/binding-linux-x64-gnu": "1.90.0", "@oxlint/binding-linux-x64-musl": "1.90.0", "@oxlint/binding-openharmony-arm64": "1.90.0", "@oxlint/binding-win32-arm64-msvc": "1.90.0", "@oxlint/binding-win32-ia32-msvc": "1.90.0", "@oxlint/binding-win32-x64-msvc": "1.90.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2005", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How=="],
"p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="],
@@ -3130,7 +3130,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.11.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.11", "@turbo/darwin-arm64": "2.11.11", "@turbo/linux-64": "2.11.11", "@turbo/linux-arm64": "2.11.11", "@turbo/windows-64": "2.11.11", "@turbo/windows-arm64": "2.11.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AI4UJbUE/2cVZdoXJ4PODc8qvrjhQQX9Fa93eKGOI6Ims/w7+kUIb9T1jhlgSnZcf+gtUruci/ohSwqtNNC5Yw=="],
"turbo": ["turbo@2.11.10", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.10", "@turbo/darwin-arm64": "2.11.10", "@turbo/linux-64": "2.11.10", "@turbo/linux-arm64": "2.11.10", "@turbo/windows-64": "2.11.10", "@turbo/windows-arm64": "2.11.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-3GJYwig8ygEI89oaOZdduJX8/aw6O9eRPYHoIt6T3FZl9hkKjiyIspufJoVgqXihPoJu8M5CSV0DlZvzfohBCw=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
+4 -5
View File
@@ -7,8 +7,8 @@
"devDependencies": {
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.93.0",
"turbo": "^2.11.11",
"oxlint": "^1.90.0",
"turbo": "^2.11.10",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
@@ -67,8 +67,7 @@
"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",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch",
"next@16.4.5": "patches/next@16.4.5.patch",
"@shikijs/langs@3.21.0": "patches/@shikijs%2Flangs@3.21.0.patch"
"next@16.4.3": "patches/next@16.4.3.patch",
"mermaid@11.16.1": "patches/mermaid@11.16.1.patch"
}
}
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/colors
## 0.4.5
### Patch Changes
- 6c12872: Expand 3-digit hex colors correctly in `hexToRgbArray`.
## 0.4.4
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
}
},
"sideEffects": false,
"version": "0.4.5",
"version": "0.4.4",
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
+1 -13
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { colorScale, hexToRgbArray } from './transformations';
import { colorScale } from './transformations';
describe('colorScale exact base', () => {
it('takes a very light tint as the exact background on step 1 (clean/bold)', () => {
@@ -67,15 +67,3 @@ describe('colorScale exact base', () => {
expect(scale[0]).not.toBe('#F5F3EF');
});
});
describe('hexToRgbArray', () => {
it('parses a 6-digit hex color', () => {
expect(hexToRgbArray('#1a2b3c')).toEqual([26, 43, 60]);
});
it('expands a 3-digit hex color', () => {
expect(hexToRgbArray('#abc')).toEqual([170, 187, 204]);
expect(hexToRgbArray('abc')).toEqual([170, 187, 204]);
expect(hexToRgbArray('#fff')).toEqual([255, 255, 255]);
});
});
+1 -3
View File
@@ -311,9 +311,7 @@ export function hexToRgbArray(hex: string): RGBColor {
const originalHex = hex;
let value = hex.replace('#', '');
if (value.length === 3) {
value = [...value].map((digit) => digit + digit).join('');
}
if (hex.length === 3) value = value + value;
const r = value.substring(0, 2);
const g = value.substring(2, 4);
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/embed
## 0.7.0
### Minor Changes
- 31e88d1: Relicense under Apache 2.0 and drop the dependency on `@gitbook/icons`.
## 0.6.0
### Minor Changes
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-4
View File
@@ -542,7 +542,3 @@ button: {
icon: 'assistant'
}
```
## License
Distributed under the [Apache License 2.0](./LICENSE).
+3 -4
View File
@@ -13,10 +13,10 @@
}
},
"sideEffects": false,
"version": "0.7.0",
"license": "Apache-2.0",
"version": "0.6.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:"
},
"peerDependencies": {
@@ -42,8 +42,7 @@
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE"
"CHANGELOG.md"
],
"publishConfig": {
"access": "public",
+1 -6
View File
@@ -1,10 +1,5 @@
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
/**
* Name of a Font Awesome icon (e.g. `bolt`), or a GitBook custom icon.
*/
// Kept as a plain string so the package doesn't depend on @gitbook/icons and its licence.
type IconName = string;
import type { IconName } from '@gitbook/icons';
/**
* Confirmation action to be displayed to the user before executing a tool.
-2
View File
@@ -158,8 +158,6 @@
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- the `vh` declaration above is the fallback on host pages whose browser lacks `dvh` */
height: calc(min(var(--gitbook-widget-window-height), calc(100dvh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
-50
View File
@@ -1,55 +1,5 @@
# gitbook
## 0.28.2
### Patch Changes
- b53e73c: Link suggested follow-up questions in markdown AI answers to the `.md` ask route so agents get markdown back.
- 4a40547: Fix the page outline not scrolling to keep the active section visible on long pages.
- 7ddd023: Fix search prewarming on public sites.
- Updated dependencies [31e88d1]
- @gitbook/embed@0.7.0
## 0.28.1
### Patch Changes
- a023a71: Warn visitors not to share sensitive personal info in the Assistant context popover.
- 6344646: Fix bash code blocks colouring the last letter of a word before `>` differently, as in `<name>`.
- 7e096d5: Bump Next.js to 16.4.3, keeping the dev-mode OOM patch.
- 198cafe: Bump Next.js to 16.4.5 and OpenNext (Cloudflare 1.20.16, AWS 4.1.15), keeping the dev-mode OOM patch.
- f61578f: Preconnect each asset origin once, skip same-origin icon URLs, and open the fonts preconnect in CORS mode so font requests can reuse it.
- 26ec43e: Stop caching failed searches, so a rate-limited or failing search is retried on the next query instead of being served from cache for 15 minutes.
- 8689736: Remove italic from dfn styling
- 04f39f1: Declare the Tailwind layer order in each component stylesheet, so a route whose first stylesheet is one of them still puts `base` before `components` and preflight does not strip their padding and borders.
- a512777: Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
- 508f1bc: Load the inline expression runtime only on pages whose code blocks or text use expressions, so other pages ship less JavaScript.
- 04f39f1: Pack the class lists that every block, heading anchor and table cell repeats into shared CSS classes, and carry a table cell's width once as a custom property instead of spelling it out as both width and min-width, so a large page carries far less markup.
- bd431f1: Split llms-full.txt into parts by size so large sites no longer fail with "content too large"
- a5d58bc: Follow site and space redirects when the MCP getPage tool resolves a page URL.
- e6b8502: Fix the MCP `getPage` tool returning "Page not found" for the Markdown URL of a page (`<page>.md`).
- e745c88: Add a search input to filter models by name on OpenAPI models pages.
- 96c5683: Let analytics tools like GA4 track outbound link clicks by using native link navigation.
- 347a1cc: Set `Partitioned` on visitor cookies set inside an iframe so authenticated embeds work cross-site.
- 3837c48: Fix encoded or non-normalized links on proxied sites redirecting to the internal proxy path instead of the public URL.
- ea01850: Support a secondary brand colour for secondary buttons and `$secondary` text, background, icon and tag colours. Sites without one render as before.
- c00a7b3: Resolve the published content with the full URL in a single lookup and always send the visitor country.
- 063e685: Resolve dark-mode image URLs in published Markdown.
- 09a8310: Serve a disallow-all robots.txt instead of redirecting to sign-in on authenticated access sites.
- b7b8ae6: Keep lists, paragraphs and code blocks as HTML in Updates RSS feeds, instead of running all the text together
- 4a72ead: Only apply a site preview customization override to the section or variant it was made for.
- 38f60f2: Render soft line breaks in paragraphs that contain buttons.
- 1383a79: Fix visitors being logged out right after signing in on sites with a logout link in the header.
- be658e1: Truncate long tab titles one by one and show the selected tab's full title, instead of hiding tabs behind a "…" menu
- Updated dependencies [6c12872]
- Updated dependencies [04f39f1]
- Updated dependencies [1b29a70]
- Updated dependencies [4dfe630]
- @gitbook/colors@0.4.5
- @gitbook/react-contentkit@0.7.18
- @gitbook/openapi-parser@3.1.0
- @gitbook/react-openapi@2.0.1
## 0.28.0
### Minor Changes
+10
View File
@@ -283,6 +283,11 @@ const testCases: TestsCase[] = [
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',
@@ -584,6 +589,11 @@ const testCases: TestsCase[] = [
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',
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.28.2",
"version": "0.28.0",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.15",
"@opennextjs/cloudflare": "1.20.16",
"@opennextjs/aws": "4.1.11",
"@opennextjs/cloudflare": "1.20.12",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -51,7 +51,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.4.5",
"next": "^16.4.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -0,0 +1,14 @@
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
export async function POST() {
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const apiClient = await context.dataFetcher.api();
await apiClient.orgs.prewarmSiteSearch(siteURLData.organization, siteURLData.site);
return new Response(null, { status: 204 });
}
@@ -146,10 +146,6 @@ export function AIChatInput(props: {
<Icon icon="message-question" className="size-3.5 opacity-7" />
{t(language, 'ai_chat_context_previous_messages')}
</li>
<li className="flex items-center gap-2">
<Icon icon="lock" className="size-3.5 opacity-7" />
{t(language, 'ai_chat_context_sensitive_info')}
</li>
</ul>
<p>{t(language, 'ai_chat_context_disclaimer')}</p>
</div>
@@ -57,6 +57,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<div
role="dialog"
data-testid="cookies-dialog"
aria-modal="true"
aria-label={tString(language, 'cookies_title')}
aria-describedby={describedById}
className={tcls(
@@ -19,7 +19,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
>
<dfn
data-annotation=""
className="not-italic cursor-help underline decoration-dotted decoration-1 underline-offset-2"
className="cursor-help underline decoration-dotted decoration-1 underline-offset-2"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- we want to be able to focus the definition to open the tooltip
tabIndex={0}
>
@@ -1,7 +1,8 @@
'use client';
import React from 'react';
import { getCodeTextFromId } from './utils';
import { useCopyToClipboard } from '@/components/hooks';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -14,30 +15,41 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
const { codeId, style } = props;
const language = useLanguage();
const { copied, copy } = useCopyToClipboard();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, 1000);
return () => {
clearTimeout(timeout);
};
}, [copied]);
const onClick = () => {
const codeText = getCodeTextFromId(codeId);
if (codeText === null) {
return;
}
copy(codeText);
navigator.clipboard.writeText(codeText);
setCopied(true);
};
return (
<>
<Button
size="xsmall"
variant="secondary"
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden')}
>
{t(language, copied ? 'code_copied' : 'code_copy')}
</Button>
{/* A label swap on the focused button isn't announced, the live region is. */}
<span role="status" className="sr-only">
{copied ? t(language, 'code_copied') : null}
</span>
</>
<Button
size="xsmall"
variant="secondary"
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden')}
>
{t(language, copied ? 'code_copied' : 'code_copy')}
</Button>
);
}
@@ -709,19 +709,6 @@ function joinLineContent(line: HighlightLine): string {
return visit(line.tokens);
}
it('keeps the last letter of a bash word before `>` in the same token', async () => {
const tokenContents = async (code: string) => {
const lines = await highlightWithInlines(singleLineBlock('bash', code));
return lines[0]!.tokens.map((t) => (t.type === 'shiki' ? t.token.content : ''));
};
const placeholder = await tokenContents('cli create --name <resource name>');
expect(placeholder.find((c) => c.includes('nam'))).toContain('name');
const fdRedirect = await tokenContents('cmd 2>&1');
expect(fdRedirect.find((c) => c.includes('2'))).toContain('2>&1');
});
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
return {
object: 'block',
@@ -129,7 +129,7 @@
.contentkit-modal {
@apply flex flex-col gap-6 relative z-50 bg-tint
rounded shadow-lg opacity-0 transition-opacity duration-300 border border-tint
w-full max-w-[768px] max-h-[90dvh] overflow-y-auto;
w-full max-w-[768px] max-h-[90vh] overflow-y-auto;
}
.contentkit-modal.contentkit-modal-opened {
@apply opacity-[1];
@@ -16,8 +16,6 @@ import { matchesText } from '../Table/searchMatch';
import { MIN_RECORDS_FOR_SEARCH } from '../Table/shouldShowSearch';
import { Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
// Imported here, behind the `next/dynamic` boundary, so only pages with OpenAPI blocks load it.
import './style.css';
// Only what survives the RSC boundary. Rebuilding the renderers on the client is what lets this
// whole subtree sit behind a `next/dynamic` chunk instead of the route's eager entry.
@@ -17,6 +17,7 @@ import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
import { Heading } from '../Heading';
import type { OpenAPIBlockClientContextProps } from './OpenAPIBlockClient';
import './style.css';
import { SCALAR_RUNTIME_PATH } from './scalar-runtime-path';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
@@ -1,46 +0,0 @@
/* Global, not in style.css: badges also show in the sidebar and outline of pages without OpenAPI blocks. */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -143,6 +143,53 @@ button.openapi-mcp {
@apply !mb-0;
}
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
/* URL */
.openapi-url {
@apply font-mono text-sm text-tint;
@@ -5,7 +5,6 @@ import React from 'react';
import { Icon } from '@gitbook/icons';
import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction';
import { useCopyToClipboard } from '@/components/hooks';
import {
Button,
ButtonGroup,
@@ -28,10 +27,25 @@ export function PromptActions(props: { prompt: string; openInAIProviders: boolea
const { prompt, openInAIProviders } = props;
const language = useLanguage();
const selectedAction = usePromptAction();
const { copied, copy } = useCopyToClipboard(COPIED_MESSAGE_DURATION);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
const copyPrompt = () => {
copy(prompt);
navigator.clipboard.writeText(prompt);
setCopied(true);
};
// The pick follows the visitor from site to site, so fall back to copying wherever the agent
@@ -43,14 +43,13 @@ describe('ViewCards', () => {
it('renders the carousel with a symmetric peek and conditional edge masks', () => {
const markup = renderWithContext(<ViewCards {...makeProps(false)} />);
// Gutter-sized peek, the full 3rem from `2xl`.
expect(markup).toContain('-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12');
expect(markup).toContain('scroll-px-4');
expect(markup).toContain('2xl:scroll-px-12');
expect(markup).toContain('-mx-12');
expect(markup).toContain('px-12');
expect(markup).toContain('scroll-px-12');
expect(markup).toContain('left-0');
expect(markup).toContain('2xl:ml-8');
expect(markup).toContain('ml-8');
expect(markup).toContain('right-0');
expect(markup).toContain('2xl:mr-8');
expect(markup).toContain('mr-8');
expect(markup).not.toContain('before:bg-linear-to-r');
expect(markup).not.toContain('after:bg-linear-to-l');
expect(markup).toContain('snap-mandatory');
@@ -49,16 +49,6 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
);
}
// The peek fits the page gutter (`CONTAINER_STYLE`) so the fade stays on screen; from `2xl` the
// cards fill the row exactly and need the full 3rem to show the next one.
const PEEK = {
margin: '-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12',
padding:
'px-4 scroll-px-4 sm:px-6 sm:scroll-px-6 md:px-8 md:scroll-px-8 2xl:px-12 2xl:scroll-px-12',
leadingButton: 'ml-0 sm:ml-2 md:ml-4 2xl:ml-8',
trailingButton: 'mr-0 sm:mr-2 md:mr-4 2xl:mr-8',
};
/**
* The carousel layout: cards lay out in a single horizontally-scrolling row that
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
@@ -80,7 +70,7 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
return (
<ScrollContainer
orientation="horizontal"
className={tcls(PEEK.margin, 'hover:z-11')}
className={tcls('-mx-12', 'hover:z-11')}
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
contentClassName={tcls(
@@ -89,18 +79,19 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
'-mt-px',
'pb-6',
'-mb-6',
PEEK.padding,
'px-12',
'scroll-px-12',
'snap-x',
'snap-mandatory'
)}
scrollByVisibleItems
leading={{
fade: true,
button: { size: 'small', className: PEEK.leadingButton },
button: { size: 'small', className: 'ml-8' },
}}
trailing={{
fade: true,
button: { size: 'small', className: PEEK.trailingButton },
button: { size: 'small', className: 'mr-8' },
}}
>
{records.map((record) => {
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getNextTabIndex } from './DynamicTabs';
describe('getNextTabIndex', () => {
it('moves with the arrow keys and wraps around', () => {
expect(getNextTabIndex('ArrowRight', 0, 3)).toBe(1);
expect(getNextTabIndex('ArrowRight', 2, 3)).toBe(0);
expect(getNextTabIndex('ArrowLeft', 0, 3)).toBe(2);
});
it('jumps to the first and last tab', () => {
expect(getNextTabIndex('Home', 2, 3)).toBe(0);
expect(getNextTabIndex('End', 0, 3)).toBe(2);
});
it('mirrors the arrows in right-to-left', () => {
expect(getNextTabIndex('ArrowLeft', 0, 3, true)).toBe(1);
expect(getNextTabIndex('ArrowRight', 0, 3, true)).toBe(2);
});
it('ignores other keys and an unknown tab', () => {
expect(getNextTabIndex('Enter', 0, 3)).toBeNull();
expect(getNextTabIndex('ArrowRight', -1, 3)).toBeNull();
});
});
@@ -151,15 +151,10 @@ const TabItemList = memo(function TabItemList(props: {
}).filter((x) => x !== undefined),
[overflowing, tabs]
);
// Roving tabindex: one tab stop for the strip, the active tab when it isn't in the overflow menu.
const visibleTabs = tabs.filter((tab) => !overflowing.has(getTabButtonId(tab.id)));
const focusableTabId =
visibleTabs.find((tab) => tab.id === activeTabId)?.id ?? visibleTabs[0]?.id ?? null;
return (
<div
ref={containerRef}
role="tablist"
onKeyDown={moveFocusBetweenTabs}
className={tcls(
'group/tabs',
'overflow-hidden',
@@ -187,7 +182,6 @@ const TabItemList = memo(function TabItemList(props: {
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
isFocusable={tab.id === focusableTabId}
isMeasuring={isMeasuring}
tab={tab}
onSelect={onSelect}
@@ -247,18 +241,16 @@ function TabsDropdownMenu(props: {
const TabItem = memo(function TabItem(props: {
ref: React.Ref<HTMLButtonElement>;
isActive: boolean;
isFocusable: boolean;
isMeasuring: boolean;
tab: TabsItem;
onSelect: (tabId: string) => void;
}) {
const { ref, tab, isActive, isFocusable, isMeasuring, onSelect } = props;
const { ref, tab, isActive, isMeasuring, onSelect } = props;
return (
<TabButton
ref={ref}
role="tab"
aria-selected={isActive}
tabIndex={isFocusable ? 0 : -1}
aria-controls={tab.id}
id={getTabButtonId(tab.id)}
title={tab.title}
@@ -351,55 +343,6 @@ function TabButton(
);
}
/**
* Arrow keys, Home and End move focus between the tabs of a tablist; Enter or Space then selects.
*/
function moveFocusBetweenTabs(event: React.KeyboardEvent<HTMLElement>) {
const current = event.target instanceof HTMLElement ? event.target : null;
if (current?.getAttribute('role') !== 'tab') {
return;
}
const tabs = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])')
);
const index = tabs.indexOf(current);
const isRTL = getComputedStyle(event.currentTarget).direction === 'rtl';
const next = getNextTabIndex(event.key, index, tabs.length, isRTL);
if (next === null) {
return;
}
event.preventDefault();
tabs[next]?.focus();
}
/**
* Index of the tab to focus for a key press, or null when the key doesn't move focus.
*/
export function getNextTabIndex(
key: string,
index: number,
count: number,
isRTL = false
): number | null {
if (index < 0 || count === 0) {
return null;
}
const forward = isRTL ? 'ArrowLeft' : 'ArrowRight';
const backward = isRTL ? 'ArrowRight' : 'ArrowLeft';
switch (key) {
case forward:
return (index + 1) % count;
case backward:
return (index - 1 + count) % count;
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/**
* Get the ID for a tab button.
*/
@@ -4,7 +4,6 @@ import { useRouter } from 'next/navigation';
import React, { useEffect, useRef } from 'react';
import { createStore, useStore } from 'zustand';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
import { integrationsAssistantTools } from '../Integrations';
@@ -108,8 +107,7 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
const { settings } = message;
embeddableConfiguration.setState(settings);
integrationsAssistantTools.setState({
// The embed types icons as plain strings to avoid depending on @gitbook/icons.
tools: settings.tools as GitBookIntegrationTool[],
tools: settings.tools,
});
const defaultPage =
@@ -50,7 +50,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
query,
results,
@@ -90,7 +89,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={true}
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { isForeignHandshake } from './channel';
describe('isForeignHandshake', () => {
const parent = {} as MessageEventSource;
const other = {} as MessageEventSource;
it('accepts a handshake from the expected window', () => {
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: parent }, parent)).toBe(
false
);
});
it('rejects a handshake from another window', () => {
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: other }, parent)).toBe(
true
);
});
it('ignores messages that are not handshakes', () => {
expect(isForeignHandshake({ data: { type: 'other' }, source: other }, parent)).toBe(false);
expect(isForeignHandshake({ data: null, source: other }, parent)).toBe(false);
});
});
@@ -9,33 +9,11 @@ export function log(...data: any[]) {
console.log(...data);
}
/**
* Whether a message is a bidc handshake sent by a window other than the expected one.
*/
export function isForeignHandshake(
event: Pick<MessageEvent, 'data' | 'source'>,
expectedSource: MessageEventSource
): boolean {
return event.data?.type === 'bidc-connect' && event.source !== expectedSource;
}
export const getChannel = memoize(() => {
if (typeof window === 'undefined' || window.parent === window) {
return null;
}
log('[gitbook] create channel with parent window');
// bidc accepts a handshake from any window; registered first and in capture so it runs before
// bidc's own listener and only the embedding page can open the channel.
window.addEventListener(
'message',
(event) => {
if (isForeignHandshake(event, window.parent)) {
event.stopImmediatePropagation();
}
},
true
);
return createChannel();
});
@@ -55,8 +55,6 @@ export async function Header(props: {
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
// Box shadows are dropped in forced colors mode, the separator needs a real border.
'forced-colors:border-b',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
@@ -9,7 +9,7 @@ interface HeaderLinksProps {
export async function HeaderLinks({ children, style }: HeaderLinksProps) {
return (
<nav
<div
className={tcls(
'header-links',
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
@@ -17,6 +17,6 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
)}
>
{children}
</nav>
</div>
);
}
@@ -193,18 +193,13 @@ export function InsightsProvider(props: InsightsProviderProps) {
}
);
// `beforeunload` doesn't fire reliably on mobile, hiding the page is the last dependable signal.
/**
* When the page is unloaded, flush all events.
*/
React.useEffect(() => {
const flushWhenHidden = () => {
if (document.visibilityState === 'hidden') {
flushEventsSync();
}
};
document.addEventListener('visibilitychange', flushWhenHidden);
window.addEventListener('pagehide', flushEventsSync);
window.addEventListener('beforeunload', flushEventsSync);
return () => {
document.removeEventListener('visibilitychange', flushWhenHidden);
window.removeEventListener('pagehide', flushEventsSync);
window.removeEventListener('beforeunload', flushEventsSync);
};
}, [flushEventsSync]);
@@ -49,8 +49,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
React.useEffect(() => {
if (activeId && activeItemRef.current && scrollContainerRef.current) {
// The list's wrapper is the element that scrolls, not the list itself.
scrollContainerRef.current.parentElement?.scrollTo({
scrollContainerRef.current?.scrollTo({
top: activeItemRef.current.offsetTop - ACTIVE_ITEM_OFFSET,
behavior: 'smooth',
});
@@ -82,7 +81,6 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
>
<a
href={`#${section.id}`}
aria-current={activeId === section.id ? 'true' : undefined}
data-cover-aware-text
className={tcls(
'relative',
@@ -16,7 +16,6 @@ import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PreservePageLayout } from './PreservePageLayout';
import { MAIN_CONTENT_ID } from '@/components/layout';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
@@ -104,7 +103,6 @@ export async function PageBody(props: {
/>
) : null}
<main
id={MAIN_CONTENT_ID}
// Comments on the whole page target anywhere in it that isn't a block.
{...(withCommentAnchors ? { 'data-comment-page': '' } : {})}
className={tcls(
@@ -1,11 +1,7 @@
import type { StaticImageData } from 'next/image';
import { assert } from 'ts-essentials';
import {
CustomizationDefaultThemeMode,
type RevisionPageDocument,
type RevisionPageDocumentCover,
} from '@gitbook/api';
import type { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
import { CONTENT_STYLE } from '../layout';
import { getCoverHeight } from './coverHeight';
@@ -148,11 +144,6 @@ export async function PageCover(props: {
y={cover.yPos}
height={height}
mask={mask}
priorityTheme={
context.customization.themes.default === CustomizationDefaultThemeMode.Dark
? 'dark'
: 'light'
}
/>
</div>
</>
@@ -25,14 +25,10 @@ interface PageCoverImageProps {
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
height: number | undefined;
mask?: 'none' | 'radial';
/** Theme most visitors see first, its cover is the LCP candidate. */
priorityTheme?: 'light' | 'dark';
}
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask, priorityTheme = 'light' } = props;
// The light cover is never demoted to low: a visitor can still have switched to the light theme.
const darkFirst = priorityTheme === 'dark' && Boolean(imgs.dark);
const { imgs, y, height, mask } = props;
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
@@ -52,7 +48,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
src={imgs.light.src}
srcSet={imgs.light.srcSet}
sizes={imgs.light.sizes}
fetchPriority={darkFirst ? 'auto' : 'high'}
fetchPriority="high"
alt="Page cover"
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
style={{
@@ -73,7 +69,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
src={imgs.dark.src}
srcSet={imgs.dark.srcSet}
sizes={imgs.dark.sizes}
fetchPriority={darkFirst ? 'high' : 'low'}
fetchPriority="low"
alt="Page cover"
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
style={{
@@ -56,6 +56,7 @@ function preloadFont(fontData: FontData) {
ReactDOM.preload(url, {
as: 'font',
crossOrigin: 'anonymous',
fetchPriority: 'high',
type: format ? `font/${format}` : undefined,
});
});
@@ -3,7 +3,6 @@
@config '../../../tailwind.config.ts';
@import "./prose.css";
@import "../DocumentView/OpenAPI/method-badge.css";
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
@@ -34,6 +33,7 @@
@utility hide-scrollbar {
/* Hide scrollbar by default */
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s ease-in-out;
&:hover {
/* Show scrollbar on hover */
@@ -52,14 +52,21 @@
rgba(0, 0, 0, 1) 96px,
rgba(0, 0, 0, 0)
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 96px,
rgba(0, 0, 0, 0)
);
}
@utility linear-mask-util {
mask-image: linear-gradient(to bottom, white, white);
mask-image: linear-gradient(to bottom, white, white);
}
@utility grid-area-1-1 {
grid-area: 1 / 1;
grid-area: 1 / 1;
}
@utility gutter-stable {
@@ -95,6 +102,41 @@
border-top-right-radius: 30%;
}
&:before {
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
translate(0, -50%);
}
&:after {
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
}
position: relative;
background-color: orange;
text-align: left;
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
&:before {
content: "";
position: absolute;
background-color: inherit;
}
&:after {
content: "";
position: absolute;
background-color: inherit;
}
width: inherit;
height: inherit;
border-top-right-radius: 30%;
&:before {
width: inherit;
height: inherit;
border-top-right-radius: 30%;
}
&:after {
width: inherit;
height: inherit;
border-top-right-radius: 30%;
}
&:before {
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
translate(0, -50%);
@@ -110,14 +152,21 @@
word-break: break-word;
overflow-wrap: anywhere;
}
word-break: break-word;
@supports (overflow-wrap: anywhere) {
word-break: break-word;
overflow-wrap: anywhere;
}
}
@utility bg-gradient-primary {
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
}
@utility bg-gradient-tint {
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
}
@utility site-background {
@@ -137,6 +186,24 @@
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
@apply leading-relaxed;
@@ -613,275 +680,3 @@ html:has(.zoom-modal) {
}
}
}
/* Enter and exit animations, referenced by the `animation` theme in tailwind.config.ts. */
@keyframes present {
from {
opacity: 0;
transform: translateY(1rem) scale(90%);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: rotateX(-10deg) scale(0.9);
}
to {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scaleOut {
from {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 0;
transform: rotateX(-10deg) scale(0.95);
}
}
@keyframes blurIn {
0% {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
}
100% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOut {
0% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
100% {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
}
}
@keyframes blurInDisplay {
0% {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
display: none;
}
100% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOutDisplay {
0% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
to {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
display: none;
}
}
@keyframes enterFromLeft {
from {
opacity: 0;
transform: translateX(-50%);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes enterFromRight {
from {
opacity: 0;
transform: translateX(50%);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes exitToLeft {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(-50%);
display: none;
}
}
@keyframes exitToRight {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(50%);
display: none;
}
}
/*
* Reduced motion keeps the fades and drops the movement. Redefining the keyframes rather than
* disabling the animations keeps their duration, so code waiting on `animationend` is unaffected.
* Each variant keeps the identity transform and filter of the full animation's end state: with
* `fill-mode: both` they persist, and dropping them would change stacking and text rasterization.
*/
@media (prefers-reduced-motion: reduce) {
@keyframes present {
from {
opacity: 0;
transform: translateY(0) scale(1);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scaleOut {
from {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 0;
transform: rotateX(0deg) scale(1);
}
}
@keyframes blurIn {
from {
filter: blur(0px);
opacity: 0;
transform: scale(1);
}
to {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOut {
from {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
to {
filter: blur(0px);
opacity: 0;
transform: scale(1);
}
}
@keyframes blurInDisplay {
from {
filter: blur(0px);
opacity: 0;
transform: scale(1);
display: none;
}
to {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOutDisplay {
from {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
to {
filter: blur(0px);
opacity: 0;
transform: scale(1);
display: none;
}
}
@keyframes enterFromLeft {
from {
opacity: 0;
transform: translateX(0);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes enterFromRight {
from {
opacity: 0;
transform: translateX(0);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes exitToLeft {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(0);
display: none;
}
}
@keyframes exitToRight {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(0);
display: none;
}
}
/* A loader must keep showing activity, so it slows down instead of stopping. */
.animate-spin {
animation-duration: 3s;
}
}
@@ -0,0 +1,96 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '../primitives/Emoji/Emoji';
import { Link } from '../primitives/Link';
import type { LocalPageResult } from './useLocalSearchResults';
import { tcls } from '@/lib/tailwind';
/**
* Renders local search results above the main server results.
*
* Layout:
* - While server results are still fetching → 2-column wrapping row (cards fill width)
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
*
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
*/
export function LocalSearchResults({
results,
fetching,
}: {
results: LocalPageResult[];
fetching: boolean;
}) {
if (results.length === 0) return null;
return (
<div
className={tcls(
'py-2',
'flex',
'flex-row',
'gap-2',
fetching
? 'flex-wrap'
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
)}
>
{results.map((result) => (
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
))}
</div>
);
}
function LocalSearchResultCard({
result,
fetching,
}: {
result: LocalPageResult;
fetching: boolean;
}) {
return (
<Link
href={result.pathname}
className={tcls(
'group',
'flex',
'flex-col',
'gap-1',
'p-3',
'rounded-corners:rounded-lg',
'circular-corners:rounded-2xl',
'text-tint',
'bg-tint-subtle',
'hover:bg-tint',
'hover:text-tint-strong',
'transition-colors',
'cursor-pointer',
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
)}
>
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
{result.emoji ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Emoji code={result.emoji} style="text-base leading-none" />
</span>
) : result.icon ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Icon icon={result.icon as IconName} className="size-4" />
</span>
) : null}
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
{result.title}
</p>
<span className="ml-auto shrink-0 text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</span>
</div>
{result.description ? (
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
) : null}
</Link>
);
}
@@ -17,7 +17,7 @@ import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearchController } from './useSearchController';
import { useSearchPopupFocusTrap } from './useSearchPopupFocusTrap';
import { t, tString, useLanguage } from '@/intl/client';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
@@ -53,7 +53,6 @@ export function SearchContainer({
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
open,
query,
@@ -150,7 +149,6 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -205,7 +203,6 @@ export function SearchContainer({
active={isSearchOpen}
onClick={open}
className={className}
aria-label={tString(language, withSearchAI ? 'search_or_ask' : 'search')}
aria-expanded={isSearchOpen}
aria-controls={resultsId}
/>
@@ -217,7 +214,6 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -13,8 +13,6 @@ interface SearchInputProps {
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: () => void;
onInput?: (event: React.FormEvent<HTMLInputElement>) => void;
onCompositionEnd?: (event: React.CompositionEvent<HTMLInputElement>) => void;
value: string;
withAI: boolean;
isOpen: boolean;
@@ -2,18 +2,24 @@ import React from 'react';
import { Icon, type IconName } from '@gitbook/icons';
import { SkeletonParagraph } from '../primitives';
import { Tooltip } from '../primitives';
import { Emoji } from '../primitives/Emoji/Emoji';
import { getPageResultPresentation } from './getPageResultHref';
import { HighlightQuery } from './HighlightQuery';
import type { MergedPageResult } from './reciprocalRankFusion';
import type { ComputedPageResult } from './search-types';
import { SearchResultItem } from './SearchResultItem';
import type { LocalPageResult } from './useLocalSearchResults';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult;
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
props: {
query: string;
item: ComputedPageResult;
item: PageItem;
active: boolean;
style?: React.CSSProperties;
},
@@ -22,19 +28,46 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
const { preview: bestSection, description, href } = getPageResultPresentation(item);
const { bestSection, description, href } = (() => {
if (item.type === 'page') {
const presentation = getPageResultPresentation(item);
return {
bestSection: presentation.preview,
description: presentation.description,
href: presentation.href,
};
}
return { bestSection: undefined, description: item.description, href: item.pathname };
})();
const preview = bestSection
? [bestSection.title, bestSection.body].filter(Boolean).join(' · ')
: undefined;
const insights = {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
};
const emoji = 'emoji' in item ? item.emoji : undefined;
const icon = 'icon' in item ? item.icon : undefined;
const leadingIcon = emoji ? (
<span className="flex size-4 shrink-0 items-center justify-center">
<Emoji code={emoji} style="text-base leading-none" />
</span>
) : icon ? (
<Icon icon={icon as IconName} className="size-4" />
) : (
<Icon icon="file-lines" className="size-4" />
);
const insights =
item.type === 'page'
? {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}
: undefined;
return (
<SearchResultItem
@@ -43,7 +76,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
active={active}
data-testid="search-page-result"
action={tString(language, 'view')}
leadingIcon={<Icon icon="file-lines" className="size-4" />}
leadingIcon={leadingIcon}
insights={insights}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{
@@ -58,7 +91,11 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
!preview && !description ? 'h-0' : ''
item.type === 'local-page' && !preview && !description
? '[[aria-busy=false]_&]:h-0'
: !preview && !description
? 'h-0'
: ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
@@ -82,15 +119,26 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<HighlightQuery query={query} text={description} />
</p>
) : null}
{item.type === 'local-page' ? (
<SkeletonParagraph
size="small"
lines={1}
className={tcls(
'absolute inset-0 origin-left',
preview || description
? 'hidden animate-blur-out'
: '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out'
)}
style={{ animationDelay: style?.animationDelay }}
/>
) : null}
</div>
</SearchResultItem>
);
});
const Breadcrumbs = (props: {
breadcrumbs: ComputedPageResult['breadcrumbs'];
withOverflow?: boolean;
}) => {
const Breadcrumbs = (props: { breadcrumbs: PageItem['breadcrumbs']; withOverflow?: boolean }) => {
const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props;
if (!breadcrumbs || breadcrumbs.length === 0) return null;
@@ -19,12 +19,12 @@ import { tcls } from '@/lib/tailwind';
export interface SearchResultsRef {
select(): boolean;
/** Activate the first result, ignoring the cursor. */
selectFirst(): boolean;
}
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
return `page:${item.id}`;
case 'page':
return `page:${item.pageId}`;
case 'record':
@@ -107,11 +107,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
ref,
() => ({
select,
selectFirst: () => {
const first = refs.current[0];
first?.click();
return Boolean(first);
},
}),
[select]
);
@@ -119,7 +114,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error) {
if (error && results.length === 0) {
return (
<output
className={tcls(
@@ -162,6 +157,26 @@ export const SearchResults = React.forwardRef(function SearchResults(
</output>
);
const partialError = error ? (
<div
className={tcls(
'mt-3',
'rounded-corners:rounded-md',
'circular-corners:rounded-2xl',
'bg-tint-subtle',
'px-3',
'py-2',
'text-center',
'text-sm',
'text-tint-subtle',
'animate-blur-in-slow'
)}
role="status"
>
{t(language, 'search_partial_error')}
</div>
) : null;
return (
<output className="flex grow flex-col" aria-busy={fetching}>
{children}
@@ -187,7 +202,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
const handleResultSelect = (
event: React.MouseEvent<HTMLAnchorElement>
) => {
const isPageResult = item.type === 'page' || item.type === 'record';
const isPageResult =
item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record';
if (
isPageResult &&
@@ -220,6 +238,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
onClickCapture: handleResultSelect,
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<motion.div
@@ -334,6 +353,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{partialError}
{fetching ? (
<div
className={tcls(
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import type { OrderedComputedResult } from './search-types';
import { computeFilterSiteSpaceIds } from './filter';
describe('computeFilterSiteSpaceIds', () => {
const currentId = 'space-current';
@@ -35,31 +34,3 @@ describe('computeFilterSiteSpaceIds', () => {
expect(r).toEqual(['space-other-a', 'space-other-b']);
});
});
describe('filterResultsBySiteSpaces', () => {
const page = (id: string, siteSpaceId?: string): OrderedComputedResult => ({
type: 'page',
id,
title: id,
href: `/${id}`,
score: 1,
pageId: id,
spaceId: 'space',
siteSpaceId,
description: '',
rank: 1,
});
it('keeps the server order and drops pages outside the scope', () => {
const results = [page('a', 'current'), page('b', 'other'), page('c', 'current'), page('d')];
expect(filterResultsBySiteSpaces(results, ['current']).map((result) => result.id)).toEqual([
'a',
'c',
]);
});
it('returns every result when the scope is unrestricted', () => {
const results = [page('b', 'other'), page('a', 'current')];
expect(filterResultsBySiteSpaces(results, undefined)).toBe(results);
});
});
@@ -1,13 +1,16 @@
import type { OrderedComputedResult } from './search-types';
import type { SearchScope } from './useSearch';
/**
* Site spaces whose pages may be shown for a UI search scope, or `undefined` for no restriction.
* Map UI search scope to local FlexSearch filtering.
*
* The server's "default" scope also returns pages from other site spaces. Without sections
* (only variants) the UI presents "default" as the current variant, so it is restricted to
* the current `siteSpaceId`. An explicitly selected section takes precedence.
* Rationale: when a site has no sections (only variants), the remote search
* "default" scope effectively queries the current variant. To keep local
* results consistent with remote ones, we also restrict "default" to the
* current `siteSpaceId` when `withSections` is false. Otherwise (sections
* present), local "default" stays unfiltered to match the broader section UI.
* An explicitly selected section takes precedence over these legacy scopes.
*/
export function computeFilterSiteSpaceIds(
scope: SearchScope,
siteSpaceId: string,
@@ -28,21 +31,3 @@ export function computeFilterSiteSpaceIds(
return withSections ? undefined : [siteSpaceId];
}
}
/** Keep the server's order, dropping pages owned by a site space outside the scope. */
export function filterResultsBySiteSpaces(
results: OrderedComputedResult[],
allowedSiteSpaceIds: string[] | undefined
): OrderedComputedResult[] {
if (!allowedSiteSpaceIds) {
return results;
}
const allowed = new Set(allowedSiteSpaceIds);
return results.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
);
}
@@ -5,4 +5,5 @@ export * from './SearchContainer';
export * from './SearchScopeControl';
export * from './search-props';
export * from './useSearch';
export * from './useLocalSearchResults';
export * from './useSearchController';
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'bun:test';
import {
type MergedPageResult,
fuseSearchResults,
getResultKey,
reciprocalRankFusion,
} from './reciprocalRankFusion';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
function localPage(id: string, title = id, siteSpaceId = 'site-space-alpha'): LocalPageResult {
return {
type: 'local-page',
id,
siteSpaceId,
title,
pathname: `/${id}`,
description: `Local description for ${title}`,
breadcrumbs: [{ label: 'Local', icon: 'book-open' }],
};
}
function remotePage(
id: string,
title = id,
score = 0,
rank = 1,
spaceId = 'space-alpha',
siteSpaceId = 'site-space-alpha'
): OrderedComputedResult {
return {
type: 'page',
id: `remote-${id}`,
pageId: id,
spaceId,
siteSpaceId,
title,
description: `Remote description for ${title}`,
href: `/${id}`,
score,
rank,
breadcrumbs: [{ label: 'Remote' }],
};
}
function remoteRecord(id: string, title = id): OrderedComputedResult {
return {
type: 'record',
id,
title,
href: `/records/${id}`,
score: 0,
description: undefined,
};
}
describe('reciprocalRankFusion', () => {
it('keeps the first 3 remote results first and in remote order', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remoteRecord('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'record:remote-2',
'page:remote-3',
]);
});
it('applies fusion only after the pinned remote results', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'page:remote-2',
'page:remote-3',
]);
const firstFusedResult = results[3];
if (!firstFusedResult) {
throw new Error('Expected a fused result after the pinned remote results');
}
expect(getResultKey(firstFusedResult)).toBe('page:local-match');
});
it('pins all remote results when fewer than 3 are present', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[remoteRecord('remote-1'), remotePage('remote-2')],
'alpha'
);
expect(results.map(getResultKey)).toEqual([
'record:remote-1',
'page:remote-2',
'page:local-match',
]);
});
it('merges a pinned remote page with the matching local page without duplicating it', () => {
const results = reciprocalRankFusion(
[localPage('remote-1', 'Local title')],
[
remotePage('remote-1', 'Remote title', 1, 1),
remotePage('remote-2'),
remotePage('remote-3'),
],
'remote'
);
const pinnedResult = results[0] as MergedPageResult;
expect(pinnedResult.type).toBe('page');
expect(pinnedResult.title).toBe('Remote title');
expect(pinnedResult.pathname).toBe('/remote-1');
expect(pinnedResult.description).toBe('Remote description for Remote title');
expect(pinnedResult.rank).toBe(1);
expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]);
expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1);
});
it('does not duplicate pinned records in the fused tail', () => {
const results = reciprocalRankFusion(
[],
[
remoteRecord('record-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remoteRecord('record-1'),
remotePage('remote-4'),
],
'remote'
);
expect(results.map(getResultKey).filter((key) => key === 'record:record-1')).toHaveLength(
1
);
});
});
describe('fuseSearchResults', () => {
it('filters every source to the current site space before fusion', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('remote-alpha-1', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha-1', 'Alpha remote one', 10, 1),
remotePage(
'local-alpha',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
remotePage('remote-alpha-2', 'Alpha remote two', 8, 3),
],
query: 'result',
allowedSiteSpaceIds: ['site-space-alpha'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha-1',
'page:remote-alpha-2',
'page:local-alpha',
]);
expect(results[0]).not.toHaveProperty('pathname');
expect(results[2]?.type).toBe('local-page');
});
it('preserves cross-site-space results when the search is unrestricted', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('local-beta', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha', 'Alpha remote result', 10, 1),
remotePage(
'remote-beta',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
],
query: 'result',
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
it('preserves every allowed site space in an intentionally multi-space search', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha'),
localPage('local-beta', 'local-beta', 'site-space-beta'),
localPage('local-gamma', 'local-gamma', 'site-space-gamma'),
],
remoteResults: [
remotePage('remote-alpha'),
remotePage('remote-beta', 'remote-beta', 0, 2, 'space-beta', 'site-space-beta'),
remotePage('remote-gamma', 'remote-gamma', 0, 3, 'space-gamma', 'site-space-gamma'),
],
query: 'page',
allowedSiteSpaceIds: ['site-space-alpha', 'site-space-beta'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
});
@@ -0,0 +1,271 @@
import type { ComputedPageResult, OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
/**
* Jaro-Winkler similarity between two strings, in the range [0, 1].
* Higher is more similar. 1 means an exact match.
* https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
*/
function jaroWinkler(s1: string, s2: string, p = 0.1): number {
if (s1 === s2) return 1;
const len1 = s1.length;
const len2 = s2.length;
if (len1 === 0 || len2 === 0) return 0;
const maxDist = Math.floor(Math.max(len1, len2) / 2) - 1;
const matched1 = new Array<boolean>(len1);
const matched2 = new Array<boolean>(len2);
for (let i = 0; i < len1; i++) matched1[i] = false;
for (let i = 0; i < len2; i++) matched2[i] = false;
let matches = 0;
for (let i = 0; i < len1; i++) {
const start = Math.max(0, i - maxDist);
const end = Math.min(len2 - 1, i + maxDist);
for (let j = start; j <= end; j++) {
if (!matched2[j] && s1.charCodeAt(i) === s2.charCodeAt(j)) {
matched1[i] = true;
matched2[j] = true;
matches++;
break;
}
}
}
if (matches === 0) return 0;
let t = 0;
let k = 0;
for (let i = 0; i < len1; i++) {
if (!matched1[i]) continue;
while (!matched2[k]) k++;
if (s1.charCodeAt(i) !== s2.charCodeAt(k)) t++;
k++;
}
const transpositions = t / 2;
const m = matches;
const jaro = (m / len1 + m / len2 + (m - transpositions) / m) / 3;
let prefix = 0;
const maxPrefix = 4;
const minLen = Math.min(len1, len2);
for (let i = 0; i < maxPrefix && i < minLen; i++) {
if (s1.charCodeAt(i) === s2.charCodeAt(i)) prefix++;
else break;
}
return jaro + prefix * p * (1 - jaro);
}
/** Standard RRF constant — moderates the impact of high-ranked items. */
const RRF_K = 60;
/** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */
const RRF_K_LOCAL = 50;
/** Number of remote results that should keep their API order before fusion. */
const PINNED_REMOTE_RESULTS_COUNT = 3;
/**
* A page result that was present in both local and remote lists.
* Local fields (icon, emoji, pathname) are carried over as a base, and remote fields
* (description, href, pageId, spaceId, title) override them.
* Breadcrumbs prefer local (has icon + emoji) and fall back to remote.
*/
export type MergedPageResult = Omit<ComputedPageResult, 'breadcrumbs'> & {
pathname?: string;
icon?: string;
emoji?: string;
breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs'];
};
/**
* Derive a stable deduplication key for any result type.
* Pages from both local and remote share the same `page:` namespace so they
* can be deduplicated against each other.
*/
export function getResultKey(
result: LocalPageResult | OrderedComputedResult | MergedPageResult
): string {
switch (result.type) {
case 'local-page':
return `page:${result.id}`;
case 'page':
return `page:${result.pageId}`;
case 'record':
return `record:${result.id}`;
}
}
type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult;
/** Filter page candidates at the final assembly boundary, immediately before ranking and fusion. */
export function fuseSearchResults(args: {
localResults: LocalPageResult[];
remoteResults: OrderedComputedResult[];
query: string;
allowedSiteSpaceIds?: string[];
}): RRFResult[] {
const { localResults, remoteResults, query, allowedSiteSpaceIds } = args;
if (!allowedSiteSpaceIds) {
return reciprocalRankFusion(localResults, remoteResults, query);
}
const allowed = new Set(allowedSiteSpaceIds);
return reciprocalRankFusion(
localResults.filter((result) => allowed.has(result.siteSpaceId)),
remoteResults.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
),
query
);
}
function mergeLocalPageWithRemotePage(
localResult: LocalPageResult,
remoteResult: ComputedPageResult
): MergedPageResult {
return {
...localResult,
...remoteResult,
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
breadcrumbs: localResult.breadcrumbs ?? remoteResult.breadcrumbs,
};
}
function mergePinnedRemoteResult(
result: OrderedComputedResult,
localResultsByKey: Map<string, LocalPageResult>
): RRFResult {
if (result.type !== 'page') {
return result;
}
const localResult = localResultsByKey.get(getResultKey(result));
if (!localResult) {
return result;
}
return mergeLocalPageWithRemotePage(localResult, result);
}
/**
* Merge local (FlexSearch) and remote (API) search results using
* Reciprocal Rank Fusion (RRF), while preserving the order of the first
* three remote results.
*
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
*
* The pinned remote results are excluded from fusion and returned first. Pages
* present in both lists are deep-merged: local fields act as the base
* (preserving icon, emoji, pathname) and remote fields override (providing the
* authoritative page description, href, pageId, spaceId, and title). Breadcrumbs prefer local (has icon
* + emoji) and fall back to remote. In the fused tail, their rank contributions
* from both lists are summed.
*
* Sections and records have no local equivalent and only accumulate their own
* rank contribution.
*
* Additionally, a Jaro-Winkler title-match bonus is applied per query word:
* for each query word that appears as a substring in the title, the best
* Jaro-Winkler similarity score against any individual title word is added.
*/
export function reciprocalRankFusion(
localResults: LocalPageResult[],
remoteResults: OrderedComputedResult[],
query: string
): RRFResult[] {
const localResultsByKey = new Map(localResults.map((result) => [getResultKey(result), result]));
const pinnedRemoteResults = remoteResults.slice(0, PINNED_REMOTE_RESULTS_COUNT);
const pinnedResultKeys = new Set(pinnedRemoteResults.map((result) => getResultKey(result)));
const remainingLocalResults = localResults.filter(
(result) => !pinnedResultKeys.has(getResultKey(result))
);
const remainingRemoteResults = remoteResults
.slice(PINNED_REMOTE_RESULTS_COUNT)
.filter((result) => !pinnedResultKeys.has(getResultKey(result)));
// Map from dedup key → { result, score }
const scoreMap = new Map<string, { result: RRFResult; score: number }>();
// Process local results first (1-indexed rank)
// Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones.
remainingLocalResults.forEach((result, index) => {
const rank = index + 1;
const key = getResultKey(result);
const contribution = 1 / (RRF_K_LOCAL + rank);
const existing = scoreMap.get(key);
if (existing) {
existing.score += contribution;
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Process remote results, deduplicating against local pages
remainingRemoteResults.forEach((result, index) => {
const rank = index + 1;
const contribution = 1 / (RRF_K + rank);
const key = getResultKey(result);
const existing = scoreMap.get(key);
if (existing) {
// Page found in both lists: sum rank contributions and deep-merge.
// Local is the base (icon, emoji, pathname), while remote provides page fields.
existing.score += contribution;
if (existing.result.type === 'local-page' && result.type === 'page') {
existing.result = mergeLocalPageWithRemotePage(existing.result, result);
} else {
existing.result = result;
}
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Apply a title-match bonus per query word using Jaro-Winkler similarity.
// For each query word that appears (as a substring) in the title, compute the
// best Jaro-Winkler score against individual title words and add it to the score.
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (queryWords.length > 0) {
for (const entry of scoreMap.values()) {
// Only apply the bonus to page or local-page results
if (entry.result.type === 'record') continue;
const title = entry.result.title?.toLowerCase() ?? '';
const titleWords = title.split(/\s+/).filter((w) => w.length > 0);
for (const word of queryWords) {
// Only apply when there is at least a substring match in the title.
if (!title.includes(word)) continue;
let best = 0;
for (const titleWord of titleWords) {
const sim = jaroWinkler(word, titleWord);
if (sim > best) best = sim;
}
entry.score += best;
}
}
}
// Sort descending by RRF score
const fusedResults = Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
.map(({ result }) => result);
return [
...pinnedRemoteResults.map((result) => mergePinnedRemoteResult(result, localResultsByKey)),
...fusedResults,
];
}
@@ -25,6 +25,7 @@ export interface SearchBaseProps {
sections: SearchSectionItem[];
withVariants: boolean;
withSiteVariants: boolean;
indexURL: string;
searchURL: string;
}
@@ -46,6 +47,7 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
visibleSections?.list.some(
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
) ?? false,
indexURL: context.linker.toPathInSite('~gitbook/site-index'),
withVariants: visibleSiteSpaces.length > 1,
};
}
@@ -1,5 +1,15 @@
'use client';
/**
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
*
* Loading strategy: the download starts as early as possible (server-rendered
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
* text so the multi-MB `JSON.parse` — main-thread work — is only paid when a
* consumer actually needs the pages.
*/
export interface SiteIndexBreadcrumb {
label: string;
icon?: string;
@@ -20,14 +30,50 @@ export interface SiteIndexPage {
breadcrumbs?: SiteIndexBreadcrumb[];
}
/** Fetch the pages of the site from the `~gitbook/site-index` JSON. */
let siteIndexText: Promise<string> | null = null;
/**
* Start (or reuse) the single-flight download of the raw index. Errors clear the
* cache so the next consumer retries.
*/
export function prefetchSiteIndex(indexURL: string): void {
fetchSiteIndexText(indexURL).catch(() => {
// Ignored: consumers surface errors when they actually read the index.
});
}
/**
* Get the parsed index pages. Parses per call (cheap for the rare second
* consumer) so the parsed object graph is never retained at module scope.
*/
export async function fetchSiteIndex(
indexURL: string
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
const response = await fetch(indexURL);
if (!response.ok) {
throw new Error(`Failed to fetch site index: ${response.status}`);
return JSON.parse(await fetchSiteIndexText(indexURL));
}
/**
* Drop the cached raw text (several MB for large sites) once a consumer has
* turned it into a longer-lived form. Purely a memory release: a later consumer
* re-fetches, hitting the HTTP cache.
*/
export function releaseSiteIndex(): void {
siteIndexText = null;
}
function fetchSiteIndexText(indexURL: string): Promise<string> {
if (!siteIndexText) {
siteIndexText = fetch(indexURL).then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
return response.text();
});
siteIndexText.catch(() => {
siteIndexText = null;
});
}
return response.json();
return siteIndexText;
}
@@ -0,0 +1,262 @@
'use client';
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
import {
type SiteIndexBreadcrumb,
type SiteIndexPage,
fetchSiteIndex,
prefetchSiteIndex,
releaseSiteIndex,
} from './site-index';
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
interface IndexPage {
[key: string]: DocumentValue | DocumentValue[];
id: string;
title: string;
description: string | null;
siteSpaceId: string;
}
/** Result type returned by this hook */
export interface LocalPageResult {
type: 'local-page';
id: string;
siteSpaceId: string;
title: string;
pathname: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: SiteIndexBreadcrumb[];
}
type LocalSearchState = {
results: LocalPageResult[];
fetching: boolean;
error: boolean;
};
// Module-level singletons — one Document per language per session.
// Keys are the page's `lang` value, or `''` when no language is set.
const cachedIndexes = new Map<string, Document<IndexPage>>();
// Side-map for data that doesn't belong in the FlexSearch index.
// Keyed by page id, shared across all language groups.
const cachedPageData = new Map<
string,
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: SiteIndexBreadcrumb[] }
>();
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: SiteIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
id: 'id',
index: ['title', 'description'],
store: ['id', 'title', 'description', 'siteSpaceId'],
tag: 'siteSpaceId',
},
tokenize: 'full',
resolution: 15,
encoder: 'Normalize',
});
for (const page of pages) {
index
.addAsync({
id: page.id,
title: page.title,
description: page.description ?? null,
siteSpaceId: page.siteSpaceId,
})
.catch(() => {
// We just ignore these errors, it's not worth failing the whole index for a single bad record
// And we still have remote search as a fallback for these cases
});
cachedPageData.set(`${page.siteSpaceId}:${page.id}`, {
pathname: page.pathname,
icon: page.icon,
emoji: page.emoji,
breadcrumbs: page.breadcrumbs?.length ? page.breadcrumbs : undefined,
});
}
return index;
}
async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document<IndexPage>>> {
if (cachedIndexes.size > 0) {
return cachedIndexes;
}
if (pendingFetch) {
return pendingFetch;
}
pendingFetch = (async () => {
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
const [{ Document }, data] = await Promise.all([
import('flexsearch'),
fetchSiteIndex(indexURL),
]);
// Group pages by their `lang` value (empty string for pages without one)
const pagesByLang = new Map<string, SiteIndexPage[]>();
for (const page of data.pages) {
const key = page.lang ?? '';
const bucket = pagesByLang.get(key);
if (bucket) {
bucket.push(page);
} else {
pagesByLang.set(key, [page]);
}
}
// Build one FlexSearch Document per language group
for (const [lang, pages] of pagesByLang) {
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
releaseSiteIndex();
return cachedIndexes;
})();
// Clear pendingFetch on error so a retry is possible
pendingFetch.catch(() => {
console.error('Error fetching/building search index', indexURL);
pendingFetch = null;
});
return pendingFetch;
}
export function useLocalSearchResults(props: {
query: string;
indexURL: string;
/** BCP-47 language code of the current site space. When provided, only results
* from pages with a matching language are returned. */
lang?: string;
/** When provided, only results from pages belonging to one of these site space IDs
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
filterSiteSpaceIds?: string[];
disabled?: boolean;
/** Whether the search surface is open. */
open: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
fetching: false,
error: false,
});
// Track whether the indexes are loaded so the search effect re-runs after load
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
const active = open || Boolean(query);
// Load the indexes once
React.useEffect(() => {
if (cachedIndexes.size > 0) {
setIndexReady(true);
return;
}
prefetchSiteIndex(indexURL);
if (!active) {
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, fetching: true, error: false }));
getOrBuildIndexes(indexURL)
.then(() => {
if (!cancelled) {
setIndexReady(true);
setState((prev) => ({ ...prev, fetching: false }));
}
})
.catch(() => {
if (!cancelled) {
setState({ results: [], fetching: false, error: true });
}
});
return () => {
cancelled = true;
};
}, [indexURL, active]);
// Perform instant local search whenever query, lang, or index readiness changes
React.useEffect(() => {
// Resolve the per-language index to query. When `lang` is not set we fall
// back to the `''` bucket (pages with no language tag).
const langKey = lang ?? '';
const index = cachedIndexes.get(langKey);
if (disabled || !indexReady || !index) {
return;
}
if (!query) {
setState({ results: [], fetching: false, error: false });
return;
}
//@ts-ignore - Typing is wrong here, tags can be arrays when using the `tag` filter option
const rawResults = index.search(query, {
enrich: true,
limit: 5,
suggest: true,
...(filterSiteSpaceIds
? {
tag: {
siteSpaceId: filterSiteSpaceIds,
},
}
: {}),
});
// Flatten and deduplicate results across fields (flexsearch returns one array per indexed field)
const seen = new Set<string>();
const results: LocalPageResult[] = [];
for (const fieldResult of rawResults) {
for (const item of fieldResult.result) {
const doc = (item as { id: string; doc: IndexPage }).doc;
const cacheKey = `${doc.siteSpaceId}:${doc.id}`;
if (!seen.has(cacheKey)) {
seen.add(cacheKey);
const extra = cachedPageData.get(cacheKey);
results.push({
type: 'local-page',
id: doc.id,
siteSpaceId: doc.siteSpaceId,
title: doc.title,
pathname: extra?.pathname ?? '',
icon: extra?.icon,
emoji: extra?.emoji,
description: (doc.description as string | null) ?? undefined,
breadcrumbs: extra?.breadcrumbs,
});
}
}
}
setState({ results, fetching: false, error: false });
}, [query, lang, filterSiteSpaceIds, indexReady, disabled]);
return state;
}
@@ -19,7 +19,6 @@ import type { SearchResultsRef } from './SearchResults';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
import { isComposingKeyEvent } from '@/lib/keyboard';
import { getLocalizedTitle } from '@/lib/sites';
function useInitialAskBootstrap(props: {
@@ -73,97 +72,17 @@ function useFilteredSiteSpaceIds(props: {
);
}
/** Edits that complete a query rather than continue typing it. */
const COMPLETING_INPUT_TYPES = new Set([
'insertFromPaste',
'insertFromDrop',
'insertReplacementText',
'insertFromYank',
'insertFromComposition',
]);
/**
* Read the intent of each edit from the native input event: completed edits are sent without the
* idle delay and a typed word boundary sends the words so far. IME composition text is searched
* like typing; committing it sends at once.
*/
function useSearchInputIntent() {
const immediateQueryRef = React.useRef<string | null>(null);
const flushRef = React.useRef<() => void>(() => {});
const consumeImmediate = React.useCallback((query: string) => {
const immediate = immediateQueryRef.current === query;
immediateQueryRef.current = null;
return immediate;
}, []);
const onInput = React.useCallback((event: React.FormEvent<HTMLInputElement>) => {
const native = event.nativeEvent as InputEvent;
if (native.isComposing) {
return;
}
const query = event.currentTarget.value.trim();
if (COMPLETING_INPUT_TYPES.has(native.inputType)) {
immediateQueryRef.current = query;
} else if (
native.inputType === 'insertText' &&
native.data &&
/^\s+$/.test(native.data) &&
query
) {
// A typed space leaves the trimmed query unchanged, so send the pending words now.
flushRef.current();
}
}, []);
const onCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
// The committed text is usually already the query, so send its pending request now.
immediateQueryRef.current = event.currentTarget.value.trim();
flushRef.current();
},
[]
);
return {
consumeImmediate,
flushRef,
inputHandlers: { onInput, onCompositionEnd },
};
}
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
fetching: boolean;
error: boolean;
isOpen: boolean;
requestKey: string;
flush: () => void;
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const {
query,
results,
fetching,
error,
isOpen,
requestKey,
flush,
resultsRef,
abort,
askCount,
onAskSelect,
} = props;
// Enter pressed before the answer arrived: open the top result of this exact query and scope,
// or Ask when that answer is empty. Closing the search cancels it.
const pendingOpenRef = React.useRef<string | null>(null);
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
fetching,
resultCount: results.length,
totalCount: results.length + askCount,
});
@@ -172,13 +91,11 @@ function useSearchKeyboardNavigation(props: {
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(1);
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault();
if (cursor !== null && cursor >= results.length) {
onAskSelect(cursor - results.length);
@@ -188,51 +105,12 @@ function useSearchKeyboardNavigation(props: {
if (resultsRef.current?.select()) {
// Stop any in-flight search request only when activating a result.
abort();
return;
}
if (query && fetching) {
pendingOpenRef.current = requestKey;
flush();
}
}
},
[
moveCursorBy,
cursor,
results.length,
onAskSelect,
resultsRef,
abort,
query,
fetching,
requestKey,
flush,
]
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
);
React.useEffect(() => {
const pending = pendingOpenRef.current;
if (pending === null) {
return;
}
if (!isOpen || pending !== requestKey) {
pendingOpenRef.current = null;
return;
}
if (fetching) {
return;
}
pendingOpenRef.current = null;
if (results.length > 0) {
if (resultsRef.current?.selectFirst()) {
abort();
}
} else if (!error && askCount > 0) {
onAskSelect(0);
}
}, [isOpen, requestKey, fetching, error, results, resultsRef, abort, askCount, onAskSelect]);
return {
cursor,
onInputKeyDown,
@@ -253,6 +131,7 @@ export function useSearchController(
withSections,
siteSpaces,
searchURL,
indexURL,
} = props;
const { assistants, config } = useAI();
@@ -369,10 +248,10 @@ export function useSearchController(
});
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
const { consumeImmediate, flushRef, inputHandlers } = useSearchInputIntent();
const { results, fetching, error, requestKey, flush, abort } = useSearchResults({
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -380,10 +259,10 @@ export function useSearchController(
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
indexURL,
lang: siteSpace.space.language,
withSections,
consumeImmediate,
});
flushRef.current = flush;
const searchValue =
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
@@ -426,11 +305,6 @@ export function useSearchController(
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
fetching,
error,
isOpen: Boolean(state?.open),
requestKey,
flush,
resultsRef,
abort,
askCount,
@@ -457,7 +331,6 @@ export function useSearchController(
setQuery,
state,
onInputKeyDown,
inputHandlers,
showAsk,
withAI,
withSearchAI,
@@ -8,21 +8,26 @@ import {
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import { computeFilterSiteSpaceIds } from './filter';
import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, fuseSearchResults } from './reciprocalRankFusion';
import { computeRemoteSearchScope } from './remote-scope';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
import type { SearchScope } from './useSearch';
import { useAI } from '@/components/AI';
export type ResultType = OrderedComputedResult | RecommendedQuestionResult;
export type ResultType =
| OrderedComputedResult
| LocalPageResult
| MergedPageResult
| RecommendedQuestionResult;
type RemoteSearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; requestKey: string; results: OrderedComputedResult[] }
| { status: 'error' };
export type { LocalPageResult, MergedPageResult };
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
/**
* We cache the recommended questions globally to avoid calling the API multiple times
@@ -35,6 +40,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
/** Whether the search surface is open. Gates building the local search index. */
open: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -43,14 +50,17 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** Whether the site has multiple sections. If false, the default scope only shows the current site space. */
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
indexURL: string;
/** BCP-47 language code of the current site space, used to filter local search results. */
lang?: string;
/** Whether the site has multiple sections. If false, treat default scope as current for local filtering. */
withSections?: boolean;
/** Returns true when the latest edit completed the query (paste, IME commit), so it is sent without the idle delay. */
consumeImmediate?: (query: string) => boolean;
}) {
const {
asEmbeddable,
disabled,
open,
query,
siteSpaceId,
siteSpaceIds,
@@ -58,8 +68,9 @@ export function useSearchResults(props: {
scope,
suggestions,
searchURL,
indexURL,
lang,
withSections,
consumeImmediate,
} = props;
const trackEvent = useTrackEvent();
@@ -76,21 +87,24 @@ export function useSearchResults(props: {
[scope, siteSpaceId, siteSpaceIds, withSections, selectedSectionSiteSpaceIds]
);
const remoteScope = React.useMemo(
() =>
computeRemoteSearchScope(scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds),
[scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds]
);
// Identifies the answer the UI needs now; an answer for any other key is never shown as current.
const requestKey = JSON.stringify([query, remoteScope]);
const { results: localResults } = useLocalSearchResults({
query,
indexURL,
lang,
disabled,
open,
filterSiteSpaceIds,
});
const [remoteState, setRemoteState] = React.useState<RemoteSearchState>({ status: 'idle' });
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], fetching: false, error: false });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
const abortRef = React.useRef<(() => void) | null>(null);
// Sends the pending request now instead of waiting for the idle delay.
const flushRef = React.useRef<(() => void) | null>(null);
const { assistants } = useAI();
const withAI = assistants.length > 0;
@@ -102,7 +116,11 @@ export function useSearchResults(props: {
}
if (!query) {
if (!withAI) {
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
@@ -113,11 +131,19 @@ export function useSearchResults(props: {
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
// Recommended questions are stored as ResultType[] already
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
let cancelled = false;
@@ -130,7 +156,11 @@ export function useSearchResults(props: {
suggestions.forEach((question) => {
questions.add(question);
});
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
@@ -156,7 +186,11 @@ export function useSearchResults(props: {
if (!cancelled) {
// Recommended questions are handled via a separate path below
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
}
}
}, 100);
@@ -166,18 +200,28 @@ export function useSearchResults(props: {
clearTimeout(timeout);
};
}
setRemoteState({ status: 'loading' });
setRemoteState({
results: [],
fetching: true,
error: false,
});
let cancelled = false;
const abortController = new AbortController();
const send = async () => {
flushRef.current = null;
const timeout = setTimeout(async () => {
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
try {
const resultsPromise = fetchSearch(remoteScope);
const resultsPromise = fetchSearch(
computeRemoteSearchScope(
scope,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds
)
);
const onResults = (results: OrderedComputedResult[]) => {
if (cancelled) {
@@ -186,34 +230,38 @@ export function useSearchResults(props: {
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState({ status: 'error' });
setRemoteState((prev) => ({ ...prev, error: true }));
return;
}
setRemoteState({ status: 'ready', requestKey, results });
setRemoteState((prev) => ({ ...prev, results }));
trackEvent({ type: 'search_type_query', query });
};
const onError = () => {
if (cancelled) {
return;
}
setRemoteState({ status: 'error' });
setRemoteState((prev) => ({ ...prev, error: true }));
};
await resultsPromise.then(onResults, onError);
if (cancelled) {
return;
}
setRemoteState((prev) => ({ ...prev, fetching: false }));
} catch {
// If there is an error, we need to catch it to avoid infinite loading state.
if (cancelled) {
return;
}
setRemoteState({ status: 'error' });
setRemoteState({
results: [],
fetching: false,
error: true,
});
}
};
const timeout = setTimeout(send, consumeImmediate?.(query) ? 0 : 150);
flushRef.current = () => {
clearTimeout(timeout);
void send();
};
}, 200);
abortRef.current = () => {
cancelled = true;
@@ -226,32 +274,29 @@ export function useSearchResults(props: {
clearTimeout(timeout);
abortController.abort();
abortRef.current = null;
flushRef.current = null;
};
}, [
query,
remoteScope,
requestKey,
scope,
trackEvent,
withAI,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds,
disabled,
suggestions,
searchURL,
asEmbeddable,
consumeImmediate,
]);
const flush = React.useCallback(() => {
flushRef.current?.();
}, []);
const abort = React.useCallback(() => {
abortRef.current?.();
abortRef.current = null;
setRemoteState((prev) => (prev.status === 'loading' ? { status: 'idle' } : prev));
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
}, []);
// Merge local and remote results.
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
if (!query) {
const recommendedQuestions =
@@ -268,12 +313,15 @@ export function useSearchResults(props: {
});
}
return remoteState.status === 'ready' && remoteState.requestKey === requestKey
? filterResultsBySiteSpaces(remoteState.results, filterSiteSpaceIds)
: [];
return fuseSearchResults({
localResults,
remoteResults: remoteState.results,
query,
allowedSiteSpaceIds: filterSiteSpaceIds,
});
}, [
remoteState,
requestKey,
localResults,
remoteState.results,
query,
filterSiteSpaceIds,
withAI,
@@ -282,15 +330,10 @@ export function useSearchResults(props: {
recentQueries,
]);
// Between a query change and its loading state, the previous answer must still read as loading.
const isOutdated = remoteState.status === 'ready' && remoteState.requestKey !== requestKey;
return {
results,
fetching: remoteState.status === 'loading' || (Boolean(query) && isOutdated),
error: remoteState.status === 'error',
requestKey,
flush,
fetching: remoteState.fetching,
error: remoteState.error,
abort,
};
}
@@ -2,12 +2,11 @@ import React from 'react';
export function useSearchResultsCursor(props: {
query: string;
fetching: boolean;
resultCount: number;
totalCount: number;
}) {
const [cursor, setCursor] = React.useState<number | null>(null);
const { query, fetching, resultCount, totalCount } = props;
const { query, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
@@ -17,12 +16,6 @@ export function useSearchResultsCursor(props: {
}, [query]);
React.useEffect(() => {
if (fetching && resultCount === 0) {
// Nothing is highlighted while results load, so Enter can't open the Ask row instead.
setCursor(null);
return;
}
if (totalCount === 0) {
return;
}
@@ -34,7 +27,7 @@ export function useSearchResultsCursor(props: {
return prev ?? 0;
});
}, [fetching, resultCount, totalCount]);
}, [resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
@@ -71,6 +71,15 @@ export async function SiteLayout(props: {
}
});
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
// index downloads twice — the omission was exactly that bug before.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
});
scripts.forEach(({ script }) => {
if (!isDeferrableScript(script)) {
ReactDOM.preload(script, {

Some files were not shown because too many files have changed in this diff Show More