Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bbfd3ca
wip
conico974 Mar 3, 2026
b60cd11
dummy useLocalSearchResults
conico974 Mar 3, 2026
8bef992
Add LocalSearchResults component and integrate into SearchContainer
conico974 Mar 3, 2026
9ebe6c3
Integrate local search results into useSearchResults and SearchContainer
conico974 Mar 3, 2026
442d2ce
Fix URL formatting in getOrBuildIndex and update tokenization method;…
conico974 Mar 3, 2026
b7e272f
Refactor LocalSearchResults and useLocalSearchResults to use pathname…
conico974 Mar 4, 2026
b7c4066
implement the route in GBO directly
conico974 Mar 4, 2026
c2d3d86
Replace anchor tag with Link component in LocalSearchResultCard for i…
conico974 Mar 4, 2026
04e9dad
add a limit to the number of page in the index
conico974 Mar 4, 2026
b326343
enable caching
conico974 Mar 4, 2026
bf26ba7
Revert "add a limit to the number of page in the index"
conico974 Mar 4, 2026
c78fb2f
Add emoji support to LocalPageResult and update LocalSearchResultCard
conico974 Mar 4, 2026
b4d07a7
Add language support to search components and indexing logic
conico974 Mar 4, 2026
6a2d417
bump vercel version
conico974 Mar 4, 2026
d5a2dd2
fix section url
conico974 Mar 4, 2026
ecd3fdd
remove revalidate
conico974 Mar 4, 2026
aeb8d89
Update index URLs to point to the new site index and add route for si…
conico974 Mar 4, 2026
46523e9
reciprocal rank fusion
conico974 Mar 15, 2026
5956db7
Refactor search components to remove SearchLocalPageResultItem and in…
conico974 Mar 15, 2026
c7f5d4d
Enhance search functionality with interaction tracking and visibility…
conico974 Mar 20, 2026
3a7826c
Merge remote-tracking branch 'origin/main' into conico/site-index
conico974 Mar 20, 2026
d362618
Refactor search result merging logic for improved stability and clarity
conico974 Mar 20, 2026
4968b7d
Add MergedPageResult type and enhance search result merging logic
conico974 Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 27 additions & 23 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.16",
"vercel": "^50.31.1"
"@changesets/cli": "^2.29.8",
"turbo": "^2.8.10",
"vercel": "catalog:"
},
"packageManager": "bun@1.3.7",
"overrides": {
Expand Down Expand Up @@ -53,7 +53,8 @@
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
"usehooks-ts": "^3.1.1",
"vercel": "^50.26.1"
}
},
"patchedDependencies": {
Expand Down
3 changes: 2 additions & 1 deletion packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"flexsearch": "^0.8.212",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
Expand Down Expand Up @@ -110,7 +111,7 @@
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "^50.15.1",
"vercel": "catalog:",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import type { NextRequest } from 'next/server';

import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { isPageIndexable } from '@/lib/seo';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLocalizedTitle,
listAllSiteSpaces,
} from '@/lib/sites';

interface Breadcrumb {
label: string;
icon?: string;
emoji?: string;
}

interface RawIndexPage {
id: string;
title: string;
pathname: string;
lang?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: Breadcrumb[];
}

type AncestorPage = RevisionPageDocument | RevisionPageGroup;

interface IndexPageEntry {
page: RevisionPageDocument;
ancestors: AncestorPage[];
}

/**
* Walk the page tree and return all indexable document pages together with
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
*/
function getIndexablePagesWithAncestors(
rootPages: RevisionPage[],
ancestors: AncestorPage[] = []
): IndexPageEntry[] {
const results: IndexPageEntry[] = [];

for (const page of rootPages) {
if (page.type === 'link' || page.type === 'computed') continue;
if (page.hidden || !isPageIndexable([], page)) continue;

if (page.type === 'document') {
results.push({ page, ancestors });
// Recurse into children with this document as an ancestor
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
} else if (page.type === 'group') {
// Groups themselves are not documents — push them only as ancestors
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
}
}

return results;
}

export const revalidate = 86400; // 1 day in seconds
export const dynamic = 'force-static';

export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, structure } = context;

const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);

const revisions = await Promise.all(
visibleSpaces.map((ss) =>
throwIfDataError(
dataFetcher.getRevision({
spaceId: ss.space.id,
revisionId: ss.space.revision,
})
)
)
);

const seen = new Set<string>();
const pages: RawIndexPage[] = [];

for (let i = 0; i < visibleSpaces.length; i++) {
const siteSpace = visibleSpaces[i]!;
const revision = revisions[i]!;
const forkedLinker = linker.withOtherSiteSpace({
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
});

const lang = siteSpace.space.language ?? undefined;
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
const { siteSection, siteSectionGroup } = sectionInfo ?? {};

for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
if (seen.has(page.id)) continue;
seen.add(page.id);

const breadcrumbs: Breadcrumb[] = [
siteSectionGroup
? {
label: getLocalizedTitle(siteSectionGroup, lang),
icon: siteSectionGroup.icon ?? undefined,
}
: undefined,
siteSection
? {
label: getLocalizedTitle(siteSection, lang),
icon: siteSection.icon ?? undefined,
}
: undefined,
...ancestors.map((a) => ({
label: a.title,
icon: a.icon ?? undefined,
emoji: a.emoji ?? undefined,
})),
].filter((c) => c !== undefined);

pages.push({
id: page.id,
title: page.title,
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
lang,
icon: page.icon ?? undefined,
emoji: page.emoji ?? undefined,
description: page.description ?? undefined,
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
});
}
}

return new Response(JSON.stringify({ pages }), {
headers: {
'Content-Type': 'application/json',
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
'Cache-Control': 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400',
},
});
}
2 changes: 2 additions & 0 deletions packages/gitbook/src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export async function getStaticSiteContext(params: RouteLayoutParams) {
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
//TODO: remove, just for easier testing
token: siteURLData.apiToken,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/gitbook/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function Header(props: {
}
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
indexURL={context.linker.toPathInSite('~gitbook/site-index')}
viewport={!withTopHeader ? 'mobile' : undefined}
searchURL={context.linker.toPathInSpace('~gitbook/search')}
/>
Expand Down
95 changes: 95 additions & 0 deletions packages/gitbook/src/components/Search/LocalSearchResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client';

import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '../primitives/Emoji/Emoji';
import { Link } from '../primitives/Link';
import type { LocalPageResult } from './useLocalSearchResults';

/**
* 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-semibold text-sm text-tint-strong leading-snug">
{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>
);
}
20 changes: 18 additions & 2 deletions packages/gitbook/src/components/Search/SearchContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface SearchContainerProps {

/** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */
searchURL: string;
/** URL for the local index JSON, e.g. from linker.toPathInSite('~gitbook/index'). */
indexURL: string;
}

/**
Expand All @@ -62,6 +64,7 @@ export function SearchContainer({
viewport,
siteSpaces,
searchURL,
indexURL,
}: SearchContainerProps) {
const { assistants, config } = useAI();

Expand Down Expand Up @@ -203,7 +206,7 @@ export function SearchContainer({
[siteSpaces, siteSpace.space.language]
);

const { results, fetching, error } = useSearchResults({
const { results, fetching, error, setInteracted, onVisibilityChange } = useSearchResults({
disabled: !(state?.query || withAI),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
Expand All @@ -212,19 +215,26 @@ export function SearchContainer({
withAI,
suggestions: config.suggestions,
searchURL,
indexURL,
lang: siteSpace.space.language,
});

const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';

const scrollContainerRef = React.useRef<HTMLDivElement>(null);

const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query: normalizedQuery,
results,
});
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
setInteracted();
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setInteracted();
moveCursorBy(1);
} else if (event.key === 'Enter') {
event.preventDefault();
Expand All @@ -239,7 +249,11 @@ export function SearchContainer({
// Only show content if there's a query or Ask is enabled
state?.query || withAI ? (
<React.Suspense fallback={null}>
<div className="scroll-py-2 overflow-y-scroll p-2">
<div
ref={scrollContainerRef}
className="scroll-py-2 overflow-y-scroll p-2"
onScroll={setInteracted}
>
{state !== null && !showAsk ? (
<SearchResults
ref={resultsRef}
Expand All @@ -249,6 +263,8 @@ export function SearchContainer({
results={results}
cursor={cursor}
error={error}
scrollContainerRef={scrollContainerRef}
onVisibilityChange={onVisibilityChange}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
Expand Down
Loading
Loading