Core & UI: Support breadcrumbs for Search API

This commit is contained in:
Fuma Nama
2025-09-21 18:45:10 +08:00
parent d1ae3e8b87
commit 6548a59b93
15 changed files with 205 additions and 137 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': patch
'fumadocs-ui': patch
---
Support breadcrumbs for Search API
+9 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { source } from '@/lib/source';
import type { OramaDocument } from 'fumadocs-core/search/orama-cloud';
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
export const revalidate = false;
@@ -10,6 +10,10 @@ export async function GET(): Promise<Response> {
.filter((page) => page.slugs[0] !== 'openapi')
.map((page) => {
const { structuredData } = page.data;
const items = getBreadcrumbItems(page.url, source.pageTree, {
includePage: false,
includeRoot: true,
});
return {
id: page.url,
@@ -18,8 +22,11 @@ export async function GET(): Promise<Response> {
url: page.url,
title: page.data.title,
description: page.data.description,
breadcrumbs: items.flatMap<string>((item) =>
typeof item.name === 'string' ? item.name : [],
),
} satisfies OramaDocument;
});
return NextResponse.json(results);
return Response.json(results);
}
+6 -37
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useMemo } from 'react';
import type * as PageTree from '@/source/page-tree/definitions';
import { normalizeUrl } from '@/utils/normalize-url';
import { findPath } from '@/utils/page-tree';
export interface BreadcrumbItem {
name: ReactNode;
@@ -118,42 +119,10 @@ export function searchPath(
nodes: PageTree.Node[],
url: string,
): PageTree.Node[] | null {
const items: PageTree.Node[] = [];
url = normalizeUrl(url);
const normalizedUrl = normalizeUrl(url);
function run(nodes: PageTree.Node[]): boolean {
let separator: PageTree.Separator | undefined;
for (const node of nodes) {
if (node.type === 'separator') separator = node;
if (node.type === 'folder') {
if (node.index?.url === url) {
if (separator) items.push(separator);
items.push(node, node.index);
return true;
}
if (run(node.children)) {
items.unshift(node);
if (separator) items.unshift(separator);
return true;
}
}
if (node.type === 'page' && node.url === url) {
if (separator) items.push(separator);
items.push(node);
return true;
}
}
return false;
}
if (run(nodes)) return items;
return null;
return findPath(
nodes,
(node) => node.type === 'page' && node.url === normalizedUrl,
);
}
+5
View File
@@ -9,6 +9,7 @@ export interface DocumentRecord {
title: string;
description?: string;
breadcrumbs?: string[];
/**
* URL to the page
@@ -75,6 +76,7 @@ export async function setIndexSettings(
'content',
'url',
'section_id',
'breadcrumbs',
],
searchableAttributes: ['title', 'section', 'content'],
attributesToSnippet: [],
@@ -95,6 +97,7 @@ function toIndex(page: DocumentRecord): BaseIndex[] {
): BaseIndex {
return {
objectID: `${page._id}-${(id++).toString()}`,
breadcrumbs: page.breadcrumbs,
title: page.title,
url: page.url,
page_id: page._id,
@@ -161,5 +164,7 @@ export interface BaseIndex {
*/
section_id?: string;
breadcrumbs?: string[];
content: string;
}
@@ -33,6 +33,7 @@ export function groupResults(hits: Hit<BaseIndex>[]): SortedResult[] {
grouped.push({
id: hit.url,
type: 'page',
breadcrumbs: hit.breadcrumbs,
url: hit.url,
content: hit.title,
});
@@ -108,6 +108,7 @@ export async function searchDocs(
id: doc.page_id,
type: 'page',
content: doc.title,
breadcrumbs: doc.breadcrumbs,
contentWithHighlights: highlighter.highlight(doc.title),
url: doc.url,
});
+4
View File
@@ -55,6 +55,7 @@ export interface OramaDocument {
* Data to be added to each section index
*/
extra_data?: object;
breadcrumbs?: string[];
}
export interface OramaIndex {
@@ -75,6 +76,8 @@ export interface OramaIndex {
*/
section?: string;
breadcrumbs?: string[];
/**
* Heading (anchor) id
*/
@@ -129,6 +132,7 @@ function toIndex(page: OramaDocument): OramaIndex[] {
section,
section_id: sectionId,
content,
breadcrumbs: page.breadcrumbs,
...page.extra_data,
};
}
+20 -17
View File
@@ -7,11 +7,22 @@ import {
} from '@orama/orama';
import { type AdvancedOptions, type SimpleOptions } from '@/search/server';
export type SimpleDocument = TypedDocument<Orama<typeof simpleSchema>>;
export const simpleSchema = {
url: 'string',
title: 'string',
breadcrumbs: 'string[]',
description: 'string',
content: 'string',
keywords: 'string',
} as const;
export type AdvancedDocument = TypedDocument<Orama<typeof advancedSchema>>;
export const advancedSchema = {
content: 'string',
page_id: 'string',
type: 'string',
breadcrumbs: 'string[]',
tags: 'enum[]',
url: 'string',
embeddings: 'vector[512]',
@@ -36,11 +47,8 @@ export async function createDB({
const mapTo: PartialSchemaDeep<AdvancedDocument>[] = [];
items.forEach((page) => {
const tags = Array.isArray(page.tag)
? page.tag
: page.tag
? [page.tag]
: [];
const pageTag = page.tag ?? [];
const tags = Array.isArray(pageTag) ? pageTag : [pageTag];
const data = page.structuredData;
let id = 0;
@@ -49,13 +57,16 @@ export async function createDB({
page_id: page.id,
type: 'page',
content: page.title,
breadcrumbs: page.breadcrumbs,
tags,
url: page.url,
});
const nextId = () => `${page.id}-${id++}`;
if (page.description) {
mapTo.push({
id: `${page.id}-${(id++).toString()}`,
id: nextId(),
page_id: page.id,
tags,
type: 'text',
@@ -66,7 +77,7 @@ export async function createDB({
for (const heading of data.headings) {
mapTo.push({
id: `${page.id}-${(id++).toString()}`,
id: nextId(),
page_id: page.id,
type: 'heading',
tags,
@@ -77,7 +88,7 @@ export async function createDB({
for (const content of data.contents) {
mapTo.push({
id: `${page.id}-${(id++).toString()}`,
id: nextId(),
page_id: page.id,
tags,
type: 'text',
@@ -91,15 +102,6 @@ export async function createDB({
return db;
}
export type SimpleDocument = TypedDocument<Orama<typeof simpleSchema>>;
export const simpleSchema = {
url: 'string',
title: 'string',
description: 'string',
content: 'string',
keywords: 'string',
} as const;
export async function createDBSimple({
indexes,
tokenizer,
@@ -120,6 +122,7 @@ export async function createDBSimple({
items.map((page) => ({
title: page.title,
description: page.description,
breadcrumbs: page.breadcrumbs,
url: page.url,
content: page.content,
keywords: page.keywords,
@@ -15,32 +15,58 @@ import { type StructuredData } from '@/mdx-plugins';
import { basename, extname } from '@/source/path';
import type { I18nConfig } from '@/i18n';
import type { Language } from '@orama/orama';
import { findPath } from '@/utils/page-tree';
type Awaitable<T> = T | Promise<T>;
async function pageToIndex(page: Page): Promise<AdvancedIndex> {
let structuredData: StructuredData | undefined;
if ('structuredData' in page.data) {
structuredData = page.data.structuredData as StructuredData;
} else if ('load' in page.data && typeof page.data.load === 'function') {
structuredData = (await page.data.load()).structuredData;
function defaultBuildIndex(source: LoaderOutput<LoaderConfig>) {
function isBreadcrumbItem(item: unknown): item is string {
return typeof item === 'string' && item.length > 0;
}
if (!structuredData)
throw new Error(
'Cannot find structured data from page, please define the page to index function.',
);
return async (page: Page): Promise<AdvancedIndex> => {
let breadcrumbs: string[] | undefined;
let structuredData: StructuredData | undefined;
return {
title: page.data.title ?? basename(page.path, extname(page.path)),
description:
'description' in page.data
? (page.data.description as string)
: undefined,
url: page.url,
id: page.url,
structuredData,
if ('structuredData' in page.data) {
structuredData = page.data.structuredData as StructuredData;
} else if ('load' in page.data && typeof page.data.load === 'function') {
structuredData = (await page.data.load()).structuredData;
}
if (!structuredData)
throw new Error(
'Cannot find structured data from page, please define the page to index function.',
);
const pageTree = source.getPageTree(page.locale);
const path = findPath(
pageTree.children,
(node) => node.type === 'page' && node.url === page.url,
);
if (path) {
breadcrumbs = [];
path.pop();
if (isBreadcrumbItem(pageTree.name)) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (!isBreadcrumbItem(segment.name)) continue;
breadcrumbs.push(segment.name);
}
}
return {
title: page.data.title ?? basename(page.path, extname(page.path)),
breadcrumbs,
description: page.data.description,
url: page.url,
id: page.url,
structuredData,
};
};
}
@@ -72,12 +98,12 @@ export function createFromSource<S extends LoaderOutput<LoaderConfig>>(
export function createFromSource<S extends LoaderOutput<LoaderConfig>>(
source: S,
_buildIndexOrOptions:
_buildIndexOrOptions?:
| ((page: InferPageType<S>) => Awaitable<AdvancedIndex>)
| Options<S> = pageToIndex,
| Options<S>,
_options?: Omit<Options<S>, 'buildIndex'>,
): SearchAPI {
const { buildIndex = pageToIndex, ...options }: Options<S> = {
const { buildIndex = defaultBuildIndex(source), ...options }: Options<S> = {
...(typeof _buildIndexOrOptions === 'function'
? {
buildIndex: _buildIndexOrOptions,
@@ -85,49 +111,32 @@ export function createFromSource<S extends LoaderOutput<LoaderConfig>>(
: _buildIndexOrOptions),
..._options,
};
const i18n = source._i18n;
let server: Promise<SearchAPI>;
if (i18n) {
const indexes = source.getLanguages().flatMap((entry) => {
return entry.pages.map(async (page) => ({
...(await buildIndex(page as InferPageType<S>)),
locale: entry.language,
}));
if (source._i18n) {
return createI18nSearchAPI('advanced', {
...options,
i18n: source._i18n,
indexes: async () => {
const indexes = source.getLanguages().flatMap((entry) => {
return entry.pages.map(async (page) => ({
...(await buildIndex(page as InferPageType<S>)),
locale: entry.language,
}));
});
return Promise.all(indexes);
},
});
server = Promise.all(indexes).then((loaded) =>
createI18nSearchAPI('advanced', {
...options,
i18n,
indexes: loaded,
}),
);
} else {
const indexes = source.getPages().map(async (page) => {
return buildIndex(page as InferPageType<S>);
});
server = Promise.all(indexes).then((loaded) =>
createSearchAPI('advanced', {
...options,
indexes: loaded,
}),
);
}
return {
async export(...args) {
return (await server).export(...args);
return createSearchAPI('advanced', {
...options,
indexes: async () => {
const indexes = source
.getPages()
.map((page) => buildIndex(page as InferPageType<S>));
return Promise.all(indexes);
},
async GET(...args) {
return (await server).GET(...args);
},
async search(...args) {
return (await server).search(...args);
},
async staticGET(...args) {
return (await server).staticGET(...args);
},
};
});
}
@@ -57,6 +57,7 @@ export async function searchAdvanced(
id: pageId,
type: 'page',
content: page.content,
breadcrumbs: page.breadcrumbs,
contentWithHighlights: highlighter.highlight(page.content),
url: page.url,
});
@@ -67,6 +68,7 @@ export async function searchAdvanced(
list.push({
id: hit.document.id.toString(),
content: hit.document.content,
breadcrumbs: hit.document.breadcrumbs,
contentWithHighlights: highlighter.highlight(hit.document.content),
type: hit.document.type as SortedResult['type'],
url: hit.document.url,
@@ -26,6 +26,7 @@ export async function searchSimple(
return result.hits.map<SortedResult>((hit) => ({
type: 'page',
content: hit.document.title,
breadcrumbs: hit.document.breadcrumbs,
contentWithHighlights: highlighter.highlight(hit.document.title),
id: hit.document.url,
url: hit.document.url,
+3
View File
@@ -101,6 +101,7 @@ export function createSearchAPI<T extends SearchType>(
export interface Index {
title: string;
description?: string;
breadcrumbs?: string[];
content: string;
url: string;
keywords?: string;
@@ -134,6 +135,8 @@ export interface AdvancedIndex {
*/
keywords?: string;
breadcrumbs?: string[];
/**
* Required if tag filter is enabled
*/
@@ -333,7 +333,7 @@ function build(id: string, ctx: PageTreeBuilderContext): PageTree.Root {
const folder = buildFolderNode('', true, ctx)!;
let root: PageTree.Root = {
$id: id,
name: folder.name,
name: folder.name || 'Docs',
children: folder.children,
};
+48
View File
@@ -119,3 +119,51 @@ function findParentFromTree(
return findParentFromTree(node.fallback, url);
}
}
/**
* Search the path of a node in the tree matched by the matcher.
*
* @returns The path to the target node (from starting root), or null if the page doesn't exist
*/
export function findPath(
nodes: PageTree.Node[],
matcher: (node: PageTree.Node) => boolean,
options: {
includeSeparator?: boolean;
} = {},
): PageTree.Node[] | null {
const { includeSeparator = true } = options;
function run(nodes: PageTree.Node[]): PageTree.Node[] | undefined {
let separator: PageTree.Separator | undefined;
for (const node of nodes) {
if (matcher(node)) {
const items: PageTree.Node[] = [];
if (separator) items.push(separator);
items.push(node);
return items;
}
if (node.type === 'separator' && includeSeparator) {
separator = node;
continue;
}
if (node.type === 'folder') {
const items =
node.index && matcher(node.index) ? [node.index] : run(node.children);
if (items) {
items.unshift(node);
if (separator) items.unshift(separator);
return items;
}
}
}
}
return run(nodes) ?? null;
}
+26 -17
View File
@@ -1,6 +1,6 @@
'use client';
import { FileText, Hash, Search as SearchIcon } from 'lucide-react';
import { ChevronRight, Hash, Search as SearchIcon } from 'lucide-react';
import {
type ComponentProps,
createContext,
@@ -184,7 +184,7 @@ export function SearchDialogContent({
aria-describedby={undefined}
{...props}
className={cn(
'fixed left-1/2 top-4 md:top-[calc(50%-250px)] z-50 w-[calc(100%-1rem)] max-w-screen-sm -translate-x-1/2 rounded-2xl border bg-fd-popover/80 backdrop-blur-xl text-fd-popover-foreground shadow-2xl shadow-black/50 overflow-hidden data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
'fixed left-1/2 top-4 md:top-[calc(50%-250px)] z-50 w-[calc(100%-1rem)] max-w-screen-sm -translate-x-1/2 rounded-xl border bg-fd-popover/80 backdrop-blur-xl text-fd-popover-foreground shadow-2xl shadow-black/50 overflow-hidden data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in',
'*:border-b *:has-[+:last-child[data-empty=true]]:border-b-0 *:data-[empty=true]:border-b-0 *:last:border-b-0',
props.className,
)}
@@ -316,12 +316,8 @@ export function SearchDialogList({
);
}
const icons = {
text: null,
heading: <Hash className="size-4 shrink-0 text-fd-muted-foreground" />,
page: (
<FileText className="size-6 text-fd-muted-foreground bg-fd-muted border p-0.5 rounded-sm shadow-sm shrink-0" />
),
const icons: Record<string, ReactNode> = {
heading: <Hash className="inline me-1 size-4 text-fd-muted-foreground" />,
};
export function SearchDialogListItem({
@@ -354,11 +350,7 @@ export function SearchDialogListItem({
)}
aria-selected={active}
className={cn(
'relative flex select-none flex-row items-center gap-2 p-2 text-start text-sm rounded-lg',
item.type !== 'page' && 'ps-8',
item.type === 'page' || item.type === 'heading'
? 'font-medium'
: 'text-fd-popover-foreground/80',
'relative select-none px-2.5 py-1.5 text-start text-sm rounded-lg',
active && 'bg-fd-accent text-fd-accent-foreground',
className,
)}
@@ -367,14 +359,31 @@ export function SearchDialogListItem({
>
{children ?? (
<>
<div className="inline-flex items-center text-fd-muted-foreground text-xs empty:hidden">
{item.breadcrumbs?.map((item, i) => (
<Fragment key={i}>
{i > 0 && <ChevronRight className="size-4" />}
{item}
</Fragment>
))}
</div>
{item.type !== 'page' && (
<div
role="none"
className="absolute start-4.5 inset-y-0 w-px bg-fd-border"
className="absolute start-3 inset-y-0 w-px bg-fd-border"
/>
)}
{icons[item.type]}
<p className="min-w-0 truncate">
<p
className={cn(
'min-w-0 truncate',
item.type !== 'page' && 'ps-4',
item.type === 'page' || item.type === 'heading'
? 'font-medium'
: 'text-fd-popover-foreground/80',
)}
>
{icons[item.type]}
{item.contentWithHighlights
? render(item.contentWithHighlights)
: item.content}
@@ -474,7 +483,7 @@ function renderHighlights(highlights: HighlightedText<ReactNode>[]): ReactNode {
return highlights.map((node, i) => {
if (node.styles?.highlight) {
return (
<span key={i} className="text-fd-primary bg-fd-primary/10">
<span key={i} className="text-fd-primary underline">
{node.content}
</span>
);