feat(Core & UI): Support search result highlighting (#2184)

* feat(search): highlight-match

* refactor: move feature flag to SearchOptions

* Add documentation to the feature

* style: misc

* changeset

* Core: moving search results highlighting features to core

* Improve customisation

* update changesets

---------

Co-authored-by: Fuma Nama <[email protected]>
This commit is contained in:
Choco
2025-08-11 15:13:46 +08:00
committed by GitHub
co-authored by Fuma Nama
parent d2cb7cda67
commit 817c237388
20 changed files with 326 additions and 62 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'fumadocs-ui': patch
'fumadocs-core': patch
---
Support search result highlighting.
Result nodes now have a `contentWithHighlights` property, you can render it with custom renderer, or a default one provided on Fumadocs UI.
+2 -2
View File
@@ -18,10 +18,10 @@ if (typeof uwuParam === 'string') {
}
const item = localStorage.getItem('uwu')
if (item === 'true') {
document.documentElement.classList.add("uwu")
}
}
`;
export function Provider({ children }: { children: ReactNode }) {
@@ -64,3 +64,24 @@ You can choose & configure the search client according to your search engine, it
A list of integrations maintained by community.
- [Trieve Search](/docs/headless/search/trieve)
### Highlight Matches
Search integrations can provide `contentWithHighlights` to highlight matches.
After configuring search client UI according to the guides above, you can customise how it is rendered.
```tsx title="components/search.tsx"
<SearchDialogList
items={query.data !== 'empty' ? query.data : null}
// [!code highlight:8]
Item={(props) => (
<SearchDialogListItem
{...props}
renderHighlights={(highlights) => {
// ...
}}
/>
)}
/>
```
+1 -1
View File
@@ -1,12 +1,12 @@
import { useRef, useState } from 'react';
import { useDebounce } from '@/utils/use-debounce';
import type { SortedResult } from '@/server/types';
import { type FetchOptions } from '@/search/client/fetch';
import { useOnChange } from '@/utils/use-on-change';
import { type StaticOptions } from '@/search/client/static';
import { type AlgoliaOptions } from '@/search/client/algolia';
import { type OramaCloudOptions } from '@/search/client/orama-cloud';
import { type MixedbreadOptions } from '@/search/client/mixedbread';
import type { SortedResult } from '@/search/shared';
interface UseDocsSearch {
search: string;
+28 -21
View File
@@ -1,6 +1,6 @@
import type { SortedResult } from '@/server/types';
import type { BaseIndex } from '@/search/algolia';
import type { Hit, LiteClient, SearchResponse } from 'algoliasearch/lite';
import { createContentHighlighter, type SortedResult } from '@/search/shared';
export interface AlgoliaOptions {
indexName: string;
@@ -53,26 +53,33 @@ export async function searchDocs(
query: string,
{ indexName, onSearch, client, locale, tag }: AlgoliaOptions,
): Promise<SortedResult[]> {
if (query.length > 0) {
const result = onSearch
? await onSearch(query, tag, locale)
: await client.searchForHits<BaseIndex>({
requests: [
{
type: 'default',
indexName,
query,
distinct: 5,
hitsPerPage: 10,
filters: tag ? `tag:${tag}` : undefined,
},
],
});
if (query.trim().length === 0) return [];
return groupResults(result.results[0].hits).filter(
(hit) => hit.type === 'page',
);
}
const result = onSearch
? await onSearch(query, tag, locale)
: await client.searchForHits<BaseIndex>({
requests: [
{
type: 'default',
indexName,
query,
distinct: 5,
hitsPerPage: 10,
filters: tag ? `tag:${tag}` : undefined,
},
],
});
return [];
const highlighter = createContentHighlighter(query);
return groupResults(result.results[0].hits).flatMap((hit) => {
if (hit.type === 'page') {
return {
...hit,
contentWithHighlights:
hit.contentWithHighlights ?? highlighter.highlight(hit.content),
};
}
return [];
});
}
@@ -85,7 +85,7 @@ export async function search(
},
});
const results = (res.data as VectorStoreSearchResult[]).flatMap((item) => {
return (res.data as VectorStoreSearchResult[]).flatMap((item) => {
const metadata = item.generated_metadata;
const url = metadata.url || '#';
@@ -116,6 +116,4 @@ export async function search(
return chunkResults;
});
return results;
}
+24 -20
View File
@@ -2,6 +2,7 @@ import type { SortedResult } from '@/server';
import type { ClientSearchParams, OramaClient } from '@oramacloud/client';
import { removeUndefined } from '@/utils/remove-undefined';
import type { OramaIndex } from '@/search/orama-cloud';
import { createContentHighlighter } from '@/search/shared';
interface CrawlerIndex {
path: string;
@@ -36,6 +37,7 @@ export async function searchDocs(
query: string,
options: OramaCloudOptions,
): Promise<SortedResult[]> {
const highlighter = createContentHighlighter(query);
const list: SortedResult[] = [];
const { index = 'default', client, params: extraParams = {}, tag } = options;
@@ -55,28 +57,28 @@ export async function searchDocs(
});
if (!result) return list;
if (index === 'crawler') {
for (const hit of result.hits) {
const doc = hit.document as unknown as CrawlerIndex;
for (const hit of result.hits) {
const doc = hit.document as unknown as CrawlerIndex;
list.push(
{
id: hit.id,
type: 'page',
content: doc.title,
url: doc.path,
},
{
id: 'page' + hit.id,
type: 'text',
content: doc.content,
url: doc.path,
},
);
}
return list;
list.push(
{
id: hit.id,
type: 'page',
content: doc.title,
contentWithHighlights: highlighter.highlight(doc.title),
url: doc.path,
},
{
id: 'page' + hit.id,
type: 'text',
content: doc.content,
contentWithHighlights: highlighter.highlight(doc.content),
url: doc.path,
},
);
}
return list;
}
const params: ClientSearchParams = {
@@ -107,6 +109,7 @@ export async function searchDocs(
id: doc.page_id,
type: 'page',
content: doc.title,
contentWithHighlights: highlighter.highlight(doc.title),
url: doc.url,
});
addedHead = true;
@@ -115,6 +118,7 @@ export async function searchDocs(
list.push({
id: doc.id,
content: doc.content,
contentWithHighlights: highlighter.highlight(doc.content),
type: doc.content === doc.section ? 'heading' : 'text',
url: doc.section_id ? `${doc.url}#${doc.section_id}` : doc.url,
});
@@ -5,6 +5,7 @@ import {
} from '@/search/orama/create-db';
import { removeUndefined } from '@/utils/remove-undefined';
import type { SortedResult } from '@/server';
import { createContentHighlighter } from '@/search/shared';
export async function searchAdvanced(
db: Orama<typeof advancedSchema>,
@@ -42,18 +43,20 @@ export async function searchAdvanced(
} as SearchParams<typeof db, AdvancedDocument>;
}
const highlighter = createContentHighlighter(query);
const result = await search(db, params);
const list: SortedResult[] = [];
for (const item of result.groups ?? []) {
const pageId = item.values[0] as string;
const page = await getByID(db, pageId);
const page = getByID(db, pageId);
if (!page) continue;
list.push({
id: pageId,
type: 'page',
content: page.content,
contentWithHighlights: highlighter.highlight(page.content),
url: page.url,
});
@@ -63,6 +66,7 @@ export async function searchAdvanced(
list.push({
id: hit.document.id.toString(),
content: hit.document.content,
contentWithHighlights: highlighter.highlight(hit.document.content),
type: hit.document.type as SortedResult['type'],
url: hit.document.url,
});
@@ -1,9 +1,10 @@
import { type Orama, search, type SearchParams } from '@orama/orama';
import type { SortedResult } from '@/server';
import {
type simpleSchema,
type SimpleDocument,
type simpleSchema,
} from '@/search/orama/create-db';
import { createContentHighlighter } from '@/search/shared';
export async function searchSimple(
db: Orama<typeof simpleSchema>,
@@ -12,6 +13,7 @@ export async function searchSimple(
SearchParams<Orama<typeof simpleSchema>, SimpleDocument>
> = {},
): Promise<SortedResult[]> {
const highlighter = createContentHighlighter(query);
const result = await search(db, {
term: query,
tolerance: 1,
@@ -25,6 +27,7 @@ export async function searchSimple(
return result.hits.map<SortedResult>((hit) => ({
type: 'page',
content: hit.document.title,
contentWithHighlights: highlighter.highlight(hit.document.title),
id: hit.document.url,
url: hit.document.url,
}));
+2 -1
View File
@@ -6,7 +6,6 @@ import {
type SearchParams,
} from '@orama/orama';
import type { StructuredData } from '@/mdx-plugins/remark-structure';
import type { SortedResult } from '@/server/types';
import { createEndpoint } from '@/search/orama/create-endpoint';
import {
type AdvancedDocument,
@@ -18,6 +17,7 @@ import {
} from '@/search/orama/create-db';
import { searchSimple } from '@/search/orama/search/simple';
import { searchAdvanced } from '@/search/orama/search/advanced';
import type { SortedResult } from '@/search/shared';
type SearchType = 'simple' | 'advanced';
@@ -162,3 +162,4 @@ export function initAdvancedSearch(options: AdvancedOptions): SearchServer {
export { createFromSource } from './orama/create-from-source';
export { createI18nSearchAPI } from './orama/create-i18n';
export * from './shared';
+75
View File
@@ -0,0 +1,75 @@
export interface SortedResult {
id: string;
url: string;
type: 'page' | 'heading' | 'text';
content: string;
contentWithHighlights?: HighlightedText[];
}
export type HighlightedText = {
type: 'text';
content: string;
styles?: {
highlight?: boolean;
};
};
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildRegexFromQuery(q: string): RegExp | null {
const trimmed = q.trim();
if (trimmed.length === 0) return null;
const terms = Array.from(
new Set(
trimmed
.split(/\s+/)
.map((t) => t.trim())
.filter(Boolean),
),
);
if (terms.length === 0) return null;
const escaped = terms.map(escapeRegExp).join('|');
return new RegExp(`(${escaped})`, 'gi');
}
export function createContentHighlighter(query: string | RegExp) {
const regex = typeof query === 'string' ? buildRegexFromQuery(query) : query;
return {
highlight(content: string): HighlightedText[] {
if (!regex) return [{ type: 'text', content }];
const out: HighlightedText[] = [];
let i = 0;
for (const match of content.matchAll(regex)) {
if (i < match.index) {
out.push({
type: 'text',
content: content.substring(i, match.index),
});
}
out.push({
type: 'text',
content: match[0],
styles: {
highlight: true,
},
});
i = match.index + match[0].length;
}
if (i < content.length) {
out.push({
type: 'text',
content: content.substring(i),
});
}
return out;
},
};
}
+3 -1
View File
@@ -2,5 +2,7 @@ export * from './get-toc';
export * from '../utils/page-tree';
export * as PageTree from './page-tree';
export * from './git-api';
export * from './types';
export * from './metadata';
// backward compat
export type { SortedResult } from '../search/shared';
-6
View File
@@ -1,6 +0,0 @@
export interface SortedResult {
id: string;
url: string;
type: 'page' | 'heading' | 'text';
content: string;
}
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from 'vitest';
import { createContentHighlighter } from '@/search/shared';
test('highlight search results', () => {
const highlighter = createContentHighlighter('hello world helloworld');
expect(highlighter.highlight('oops hello, world hello! worldhello'))
.toMatchInlineSnapshot(`
[
{
"content": "oops ",
"type": "text",
},
{
"content": "hello",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": ", ",
"type": "text",
},
{
"content": "world",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": " ",
"type": "text",
},
{
"content": "hello",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": "! ",
"type": "text",
},
{
"content": "world",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": "hello",
"styles": {
"highlight": true,
},
"type": "text",
},
]
`);
expect(highlighter.highlight('helloworld!!!')).toMatchInlineSnapshot(`
[
{
"content": "hello",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": "world",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": "!!!",
"type": "text",
},
]
`);
expect(highlighter.highlight('wor ld hello')).toMatchInlineSnapshot(`
[
{
"content": "wor ld ",
"type": "text",
},
{
"content": "hello",
"styles": {
"highlight": true,
},
"type": "text",
},
]
`);
});
+19
View File
@@ -58,12 +58,31 @@ something`,
[
{
"content": "Index",
"contentWithHighlights": [
{
"content": "Index",
"type": "text",
},
],
"id": "1",
"type": "page",
"url": "/",
},
{
"content": "Hello World",
"contentWithHighlights": [
{
"content": "Hello",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": " World",
"type": "text",
},
],
"id": "1-0",
"type": "heading",
"url": "/#hello-world",
@@ -18,9 +18,10 @@ import {
SearchDialogOverlay,
TagsList,
TagsListItem,
type SharedProps,
} from './search';
import type { SortedResult } from 'fumadocs-core/server';
import type { SearchLink, SharedProps, TagItem } from '@/contexts/search';
import type { SearchLink, TagItem } from '@/contexts/search';
import { useI18n } from '@/contexts/i18n';
export interface AlgoliaSearchDialogProps extends SharedProps {
@@ -16,9 +16,10 @@ import {
SearchDialogOverlay,
TagsList,
TagsListItem,
type SharedProps,
} from './search';
import type { SortedResult } from 'fumadocs-core/server';
import type { SearchLink, SharedProps, TagItem } from '@/contexts/search';
import type { SearchLink, TagItem } from '@/contexts/search';
export interface DefaultSearchDialogProps extends SharedProps {
links?: SearchLink[];
@@ -18,9 +18,10 @@ import {
SearchDialogOverlay,
TagsList,
TagsListItem,
type SharedProps,
} from './search';
import type { SortedResult } from 'fumadocs-core/server';
import type { SearchLink, SharedProps, TagItem } from '@/contexts/search';
import type { SearchLink, TagItem } from '@/contexts/search';
import { useI18n } from '@/contexts/i18n';
export interface OramaSearchDialogProps extends SharedProps {
+22 -1
View File
@@ -29,6 +29,7 @@ import type { SharedProps } from '@/contexts/search';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import scrollIntoView from 'scroll-into-view-if-needed';
import { buttonVariants } from '@/components/ui/button';
import type { HighlightedText } from 'fumadocs-core/search/server';
type ReactSortedResult = Omit<SortedResult, 'content'> & {
external?: boolean;
@@ -326,8 +327,10 @@ export function SearchDialogListItem({
item,
className,
children,
renderHighlights: render = renderHighlights,
...props
}: ComponentProps<'button'> & {
renderHighlights?: typeof renderHighlights;
item: ReactSortedResult;
}) {
const { active: activeId, setActive } = useSearchList();
@@ -370,7 +373,11 @@ export function SearchDialogListItem({
/>
)}
{icons[item.type]}
<p className="min-w-0 truncate">{item.content}</p>
<p className="min-w-0 truncate">
{item.contentWithHighlights
? render(item.contentWithHighlights)
: item.content}
</p>
</>
)}
</button>
@@ -462,6 +469,20 @@ export function TagsListItem({
);
}
function renderHighlights(highlights: HighlightedText[]): ReactNode {
return highlights.map((node, i) => {
if (node.styles?.highlight) {
return (
<span key={i} className="text-fd-primary bg-fd-primary/10">
{node.content}
</span>
);
}
return <Fragment key={i}>{node.content}</Fragment>;
});
}
export function useSearch() {
const ctx = useContext(Context);
if (!ctx) throw new Error('Missing <SearchDialog />');
+5 -1
View File
@@ -132,7 +132,11 @@ export function SearchProvider({
return (
<SearchContext.Provider
value={useMemo(
() => ({ enabled: true, hotKey, setOpenSearch: setIsOpen }),
() => ({
enabled: true,
hotKey,
setOpenSearch: setIsOpen,
}),
[hotKey],
)}
>