Compare commits

..

1 Commits

Author SHA1 Message Date
Peter White 91ec127e01 Search the API directly from the browser on published sites
The browser posts to the search API with the content token of the page and
builds results with the code the search route uses. The route stays as the
fallback for previews, expiring tokens and failed direct calls.

Drop the cache directive on the search fetcher: its key holds the token and
the typed prefix, so entries are written and not read back.

searchSpikeOverrides.ts is a measurement switch for the preview deploy and
must be removed before merge.

Part of RND-13965
2026-11-12 12:58:14 +00:00
121 changed files with 2083 additions and 1533 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix horizontal page scroll caused by card carousels bleeding past the viewport.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Remove duplicated declarations from the global stylesheet.
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/icons": patch
---
Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Limit the MCP `searchDocumentation` tool to one language: the one the client passes in the new optional `language` argument, or else the language of the site variant the MCP endpoint is served from.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Flush insights events when the page is hidden, preload the search index at low priority and prioritize the dark page cover on dark-default sites.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the search button accessible name, hoverable link previews, IME-safe Enter on Safari, cookie banner semantics, active outline item announcement and textarea shrinking.
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Respect reduced motion for enter and exit animations, keep the header separator and input focus visible in forced colors mode, and size mobile surfaces with dynamic viewport units.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send only the site origin as referrer to third parties, and only let the embedding page open the embed messaging channel.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Show one server-ordered search list and open its top result on Enter.
+16 -42
View File
@@ -4,11 +4,6 @@ on:
push:
branches:
- main
# A newer run for the same PR supersedes the previous one. Each push to main gets a group of its
# own, so main runs are never queued behind or cancelled by another run.
concurrency:
group: ${{ github.event_name == 'pull_request_target' && format('preview-pr-{0}', github.event.pull_request.number) || format('preview-run-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
@@ -23,9 +18,8 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -50,9 +44,8 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -89,7 +82,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: ${{ !cancelled() && github.event_name != 'push' }}
if: always() && github.event_name != 'push'
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -102,25 +95,6 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
# A deploy that didn't succeed has no URL (e.g. a re-run hitting Vercel's "deploymentId
# already exists"), so keep the URL the comment already shows instead of blanking it.
- name: Resolve deployment URLs
id: urls
env:
PREVIOUS_COMMENT: ${{ steps.fc.outputs.comment-body }}
VERCEL_RESULT: ${{ needs.deploy-v2-vercel.result }}
VERCEL_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
CLOUDFLARE_RESULT: ${{ needs.deploy-v2-cloudflare.result }}
CLOUDFLARE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
run: |
previous_url() {
sed -n "s/^| $1 | \[\([^]]*\)\].*/\1/p" <<<"$PREVIOUS_COMMENT" | head -n 1
}
if [ "$VERCEL_RESULT" != success ]; then VERCEL_URL=$(previous_url Vercel); fi
if [ "$CLOUDFLARE_RESULT" != success ]; then CLOUDFLARE_URL=$(previous_url Cloudflare); fi
echo "vercel=$VERCEL_URL" >> "$GITHUB_OUTPUT"
echo "cloudflare=$CLOUDFLARE_URL" >> "$GITHUB_OUTPUT"
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
with:
@@ -131,21 +105,21 @@ jobs:
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ steps.urls.outputs.vercel }}](${{ steps.urls.outputs.vercel }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ steps.urls.outputs.cloudflare }}](${{ steps.urls.outputs.cloudflare }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | `2v` | `2c` |
| --- | --- | --- |
| GitBook | [${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs](${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs](${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs) |
| E2E | [${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open) |
| GitBook | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v2-vercel:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -169,7 +143,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -193,7 +167,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -217,7 +191,7 @@ jobs:
# Style recalc depends on the client bundle, not the host, so this runs here only and
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
- name: Run style invalidation tests
if: ${{ !cancelled() }}
if: always()
working-directory: packages/gitbook
run: bun run e2e-style-perf
env:
@@ -226,7 +200,7 @@ jobs:
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
- name: Build style invalidation report
if: ${{ !cancelled() && github.event_name != 'push' }}
if: always() && github.event_name != 'push'
id: style-perf
working-directory: packages/gitbook
run: |
@@ -245,7 +219,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Find style invalidation comment
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/find-comment@v3
id: fc-style-perf
with:
@@ -254,7 +228,7 @@ jobs:
body-includes: 'Style invalidation on a large API reference'
- name: Create or update style invalidation comment
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
@@ -265,7 +239,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -289,7 +263,7 @@ jobs:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
-2
View File
@@ -158,8 +158,6 @@
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- the `vh` declaration above is the fallback on host pages whose browser lacks `dvh` */
height: calc(min(var(--gitbook-widget-window-height), calc(100dvh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
@@ -1,13 +1,10 @@
import { type NextRequest, NextResponse } from 'next/server';
import { orderSearchResultGroups } from './orderSearchResults';
import type {
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { SearchSiteContentRequest } from '@/components/Search/search-types';
import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { transformSitePageResult } from '@/lib/search';
import { computeSearchResults } from '@/lib/search-results';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLinkerForSiteSpace } from '@/lib/sites';
@@ -56,49 +53,24 @@ export async function POST(request: NextRequest) {
? getLinkerForSiteSpace(context.linker, currentSiteSpace.siteSpace, revision.pages)
: context.linker;
const results = orderSearchResultGroups<OrderedComputedResult>(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
score: resultItem.score,
};
const results = computeSearchResults(searchResults, (spaceItem, pageItem) => {
const isCurrentRevisionSpace = Boolean(revision && spaceItem.id === siteURLData.space);
const found =
isCurrentRevisionSpace && currentSiteSpace
? currentSiteSpace
: findSiteSpaceBy(structure, (siteSpace) => siteSpace.space.id === spaceItem.id);
return { type: 'context' as const, results: [result] };
}
const isCurrentRevisionSpace = Boolean(revision && resultItem.id === siteURLData.space);
const found =
isCurrentRevisionSpace && currentSiteSpace
? currentSiteSpace
: findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
return {
type: 'pages' as const,
results: resultItem.pages.flatMap((pageItem) => {
const result = transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
revisionPages: isCurrentRevisionSpace ? revision?.pages : undefined,
});
return result ? [{ rank: pageItem.rank, result }] : [];
}),
};
})
);
return transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
pageItem,
spaceItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
revisionPages: isCurrentRevisionSpace ? revision?.pages : undefined,
});
});
return NextResponse.json(results);
}
@@ -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
@@ -43,14 +43,13 @@ describe('ViewCards', () => {
it('renders the carousel with a symmetric peek and conditional edge masks', () => {
const markup = renderWithContext(<ViewCards {...makeProps(false)} />);
// Gutter-sized peek, the full 3rem from `2xl`.
expect(markup).toContain('-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12');
expect(markup).toContain('scroll-px-4');
expect(markup).toContain('2xl:scroll-px-12');
expect(markup).toContain('-mx-12');
expect(markup).toContain('px-12');
expect(markup).toContain('scroll-px-12');
expect(markup).toContain('left-0');
expect(markup).toContain('2xl:ml-8');
expect(markup).toContain('ml-8');
expect(markup).toContain('right-0');
expect(markup).toContain('2xl:mr-8');
expect(markup).toContain('mr-8');
expect(markup).not.toContain('before:bg-linear-to-r');
expect(markup).not.toContain('after:bg-linear-to-l');
expect(markup).toContain('snap-mandatory');
@@ -49,16 +49,6 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
);
}
// The peek fits the page gutter (`CONTAINER_STYLE`) so the fade stays on screen; from `2xl` the
// cards fill the row exactly and need the full 3rem to show the next one.
const PEEK = {
margin: '-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12',
padding:
'px-4 scroll-px-4 sm:px-6 sm:scroll-px-6 md:px-8 md:scroll-px-8 2xl:px-12 2xl:scroll-px-12',
leadingButton: 'ml-0 sm:ml-2 md:ml-4 2xl:ml-8',
trailingButton: 'mr-0 sm:mr-2 md:mr-4 2xl:mr-8',
};
/**
* The carousel layout: cards lay out in a single horizontally-scrolling row that
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
@@ -80,7 +70,7 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
return (
<ScrollContainer
orientation="horizontal"
className={tcls(PEEK.margin, 'hover:z-11')}
className={tcls('-mx-12', 'hover:z-11')}
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
contentClassName={tcls(
@@ -89,18 +79,19 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
'-mt-px',
'pb-6',
'-mb-6',
PEEK.padding,
'px-12',
'scroll-px-12',
'snap-x',
'snap-mandatory'
)}
scrollByVisibleItems
leading={{
fade: true,
button: { size: 'small', className: PEEK.leadingButton },
button: { size: 'small', className: 'ml-8' },
}}
trailing={{
fade: true,
button: { size: 'small', className: PEEK.trailingButton },
button: { size: 'small', className: 'mr-8' },
}}
>
{records.map((record) => {
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getNextTabIndex } from './DynamicTabs';
describe('getNextTabIndex', () => {
it('moves with the arrow keys and wraps around', () => {
expect(getNextTabIndex('ArrowRight', 0, 3)).toBe(1);
expect(getNextTabIndex('ArrowRight', 2, 3)).toBe(0);
expect(getNextTabIndex('ArrowLeft', 0, 3)).toBe(2);
});
it('jumps to the first and last tab', () => {
expect(getNextTabIndex('Home', 2, 3)).toBe(0);
expect(getNextTabIndex('End', 0, 3)).toBe(2);
});
it('mirrors the arrows in right-to-left', () => {
expect(getNextTabIndex('ArrowLeft', 0, 3, true)).toBe(1);
expect(getNextTabIndex('ArrowRight', 0, 3, true)).toBe(2);
});
it('ignores other keys and an unknown tab', () => {
expect(getNextTabIndex('Enter', 0, 3)).toBeNull();
expect(getNextTabIndex('ArrowRight', -1, 3)).toBeNull();
});
});
@@ -151,15 +151,10 @@ const TabItemList = memo(function TabItemList(props: {
}).filter((x) => x !== undefined),
[overflowing, tabs]
);
// Roving tabindex: one tab stop for the strip, the active tab when it isn't in the overflow menu.
const visibleTabs = tabs.filter((tab) => !overflowing.has(getTabButtonId(tab.id)));
const focusableTabId =
visibleTabs.find((tab) => tab.id === activeTabId)?.id ?? visibleTabs[0]?.id ?? null;
return (
<div
ref={containerRef}
role="tablist"
onKeyDown={moveFocusBetweenTabs}
className={tcls(
'group/tabs',
'overflow-hidden',
@@ -187,7 +182,6 @@ const TabItemList = memo(function TabItemList(props: {
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
isFocusable={tab.id === focusableTabId}
isMeasuring={isMeasuring}
tab={tab}
onSelect={onSelect}
@@ -247,18 +241,16 @@ function TabsDropdownMenu(props: {
const TabItem = memo(function TabItem(props: {
ref: React.Ref<HTMLButtonElement>;
isActive: boolean;
isFocusable: boolean;
isMeasuring: boolean;
tab: TabsItem;
onSelect: (tabId: string) => void;
}) {
const { ref, tab, isActive, isFocusable, isMeasuring, onSelect } = props;
const { ref, tab, isActive, isMeasuring, onSelect } = props;
return (
<TabButton
ref={ref}
role="tab"
aria-selected={isActive}
tabIndex={isFocusable ? 0 : -1}
aria-controls={tab.id}
id={getTabButtonId(tab.id)}
title={tab.title}
@@ -351,55 +343,6 @@ function TabButton(
);
}
/**
* Arrow keys, Home and End move focus between the tabs of a tablist; Enter or Space then selects.
*/
function moveFocusBetweenTabs(event: React.KeyboardEvent<HTMLElement>) {
const current = event.target instanceof HTMLElement ? event.target : null;
if (current?.getAttribute('role') !== 'tab') {
return;
}
const tabs = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])')
);
const index = tabs.indexOf(current);
const isRTL = getComputedStyle(event.currentTarget).direction === 'rtl';
const next = getNextTabIndex(event.key, index, tabs.length, isRTL);
if (next === null) {
return;
}
event.preventDefault();
tabs[next]?.focus();
}
/**
* Index of the tab to focus for a key press, or null when the key doesn't move focus.
*/
export function getNextTabIndex(
key: string,
index: number,
count: number,
isRTL = false
): number | null {
if (index < 0 || count === 0) {
return null;
}
const forward = isRTL ? 'ArrowLeft' : 'ArrowRight';
const backward = isRTL ? 'ArrowRight' : 'ArrowLeft';
switch (key) {
case forward:
return (index + 1) % count;
case backward:
return (index - 1 + count) % count;
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/**
* Get the ID for a tab button.
*/
@@ -50,7 +50,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
query,
results,
@@ -90,7 +89,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={true}
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { isForeignHandshake } from './channel';
describe('isForeignHandshake', () => {
const parent = {} as MessageEventSource;
const other = {} as MessageEventSource;
it('accepts a handshake from the expected window', () => {
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: parent }, parent)).toBe(
false
);
});
it('rejects a handshake from another window', () => {
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: other }, parent)).toBe(
true
);
});
it('ignores messages that are not handshakes', () => {
expect(isForeignHandshake({ data: { type: 'other' }, source: other }, parent)).toBe(false);
expect(isForeignHandshake({ data: null, source: other }, parent)).toBe(false);
});
});
@@ -9,33 +9,11 @@ export function log(...data: any[]) {
console.log(...data);
}
/**
* Whether a message is a bidc handshake sent by a window other than the expected one.
*/
export function isForeignHandshake(
event: Pick<MessageEvent, 'data' | 'source'>,
expectedSource: MessageEventSource
): boolean {
return event.data?.type === 'bidc-connect' && event.source !== expectedSource;
}
export const getChannel = memoize(() => {
if (typeof window === 'undefined' || window.parent === window) {
return null;
}
log('[gitbook] create channel with parent window');
// bidc accepts a handshake from any window; registered first and in capture so it runs before
// bidc's own listener and only the embedding page can open the channel.
window.addEventListener(
'message',
(event) => {
if (isForeignHandshake(event, window.parent)) {
event.stopImmediatePropagation();
}
},
true
);
return createChannel();
});
@@ -55,8 +55,6 @@ export async function Header(props: {
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
// Box shadows are dropped in forced colors mode, the separator needs a real border.
'forced-colors:border-b',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
@@ -9,7 +9,7 @@ interface HeaderLinksProps {
export async function HeaderLinks({ children, style }: HeaderLinksProps) {
return (
<nav
<div
className={tcls(
'header-links',
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
@@ -17,6 +17,6 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
)}
>
{children}
</nav>
</div>
);
}
@@ -193,18 +193,13 @@ export function InsightsProvider(props: InsightsProviderProps) {
}
);
// `beforeunload` doesn't fire reliably on mobile, hiding the page is the last dependable signal.
/**
* When the page is unloaded, flush all events.
*/
React.useEffect(() => {
const flushWhenHidden = () => {
if (document.visibilityState === 'hidden') {
flushEventsSync();
}
};
document.addEventListener('visibilitychange', flushWhenHidden);
window.addEventListener('pagehide', flushEventsSync);
window.addEventListener('beforeunload', flushEventsSync);
return () => {
document.removeEventListener('visibilitychange', flushWhenHidden);
window.removeEventListener('pagehide', flushEventsSync);
window.removeEventListener('beforeunload', flushEventsSync);
};
}, [flushEventsSync]);
@@ -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;
}
}
@@ -0,0 +1,96 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '../primitives/Emoji/Emoji';
import { Link } from '../primitives/Link';
import type { LocalPageResult } from './useLocalSearchResults';
import { tcls } from '@/lib/tailwind';
/**
* Renders local search results above the main server results.
*
* Layout:
* - While server results are still fetching → 2-column wrapping row (cards fill width)
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
*
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
*/
export function LocalSearchResults({
results,
fetching,
}: {
results: LocalPageResult[];
fetching: boolean;
}) {
if (results.length === 0) return null;
return (
<div
className={tcls(
'py-2',
'flex',
'flex-row',
'gap-2',
fetching
? 'flex-wrap'
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
)}
>
{results.map((result) => (
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
))}
</div>
);
}
function LocalSearchResultCard({
result,
fetching,
}: {
result: LocalPageResult;
fetching: boolean;
}) {
return (
<Link
href={result.pathname}
className={tcls(
'group',
'flex',
'flex-col',
'gap-1',
'p-3',
'rounded-corners:rounded-lg',
'circular-corners:rounded-2xl',
'text-tint',
'bg-tint-subtle',
'hover:bg-tint',
'hover:text-tint-strong',
'transition-colors',
'cursor-pointer',
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
)}
>
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
{result.emoji ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Emoji code={result.emoji} style="text-base leading-none" />
</span>
) : result.icon ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Icon icon={result.icon as IconName} className="size-4" />
</span>
) : null}
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
{result.title}
</p>
<span className="ml-auto shrink-0 text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</span>
</div>
{result.description ? (
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
) : null}
</Link>
);
}
@@ -17,7 +17,7 @@ import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearchController } from './useSearchController';
import { useSearchPopupFocusTrap } from './useSearchPopupFocusTrap';
import { t, tString, useLanguage } from '@/intl/client';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
@@ -53,7 +53,6 @@ export function SearchContainer({
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
open,
query,
@@ -150,7 +149,6 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -205,7 +203,6 @@ export function SearchContainer({
active={isSearchOpen}
onClick={open}
className={className}
aria-label={tString(language, withSearchAI ? 'search_or_ask' : 'search')}
aria-expanded={isSearchOpen}
aria-controls={resultsId}
/>
@@ -217,7 +214,6 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -13,8 +13,6 @@ interface SearchInputProps {
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: () => void;
onInput?: (event: React.FormEvent<HTMLInputElement>) => void;
onCompositionEnd?: (event: React.CompositionEvent<HTMLInputElement>) => void;
value: string;
withAI: boolean;
isOpen: boolean;
@@ -2,18 +2,24 @@ import React from 'react';
import { Icon, type IconName } from '@gitbook/icons';
import { SkeletonParagraph } from '../primitives';
import { Tooltip } from '../primitives';
import { Emoji } from '../primitives/Emoji/Emoji';
import { getPageResultPresentation } from './getPageResultHref';
import { HighlightQuery } from './HighlightQuery';
import type { MergedPageResult } from './reciprocalRankFusion';
import type { ComputedPageResult } from './search-types';
import { SearchResultItem } from './SearchResultItem';
import type { LocalPageResult } from './useLocalSearchResults';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult;
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
props: {
query: string;
item: ComputedPageResult;
item: PageItem;
active: boolean;
style?: React.CSSProperties;
},
@@ -22,19 +28,46 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
const { preview: bestSection, description, href } = getPageResultPresentation(item);
const { bestSection, description, href } = (() => {
if (item.type === 'page') {
const presentation = getPageResultPresentation(item);
return {
bestSection: presentation.preview,
description: presentation.description,
href: presentation.href,
};
}
return { bestSection: undefined, description: item.description, href: item.pathname };
})();
const preview = bestSection
? [bestSection.title, bestSection.body].filter(Boolean).join(' · ')
: undefined;
const insights = {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
};
const emoji = 'emoji' in item ? item.emoji : undefined;
const icon = 'icon' in item ? item.icon : undefined;
const leadingIcon = emoji ? (
<span className="flex size-4 shrink-0 items-center justify-center">
<Emoji code={emoji} style="text-base leading-none" />
</span>
) : icon ? (
<Icon icon={icon as IconName} className="size-4" />
) : (
<Icon icon="file-lines" className="size-4" />
);
const insights =
item.type === 'page'
? {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}
: undefined;
return (
<SearchResultItem
@@ -43,7 +76,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
active={active}
data-testid="search-page-result"
action={tString(language, 'view')}
leadingIcon={<Icon icon="file-lines" className="size-4" />}
leadingIcon={leadingIcon}
insights={insights}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{
@@ -58,7 +91,11 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
!preview && !description ? 'h-0' : ''
item.type === 'local-page' && !preview && !description
? '[[aria-busy=false]_&]:h-0'
: !preview && !description
? 'h-0'
: ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
@@ -82,15 +119,26 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<HighlightQuery query={query} text={description} />
</p>
) : null}
{item.type === 'local-page' ? (
<SkeletonParagraph
size="small"
lines={1}
className={tcls(
'absolute inset-0 origin-left',
preview || description
? 'hidden animate-blur-out'
: '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out'
)}
style={{ animationDelay: style?.animationDelay }}
/>
) : null}
</div>
</SearchResultItem>
);
});
const Breadcrumbs = (props: {
breadcrumbs: ComputedPageResult['breadcrumbs'];
withOverflow?: boolean;
}) => {
const Breadcrumbs = (props: { breadcrumbs: PageItem['breadcrumbs']; withOverflow?: boolean }) => {
const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props;
if (!breadcrumbs || breadcrumbs.length === 0) return null;
@@ -19,12 +19,12 @@ import { tcls } from '@/lib/tailwind';
export interface SearchResultsRef {
select(): boolean;
/** Activate the first result, ignoring the cursor. */
selectFirst(): boolean;
}
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
return `page:${item.id}`;
case 'page':
return `page:${item.pageId}`;
case 'record':
@@ -107,11 +107,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
ref,
() => ({
select,
selectFirst: () => {
const first = refs.current[0];
first?.click();
return Boolean(first);
},
}),
[select]
);
@@ -119,7 +114,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error) {
if (error && results.length === 0) {
return (
<output
className={tcls(
@@ -162,6 +157,26 @@ export const SearchResults = React.forwardRef(function SearchResults(
</output>
);
const partialError = error ? (
<div
className={tcls(
'mt-3',
'rounded-corners:rounded-md',
'circular-corners:rounded-2xl',
'bg-tint-subtle',
'px-3',
'py-2',
'text-center',
'text-sm',
'text-tint-subtle',
'animate-blur-in-slow'
)}
role="status"
>
{t(language, 'search_partial_error')}
</div>
) : null;
return (
<output className="flex grow flex-col" aria-busy={fetching}>
{children}
@@ -187,7 +202,10 @@ export const SearchResults = React.forwardRef(function SearchResults(
const handleResultSelect = (
event: React.MouseEvent<HTMLAnchorElement>
) => {
const isPageResult = item.type === 'page' || item.type === 'record';
const isPageResult =
item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record';
if (
isPageResult &&
@@ -220,6 +238,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
onClickCapture: handleResultSelect,
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<motion.div
@@ -334,6 +353,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{partialError}
{fetching ? (
<div
className={tcls(
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import type { OrderedComputedResult } from './search-types';
import { computeFilterSiteSpaceIds } from './filter';
describe('computeFilterSiteSpaceIds', () => {
const currentId = 'space-current';
@@ -35,31 +34,3 @@ describe('computeFilterSiteSpaceIds', () => {
expect(r).toEqual(['space-other-a', 'space-other-b']);
});
});
describe('filterResultsBySiteSpaces', () => {
const page = (id: string, siteSpaceId?: string): OrderedComputedResult => ({
type: 'page',
id,
title: id,
href: `/${id}`,
score: 1,
pageId: id,
spaceId: 'space',
siteSpaceId,
description: '',
rank: 1,
});
it('keeps the server order and drops pages outside the scope', () => {
const results = [page('a', 'current'), page('b', 'other'), page('c', 'current'), page('d')];
expect(filterResultsBySiteSpaces(results, ['current']).map((result) => result.id)).toEqual([
'a',
'c',
]);
});
it('returns every result when the scope is unrestricted', () => {
const results = [page('b', 'other'), page('a', 'current')];
expect(filterResultsBySiteSpaces(results, undefined)).toBe(results);
});
});
@@ -1,13 +1,16 @@
import type { OrderedComputedResult } from './search-types';
import type { SearchScope } from './useSearch';
/**
* Site spaces whose pages may be shown for a UI search scope, or `undefined` for no restriction.
* Map UI search scope to local FlexSearch filtering.
*
* The server's "default" scope also returns pages from other site spaces. Without sections
* (only variants) the UI presents "default" as the current variant, so it is restricted to
* the current `siteSpaceId`. An explicitly selected section takes precedence.
* Rationale: when a site has no sections (only variants), the remote search
* "default" scope effectively queries the current variant. To keep local
* results consistent with remote ones, we also restrict "default" to the
* current `siteSpaceId` when `withSections` is false. Otherwise (sections
* present), local "default" stays unfiltered to match the broader section UI.
* An explicitly selected section takes precedence over these legacy scopes.
*/
export function computeFilterSiteSpaceIds(
scope: SearchScope,
siteSpaceId: string,
@@ -28,21 +31,3 @@ export function computeFilterSiteSpaceIds(
return withSections ? undefined : [siteSpaceId];
}
}
/** Keep the server's order, dropping pages owned by a site space outside the scope. */
export function filterResultsBySiteSpaces(
results: OrderedComputedResult[],
allowedSiteSpaceIds: string[] | undefined
): OrderedComputedResult[] {
if (!allowedSiteSpaceIds) {
return results;
}
const allowed = new Set(allowedSiteSpaceIds);
return results.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
);
}
@@ -5,4 +5,5 @@ export * from './SearchContainer';
export * from './SearchScopeControl';
export * from './search-props';
export * from './useSearch';
export * from './useLocalSearchResults';
export * from './useSearchController';
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'bun:test';
import {
type MergedPageResult,
fuseSearchResults,
getResultKey,
reciprocalRankFusion,
} from './reciprocalRankFusion';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
function localPage(id: string, title = id, siteSpaceId = 'site-space-alpha'): LocalPageResult {
return {
type: 'local-page',
id,
siteSpaceId,
title,
pathname: `/${id}`,
description: `Local description for ${title}`,
breadcrumbs: [{ label: 'Local', icon: 'book-open' }],
};
}
function remotePage(
id: string,
title = id,
score = 0,
rank = 1,
spaceId = 'space-alpha',
siteSpaceId = 'site-space-alpha'
): OrderedComputedResult {
return {
type: 'page',
id: `remote-${id}`,
pageId: id,
spaceId,
siteSpaceId,
title,
description: `Remote description for ${title}`,
href: `/${id}`,
score,
rank,
breadcrumbs: [{ label: 'Remote' }],
};
}
function remoteRecord(id: string, title = id): OrderedComputedResult {
return {
type: 'record',
id,
title,
href: `/records/${id}`,
score: 0,
description: undefined,
};
}
describe('reciprocalRankFusion', () => {
it('keeps the first 3 remote results first and in remote order', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remoteRecord('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'record:remote-2',
'page:remote-3',
]);
});
it('applies fusion only after the pinned remote results', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'page:remote-2',
'page:remote-3',
]);
const firstFusedResult = results[3];
if (!firstFusedResult) {
throw new Error('Expected a fused result after the pinned remote results');
}
expect(getResultKey(firstFusedResult)).toBe('page:local-match');
});
it('pins all remote results when fewer than 3 are present', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[remoteRecord('remote-1'), remotePage('remote-2')],
'alpha'
);
expect(results.map(getResultKey)).toEqual([
'record:remote-1',
'page:remote-2',
'page:local-match',
]);
});
it('merges a pinned remote page with the matching local page without duplicating it', () => {
const results = reciprocalRankFusion(
[localPage('remote-1', 'Local title')],
[
remotePage('remote-1', 'Remote title', 1, 1),
remotePage('remote-2'),
remotePage('remote-3'),
],
'remote'
);
const pinnedResult = results[0] as MergedPageResult;
expect(pinnedResult.type).toBe('page');
expect(pinnedResult.title).toBe('Remote title');
expect(pinnedResult.pathname).toBe('/remote-1');
expect(pinnedResult.description).toBe('Remote description for Remote title');
expect(pinnedResult.rank).toBe(1);
expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]);
expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1);
});
it('does not duplicate pinned records in the fused tail', () => {
const results = reciprocalRankFusion(
[],
[
remoteRecord('record-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remoteRecord('record-1'),
remotePage('remote-4'),
],
'remote'
);
expect(results.map(getResultKey).filter((key) => key === 'record:record-1')).toHaveLength(
1
);
});
});
describe('fuseSearchResults', () => {
it('filters every source to the current site space before fusion', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('remote-alpha-1', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha-1', 'Alpha remote one', 10, 1),
remotePage(
'local-alpha',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
remotePage('remote-alpha-2', 'Alpha remote two', 8, 3),
],
query: 'result',
allowedSiteSpaceIds: ['site-space-alpha'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha-1',
'page:remote-alpha-2',
'page:local-alpha',
]);
expect(results[0]).not.toHaveProperty('pathname');
expect(results[2]?.type).toBe('local-page');
});
it('preserves cross-site-space results when the search is unrestricted', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('local-beta', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha', 'Alpha remote result', 10, 1),
remotePage(
'remote-beta',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
],
query: 'result',
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
it('preserves every allowed site space in an intentionally multi-space search', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha'),
localPage('local-beta', 'local-beta', 'site-space-beta'),
localPage('local-gamma', 'local-gamma', 'site-space-gamma'),
],
remoteResults: [
remotePage('remote-alpha'),
remotePage('remote-beta', 'remote-beta', 0, 2, 'space-beta', 'site-space-beta'),
remotePage('remote-gamma', 'remote-gamma', 0, 3, 'space-gamma', 'site-space-gamma'),
],
query: 'page',
allowedSiteSpaceIds: ['site-space-alpha', 'site-space-beta'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
});
@@ -0,0 +1,271 @@
import type { ComputedPageResult, OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
/**
* Jaro-Winkler similarity between two strings, in the range [0, 1].
* Higher is more similar. 1 means an exact match.
* https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
*/
function jaroWinkler(s1: string, s2: string, p = 0.1): number {
if (s1 === s2) return 1;
const len1 = s1.length;
const len2 = s2.length;
if (len1 === 0 || len2 === 0) return 0;
const maxDist = Math.floor(Math.max(len1, len2) / 2) - 1;
const matched1 = new Array<boolean>(len1);
const matched2 = new Array<boolean>(len2);
for (let i = 0; i < len1; i++) matched1[i] = false;
for (let i = 0; i < len2; i++) matched2[i] = false;
let matches = 0;
for (let i = 0; i < len1; i++) {
const start = Math.max(0, i - maxDist);
const end = Math.min(len2 - 1, i + maxDist);
for (let j = start; j <= end; j++) {
if (!matched2[j] && s1.charCodeAt(i) === s2.charCodeAt(j)) {
matched1[i] = true;
matched2[j] = true;
matches++;
break;
}
}
}
if (matches === 0) return 0;
let t = 0;
let k = 0;
for (let i = 0; i < len1; i++) {
if (!matched1[i]) continue;
while (!matched2[k]) k++;
if (s1.charCodeAt(i) !== s2.charCodeAt(k)) t++;
k++;
}
const transpositions = t / 2;
const m = matches;
const jaro = (m / len1 + m / len2 + (m - transpositions) / m) / 3;
let prefix = 0;
const maxPrefix = 4;
const minLen = Math.min(len1, len2);
for (let i = 0; i < maxPrefix && i < minLen; i++) {
if (s1.charCodeAt(i) === s2.charCodeAt(i)) prefix++;
else break;
}
return jaro + prefix * p * (1 - jaro);
}
/** Standard RRF constant — moderates the impact of high-ranked items. */
const RRF_K = 60;
/** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */
const RRF_K_LOCAL = 50;
/** Number of remote results that should keep their API order before fusion. */
const PINNED_REMOTE_RESULTS_COUNT = 3;
/**
* A page result that was present in both local and remote lists.
* Local fields (icon, emoji, pathname) are carried over as a base, and remote fields
* (description, href, pageId, spaceId, title) override them.
* Breadcrumbs prefer local (has icon + emoji) and fall back to remote.
*/
export type MergedPageResult = Omit<ComputedPageResult, 'breadcrumbs'> & {
pathname?: string;
icon?: string;
emoji?: string;
breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs'];
};
/**
* Derive a stable deduplication key for any result type.
* Pages from both local and remote share the same `page:` namespace so they
* can be deduplicated against each other.
*/
export function getResultKey(
result: LocalPageResult | OrderedComputedResult | MergedPageResult
): string {
switch (result.type) {
case 'local-page':
return `page:${result.id}`;
case 'page':
return `page:${result.pageId}`;
case 'record':
return `record:${result.id}`;
}
}
type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult;
/** Filter page candidates at the final assembly boundary, immediately before ranking and fusion. */
export function fuseSearchResults(args: {
localResults: LocalPageResult[];
remoteResults: OrderedComputedResult[];
query: string;
allowedSiteSpaceIds?: string[];
}): RRFResult[] {
const { localResults, remoteResults, query, allowedSiteSpaceIds } = args;
if (!allowedSiteSpaceIds) {
return reciprocalRankFusion(localResults, remoteResults, query);
}
const allowed = new Set(allowedSiteSpaceIds);
return reciprocalRankFusion(
localResults.filter((result) => allowed.has(result.siteSpaceId)),
remoteResults.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
),
query
);
}
function mergeLocalPageWithRemotePage(
localResult: LocalPageResult,
remoteResult: ComputedPageResult
): MergedPageResult {
return {
...localResult,
...remoteResult,
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
breadcrumbs: localResult.breadcrumbs ?? remoteResult.breadcrumbs,
};
}
function mergePinnedRemoteResult(
result: OrderedComputedResult,
localResultsByKey: Map<string, LocalPageResult>
): RRFResult {
if (result.type !== 'page') {
return result;
}
const localResult = localResultsByKey.get(getResultKey(result));
if (!localResult) {
return result;
}
return mergeLocalPageWithRemotePage(localResult, result);
}
/**
* Merge local (FlexSearch) and remote (API) search results using
* Reciprocal Rank Fusion (RRF), while preserving the order of the first
* three remote results.
*
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
*
* The pinned remote results are excluded from fusion and returned first. Pages
* present in both lists are deep-merged: local fields act as the base
* (preserving icon, emoji, pathname) and remote fields override (providing the
* authoritative page description, href, pageId, spaceId, and title). Breadcrumbs prefer local (has icon
* + emoji) and fall back to remote. In the fused tail, their rank contributions
* from both lists are summed.
*
* Sections and records have no local equivalent and only accumulate their own
* rank contribution.
*
* Additionally, a Jaro-Winkler title-match bonus is applied per query word:
* for each query word that appears as a substring in the title, the best
* Jaro-Winkler similarity score against any individual title word is added.
*/
export function reciprocalRankFusion(
localResults: LocalPageResult[],
remoteResults: OrderedComputedResult[],
query: string
): RRFResult[] {
const localResultsByKey = new Map(localResults.map((result) => [getResultKey(result), result]));
const pinnedRemoteResults = remoteResults.slice(0, PINNED_REMOTE_RESULTS_COUNT);
const pinnedResultKeys = new Set(pinnedRemoteResults.map((result) => getResultKey(result)));
const remainingLocalResults = localResults.filter(
(result) => !pinnedResultKeys.has(getResultKey(result))
);
const remainingRemoteResults = remoteResults
.slice(PINNED_REMOTE_RESULTS_COUNT)
.filter((result) => !pinnedResultKeys.has(getResultKey(result)));
// Map from dedup key → { result, score }
const scoreMap = new Map<string, { result: RRFResult; score: number }>();
// Process local results first (1-indexed rank)
// Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones.
remainingLocalResults.forEach((result, index) => {
const rank = index + 1;
const key = getResultKey(result);
const contribution = 1 / (RRF_K_LOCAL + rank);
const existing = scoreMap.get(key);
if (existing) {
existing.score += contribution;
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Process remote results, deduplicating against local pages
remainingRemoteResults.forEach((result, index) => {
const rank = index + 1;
const contribution = 1 / (RRF_K + rank);
const key = getResultKey(result);
const existing = scoreMap.get(key);
if (existing) {
// Page found in both lists: sum rank contributions and deep-merge.
// Local is the base (icon, emoji, pathname), while remote provides page fields.
existing.score += contribution;
if (existing.result.type === 'local-page' && result.type === 'page') {
existing.result = mergeLocalPageWithRemotePage(existing.result, result);
} else {
existing.result = result;
}
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Apply a title-match bonus per query word using Jaro-Winkler similarity.
// For each query word that appears (as a substring) in the title, compute the
// best Jaro-Winkler score against individual title words and add it to the score.
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (queryWords.length > 0) {
for (const entry of scoreMap.values()) {
// Only apply the bonus to page or local-page results
if (entry.result.type === 'record') continue;
const title = entry.result.title?.toLowerCase() ?? '';
const titleWords = title.split(/\s+/).filter((w) => w.length > 0);
for (const word of queryWords) {
// Only apply when there is at least a substring match in the title.
if (!title.includes(word)) continue;
let best = 0;
for (const titleWord of titleWords) {
const sim = jaroWinkler(word, titleWord);
if (sim > best) best = sim;
}
entry.score += best;
}
}
}
// Sort descending by RRF score
const fusedResults = Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
.map(({ result }) => result);
return [
...pinnedRemoteResults.map((result) => mergePinnedRemoteResult(result, localResultsByKey)),
...fusedResults,
];
}
@@ -2,6 +2,8 @@ import type { SiteExternalLink, SiteSection, SiteSectionGroup, SiteSpace } from
import { hasMultipleSiteSections } from '../SiteSections';
import type { GitBookSiteContext } from '@/lib/context';
import { getSearchSpaceLookup } from '@/lib/search';
import type { DirectSearchProps } from '@/lib/search-results';
import { getLocalizedTitle } from '@/lib/sites';
export type SearchSection = Pick<SiteSection, 'id' | 'icon' | 'object'> & {
@@ -25,7 +27,9 @@ export interface SearchBaseProps {
sections: SearchSectionItem[];
withVariants: boolean;
withSiteVariants: boolean;
indexURL: string;
searchURL: string;
directSearch?: DirectSearchProps;
}
export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps {
@@ -37,6 +41,9 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
return {
searchURL: context.linker.toPathInSpace('~gitbook/search'),
directSearch: context.directSearch
? { ...context.directSearch, spaces: getSearchSpaceLookup(context.structure) }
: undefined,
section,
sections,
siteSpace,
@@ -46,6 +53,7 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
visibleSections?.list.some(
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
) ?? false,
indexURL: context.linker.toPathInSite('~gitbook/site-index'),
withVariants: visibleSiteSpaces.length > 1,
};
}
@@ -0,0 +1,37 @@
// Spike only: lets a measurement harness switch the search arms from the browser without a rebuild,
// via `localStorage.gb_search_spike = '{"direct": false, "debounceMs": 200}'`.
// Delete this module (and its two call sites in useSearchResults.ts) when the spike ends.
const STORAGE_KEY = 'gb_search_spike';
type SearchSpikeOverrides = {
/** `false` forces the search route, `true` uses the API directly whenever the page allows it. */
direct?: boolean;
/** Replaces the search debounce. */
debounceMs?: number;
};
export function getSearchSpikeOverrides(): SearchSpikeOverrides {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return {};
}
const { direct, debounceMs } = parsed as Record<string, unknown>;
return {
direct: typeof direct === 'boolean' ? direct : undefined,
debounceMs:
typeof debounceMs === 'number' && Number.isFinite(debounceMs) && debounceMs >= 0
? debounceMs
: undefined,
};
} catch {
return {};
}
}
@@ -1,5 +1,15 @@
'use client';
/**
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
*
* Loading strategy: the download starts as early as possible (server-rendered
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
* text so the multi-MB `JSON.parse` — main-thread work — is only paid when a
* consumer actually needs the pages.
*/
export interface SiteIndexBreadcrumb {
label: string;
icon?: string;
@@ -20,14 +30,50 @@ export interface SiteIndexPage {
breadcrumbs?: SiteIndexBreadcrumb[];
}
/** Fetch the pages of the site from the `~gitbook/site-index` JSON. */
let siteIndexText: Promise<string> | null = null;
/**
* Start (or reuse) the single-flight download of the raw index. Errors clear the
* cache so the next consumer retries.
*/
export function prefetchSiteIndex(indexURL: string): void {
fetchSiteIndexText(indexURL).catch(() => {
// Ignored: consumers surface errors when they actually read the index.
});
}
/**
* Get the parsed index pages. Parses per call (cheap for the rare second
* consumer) so the parsed object graph is never retained at module scope.
*/
export async function fetchSiteIndex(
indexURL: string
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
const response = await fetch(indexURL);
if (!response.ok) {
throw new Error(`Failed to fetch site index: ${response.status}`);
return JSON.parse(await fetchSiteIndexText(indexURL));
}
/**
* Drop the cached raw text (several MB for large sites) once a consumer has
* turned it into a longer-lived form. Purely a memory release: a later consumer
* re-fetches, hitting the HTTP cache.
*/
export function releaseSiteIndex(): void {
siteIndexText = null;
}
function fetchSiteIndexText(indexURL: string): Promise<string> {
if (!siteIndexText) {
siteIndexText = fetch(indexURL).then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
return response.text();
});
siteIndexText.catch(() => {
siteIndexText = null;
});
}
return response.json();
return siteIndexText;
}
@@ -0,0 +1,262 @@
'use client';
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
import {
type SiteIndexBreadcrumb,
type SiteIndexPage,
fetchSiteIndex,
prefetchSiteIndex,
releaseSiteIndex,
} from './site-index';
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
interface IndexPage {
[key: string]: DocumentValue | DocumentValue[];
id: string;
title: string;
description: string | null;
siteSpaceId: string;
}
/** Result type returned by this hook */
export interface LocalPageResult {
type: 'local-page';
id: string;
siteSpaceId: string;
title: string;
pathname: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: SiteIndexBreadcrumb[];
}
type LocalSearchState = {
results: LocalPageResult[];
fetching: boolean;
error: boolean;
};
// Module-level singletons — one Document per language per session.
// Keys are the page's `lang` value, or `''` when no language is set.
const cachedIndexes = new Map<string, Document<IndexPage>>();
// Side-map for data that doesn't belong in the FlexSearch index.
// Keyed by page id, shared across all language groups.
const cachedPageData = new Map<
string,
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: SiteIndexBreadcrumb[] }
>();
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: SiteIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
id: 'id',
index: ['title', 'description'],
store: ['id', 'title', 'description', 'siteSpaceId'],
tag: 'siteSpaceId',
},
tokenize: 'full',
resolution: 15,
encoder: 'Normalize',
});
for (const page of pages) {
index
.addAsync({
id: page.id,
title: page.title,
description: page.description ?? null,
siteSpaceId: page.siteSpaceId,
})
.catch(() => {
// We just ignore these errors, it's not worth failing the whole index for a single bad record
// And we still have remote search as a fallback for these cases
});
cachedPageData.set(`${page.siteSpaceId}:${page.id}`, {
pathname: page.pathname,
icon: page.icon,
emoji: page.emoji,
breadcrumbs: page.breadcrumbs?.length ? page.breadcrumbs : undefined,
});
}
return index;
}
async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document<IndexPage>>> {
if (cachedIndexes.size > 0) {
return cachedIndexes;
}
if (pendingFetch) {
return pendingFetch;
}
pendingFetch = (async () => {
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
const [{ Document }, data] = await Promise.all([
import('flexsearch'),
fetchSiteIndex(indexURL),
]);
// Group pages by their `lang` value (empty string for pages without one)
const pagesByLang = new Map<string, SiteIndexPage[]>();
for (const page of data.pages) {
const key = page.lang ?? '';
const bucket = pagesByLang.get(key);
if (bucket) {
bucket.push(page);
} else {
pagesByLang.set(key, [page]);
}
}
// Build one FlexSearch Document per language group
for (const [lang, pages] of pagesByLang) {
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
releaseSiteIndex();
return cachedIndexes;
})();
// Clear pendingFetch on error so a retry is possible
pendingFetch.catch(() => {
console.error('Error fetching/building search index', indexURL);
pendingFetch = null;
});
return pendingFetch;
}
export function useLocalSearchResults(props: {
query: string;
indexURL: string;
/** BCP-47 language code of the current site space. When provided, only results
* from pages with a matching language are returned. */
lang?: string;
/** When provided, only results from pages belonging to one of these site space IDs
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
filterSiteSpaceIds?: string[];
disabled?: boolean;
/** Whether the search surface is open. */
open: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
fetching: false,
error: false,
});
// Track whether the indexes are loaded so the search effect re-runs after load
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
const active = open || Boolean(query);
// Load the indexes once
React.useEffect(() => {
if (cachedIndexes.size > 0) {
setIndexReady(true);
return;
}
prefetchSiteIndex(indexURL);
if (!active) {
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, fetching: true, error: false }));
getOrBuildIndexes(indexURL)
.then(() => {
if (!cancelled) {
setIndexReady(true);
setState((prev) => ({ ...prev, fetching: false }));
}
})
.catch(() => {
if (!cancelled) {
setState({ results: [], fetching: false, error: true });
}
});
return () => {
cancelled = true;
};
}, [indexURL, active]);
// Perform instant local search whenever query, lang, or index readiness changes
React.useEffect(() => {
// Resolve the per-language index to query. When `lang` is not set we fall
// back to the `''` bucket (pages with no language tag).
const langKey = lang ?? '';
const index = cachedIndexes.get(langKey);
if (disabled || !indexReady || !index) {
return;
}
if (!query) {
setState({ results: [], fetching: false, error: false });
return;
}
//@ts-ignore - Typing is wrong here, tags can be arrays when using the `tag` filter option
const rawResults = index.search(query, {
enrich: true,
limit: 5,
suggest: true,
...(filterSiteSpaceIds
? {
tag: {
siteSpaceId: filterSiteSpaceIds,
},
}
: {}),
});
// Flatten and deduplicate results across fields (flexsearch returns one array per indexed field)
const seen = new Set<string>();
const results: LocalPageResult[] = [];
for (const fieldResult of rawResults) {
for (const item of fieldResult.result) {
const doc = (item as { id: string; doc: IndexPage }).doc;
const cacheKey = `${doc.siteSpaceId}:${doc.id}`;
if (!seen.has(cacheKey)) {
seen.add(cacheKey);
const extra = cachedPageData.get(cacheKey);
results.push({
type: 'local-page',
id: doc.id,
siteSpaceId: doc.siteSpaceId,
title: doc.title,
pathname: extra?.pathname ?? '',
icon: extra?.icon,
emoji: extra?.emoji,
description: (doc.description as string | null) ?? undefined,
breadcrumbs: extra?.breadcrumbs,
});
}
}
}
setState({ results, fetching: false, error: false });
}, [query, lang, filterSiteSpaceIds, indexReady, disabled]);
return state;
}
@@ -19,7 +19,6 @@ import type { SearchResultsRef } from './SearchResults';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
import { isComposingKeyEvent } from '@/lib/keyboard';
import { getLocalizedTitle } from '@/lib/sites';
function useInitialAskBootstrap(props: {
@@ -73,97 +72,17 @@ function useFilteredSiteSpaceIds(props: {
);
}
/** Edits that complete a query rather than continue typing it. */
const COMPLETING_INPUT_TYPES = new Set([
'insertFromPaste',
'insertFromDrop',
'insertReplacementText',
'insertFromYank',
'insertFromComposition',
]);
/**
* Read the intent of each edit from the native input event: completed edits are sent without the
* idle delay and a typed word boundary sends the words so far. IME composition text is searched
* like typing; committing it sends at once.
*/
function useSearchInputIntent() {
const immediateQueryRef = React.useRef<string | null>(null);
const flushRef = React.useRef<() => void>(() => {});
const consumeImmediate = React.useCallback((query: string) => {
const immediate = immediateQueryRef.current === query;
immediateQueryRef.current = null;
return immediate;
}, []);
const onInput = React.useCallback((event: React.FormEvent<HTMLInputElement>) => {
const native = event.nativeEvent as InputEvent;
if (native.isComposing) {
return;
}
const query = event.currentTarget.value.trim();
if (COMPLETING_INPUT_TYPES.has(native.inputType)) {
immediateQueryRef.current = query;
} else if (
native.inputType === 'insertText' &&
native.data &&
/^\s+$/.test(native.data) &&
query
) {
// A typed space leaves the trimmed query unchanged, so send the pending words now.
flushRef.current();
}
}, []);
const onCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
// The committed text is usually already the query, so send its pending request now.
immediateQueryRef.current = event.currentTarget.value.trim();
flushRef.current();
},
[]
);
return {
consumeImmediate,
flushRef,
inputHandlers: { onInput, onCompositionEnd },
};
}
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
fetching: boolean;
error: boolean;
isOpen: boolean;
requestKey: string;
flush: () => void;
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const {
query,
results,
fetching,
error,
isOpen,
requestKey,
flush,
resultsRef,
abort,
askCount,
onAskSelect,
} = props;
// Enter pressed before the answer arrived: open the top result of this exact query and scope,
// or Ask when that answer is empty. Closing the search cancels it.
const pendingOpenRef = React.useRef<string | null>(null);
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
fetching,
resultCount: results.length,
totalCount: results.length + askCount,
});
@@ -172,13 +91,11 @@ function useSearchKeyboardNavigation(props: {
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(1);
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault();
if (cursor !== null && cursor >= results.length) {
onAskSelect(cursor - results.length);
@@ -188,51 +105,12 @@ function useSearchKeyboardNavigation(props: {
if (resultsRef.current?.select()) {
// Stop any in-flight search request only when activating a result.
abort();
return;
}
if (query && fetching) {
pendingOpenRef.current = requestKey;
flush();
}
}
},
[
moveCursorBy,
cursor,
results.length,
onAskSelect,
resultsRef,
abort,
query,
fetching,
requestKey,
flush,
]
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
);
React.useEffect(() => {
const pending = pendingOpenRef.current;
if (pending === null) {
return;
}
if (!isOpen || pending !== requestKey) {
pendingOpenRef.current = null;
return;
}
if (fetching) {
return;
}
pendingOpenRef.current = null;
if (results.length > 0) {
if (resultsRef.current?.selectFirst()) {
abort();
}
} else if (!error && askCount > 0) {
onAskSelect(0);
}
}, [isOpen, requestKey, fetching, error, results, resultsRef, abort, askCount, onAskSelect]);
return {
cursor,
onInputKeyDown,
@@ -253,6 +131,8 @@ export function useSearchController(
withSections,
siteSpaces,
searchURL,
directSearch,
indexURL,
} = props;
const { assistants, config } = useAI();
@@ -369,10 +249,10 @@ export function useSearchController(
});
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
const { consumeImmediate, flushRef, inputHandlers } = useSearchInputIntent();
const { results, fetching, error, requestKey, flush, abort } = useSearchResults({
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -380,10 +260,11 @@ export function useSearchController(
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
directSearch,
indexURL,
lang: siteSpace.space.language,
withSections,
consumeImmediate,
});
flushRef.current = flush;
const searchValue =
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
@@ -426,11 +307,6 @@ export function useSearchController(
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
fetching,
error,
isOpen: Boolean(state?.open),
requestKey,
flush,
resultsRef,
abort,
askCount,
@@ -457,7 +333,6 @@ export function useSearchController(
setQuery,
state,
onInputKeyDown,
inputHandlers,
showAsk,
withAI,
withSearchAI,
@@ -1,28 +1,48 @@
import { readStreamableValue } from 'ai/rsc';
import { jwtDecode } from 'jwt-decode';
import React from 'react';
import { assert } from 'ts-essentials';
import type { SearchRecordResult, SearchSpaceResult } from '@gitbook/api';
import { useTrackEvent } from '../Insights';
import {
type RecommendedQuestionResult,
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import { computeFilterSiteSpaceIds } from './filter';
import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, fuseSearchResults } from './reciprocalRankFusion';
import { computeRemoteSearchScope } from './remote-scope';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import { getSearchSpikeOverrides } from './searchSpikeOverrides';
import { streamRecommendedQuestions } from './server-actions';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
import type { SearchScope } from './useSearch';
import { useAI } from '@/components/AI';
import { getEmbeddableLinker } from '@/lib/embeddable-linker';
import { createSiteLinker } from '@/lib/links';
import { removeTrailingSlash } from '@/lib/paths';
import {
type DirectSearchProps,
computePublishedSearchResults,
toSearchSiteContentBody,
} from '@/lib/search-results';
export type ResultType = OrderedComputedResult | RecommendedQuestionResult;
export type ResultType =
| OrderedComputedResult
| LocalPageResult
| MergedPageResult
| RecommendedQuestionResult;
type RemoteSearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; requestKey: string; results: OrderedComputedResult[] }
| { status: 'error' };
export type { LocalPageResult, MergedPageResult };
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
/** Delay between the last keystroke and the remote search request. */
const SEARCH_DEBOUNCE_MS = 200;
/**
* We cache the recommended questions globally to avoid calling the API multiple times
@@ -35,6 +55,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
/** Whether the search surface is open. Gates building the local search index. */
open: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -43,14 +65,19 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** Whether the site has multiple sections. If false, the default scope only shows the current site space. */
/** Target to query the search API directly from the browser, when the page allows it. */
directSearch?: DirectSearchProps;
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
indexURL: string;
/** BCP-47 language code of the current site space, used to filter local search results. */
lang?: string;
/** Whether the site has multiple sections. If false, treat default scope as current for local filtering. */
withSections?: boolean;
/** Returns true when the latest edit completed the query (paste, IME commit), so it is sent without the idle delay. */
consumeImmediate?: (query: string) => boolean;
}) {
const {
asEmbeddable,
disabled,
open,
query,
siteSpaceId,
siteSpaceIds,
@@ -58,8 +85,10 @@ export function useSearchResults(props: {
scope,
suggestions,
searchURL,
directSearch,
indexURL,
lang,
withSections,
consumeImmediate,
} = props;
const trackEvent = useTrackEvent();
@@ -76,21 +105,24 @@ export function useSearchResults(props: {
[scope, siteSpaceId, siteSpaceIds, withSections, selectedSectionSiteSpaceIds]
);
const remoteScope = React.useMemo(
() =>
computeRemoteSearchScope(scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds),
[scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds]
);
// Identifies the answer the UI needs now; an answer for any other key is never shown as current.
const requestKey = JSON.stringify([query, remoteScope]);
const { results: localResults } = useLocalSearchResults({
query,
indexURL,
lang,
disabled,
open,
filterSiteSpaceIds,
});
const [remoteState, setRemoteState] = React.useState<RemoteSearchState>({ status: 'idle' });
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], fetching: false, error: false });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
const abortRef = React.useRef<(() => void) | null>(null);
// Sends the pending request now instead of waiting for the idle delay.
const flushRef = React.useRef<(() => void) | null>(null);
const { assistants } = useAI();
const withAI = assistants.length > 0;
@@ -102,7 +134,11 @@ export function useSearchResults(props: {
}
if (!query) {
if (!withAI) {
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
@@ -113,11 +149,19 @@ export function useSearchResults(props: {
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
// Recommended questions are stored as ResultType[] already
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
let cancelled = false;
@@ -130,7 +174,11 @@ export function useSearchResults(props: {
suggestions.forEach((question) => {
questions.add(question);
});
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
@@ -156,7 +204,11 @@ export function useSearchResults(props: {
if (!cancelled) {
// Recommended questions are handled via a separate path below
setRemoteState({ status: 'idle' });
setRemoteState({
results: [],
fetching: false,
error: false,
});
}
}
}, 100);
@@ -166,18 +218,33 @@ export function useSearchResults(props: {
clearTimeout(timeout);
};
}
setRemoteState({ status: 'loading' });
setRemoteState({
results: [],
fetching: true,
error: false,
});
let cancelled = false;
const abortController = new AbortController();
const send = async () => {
flushRef.current = null;
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
const timeout = setTimeout(async () => {
const fetchSearch = (scope: SearchSiteContentScope): Promise<OrderedComputedResult[]> =>
fetchSearchResults({
searchURL,
directSearch,
scope,
query,
signal: abortController.signal,
asEmbeddable,
});
try {
const resultsPromise = fetchSearch(remoteScope);
const resultsPromise = fetchSearch(
computeRemoteSearchScope(
scope,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds
)
);
const onResults = (results: OrderedComputedResult[]) => {
if (cancelled) {
@@ -186,34 +253,38 @@ export function useSearchResults(props: {
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState({ status: 'error' });
setRemoteState((prev) => ({ ...prev, error: true }));
return;
}
setRemoteState({ status: 'ready', requestKey, results });
setRemoteState((prev) => ({ ...prev, results }));
trackEvent({ type: 'search_type_query', query });
};
const onError = () => {
if (cancelled) {
return;
}
setRemoteState({ status: 'error' });
setRemoteState((prev) => ({ ...prev, error: true }));
};
await resultsPromise.then(onResults, onError);
if (cancelled) {
return;
}
setRemoteState((prev) => ({ ...prev, fetching: false }));
} catch {
// If there is an error, we need to catch it to avoid infinite loading state.
if (cancelled) {
return;
}
setRemoteState({ status: 'error' });
setRemoteState({
results: [],
fetching: false,
error: true,
});
}
};
const timeout = setTimeout(send, consumeImmediate?.(query) ? 0 : 150);
flushRef.current = () => {
clearTimeout(timeout);
void send();
};
}, getSearchSpikeOverrides().debounceMs ?? SEARCH_DEBOUNCE_MS);
abortRef.current = () => {
cancelled = true;
@@ -226,32 +297,30 @@ export function useSearchResults(props: {
clearTimeout(timeout);
abortController.abort();
abortRef.current = null;
flushRef.current = null;
};
}, [
query,
remoteScope,
requestKey,
scope,
trackEvent,
withAI,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds,
disabled,
suggestions,
searchURL,
directSearch,
asEmbeddable,
consumeImmediate,
]);
const flush = React.useCallback(() => {
flushRef.current?.();
}, []);
const abort = React.useCallback(() => {
abortRef.current?.();
abortRef.current = null;
setRemoteState((prev) => (prev.status === 'loading' ? { status: 'idle' } : prev));
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
}, []);
// Merge local and remote results.
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
if (!query) {
const recommendedQuestions =
@@ -268,12 +337,15 @@ export function useSearchResults(props: {
});
}
return remoteState.status === 'ready' && remoteState.requestKey === requestKey
? filterResultsBySiteSpaces(remoteState.results, filterSiteSpaceIds)
: [];
return fuseSearchResults({
localResults,
remoteResults: remoteState.results,
query,
allowedSiteSpaceIds: filterSiteSpaceIds,
});
}, [
remoteState,
requestKey,
localResults,
remoteState.results,
query,
filterSiteSpaceIds,
withAI,
@@ -282,27 +354,115 @@ export function useSearchResults(props: {
recentQueries,
]);
// Between a query change and its loading state, the previous answer must still read as loading.
const isOutdated = remoteState.status === 'ready' && remoteState.requestKey !== requestKey;
return {
results,
fetching: remoteState.status === 'loading' || (Boolean(query) && isOutdated),
error: remoteState.status === 'error',
requestKey,
flush,
fetching: remoteState.fetching,
error: remoteState.error,
abort,
};
}
/**
* Fetch search results from the search API, or from the search route when the page can't query it.
*/
async function fetchSearchResults(input: {
searchURL: string;
directSearch: DirectSearchProps | undefined;
scope: SearchSiteContentScope;
query: string;
signal: AbortSignal;
asEmbeddable: boolean | undefined;
}): Promise<OrderedComputedResult[]> {
const { searchURL, directSearch, scope, query, signal, asEmbeddable } = input;
if (directSearch && canSearchDirectly(directSearch)) {
try {
return await fetchDirectSearchResults({
directSearch,
scope,
query,
signal,
asEmbeddable: Boolean(asEmbeddable),
});
} catch (error) {
if (signal.aborted) {
throw error;
}
}
}
return fetchRouteSearchResults(searchURL, scope, query, signal, asEmbeddable);
}
function canSearchDirectly(directSearch: DirectSearchProps): boolean {
if (getSearchSpikeOverrides().direct === false) {
return false;
}
try {
const { exp } = jwtDecode<{ exp?: number }>(directSearch.apiToken);
// Same margin as the static routes, which stop serving a token this close to expiry.
return typeof exp === 'number' && exp > Date.now() / 1000 + 120;
} catch {
return false;
}
}
async function fetchDirectSearchResults(input: {
directSearch: DirectSearchProps;
scope: SearchSiteContentScope;
query: string;
signal: AbortSignal;
asEmbeddable: boolean;
}): Promise<OrderedComputedResult[]> {
const { directSearch, scope, query, signal, asEmbeddable } = input;
const { apiURL, apiToken, organizationId, siteId, spaces } = directSearch;
if (query.length <= 1) {
return [];
}
const response = await fetch(
`${removeTrailingSlash(apiURL)}/v1/orgs/${organizationId}/sites/${siteId}/search`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(toSearchSiteContentBody(query, scope)),
signal,
}
);
if (!response.ok) {
throw new Error(`Direct search request failed: ${response.status}`);
}
const data = (await response.json()) as {
items?: (SearchSpaceResult | SearchRecordResult)[];
};
if (!Array.isArray(data?.items)) {
throw new Error('Direct search response has no items');
}
const linker = createSiteLinker(directSearch.linker);
return computePublishedSearchResults({
asEmbeddable,
linker: asEmbeddable ? getEmbeddableLinker(linker) : linker,
searchResults: data.items,
spaces,
});
}
/**
* Fetch search results from the search API route.
*/
async function fetchSearchResults(
async function fetchRouteSearchResults(
searchURL: string,
scope: SearchSiteContentScope,
query: string,
signal?: AbortSignal,
signal: AbortSignal,
asEmbeddable?: boolean
): Promise<OrderedComputedResult[]> {
const response = await fetch(searchURL, {
@@ -2,12 +2,11 @@ import React from 'react';
export function useSearchResultsCursor(props: {
query: string;
fetching: boolean;
resultCount: number;
totalCount: number;
}) {
const [cursor, setCursor] = React.useState<number | null>(null);
const { query, fetching, resultCount, totalCount } = props;
const { query, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
@@ -17,12 +16,6 @@ export function useSearchResultsCursor(props: {
}, [query]);
React.useEffect(() => {
if (fetching && resultCount === 0) {
// Nothing is highlighted while results load, so Enter can't open the Ask row instead.
setCursor(null);
return;
}
if (totalCount === 0) {
return;
}
@@ -34,7 +27,7 @@ export function useSearchResultsCursor(props: {
return prev ?? 0;
});
}, [fetching, resultCount, totalCount]);
}, [resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
@@ -65,11 +65,25 @@ export async function SiteLayout(props: {
const scripts = withTracking ? context.scripts : [];
// React dedupes preconnects by href, so reduce to the origin; relative URLs are same-origin.
[GITBOOK_API_PUBLIC_URL, GITBOOK_ICONS_URL, GITBOOK_ASSETS_URL].forEach((url) => {
[GITBOOK_ICONS_URL, GITBOOK_ASSETS_URL].forEach((url) => {
if (url && URL.canParse(url)) {
ReactDOM.preconnect(new URL(url).origin);
}
});
// Search queries the API with a CORS fetch without credentials, which only reuses an
// anonymous connection.
if (URL.canParse(GITBOOK_API_PUBLIC_URL)) {
ReactDOM.preconnect(new URL(GITBOOK_API_PUBLIC_URL).origin, { crossOrigin: 'anonymous' });
}
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
// index downloads twice — the omission was exactly that bug before.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
});
scripts.forEach(({ script }) => {
if (!isDeferrableScript(script)) {
@@ -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. The site index is
// CDN-cached, so this adds no origin request — see getRelatedPages.
// 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.
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,8 +259,9 @@ 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 origin request per 404), this reads
* the CDN-cached page index served at `~gitbook/site-index`.
* 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.
* 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 />
@@ -144,8 +142,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
</>
) : null}
{/* Chat panel shifts content left when open. Clipped so blocks bleeding into the gutters (card carousels) can't widen the page. */}
<div className="overflow-x-clip lg:chat-open:mr-(--ai-chat-width) motion-safe:transition-all motion-safe:duration-300">
{/* Chat panel shifts content left when open */}
<div className="lg:chat-open:mr-(--ai-chat-width) motion-safe:transition-all motion-safe:duration-300">
<div
className={tcls(
'flex',
@@ -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 page index (`~gitbook/site-index`).
* Return the URL of the site search 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>
);
}
+1 -1
View File
@@ -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,6 +42,7 @@ export const ar: TranslationLanguage = {
search_ask_no_answer:
'تعذر العثور على إجابة لسؤالك. يمكنك محاولة إعادة صياغته أو جعله أكثر تحديدا.',
search_ask_error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقا.',
search_partial_error: 'تعذر تحميل بعض النتائج. يتم عرض النتائج المتاحة.',
on_this_page: 'في هذه الصفحة',
next_page: 'التالي',
previous_page: 'السابق',
+2 -1
View File
@@ -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,6 +42,8 @@ export const bg: TranslationLanguage = {
search_ask_no_answer:
'Не беше намерен отговор на въпроса ви. Можете да опитате да го преформулирате или да бъдете по-конкретни.',
search_ask_error: 'Нещо се обърка. Моля, опитайте отново по-късно.',
search_partial_error:
'Някои резултати не можаха да се заредят. Показват се наличните резултати.',
on_this_page: 'На тази страница',
next_page: 'Следваща',
previous_page: 'Предишна',
+1 -1
View File
@@ -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,6 +42,7 @@ 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í',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',
+2 -1
View File
@@ -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,6 +43,8 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ export const el: TranslationLanguage = {
search_ask_no_answer:
'Δεν ήταν δυνατή η εύρεση απάντησης στην ερώτησή σας. Δοκιμάστε να τη διατυπώσετε διαφορετικά ή πιο συγκεκριμένα.',
search_ask_error: 'Κάτι πήγε στραβά. Δοκιμάστε ξανά αργότερα.',
search_partial_error:
'Δεν ήταν δυνατή η φόρτωση ορισμένων αποτελεσμάτων. Εμφανίζονται τα διαθέσιμα αποτελέσματα.',
on_this_page: 'Σε αυτήν τη σελίδα',
next_page: 'Επόμενη',
previous_page: 'Προηγούμενη',
+1 -1
View File
@@ -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,6 +40,7 @@ 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',
+2 -1
View File
@@ -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,6 +43,8 @@ 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',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ 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',
+1 -1
View File
@@ -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,6 +42,7 @@ export const he: TranslationLanguage = {
search_ask_no_answer:
'לא נמצאה תשובה לשאלה שלך. אפשר לנסות לנסח אותה מחדש או להיות מדויקים יותר.',
search_ask_error: 'משהו השתבש. נסה שוב מאוחר יותר.',
search_partial_error: 'לא ניתן היה לטעון חלק מהתוצאות. מוצגות התוצאות הזמינות.',
on_this_page: 'בדף הזה',
next_page: 'הבא',
previous_page: 'הקודם',
+1 -1
View File
@@ -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,6 +42,7 @@ export const hi: TranslationLanguage = {
search_ask_no_answer:
'आपके प्रश्न का उत्तर नहीं मिल सका। आप इसे फिर से लिखने या अधिक विशिष्ट बनाने की कोशिश कर सकते हैं।',
search_ask_error: 'कुछ गलत हो गया। कृपया बाद में फिर कोशिश करें।',
search_partial_error: 'कुछ परिणाम लोड नहीं हो सके। उपलब्ध परिणाम दिखाए जा रहे हैं।',
on_this_page: 'इस पृष्ठ पर',
next_page: 'अगला',
previous_page: 'पिछला',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ 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ő',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',
+2 -1
View File
@@ -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,6 +43,8 @@ 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',
+1 -1
View File
@@ -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,6 +43,7 @@ export const ja: TranslationLanguage = {
search_ask_no_answer:
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
search_partial_error: '一部の結果を読み込めませんでした。利用可能な結果を表示しています。',
on_this_page: 'このページ内',
next_page: '次へ',
previous_page: '前へ',
+1 -1
View File
@@ -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,6 +43,7 @@ export const ko: TranslationLanguage = {
search_ask_no_answer:
'질문에 대한 답변을 찾을 수 없습니다. 질문을 다시 표현하거나 더 구체적으로 작성해 보세요.',
search_ask_error: '문제가 발생했습니다. 나중에 다시 시도해 주세요.',
search_partial_error: '일부 결과를 불러오지 못했습니다. 사용 가능한 결과를 표시합니다.',
on_this_page: '이 페이지에서',
next_page: '다음',
previous_page: '이전',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',
+1 -1
View File
@@ -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,6 +42,7 @@ 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ā',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',
+2 -1
View File
@@ -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,6 +43,8 @@ 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',
+1 -1
View File
@@ -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,6 +43,7 @@ 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',
+1 -1
View File
@@ -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,6 +42,7 @@ 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,6 +43,8 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ 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',
+2 -1
View File
@@ -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,6 +43,8 @@ export const ru: TranslationLanguage = {
search_ask_no_answer:
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
search_partial_error:
'Не удалось загрузить некоторые результаты. Показаны доступные результаты.',
on_this_page: 'На этой странице',
next_page: 'Следующая',
previous_page: 'Предыдущая',
+2 -1
View File
@@ -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,6 +42,8 @@ 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',
+2 -1
View File
@@ -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,6 +42,8 @@ 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',
+1 -1
View File
@@ -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,6 +42,7 @@ 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',

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