Skip to content

Add breadcrumbs to search results #2772

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 28, 2025
5 changes: 5 additions & 0 deletions .changeset/giant-carrots-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Add breadcrumbs to search results
44 changes: 39 additions & 5 deletions packages/gitbook/src/components/Search/SearchPageResultItem.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Icon } from '@gitbook/icons';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';

import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
import type { ComputedPageResult } from './server-actions';
@@ -16,6 +16,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
) {
const { query, item, active } = props;

const breadcrumbs =
item.breadcrumbs?.map((crumb) => (
<span key={crumb.label} className="flex items-center gap-1">
{crumb.icon ? <Icon className="size-3" icon={crumb.icon as IconName} /> : null}
{crumb.label}
</span>
)) ?? [];

return (
<Link
ref={ref}
@@ -53,18 +61,44 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
/>
</div>
<div className={tcls('flex', 'flex-col', 'w-full')}>
{item.spaceTitle ? (
{breadcrumbs.length > 0 ? (
<div
className={tcls(
'text-xs',
'opacity-6',
'contrast-more:opacity-11',
'font-normal',
'uppercase',
'tracking-wider',
'mb-1'
'mb-1',
'flex',
'flex-wrap',
'gap-x-2',
'gap-y-1',
'items-center'
)}
>
{item.spaceTitle}
{(breadcrumbs.length > 3
? [
...breadcrumbs.slice(0, 2),
<Icon key="ellipsis" icon="ellipsis-h" className="size-3" />,
...breadcrumbs.slice(-1),
]
: breadcrumbs
).map((crumb, index) => (
<>
{index !== 0 ? (
<Icon
key={`${crumb.key}-icon`}
icon="chevron-right"
className="size-3"
/>
) : null}
<span key={crumb.key} className="line-clamp-1">
{crumb}
</span>
</>
))}
</div>
) : null}
<HighlightQuery query={query} text={item.title} />
43 changes: 36 additions & 7 deletions packages/gitbook/src/components/Search/server-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use server';

import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById } from '@/lib/sites';
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import type {
@@ -10,6 +10,8 @@ import type {
SearchAIRecommendedQuestionStream,
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
Space,
} from '@gitbook/api';
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
@@ -19,6 +21,7 @@ import type * as React from 'react';

import { joinPathWithBaseURL } from '@/lib/paths';
import { isV2 } from '@/lib/v2';
import type { IconName } from '@gitbook/icons';
import { throwIfDataError } from '@v2/lib/data';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { DocumentView } from '../DocumentView';
@@ -46,8 +49,7 @@ export interface ComputedPageResult {
pageId: string;
spaceId: string;

/** When part of a multi-spaces search, the title of the space */
spaceTitle?: string;
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
}

export interface AskAnswerSource {
@@ -258,7 +260,16 @@ async function searchSiteContent(
return (
await Promise.all(
searchResults.map(async (spaceItem) => {
const sections = getSiteStructureSections(structure).flatMap((item) =>
item.object === 'site-section-group' ? [item, ...item.sections] : item
);
const siteSpace = findSiteSpaceById(structure, spaceItem.id);
const siteSection = sections.find(
(section) => section.id === siteSpace?.section
) as SiteSection;
const siteSectionGroup = siteSection?.sectionGroup
? sections.find((sectionGroup) => sectionGroup.id === siteSection.sectionGroup)
: null;

return Promise.all(
spaceItem.pages.map((pageItem) =>
@@ -267,6 +278,8 @@ async function searchSiteContent(
spaceItem,
space: siteSpace?.space,
spaceURL: siteSpace?.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
)
);
@@ -348,9 +361,11 @@ async function transformSitePageResult(
spaceItem: SearchSpaceResult;
space?: Space;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): Promise<OrderedComputedResult[]> {
const { pageItem, spaceItem, space, spaceURL } = args;
const { pageItem, spaceItem, space, spaceURL, siteSection, siteSectionGroup } = args;
const { linker } = context;

const page: ComputedPageResult = {
@@ -360,12 +375,26 @@ async function transformSitePageResult(
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
spaceTitle: space?.title,
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
},
(!siteSection || siteSection?.siteSpaces.length > 1) && space
? {
label: space?.title,
}
: undefined,
].filter((item) => item !== undefined),
};

const sections = await Promise.all(
const pageSections = await Promise.all(
pageItem.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
@@ -379,5 +408,5 @@ async function transformSitePageResult(
})) ?? []
);

return [page, ...sections];
return [page, ...pageSections];
}