Compare commits

..

3 Commits

Author SHA1 Message Date
Peter White 3c4ff5527d Merge remote-tracking branch 'origin/main' into peter/remove-local-search 2026-11-12 14:42:24 +00:00
Peter White 238cf5c824 changeset 2026-11-12 14:42:18 +00:00
Peter White 575bfdfb2e Show only server results in site search 2026-11-12 14:42:18 +00:00
108 changed files with 423 additions and 1934 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix horizontal page scroll caused by card carousels bleeding past the viewport.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show only the server's results in site search, with a loading state until they arrive.
+16 -42
View File
@@ -4,11 +4,6 @@ on:
push:
branches:
- main
# A newer run for the same PR supersedes the previous one. Each push to main gets a group of its
# own, so main runs are never queued behind or cancelled by another run.
concurrency:
group: ${{ github.event_name == 'pull_request_target' && format('preview-pr-{0}', github.event.pull_request.number) || format('preview-run-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
@@ -23,9 +18,8 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -50,9 +44,8 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -89,7 +82,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: ${{ !cancelled() && github.event_name != 'push' }}
if: always() && github.event_name != 'push'
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -102,25 +95,6 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
# A deploy that didn't succeed has no URL (e.g. a re-run hitting Vercel's "deploymentId
# already exists"), so keep the URL the comment already shows instead of blanking it.
- name: Resolve deployment URLs
id: urls
env:
PREVIOUS_COMMENT: ${{ steps.fc.outputs.comment-body }}
VERCEL_RESULT: ${{ needs.deploy-v2-vercel.result }}
VERCEL_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
CLOUDFLARE_RESULT: ${{ needs.deploy-v2-cloudflare.result }}
CLOUDFLARE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
run: |
previous_url() {
sed -n "s/^| $1 | \[\([^]]*\)\].*/\1/p" <<<"$PREVIOUS_COMMENT" | head -n 1
}
if [ "$VERCEL_RESULT" != success ]; then VERCEL_URL=$(previous_url Vercel); fi
if [ "$CLOUDFLARE_RESULT" != success ]; then CLOUDFLARE_URL=$(previous_url Cloudflare); fi
echo "vercel=$VERCEL_URL" >> "$GITHUB_OUTPUT"
echo "cloudflare=$CLOUDFLARE_URL" >> "$GITHUB_OUTPUT"
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
with:
@@ -131,21 +105,21 @@ jobs:
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ steps.urls.outputs.vercel }}](${{ steps.urls.outputs.vercel }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ steps.urls.outputs.cloudflare }}](${{ steps.urls.outputs.cloudflare }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | `2v` | `2c` |
| --- | --- | --- |
| GitBook | [${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs](${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs](${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs) |
| E2E | [${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open) |
| GitBook | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v2-vercel:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -169,7 +143,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -193,7 +167,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -217,7 +191,7 @@ jobs:
# Style recalc depends on the client bundle, not the host, so this runs here only and
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
- name: Run style invalidation tests
if: ${{ !cancelled() }}
if: always()
working-directory: packages/gitbook
run: bun run e2e-style-perf
env:
@@ -226,7 +200,7 @@ jobs:
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
- name: Build style invalidation report
if: ${{ !cancelled() && github.event_name != 'push' }}
if: always() && github.event_name != 'push'
id: style-perf
working-directory: packages/gitbook
run: |
@@ -245,7 +219,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Find style invalidation comment
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/find-comment@v3
id: fc-style-perf
with:
@@ -254,7 +228,7 @@ jobs:
body-includes: 'Style invalidation on a large API reference'
- name: Create or update style invalidation comment
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
@@ -265,7 +239,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -289,7 +263,7 @@ jobs:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
+5 -8
View File
@@ -8,7 +8,7 @@
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.93.0",
"turbo": "^2.11.11",
"turbo": "^2.11.10",
"vercel": "50.37.3",
},
},
@@ -49,7 +49,7 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.7.1",
"version": "0.7.0",
"dependencies": {
"@gitbook/api": "catalog:",
"bidc": "catalog:",
@@ -109,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.28.3",
"version": "0.28.2",
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
@@ -142,7 +142,6 @@
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"flexsearch": "^0.8.212",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
@@ -227,7 +226,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.5.1",
"version": "0.5.0",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -265,7 +264,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.19",
"version": "0.7.18",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -2120,8 +2119,6 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="],
"focus-trap": ["focus-trap@7.6.1", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-nB8y4nQl8PshahLpGKZOq1sb0xrMVFSn6at7u/qOsBZTlZRzaapISGENcB6mOkoezbClZyiMwEF/dGY8AZ00rA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/embed
## 0.7.1
### Patch Changes
- 65210b7: 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.
## 0.7.0
### Minor Changes
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.7.1",
"version": "0.7.0",
"license": "Apache-2.0",
"dependencies": {
"@gitbook/api": "catalog:",
-2
View File
@@ -158,8 +158,6 @@
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- the `vh` declaration above is the fallback on host pages whose browser lacks `dvh` */
height: calc(min(var(--gitbook-widget-window-height), calc(100dvh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
-19
View File
@@ -1,24 +1,5 @@
# gitbook
## 0.28.3
### Patch Changes
- 0c3a51e: Fix horizontal page scroll caused by card carousels bleeding past the viewport.
- 5997a66: Remove duplicated declarations from the global stylesheet.
- 265ca4b: Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
- 9b5b8c8: Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
- 19660ed: 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.
- 19660ed: Fix the search button accessible name, hoverable link previews, IME-safe Enter on Safari, cookie banner semantics, active outline item announcement and textarea shrinking.
- 65210b7: 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.
- 3e86382: Send only the site origin as referrer to third parties, and only let the embedding page open the embed messaging channel.
- 7d1cd9f: Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
- Updated dependencies [265ca4b]
- Updated dependencies [65210b7]
- @gitbook/icons@0.5.1
- @gitbook/embed@0.7.1
- @gitbook/react-contentkit@0.7.19
## 0.28.2
### Patch Changes
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.28.3",
"version": "0.28.2",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
@@ -34,7 +34,6 @@
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"flexsearch": "^0.8.212",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
@@ -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(
@@ -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>
);
}
@@ -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
@@ -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.
*/
@@ -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]);
@@ -82,7 +82,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;
}
}
@@ -1,96 +0,0 @@
'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), {
@@ -203,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}
/>
@@ -2,24 +2,18 @@ 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: PageItem;
item: ComputedPageResult;
active: boolean;
style?: React.CSSProperties;
},
@@ -28,47 +22,11 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
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, description, href } = getPageResultPresentation(item);
const preview = bestSection
? [bestSection.title, bestSection.body].filter(Boolean).join(' · ')
: undefined;
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
ref={ref}
@@ -76,8 +34,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
active={active}
data-testid="search-page-result"
action={tString(language, 'view')}
leadingIcon={leadingIcon}
insights={insights}
leadingIcon={<Icon icon="file-lines" className="size-4" />}
insights={{
type: 'search_open_result',
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{
...style,
@@ -91,11 +56,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
item.type === 'local-page' && !preview && !description
? '[[aria-busy=false]_&]:h-0'
: !preview && !description
? 'h-0'
: ''
!preview && !description ? 'h-0' : ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
@@ -119,26 +80,15 @@ 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: PageItem['breadcrumbs']; withOverflow?: boolean }) => {
const Breadcrumbs = (props: {
breadcrumbs: ComputedPageResult['breadcrumbs'];
withOverflow?: boolean;
}) => {
const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props;
if (!breadcrumbs || breadcrumbs.length === 0) return null;
@@ -23,8 +23,6 @@ export interface SearchResultsRef {
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
return `page:${item.id}`;
case 'page':
return `page:${item.pageId}`;
case 'record':
@@ -114,7 +112,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error && results.length === 0) {
if (error) {
return (
<output
className={tcls(
@@ -157,26 +155,6 @@ 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}
@@ -202,10 +180,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const handleResultSelect = (
event: React.MouseEvent<HTMLAnchorElement>
) => {
const isPageResult =
item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record';
const isPageResult = item.type === 'page' || item.type === 'record';
if (
isPageResult &&
@@ -238,7 +213,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
onClickCapture: handleResultSelect,
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<motion.div
@@ -353,7 +327,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{partialError}
{fetching ? (
<div
className={tcls(
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { computeFilterSiteSpaceIds } from './filter';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import type { OrderedComputedResult } from './search-types';
describe('computeFilterSiteSpaceIds', () => {
const currentId = 'space-current';
@@ -16,7 +17,7 @@ describe('computeFilterSiteSpaceIds', () => {
expect(r).toEqual(ids);
});
it('default with sections → undefined (no local filter)', () => {
it('default with sections → undefined (no filter)', () => {
const r = computeFilterSiteSpaceIds('default', currentId, ids, true);
expect(r).toBeUndefined();
});
@@ -34,3 +35,41 @@ describe('computeFilterSiteSpaceIds', () => {
expect(r).toEqual(['space-other-a', 'space-other-b']);
});
});
describe('filterResultsBySiteSpaces', () => {
const page = (pageId: string, siteSpaceId?: string): OrderedComputedResult => ({
type: 'page',
id: `space/${pageId}`,
title: pageId,
description: '',
href: `/${pageId}`,
pageId,
spaceId: 'space',
siteSpaceId,
score: 1,
rank: 1,
});
const record: OrderedComputedResult = {
type: 'record',
id: 'record',
title: 'Record',
description: undefined,
href: 'https://example.com',
score: 1,
};
const inScope = page('b', 'current');
const alsoInScope = page('d', 'current');
const results = [page('a', 'other'), inScope, record, page('c'), alsoInScope];
it('keeps the server order and drops pages outside the allowed site spaces', () => {
expect(filterResultsBySiteSpaces(results, ['current'])).toEqual([
inScope,
record,
alsoInScope,
]);
});
it('returns every result when the scope is unrestricted', () => {
expect(filterResultsBySiteSpaces(results, undefined)).toBe(results);
});
});
@@ -1,16 +1,13 @@
import type { OrderedComputedResult } from './search-types';
import type { SearchScope } from './useSearch';
/**
* Map UI search scope to local FlexSearch filtering.
* Site spaces whose pages may be shown for a UI search scope, or `undefined` for no restriction.
*
* 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.
* 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.
*/
export function computeFilterSiteSpaceIds(
scope: SearchScope,
siteSpaceId: string,
@@ -31,3 +28,21 @@ 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,5 +5,4 @@ export * from './SearchContainer';
export * from './SearchScopeControl';
export * from './search-props';
export * from './useSearch';
export * from './useLocalSearchResults';
export * from './useSearchController';
@@ -1,239 +0,0 @@
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',
]);
});
});
@@ -1,271 +0,0 @@
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,7 +25,6 @@ export interface SearchBaseProps {
sections: SearchSectionItem[];
withVariants: boolean;
withSiteVariants: boolean;
indexURL: string;
searchURL: string;
}
@@ -47,7 +46,6 @@ 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,15 +1,5 @@
'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;
@@ -30,50 +20,14 @@ export interface SiteIndexPage {
breadcrumbs?: SiteIndexBreadcrumb[];
}
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.
*/
/** Fetch the pages of the site from the `~gitbook/site-index` JSON. */
export async function fetchSiteIndex(
indexURL: string
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
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;
});
const response = await fetch(indexURL);
if (!response.ok) {
throw new Error(`Failed to fetch site index: ${response.status}`);
}
return siteIndexText;
return response.json();
}
@@ -1,262 +0,0 @@
'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: {
@@ -76,14 +75,16 @@ function useFilteredSiteSpaceIds(props: {
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
fetching: boolean;
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const { query, results, fetching, resultsRef, abort, askCount, onAskSelect } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
fetching,
resultCount: results.length,
totalCount: results.length + askCount,
});
@@ -96,7 +97,7 @@ function useSearchKeyboardNavigation(props: {
} else if (event.key === 'ArrowDown') {
event.preventDefault();
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);
@@ -132,7 +133,6 @@ export function useSearchController(
withSections,
siteSpaces,
searchURL,
indexURL,
} = props;
const { assistants, config } = useAI();
@@ -252,7 +252,6 @@ export function useSearchController(
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -260,8 +259,6 @@ export function useSearchController(
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
indexURL,
lang: siteSpace.space.language,
withSections,
});
@@ -306,6 +303,7 @@ export function useSearchController(
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
fetching,
resultsRef,
abort,
askCount,
@@ -8,26 +8,15 @@ import {
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import { computeFilterSiteSpaceIds } from './filter';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } 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
| LocalPageResult
| MergedPageResult
| RecommendedQuestionResult;
export type { LocalPageResult, MergedPageResult };
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
export type ResultType = OrderedComputedResult | RecommendedQuestionResult;
/**
* We cache the recommended questions globally to avoid calling the API multiple times
@@ -40,8 +29,6 @@ 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[];
@@ -50,17 +37,12 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** 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. */
/** Whether the site has multiple sections. If false, the default scope only shows the current site space. */
withSections?: boolean;
}) {
const {
asEmbeddable,
disabled,
open,
query,
siteSpaceId,
siteSpaceIds,
@@ -68,8 +50,6 @@ export function useSearchResults(props: {
scope,
suggestions,
searchURL,
indexURL,
lang,
withSections,
} = props;
@@ -87,15 +67,6 @@ export function useSearchResults(props: {
[scope, siteSpaceId, siteSpaceIds, withSections, selectedSectionSiteSpaceIds]
);
const { results: localResults } = useLocalSearchResults({
query,
indexURL,
lang,
disabled,
open,
filterSiteSpaceIds,
});
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
fetching: boolean;
@@ -295,8 +266,6 @@ export function useSearchResults(props: {
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 =
@@ -313,14 +282,8 @@ export function useSearchResults(props: {
});
}
return fuseSearchResults({
localResults,
remoteResults: remoteState.results,
query,
allowedSiteSpaceIds: filterSiteSpaceIds,
});
return filterResultsBySiteSpaces(remoteState.results, filterSiteSpaceIds);
}, [
localResults,
remoteState.results,
query,
filterSiteSpaceIds,
@@ -2,11 +2,12 @@ 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, resultCount, totalCount } = props;
const { query, fetching, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
@@ -16,6 +17,12 @@ export function useSearchResultsCursor(props: {
}, [query]);
React.useEffect(() => {
if (fetching && resultCount === 0) {
// Nothing is highlighted while results load, so Enter can't open the wrong row.
setCursor(null);
return;
}
if (totalCount === 0) {
return;
}
@@ -27,7 +34,7 @@ export function useSearchResultsCursor(props: {
return prev ?? 0;
});
}, [resultCount, totalCount]);
}, [fetching, resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
@@ -71,17 +71,6 @@ 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.
// Low priority: it's only needed once search opens and must not compete with the LCP.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
fetchPriority: 'low',
});
scripts.forEach(({ script }) => {
if (!isDeferrableScript(script)) {
ReactDOM.preload(script, {
@@ -159,7 +159,7 @@ export function ConsentLayout(props: React.HTMLAttributes<HTMLDivElement>) {
const { children, className, ...rest } = props;
return (
<main className="site-background flex min-h-dvh flex-col" {...rest}>
<main className="site-background flex min-h-screen flex-col" {...rest}>
<div
className={tcls(
'flex w-full flex-1 items-center justify-center',
@@ -22,7 +22,6 @@ import {
useSpaceBasePath,
} from '../SpaceLayout/SpaceLayoutContext';
import { TrackPageViewEvent } from '@/components/Insights';
import { MAIN_CONTENT_ID } from '@/components/layout';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
@@ -70,8 +69,8 @@ export function SitePageNotFound() {
return;
}
// Otherwise, rank the site's pages against the path that 404'd. We reuse the search index
// (already preloaded and CDN-cached), so this adds no origin request — see getRelatedPages.
// Otherwise, rank the site's pages against the path that 404'd. The site index is
// CDN-cached, so this adds no origin request — see getRelatedPages.
let active = true;
getRelatedPages(siteIndexURL, pathname ?? '', siteSpaceId).then(
(pages) => {
@@ -103,7 +102,6 @@ export function SitePageNotFound() {
<CurrentPageProvider page={null}>
<div className="lg:has-sidebar:-ml-84 w-full">
<main
id={MAIN_CONTENT_ID}
className={tcls(
// `page-no-toc` hides the TOC (like PageBody), and `layout-wide` takes the
// now-empty sidebar out of flow so the content stays centered on all layouts.
@@ -261,9 +259,8 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
/**
* Return the pages whose path is closest to the one that 404'd.
*
* Rather than asking the server (which would mean an extra request per 404), this reuses the
* search index served at `~gitbook/site-index` — preloaded on every page and shared with
* instant search via the module cache in `site-index.ts`, so at most one request is made.
* Rather than asking the server (which would mean an extra origin request per 404), this reads
* the CDN-cached page index served at `~gitbook/site-index`.
* The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404
* runs server-side from the full page tree).
*/
@@ -22,7 +22,7 @@ const POPUP_OFFSET = 4;
const OPEN_DELAY_MS = 200;
const MOTION = 'duration-300 ease-[cubic-bezier(0.83,0,0.17,1)]';
// Bounds the scrolling content too: the popup settles at `height: auto`, so `h-full` doesn't.
const MAX_POPUP_HEIGHT = 'max-h-[calc(100dvh-8rem)]';
const MAX_POPUP_HEIGHT = 'max-h-[calc(100vh-8rem)]';
const MAX_ITEMS_PER_COLUMN = 10; // number of items per column
const GROUP_MASONRY_THRESHOLD = 3; // if a section group has more than this many child groups, it will be shown in a masonry grid
const COLUMN_WIDTH = '18rem';
@@ -12,7 +12,6 @@ import { CurrentContentProvider } from '../hooks';
import { InsightsProvider, VisitorProvider } from '../Insights';
import { CONTAINER_STYLE } from '../layout';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SkipToContent } from '../primitives/SkipToContent';
import { SearchContainer, getSearchBaseProps } from '../Search';
import {
SiteSectionList,
@@ -132,7 +131,6 @@ export function SpaceLayout(props: SpaceLayoutProps) {
return (
<SpaceLayoutServerContext {...props}>
<SkipToContent />
<Announcement context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<NavigationLoader />
@@ -162,8 +160,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
// Ensure the footer is display below the viewport even if the content is not enough
withTopHeader
? [
'site-header:min-h-[calc(100dvh-65px)]',
'site-header-sections:min-h-[calc(100dvh-109px)]',
'site-header:min-h-[calc(100vh-65px)]',
'site-header-sections:min-h-[calc(100vh-109px)]',
]
: 'lg:min-h-screen'
)}
@@ -70,7 +70,7 @@ export function useIsLoggedInVisitor() {
}
/**
* Return the URL of the site search index (`~gitbook/site-index`).
* Return the URL of the site page index (`~gitbook/site-index`).
*/
export function useSiteIndexURL() {
const context = React.useContext(SpaceLayoutContext);
@@ -26,7 +26,6 @@ export function ToggleableLinkItem(
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
const hasBeenToggled = useRef(false);
const linkId = React.useId();
// Update the visibility of the children if one of the descendants becomes active.
React.useEffect(() => {
@@ -61,16 +60,10 @@ export function ToggleableLinkItem(
}
return (
<DescendantsRenderer
descendants={descendants}
isOpen={isOpen}
setIsOpen={handleToggle}
labelledBy={linkId}
>
<DescendantsRenderer descendants={descendants} isOpen={isOpen} setIsOpen={handleToggle}>
{({ descendants, toggler }) => (
<>
<LinkItem
id={linkId}
href={href}
insights={insights}
isActive={isActive}
@@ -95,12 +88,12 @@ export function ToggleableLinkItem(
}
function LinkItem(
props: Pick<LinkProps, 'href' | 'insights' | 'children' | 'id'> & {
props: Pick<LinkProps, 'href' | 'insights' | 'children'> & {
isActive: boolean;
onActiveClick?: () => void;
}
) {
const { id, isActive, href, insights, children, onActiveClick } = props;
const { isActive, href, insights, children, onActiveClick } = props;
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isActive && onActiveClick) {
@@ -111,7 +104,6 @@ function LinkItem(
return (
<Link
id={id}
data-active={isActive}
href={href}
insights={insights}
@@ -131,21 +123,18 @@ function DescendantsRenderer(props: {
descendants: React.ReactNode;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
/** Id of the link the toggle belongs to, used as its accessible name. */
labelledBy: string;
children: (renderProps: {
descendants: React.ReactNode;
toggler: React.ReactNode;
}) => React.ReactNode;
}) {
const { descendants, isOpen, setIsOpen, labelledBy } = props;
const { descendants, isOpen, setIsOpen } = props;
return props.children({
toggler: (
<Toggler
isLinkActive={isOpen}
isOpen={isOpen}
labelledBy={labelledBy}
onToggle={() => {
setIsOpen((prev) => !prev);
}}
@@ -155,13 +144,8 @@ function DescendantsRenderer(props: {
});
}
function Toggler(props: {
isLinkActive: boolean;
isOpen: boolean;
labelledBy: string;
onToggle: () => void;
}) {
const { isOpen, labelledBy, onToggle } = props;
function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () => void }) {
const { isOpen, onToggle } = props;
return (
<Button
icon={
@@ -180,12 +164,9 @@ function Toggler(props: {
size="xsmall"
iconOnly
variant="blank"
// Named after its page and focusable: the link only toggles on the current page, so
// this is the only keyboard way to expand another page's children.
aria-labelledby={labelledBy}
aria-expanded={isOpen}
// Cancels the Button's expanded highlight: an open chevron must look like a closed one.
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base aria-expanded:bg-transparent aria-expanded:text-current aria-expanded:hover:bg-tint-hover"
aria-hidden="true" // The button has no label or focus so hiding it from screen readers.
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
tabIndex={-1} // Prevent focus on the button since it's already inside a clickable link that performs the same toggle action.
/>
);
}
@@ -10,4 +10,3 @@ export * from './useNow';
export * from './useListOverflow';
export * from './useCurrentPageMetadata';
export * from './useBackToSpace';
export * from './useCopyToClipboard';
@@ -1,31 +0,0 @@
'use client';
import React from 'react';
/**
* Copy text to the clipboard and expose a `copied` flag that resets after `duration`.
* The flag is only set once the write succeeded.
*/
export function useCopyToClipboard(duration = 1000) {
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => setCopied(false), duration);
return () => clearTimeout(timeout);
}, [copied, duration]);
const copy = React.useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
// Clipboard access can be denied (permissions, insecure context): don't claim success.
setCopied(false);
}
}, []);
return { copied, copy };
}
@@ -50,6 +50,3 @@ export const SIDE_COLUMN_WITHOUT_HEADER_AND_COVER: ClassValue = [
'top-[240px]',
'h-[calc(100vh-240px)]',
];
/** Id of the `<main>` landmark, target of the skip link. */
export const MAIN_CONTENT_ID = 'gitbook-main-content';
@@ -187,7 +187,7 @@ export const Button = React.forwardRef<
className={domClassName}
classNames={['ButtonStyles']}
insights={insights}
aria-label={typeof label === 'string' ? label : undefined}
aria-label={label?.toString()}
aria-pressed={active}
target={target}
data-active={active}
@@ -200,7 +200,7 @@ export const Button = React.forwardRef<
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
className={tcls(buttonOnlyClassNames, domClassName)}
aria-label={typeof label === 'string' ? label : undefined}
aria-label={label?.toString()}
aria-pressed={active}
disabled={disabled}
data-active={active}
@@ -43,7 +43,7 @@ export function HoverCard(
>
<PreviewCard.Popup
{...popupProps}
className="pointer-events-auto animate-scale-in data-closed:animate-scale-out"
className="animate-scale-in data-closed:animate-scale-out"
>
<div
className={tcls(
@@ -8,7 +8,6 @@ import { useControlledState } from '../hooks/useControlledState';
import { Button, type ButtonProps } from './Button';
import { KeyboardShortcut, type KeyboardShortcutProps } from './KeyboardShortcut';
import { tString, useLanguage } from '@/intl/client';
import { isComposingKeyEvent } from '@/lib/keyboard';
import { tcls } from '@/lib/tailwind';
type CustomInputProps = {
@@ -167,16 +166,8 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
if (multiline && resize && ref.current) {
// TODO: replace with `field-sizing: content` when more broadly supported. https://caniuse.com/?search=field-sizing
// scrollHeight never goes below the current height, so shrinking needs a measure with the
// height reset. Only done on deletion: collapsing the field can shift the page scroll.
const previousHeight = ref.current.style.height;
const isShrinking = newValue.length < value.length;
if (isShrinking) {
ref.current.style.height = 'auto';
}
const nextHeight = ref.current.scrollHeight;
ref.current.style.height = previousHeight;
setHeight(nextHeight);
// Reset the height to auto, then set it to the scroll height. If we don't reset, the height will only ever grow.
setHeight(ref.current.scrollHeight);
}
};
@@ -202,7 +193,12 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
// If the user wants to handle the keydown by itself, we let him do it.
if (event.defaultPrevented) return;
if (event.key === 'Enter' && !event.shiftKey && !isComposingKeyEvent(event) && hasValue) {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
hasValue
) {
event.preventDefault();
handleSubmit();
} else if (event.key === 'Escape') {
@@ -254,7 +250,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
? 'cursor-not-allowed border-tint-subtle bg-tint-subtle text-tint/8'
: [
'depth-subtle:focus-within:-translate-y-px depth-subtle:hover:not-has-[button:hover]:-translate-y-px depth-subtle:shadow-xs',
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 forced-colors:focus-within:outline-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
],
multiline ? 'flex-col' : 'flex-row',
sizes[sizing].container,
@@ -1,19 +0,0 @@
'use client';
import { MAIN_CONTENT_ID } from '../layout';
import { tString, useLanguage } from '@/intl/client';
/**
* Link shown on keyboard focus to jump over the header and navigation.
*/
export function SkipToContent() {
const language = useLanguage();
return (
<a
href={`#${MAIN_CONTENT_ID}`}
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-tint-base focus:px-4 focus:py-2 focus:text-sm focus:text-tint-strong focus:shadow-lg focus:ring-2 focus:ring-primary"
>
{tString(language, 'skip_to_content')}
</a>
);
}
@@ -10,7 +10,6 @@ export const ar: TranslationLanguage = {
switch_to_light_theme: 'التبديل إلى السمة الفاتحة',
switch_to_system_theme: 'التبديل إلى سمة النظام',
search: 'بحث',
skip_to_content: 'تخطي إلى المحتوى',
clear: 'مسح',
table_filtered_by_selection: 'يطابق الاختيار: ${1}',
table_clear_selection: 'مسح الاختيار',
@@ -43,7 +42,6 @@ export const ar: TranslationLanguage = {
search_ask_no_answer:
'تعذر العثور على إجابة لسؤالك. يمكنك محاولة إعادة صياغته أو جعله أكثر تحديدا.',
search_ask_error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقا.',
search_partial_error: 'تعذر تحميل بعض النتائج. يتم عرض النتائج المتاحة.',
on_this_page: 'في هذه الصفحة',
next_page: 'التالي',
previous_page: 'السابق',
@@ -10,7 +10,6 @@ export const bg: TranslationLanguage = {
switch_to_light_theme: 'Превключване към светла тема',
switch_to_system_theme: 'Превключване към системната тема',
search: 'Търсене',
skip_to_content: 'Към съдържанието',
clear: 'Изчистване',
table_filtered_by_selection: 'Съответства на избора: ${1}',
table_clear_selection: 'Изчисти избора',
@@ -43,8 +42,6 @@ export const bg: TranslationLanguage = {
search_ask_no_answer:
'Не беше намерен отговор на въпроса ви. Можете да опитате да го преформулирате или да бъдете по-конкретни.',
search_ask_error: 'Нещо се обърка. Моля, опитайте отново по-късно.',
search_partial_error:
'Някои резултати не можаха да се заредят. Показват се наличните резултати.',
on_this_page: 'На тази страница',
next_page: 'Следваща',
previous_page: 'Предишна',
@@ -10,7 +10,6 @@ export const cs: TranslationLanguage = {
switch_to_light_theme: 'Přepnout na světlý motiv',
switch_to_system_theme: 'Přepnout na systémový motiv',
search: 'Hledat',
skip_to_content: 'Přejít k obsahu',
clear: 'Vymazat',
table_filtered_by_selection: 'Odpovídá výběru: ${1}',
table_clear_selection: 'Zrušit výběr',
@@ -43,7 +42,6 @@ export const cs: TranslationLanguage = {
search_ask_no_answer:
'Na vaši otázku se nepodařilo najít odpověď. Zkuste ji přeformulovat nebo být konkrétnější.',
search_ask_error: 'Něco se pokazilo. Zkuste to prosím znovu později.',
search_partial_error: 'Některé výsledky se nepodařilo načíst. Zobrazují se dostupné výsledky.',
on_this_page: 'Na této stránce',
next_page: 'Další',
previous_page: 'Předchozí',
@@ -10,7 +10,6 @@ export const da: TranslationLanguage = {
switch_to_light_theme: 'Skift til lyst tema',
switch_to_system_theme: 'Skift til systemtema',
search: 'Søg',
skip_to_content: 'Spring til indhold',
clear: 'Ryd',
table_filtered_by_selection: 'Matcher valg: ${1}',
table_clear_selection: 'Ryd valg',
@@ -43,7 +42,6 @@ export const da: TranslationLanguage = {
search_ask_no_answer:
'Der kunne ikke findes et svar på dit spørgsmål. Prøv at omformulere det eller være mere specifik.',
search_ask_error: 'Noget gik galt. Prøv igen senere.',
search_partial_error: 'Nogle resultater kunne ikke indlæses. Viser tilgængelige resultater.',
on_this_page: 'På denne side',
next_page: 'Næste',
previous_page: 'Forrige',
@@ -10,7 +10,6 @@ export const de: TranslationLanguage = {
switch_to_light_theme: 'Zum hellen Modus wechseln',
switch_to_system_theme: 'Zum Systemmodus wechseln',
search: 'Suche',
skip_to_content: 'Zum Inhalt springen',
clear: 'Löschen',
table_filtered_by_selection: 'Entspricht Auswahl: ${1}',
table_clear_selection: 'Auswahl zurücksetzen',
@@ -44,8 +43,6 @@ export const de: TranslationLanguage = {
search_ask_no_answer:
'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.',
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
search_partial_error:
'Einige Ergebnisse konnten nicht geladen werden. Verfügbare Ergebnisse werden angezeigt.',
on_this_page: 'Auf dieser Seite',
next_page: 'Nächste',
previous_page: 'Vorherige',
@@ -10,7 +10,6 @@ export const el: TranslationLanguage = {
switch_to_light_theme: 'Αλλαγή σε φωτεινό θέμα',
switch_to_system_theme: 'Αλλαγή στο θέμα συστήματος',
search: 'Αναζήτηση',
skip_to_content: 'Μετάβαση στο περιεχόμενο',
clear: 'Εκκαθάριση',
table_filtered_by_selection: 'Ταιριάζει με την επιλογή: ${1}',
table_clear_selection: 'Εκκαθάριση επιλογής',
@@ -43,8 +42,6 @@ export const el: TranslationLanguage = {
search_ask_no_answer:
'Δεν ήταν δυνατή η εύρεση απάντησης στην ερώτησή σας. Δοκιμάστε να τη διατυπώσετε διαφορετικά ή πιο συγκεκριμένα.',
search_ask_error: 'Κάτι πήγε στραβά. Δοκιμάστε ξανά αργότερα.',
search_partial_error:
'Δεν ήταν δυνατή η φόρτωση ορισμένων αποτελεσμάτων. Εμφανίζονται τα διαθέσιμα αποτελέσματα.',
on_this_page: 'Σε αυτήν τη σελίδα',
next_page: 'Επόμενη',
previous_page: 'Προηγούμενη',
@@ -8,7 +8,6 @@ export const en = {
switch_to_light_theme: 'Switch to light theme',
switch_to_system_theme: 'Switch to system theme',
search: 'Search',
skip_to_content: 'Skip to content',
clear: 'Clear',
table_filtered_by_selection: 'Matches selection: ${1}',
table_clear_selection: 'Clear selection',
@@ -41,7 +40,6 @@ export const en = {
search_ask_no_answer:
'An answer could not be found for your question. You could try rephrasing it, or be more specific.',
search_ask_error: 'Something went wrong. Please try again later.',
search_partial_error: "Some results couldn't load. Showing available results.",
on_this_page: 'On this page',
next_page: 'Next',
previous_page: 'Previous',
@@ -10,7 +10,6 @@ export const es: TranslationLanguage = {
switch_to_light_theme: 'Cambiar a tema claro',
switch_to_system_theme: 'Cambiar a tema del sistema',
search: 'Buscar',
skip_to_content: 'Saltar al contenido',
clear: 'Limpiar',
table_filtered_by_selection: 'Coincide con la selección: ${1}',
table_clear_selection: 'Borrar selección',
@@ -44,8 +43,6 @@ export const es: TranslationLanguage = {
search_ask_no_answer:
'No se pudo encontrar una respuesta para su pregunta. Puede intentar reformularla o ser más específico.',
search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.',
search_partial_error:
'No se pudieron cargar algunos resultados. Se muestran los resultados disponibles.',
on_this_page: 'En esta página',
next_page: 'Siguiente',
previous_page: 'Anterior',
@@ -10,7 +10,6 @@ export const et: TranslationLanguage = {
switch_to_light_theme: 'Lülitu heledale teemale',
switch_to_system_theme: 'Lülitu süsteemi teemale',
search: 'Otsi',
skip_to_content: 'Liigu sisu juurde',
clear: 'Tühjenda',
table_filtered_by_selection: 'Vastab valikule: ${1}',
table_clear_selection: 'Tühjenda valik',
@@ -43,7 +42,6 @@ export const et: TranslationLanguage = {
search_ask_no_answer:
'Teie küsimusele ei leitud vastust. Proovige seda ümber sõnastada või olla täpsem.',
search_ask_error: 'Midagi läks valesti. Palun proovige hiljem uuesti.',
search_partial_error: 'Mõnda tulemust ei saanud laadida. Kuvatakse saadaolevad tulemused.',
on_this_page: 'Sellel lehel',
next_page: 'Järgmine',
previous_page: 'Eelmine',
@@ -10,7 +10,6 @@ export const fi: TranslationLanguage = {
switch_to_light_theme: 'Vaihda vaaleaan teemaan',
switch_to_system_theme: 'Vaihda järjestelmän teemaan',
search: 'Haku',
skip_to_content: 'Siirry sisältöön',
clear: 'Tyhjennä',
table_filtered_by_selection: 'Vastaa valintaa: ${1}',
table_clear_selection: 'Tyhjennä valinta',
@@ -43,8 +42,6 @@ export const fi: TranslationLanguage = {
search_ask_no_answer:
'Kysymykseesi ei löytynyt vastausta. Voit yrittää muotoilla sen uudelleen tai olla tarkempi.',
search_ask_error: 'Jokin meni pieleen. Yritä myöhemmin uudelleen.',
search_partial_error:
'Joitakin tuloksia ei voitu ladata. Näytetään saatavilla olevat tulokset.',
on_this_page: 'Tällä sivulla',
next_page: 'Seuraava',
previous_page: 'Edellinen',
@@ -10,7 +10,6 @@ export const fr: TranslationLanguage = {
switch_to_light_theme: 'Activer le thème clair',
switch_to_system_theme: 'Utiliser le thème du système',
search: 'Rechercher',
skip_to_content: 'Aller au contenu',
clear: 'Effacer',
table_filtered_by_selection: 'Correspond à la sélection : ${1}',
table_clear_selection: 'Effacer la sélection',
@@ -43,8 +42,6 @@ export const fr: TranslationLanguage = {
search_ask_sources_no_answer: 'Pages associées',
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
search_ask_error: 'Une erreur est survenue. Veuillez réessayer plus tard.',
search_partial_error:
'Certains résultats n’ont pas pu être chargés. Les résultats disponibles sont affichés.',
on_this_page: 'Sur cette page',
next_page: 'Suivant',
previous_page: 'Précédent',
@@ -10,7 +10,6 @@ export const he: TranslationLanguage = {
switch_to_light_theme: 'מעבר לערכת נושא בהירה',
switch_to_system_theme: 'מעבר לערכת הנושא של המערכת',
search: 'חיפוש',
skip_to_content: 'דילוג לתוכן',
clear: 'ניקוי',
table_filtered_by_selection: 'תואם לבחירה: ${1}',
table_clear_selection: 'נקה בחירה',
@@ -43,7 +42,6 @@ export const he: TranslationLanguage = {
search_ask_no_answer:
'לא נמצאה תשובה לשאלה שלך. אפשר לנסות לנסח אותה מחדש או להיות מדויקים יותר.',
search_ask_error: 'משהו השתבש. נסה שוב מאוחר יותר.',
search_partial_error: 'לא ניתן היה לטעון חלק מהתוצאות. מוצגות התוצאות הזמינות.',
on_this_page: 'בדף הזה',
next_page: 'הבא',
previous_page: 'הקודם',
@@ -10,7 +10,6 @@ export const hi: TranslationLanguage = {
switch_to_light_theme: 'लाइट थीम पर जाएं',
switch_to_system_theme: 'सिस्टम थीम पर जाएं',
search: 'खोजें',
skip_to_content: 'सामग्री पर जाएं',
clear: 'साफ करें',
table_filtered_by_selection: 'चयन से मेल खाता है: ${1}',
table_clear_selection: 'चयन साफ़ करें',
@@ -43,7 +42,6 @@ export const hi: TranslationLanguage = {
search_ask_no_answer:
'आपके प्रश्न का उत्तर नहीं मिल सका। आप इसे फिर से लिखने या अधिक विशिष्ट बनाने की कोशिश कर सकते हैं।',
search_ask_error: 'कुछ गलत हो गया। कृपया बाद में फिर कोशिश करें।',
search_partial_error: 'कुछ परिणाम लोड नहीं हो सके। उपलब्ध परिणाम दिखाए जा रहे हैं।',
on_this_page: 'इस पृष्ठ पर',
next_page: 'अगला',
previous_page: 'पिछला',
@@ -10,7 +10,6 @@ export const hr: TranslationLanguage = {
switch_to_light_theme: 'Prebaci na svijetlu temu',
switch_to_system_theme: 'Prebaci na temu sustava',
search: 'Pretraži',
skip_to_content: 'Preskoči na sadržaj',
clear: 'Očisti',
table_filtered_by_selection: 'Odgovara odabiru: ${1}',
table_clear_selection: 'Očisti odabir',
@@ -43,7 +42,6 @@ export const hr: TranslationLanguage = {
search_ask_no_answer:
'Nije bilo moguće pronaći odgovor na vaše pitanje. Pokušajte ga preformulirati ili biti konkretniji.',
search_ask_error: 'Nešto je pošlo po zlu. Pokušajte ponovno kasnije.',
search_partial_error: 'Neki se rezultati nisu mogli učitati. Prikazuju se dostupni rezultati.',
on_this_page: 'Na ovoj stranici',
next_page: 'Sljedeća',
previous_page: 'Prethodna',
@@ -10,7 +10,6 @@ export const hu: TranslationLanguage = {
switch_to_light_theme: 'Váltás világos témára',
switch_to_system_theme: 'Váltás rendszer témára',
search: 'Keresés',
skip_to_content: 'Ugrás a tartalomra',
clear: 'Törlés',
table_filtered_by_selection: 'Megfelel a kijelölésnek: ${1}',
table_clear_selection: 'Kijelölés törlése',
@@ -43,8 +42,6 @@ export const hu: TranslationLanguage = {
search_ask_no_answer:
'Nem található válasz a kérdésére. Próbálja meg átfogalmazni, vagy legyen pontosabb.',
search_ask_error: 'Valami hiba történt. Kérjük, próbálja újra később.',
search_partial_error:
'Néhány találatot nem sikerült betölteni. Az elérhető találatok jelennek meg.',
on_this_page: 'Ezen az oldalon',
next_page: 'Következő',
previous_page: 'Előző',
@@ -10,7 +10,6 @@ export const id: TranslationLanguage = {
switch_to_light_theme: 'Beralih ke tema terang',
switch_to_system_theme: 'Beralih ke tema sistem',
search: 'Cari',
skip_to_content: 'Lewati ke konten',
clear: 'Bersihkan',
table_filtered_by_selection: 'Sesuai pilihan: ${1}',
table_clear_selection: 'Hapus pilihan',
@@ -43,7 +42,6 @@ export const id: TranslationLanguage = {
search_ask_no_answer:
'Jawaban untuk pertanyaan Anda tidak dapat ditemukan. Anda dapat mencoba menyusun ulang atau membuatnya lebih spesifik.',
search_ask_error: 'Terjadi kesalahan. Silakan coba lagi nanti.',
search_partial_error: 'Beberapa hasil tidak dapat dimuat. Menampilkan hasil yang tersedia.',
on_this_page: 'Di halaman ini',
next_page: 'Berikutnya',
previous_page: 'Sebelumnya',
@@ -10,7 +10,6 @@ export const it: TranslationLanguage = {
switch_to_light_theme: 'Passa al tema chiaro',
switch_to_system_theme: 'Passa al tema di sistema',
search: 'Cerca',
skip_to_content: 'Vai al contenuto',
clear: 'Cancella',
table_filtered_by_selection: 'Corrisponde alla selezione: ${1}',
table_clear_selection: 'Cancella selezione',
@@ -44,8 +43,6 @@ export const it: TranslationLanguage = {
search_ask_no_answer:
'Non è stato possibile trovare una risposta alla tua domanda. Prova a riformularla o a essere più specifico.',
search_ask_error: 'Qualcosa è andato storto. Riprova più tardi.',
search_partial_error:
'Non è stato possibile caricare alcuni risultati. Visualizziamo i risultati disponibili.',
on_this_page: 'In questa pagina',
next_page: 'Successivo',
previous_page: 'Precedente',
@@ -10,7 +10,6 @@ export const ja: TranslationLanguage = {
switch_to_light_theme: 'ライトテーマに切り替え',
switch_to_system_theme: 'システムのテーマに切り替え',
search: '検索',
skip_to_content: 'コンテンツへスキップ',
clear: 'クリア',
table_filtered_by_selection: '選択に一致: ${1}',
table_clear_selection: '選択をクリア',
@@ -44,7 +43,6 @@ export const ja: TranslationLanguage = {
search_ask_no_answer:
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
search_partial_error: '一部の結果を読み込めませんでした。利用可能な結果を表示しています。',
on_this_page: 'このページ内',
next_page: '次へ',
previous_page: '前へ',
@@ -10,7 +10,6 @@ export const ko: TranslationLanguage = {
switch_to_light_theme: '라이트 테마로 전환',
switch_to_system_theme: '시스템 테마로 전환',
search: '검색',
skip_to_content: '본문으로 건너뛰기',
clear: '지우기',
table_filtered_by_selection: '선택과 일치: ${1}',
table_clear_selection: '선택 지우기',
@@ -44,7 +43,6 @@ export const ko: TranslationLanguage = {
search_ask_no_answer:
'질문에 대한 답변을 찾을 수 없습니다. 질문을 다시 표현하거나 더 구체적으로 작성해 보세요.',
search_ask_error: '문제가 발생했습니다. 나중에 다시 시도해 주세요.',
search_partial_error: '일부 결과를 불러오지 못했습니다. 사용 가능한 결과를 표시합니다.',
on_this_page: '이 페이지에서',
next_page: '다음',
previous_page: '이전',
@@ -10,7 +10,6 @@ export const lt: TranslationLanguage = {
switch_to_light_theme: 'Perjungti į šviesią temą',
switch_to_system_theme: 'Perjungti į sistemos temą',
search: 'Ieškoti',
skip_to_content: 'Pereiti prie turinio',
clear: 'Išvalyti',
table_filtered_by_selection: 'Atitinka pasirinkimą: ${1}',
table_clear_selection: 'Išvalyti pasirinkimą',
@@ -43,7 +42,6 @@ export const lt: TranslationLanguage = {
search_ask_no_answer:
'Nepavyko rasti atsakymo į jūsų klausimą. Galite pabandyti jį performuluoti arba pateikti konkrečiau.',
search_ask_error: 'Kažkas nepavyko. Bandykite dar kartą vėliau.',
search_partial_error: 'Nepavyko įkelti kai kurių rezultatų. Rodomi pasiekiami rezultatai.',
on_this_page: 'Šiame puslapyje',
next_page: 'Kitas',
previous_page: 'Ankstesnis',
@@ -10,7 +10,6 @@ export const lv: TranslationLanguage = {
switch_to_light_theme: 'Pārslēgt uz gaišo motīvu',
switch_to_system_theme: 'Pārslēgt uz sistēmas motīvu',
search: 'Meklēt',
skip_to_content: 'Pāriet uz saturu',
clear: 'Notīrīt',
table_filtered_by_selection: 'Atbilst izvēlei: ${1}',
table_clear_selection: 'Notīrīt izvēli',
@@ -43,7 +42,6 @@ export const lv: TranslationLanguage = {
search_ask_no_answer:
'Neizdevās atrast atbildi uz jūsu jautājumu. Varat mēģināt to pārfrāzēt vai būt konkrētāks.',
search_ask_error: 'Kaut kas nogāja greizi. Lūdzu, mēģiniet vēlāk vēlreiz.',
search_partial_error: 'Dažus rezultātus neizdevās ielādēt. Tiek rādīti pieejamie rezultāti.',
on_this_page: 'Šajā lapā',
next_page: 'Nākamā',
previous_page: 'Iepriekšējā',
@@ -10,7 +10,6 @@ export const ms: TranslationLanguage = {
switch_to_light_theme: 'Tukar kepada tema cerah',
switch_to_system_theme: 'Tukar kepada tema sistem',
search: 'Cari',
skip_to_content: 'Langkau ke kandungan',
clear: 'Kosongkan',
table_filtered_by_selection: 'Sepadan dengan pilihan: ${1}',
table_clear_selection: 'Kosongkan pilihan',
@@ -43,7 +42,6 @@ export const ms: TranslationLanguage = {
search_ask_no_answer:
'Jawapan untuk soalan anda tidak ditemui. Anda boleh cuba menyusunnya semula atau bertanya dengan lebih khusus.',
search_ask_error: 'Ada sesuatu yang tidak kena. Sila cuba lagi kemudian.',
search_partial_error: 'Sesetengah hasil tidak dapat dimuatkan. Memaparkan hasil yang tersedia.',
on_this_page: 'Pada halaman ini',
next_page: 'Seterusnya',
previous_page: 'Sebelumnya',
@@ -10,7 +10,6 @@ export const nl: TranslationLanguage = {
switch_to_light_theme: 'Schakel over naar lichte modus',
switch_to_system_theme: 'Schakel over naar systeemmodus',
search: 'Zoeken',
skip_to_content: 'Naar inhoud',
clear: 'Wissen',
table_filtered_by_selection: 'Komt overeen met selectie: ${1}',
table_clear_selection: 'Selectie wissen',
@@ -44,8 +43,6 @@ export const nl: TranslationLanguage = {
search_ask_no_answer:
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
search_partial_error:
'Sommige resultaten konden niet worden geladen. Beschikbare resultaten worden weergegeven.',
on_this_page: 'Op deze pagina',
next_page: 'Volgende',
previous_page: 'Vorige',
@@ -10,7 +10,6 @@ export const no: TranslationLanguage = {
switch_to_light_theme: 'Bytt til lyst tema',
switch_to_system_theme: 'Bytt til systemtema',
search: 'Søk',
skip_to_content: 'Hopp til innhold',
clear: 'Tøm',
table_filtered_by_selection: 'Samsvarer med valg: ${1}',
table_clear_selection: 'Fjern valg',
@@ -44,7 +43,6 @@ export const no: TranslationLanguage = {
search_ask_no_answer:
'Et svar på spørsmålet ditt kunne ikke finnes. Prøv å omformulere det eller være mer spesifikk.',
search_ask_error: 'Noe gikk galt. Vennligst prøv igjen senere.',
search_partial_error: 'Noen resultater kunne ikke lastes inn. Viser tilgjengelige resultater.',
on_this_page: 'På denne siden',
next_page: 'Neste',
previous_page: 'Forrige',
@@ -10,7 +10,6 @@ export const pl: TranslationLanguage = {
switch_to_light_theme: 'Przełącz na jasny motyw',
switch_to_system_theme: 'Przełącz na motyw systemowy',
search: 'Szukaj',
skip_to_content: 'Przejdź do treści',
clear: 'Wyczyść',
table_filtered_by_selection: 'Zgodne z wyborem: ${1}',
table_clear_selection: 'Wyczyść wybór',
@@ -43,7 +42,6 @@ export const pl: TranslationLanguage = {
search_ask_no_answer:
'Nie udało się znaleźć odpowiedzi na Twoje pytanie. Spróbuj sformułować je inaczej lub bardziej szczegółowo.',
search_ask_error: 'Coś poszło nie tak. Spróbuj ponownie później.',
search_partial_error: 'Nie udało się wczytać niektórych wyników. Wyświetlamy dostępne wyniki.',
on_this_page: 'Na tej stronie',
next_page: 'Dalej',
previous_page: 'Wstecz',
@@ -10,7 +10,6 @@ export const pt_br: TranslationLanguage = {
switch_to_light_theme: 'Mudar para modo claro',
switch_to_system_theme: 'Mudar para configuração do sistema',
search: 'Buscar',
skip_to_content: 'Pular para o conteúdo',
clear: 'Limpar',
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
table_clear_selection: 'Limpar seleção',
@@ -44,8 +43,6 @@ export const pt_br: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo deu errado. Por favor, tente novamente mais tarde.',
search_partial_error:
'Alguns resultados não puderam ser carregados. Mostrando os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Próximo',
previous_page: 'Anterior',
@@ -10,7 +10,6 @@ export const pt: TranslationLanguage = {
switch_to_light_theme: 'Mudar para o tema claro',
switch_to_system_theme: 'Mudar para o tema do sistema',
search: 'Pesquisar',
skip_to_content: 'Saltar para o conteúdo',
clear: 'Limpar',
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
table_clear_selection: 'Limpar seleção',
@@ -43,8 +42,6 @@ export const pt: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para a sua pergunta. Pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo correu mal. Tente novamente mais tarde.',
search_partial_error:
'Não foi possível carregar alguns resultados. A mostrar os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Seguinte',
previous_page: 'Anterior',
@@ -10,7 +10,6 @@ export const ro: TranslationLanguage = {
switch_to_light_theme: 'Comută la tema luminoasă',
switch_to_system_theme: 'Comută la tema sistemului',
search: 'Caută',
skip_to_content: 'Salt la conținut',
clear: 'Șterge',
table_filtered_by_selection: 'Se potrivește cu selecția: ${1}',
table_clear_selection: 'Șterge selecția',
@@ -43,8 +42,6 @@ export const ro: TranslationLanguage = {
search_ask_no_answer:
'Nu s-a putut găsi un răspuns la întrebarea ta. Poți încerca să o reformulezi sau să fii mai specific.',
search_ask_error: 'Ceva nu a mers bine. Te rugăm să încerci din nou mai târziu.',
search_partial_error:
'Unele rezultate nu au putut fi încărcate. Se afișează rezultatele disponibile.',
on_this_page: 'Pe această pagină',
next_page: 'Următoarea',
previous_page: 'Anterioara',
@@ -10,7 +10,6 @@ export const ru: TranslationLanguage = {
switch_to_light_theme: 'Переключиться на светлую тему',
switch_to_system_theme: 'Переключиться на системную тему',
search: 'Поиск',
skip_to_content: 'Перейти к содержимому',
clear: 'Очистить',
table_filtered_by_selection: 'Соответствует выбору: ${1}',
table_clear_selection: 'Очистить выбор',
@@ -44,8 +43,6 @@ export const ru: TranslationLanguage = {
search_ask_no_answer:
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
search_partial_error:
'Не удалось загрузить некоторые результаты. Показаны доступные результаты.',
on_this_page: 'На этой странице',
next_page: 'Следующая',
previous_page: 'Предыдущая',
@@ -10,7 +10,6 @@ export const sk: TranslationLanguage = {
switch_to_light_theme: 'Prepnúť na svetlý motív',
switch_to_system_theme: 'Prepnúť na systémový motív',
search: 'Hľadať',
skip_to_content: 'Preskočiť na obsah',
clear: 'Vymazať',
table_filtered_by_selection: 'Zodpovedá výberu: ${1}',
table_clear_selection: 'Zrušiť výber',
@@ -43,8 +42,6 @@ export const sk: TranslationLanguage = {
search_ask_no_answer:
'Na vašu otázku sa nepodarilo nájsť odpoveď. Skúste ju preformulovať alebo byť konkrétnejší.',
search_ask_error: 'Niečo sa pokazilo. Skúste to znova neskôr.',
search_partial_error:
'Niektoré výsledky sa nepodarilo načítať. Zobrazujú sa dostupné výsledky.',
on_this_page: 'Na tejto stránke',
next_page: 'Ďalej',
previous_page: 'Predchádzajúca',
@@ -10,7 +10,6 @@ export const sl: TranslationLanguage = {
switch_to_light_theme: 'Preklopi na svetlo temo',
switch_to_system_theme: 'Preklopi na sistemsko temo',
search: 'Išči',
skip_to_content: 'Preskoči na vsebino',
clear: 'Počisti',
table_filtered_by_selection: 'Ustreza izbiri: ${1}',
table_clear_selection: 'Počisti izbiro',
@@ -43,8 +42,6 @@ export const sl: TranslationLanguage = {
search_ask_no_answer:
'Odgovora na vaše vprašanje ni bilo mogoče najti. Poskusite ga preoblikovati ali biti bolj natančni.',
search_ask_error: 'Nekaj je šlo narobe. Poskusite znova pozneje.',
search_partial_error:
'Nekaterih rezultatov ni bilo mogoče naložiti. Prikazani so razpoložljivi rezultati.',
on_this_page: 'Na tej strani',
next_page: 'Naslednja',
previous_page: 'Prejšnja',
@@ -10,7 +10,6 @@ export const sv: TranslationLanguage = {
switch_to_light_theme: 'Byt till ljust tema',
switch_to_system_theme: 'Byt till systemtema',
search: 'Sök',
skip_to_content: 'Hoppa till innehåll',
clear: 'Rensa',
table_filtered_by_selection: 'Matchar val: ${1}',
table_clear_selection: 'Rensa val',
@@ -43,7 +42,6 @@ export const sv: TranslationLanguage = {
search_ask_no_answer:
'Det gick inte att hitta ett svar på din fråga. Du kan försöka formulera om den eller vara mer specifik.',
search_ask_error: 'Något gick fel. Försök igen senare.',
search_partial_error: 'Vissa resultat kunde inte läsas in. Visar tillgängliga resultat.',
on_this_page: 'På den här sidan',
next_page: 'Nästa',
previous_page: 'Föregående',
@@ -10,7 +10,6 @@ export const th: TranslationLanguage = {
switch_to_light_theme: 'เปลี่ยนเป็นธีมสว่าง',
switch_to_system_theme: 'เปลี่ยนเป็นธีมของระบบ',
search: 'ค้นหา',
skip_to_content: 'ข้ามไปยังเนื้อหา',
clear: 'ล้าง',
table_filtered_by_selection: 'ตรงกับการเลือก: ${1}',
table_clear_selection: 'ล้างการเลือก',
@@ -42,7 +41,6 @@ export const th: TranslationLanguage = {
search_ask_sources_no_answer: 'หน้าที่เกี่ยวข้อง',
search_ask_no_answer: 'ไม่พบคำตอบสำหรับคำถามของคุณ ลองเรียบเรียงใหม่หรือถามให้เฉพาะเจาะจงขึ้น',
search_ask_error: 'มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง',
search_partial_error: 'โหลดผลลัพธ์บางรายการไม่ได้ กำลังแสดงผลลัพธ์ที่พร้อมใช้งาน',
on_this_page: 'ในหน้านี้',
next_page: 'ถัดไป',
previous_page: 'ก่อนหน้า',
@@ -10,7 +10,6 @@ export const tr: TranslationLanguage = {
switch_to_light_theme: 'Açık temaya geç',
switch_to_system_theme: 'Sistem temasına geç',
search: 'Ara',
skip_to_content: 'İçeriğe geç',
clear: 'Temizle',
table_filtered_by_selection: 'Seçimle eşleşiyor: ${1}',
table_clear_selection: 'Seçimi temizle',
@@ -43,7 +42,6 @@ export const tr: TranslationLanguage = {
search_ask_no_answer:
'Sorunuz için bir cevap bulunamadı. Soruyu yeniden ifade etmeyi veya daha spesifik olmayı deneyebilirsiniz.',
search_ask_error: 'Bir şeyler ters gitti. Lütfen daha sonra tekrar deneyin.',
search_partial_error: 'Bazı sonuçlar yüklenemedi. Kullanılabilir sonuçlar gösteriliyor.',
on_this_page: 'Bu sayfada',
next_page: 'Sonraki',
previous_page: 'Önceki',
@@ -10,7 +10,6 @@ export const uk: TranslationLanguage = {
switch_to_light_theme: 'Перемкнути на світлу тему',
switch_to_system_theme: 'Перемкнути на системну тему',
search: 'Пошук',
skip_to_content: 'Перейти до вмісту',
clear: 'Очистити',
table_filtered_by_selection: 'Відповідає вибору: ${1}',
table_clear_selection: 'Очистити вибір',
@@ -43,7 +42,6 @@ export const uk: TranslationLanguage = {
search_ask_no_answer:
'Не вдалося знайти відповідь на ваше запитання. Спробуйте переформулювати його або зробити конкретнішим.',
search_ask_error: 'Щось пішло не так. Спробуйте ще раз пізніше.',
search_partial_error: 'Не вдалося завантажити деякі результати. Показано доступні результати.',
on_this_page: 'На цій сторінці',
next_page: 'Наступна',
previous_page: 'Попередня',
@@ -10,7 +10,6 @@ export const vi: TranslationLanguage = {
switch_to_light_theme: 'Chuyển sang giao diện sáng',
switch_to_system_theme: 'Chuyển sang giao diện hệ thống',
search: 'Tìm kiếm',
skip_to_content: 'Chuyển đến nội dung',
clear: 'Xóa',
table_filtered_by_selection: 'Khớp với lựa chọn: ${1}',
table_clear_selection: 'Xóa lựa chọn',
@@ -43,7 +42,6 @@ export const vi: TranslationLanguage = {
search_ask_no_answer:
'Không tìm thấy câu trả lời cho câu hỏi của bạn. Bạn có thể thử diễn đạt lại hoặc hỏi cụ thể hơn.',
search_ask_error: 'Đã xảy ra lỗi. Vui lòng thử lại sau.',
search_partial_error: 'Không thể tải một số kết quả. Đang hiển thị các kết quả có sẵn.',
on_this_page: 'Trên trang này',
next_page: 'Tiếp theo',
previous_page: 'Trước',
@@ -10,7 +10,6 @@ export const yue: TranslationLanguage = {
switch_to_light_theme: '切換到淺色主題',
switch_to_system_theme: '切換到系統主題',
search: '搜尋',
skip_to_content: '跳至內容',
clear: '清除',
table_filtered_by_selection: '符合選擇:${1}',
table_clear_selection: '清除選擇',
@@ -42,7 +41,6 @@ export const yue: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '搵唔到你問題嘅答案。你可以試下改寫,或者問得更具體。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果載入唔到。顯示緊可用嘅結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -10,7 +10,6 @@ export const zh_tw: TranslationLanguage = {
switch_to_light_theme: '切換至淺色主題',
switch_to_system_theme: '切換至系統主題',
search: '搜尋',
skip_to_content: '跳至內容',
clear: '清除',
table_filtered_by_selection: '符合選擇:${1}',
table_clear_selection: '清除選擇',
@@ -42,7 +41,6 @@ export const zh_tw: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '找不到您問題的答案。您可以嘗試改寫問題,或提出更具體的內容。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果無法載入。正在顯示可用結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -10,7 +10,6 @@ export const zh: TranslationLanguage = {
switch_to_light_theme: '切换到浅色主题',
switch_to_system_theme: '切换到系统主题',
search: '搜索',
skip_to_content: '跳转到内容',
clear: '清除',
table_filtered_by_selection: '符合选择:${1}',
table_clear_selection: '清除选择',
@@ -43,7 +42,6 @@ export const zh: TranslationLanguage = {
search_ask_sources_no_answer: '相关页面',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。',
search_partial_error: '部分结果无法加载。正在显示可用结果。',
on_this_page: '在本页',
next_page: '下一页',
previous_page: '上一页',
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { isComposingKeyEvent } from './keyboard';
describe('isComposingKeyEvent', () => {
it('is false for a regular Enter', () => {
expect(isComposingKeyEvent({ nativeEvent: { isComposing: false, keyCode: 13 } })).toBe(
false
);
});
it('is true while composing', () => {
expect(isComposingKeyEvent({ nativeEvent: { isComposing: true, keyCode: 13 } })).toBe(true);
});
it('is true for the Safari confirming keydown', () => {
expect(isComposingKeyEvent({ nativeEvent: { isComposing: false, keyCode: 229 } })).toBe(
true
);
});
});

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