mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-14 00:50:02 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dca38796d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Bump Next.js to 16.4.3, keeping the dev-mode OOM patch.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix horizontal page scroll caused by card carousels bleeding past the viewport.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Split llms-full.txt into parts by size so large sites no longer fail with "content too large"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Follow site and space redirects when the MCP getPage tool resolves a page URL.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Show one server-ordered search list and open its top result on Enter.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.4",
|
||||
"devDependencies": {
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -542,7 +542,3 @@ button: {
|
||||
icon: 'assistant'
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
@@ -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
Reference in New Issue
Block a user