mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 06:31:49 +00:00
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:
@@ -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.
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface SortedResult {
|
||||
id: string;
|
||||
url: string;
|
||||
type: 'page' | 'heading' | 'text';
|
||||
content: string;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />');
|
||||
|
||||
@@ -132,7 +132,11 @@ export function SearchProvider({
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
value={useMemo(
|
||||
() => ({ enabled: true, hotKey, setOpenSearch: setIsOpen }),
|
||||
() => ({
|
||||
enabled: true,
|
||||
hotKey,
|
||||
setOpenSearch: setIsOpen,
|
||||
}),
|
||||
[hotKey],
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user