Core & UI: support Markdown in search results

This commit is contained in:
Fuma Nama
2026-02-11 22:51:24 +08:00
parent 29c6419e1f
commit 9241992f44
19 changed files with 611 additions and 215 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-ui': minor
'fumadocs-core': minor
'@fumadocs/base-ui': minor
---
Support Markdown in search results
+6 -2
View File
@@ -111,12 +111,15 @@
"next-themes": "^0.4.6",
"react-medium-image-zoom": "^5.4.0",
"react-remove-scroll": "^2.7.2",
"rehype-raw": "^7.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@fumadocs/cli": "workspace:*",
"@tailwindcss/cli": "^4.1.18",
"@types/hast": "^3.0.4",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
@@ -124,7 +127,8 @@
"fumadocs-core": "workspace:*",
"tailwindcss": "^4.1.18",
"tsconfig": "workspace:*",
"tsdown": "^0.20.3"
"tsdown": "^0.20.3",
"unified": "^11.0.5"
},
"peerDependencies": {
"@types/react": "*",
+108 -23
View File
@@ -24,6 +24,11 @@ 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 { createMarkdownRenderer } from 'fumadocs-core/content/md';
import rehypeRaw from 'rehype-raw';
import { visit } from 'unist-util-visit';
import type { Transformer } from 'unified';
import type { Root } from 'hast';
export type SearchItemType =
| (BaseResultType & {
@@ -68,6 +73,97 @@ const TagsListContext = createContext<{
allowClear: boolean;
} | null>(null);
const mdRenderer = createMarkdownRenderer({
remarkRehypeOptions: {
allowDangerousHtml: true,
},
rehypePlugins: [rehypeRaw, rehypeCustomElements],
});
const badgeVariant = cva('rounded-md border px-0.5 bg-fd-muted text-fd-muted-foreground', {
variants: {
variant: {
primary: 'bg-fd-primary text-fd-primary-foreground',
},
},
});
const mdComponents = {
mark(props: ComponentProps<'mark'>) {
return <span {...props} className={cn('text-fd-primary underline', props.className)} />;
},
a({ children }: ComponentProps<'a'>) {
return <span>{children}</span>;
},
p(props: ComponentProps<'p'>) {
return <p {...props} className="min-w-0" />;
},
code({ children, className, ...props }: ComponentProps<'pre'>) {
return (
<code
className={cn(
'border rounded-md -mx-0.5 px-0.5 bg-fd-secondary text-fd-secondary-foreground',
className,
)}
{...props}
>
{children}
</code>
);
},
custom({
_tagName,
children,
...rest
}: Record<string, unknown> & { _tagName: string; children: ReactNode }) {
const propNodes = Object.entries(rest).map(([k, v]) => {
if (typeof v !== 'string') return;
return (
<code key={k} className={cn(badgeVariant(), 'truncate')}>
{k}: {v}
</code>
);
});
return (
<span {...rest}>
<span className="flex min-w-0 text-xs items-center gap-2">
<code className={cn(badgeVariant({ variant: 'primary' }))}>{_tagName}</code>
{propNodes}
</span>
{children}
</span>
);
},
pre(props: ComponentProps<'pre'>) {
return (
<pre
{...props}
className={cn(
'border rounded-md p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden',
props.className,
)}
>
{props.children}
</pre>
);
},
};
function rehypeCustomElements(): Transformer<Root, Root> {
return (tree) => {
visit(tree, (node) => {
if (
node.type === 'element' &&
document.createElement(node.tagName) instanceof HTMLUnknownElement
) {
node.properties._tagName = node.tagName;
node.tagName = 'custom';
}
});
};
}
export function SearchDialog({
open,
onOpenChange,
@@ -171,7 +267,7 @@ export function SearchDialogOverlay({
{...props}
className={(s) =>
cn(
'fixed inset-0 z-50 backdrop-blur-xs bg-fd-overlay data-[open]:animate-fd-fade-in data-[closed]:animate-fd-fade-out',
'fixed inset-0 z-50 backdrop-blur-xs bg-fd-overlay data-open:animate-fd-fade-in data-closed:animate-fd-fade-out',
typeof className === 'function' ? className(s) : className,
)
}
@@ -193,7 +289,7 @@ export function SearchDialogContent({
{...props}
className={(s) =>
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-xl border bg-fd-popover text-fd-popover-foreground shadow-2xl shadow-black/50 overflow-hidden data-[closed]:animate-fd-dialog-out data-[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 text-fd-popover-foreground shadow-2xl shadow-black/50 overflow-hidden data-closed:animate-fd-dialog-out dataopen: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',
typeof className === 'function' ? className(s) : className,
)
@@ -316,10 +412,13 @@ export function SearchDialogListItem({
item,
className,
children,
renderHighlights: render = renderHighlights,
renderMarkdown = (s) => <mdRenderer.Markdown components={mdComponents}>{s}</mdRenderer.Markdown>,
renderHighlights: _,
...props
}: ComponentProps<'button'> & {
renderHighlights?: typeof renderHighlights;
renderMarkdown?: (v: string) => ReactNode;
/** @deprecated highlight blocks is now wrapped in `<mark />`, use `renderMarkdown` to handle instead. */
renderHighlights?: (blocks: HighlightedText<ReactNode>[]) => ReactNode;
item: SearchItemType;
}) {
const { active: activeId, setActive } = useSearchList();
@@ -342,9 +441,9 @@ export function SearchDialogListItem({
{item.type !== 'page' && (
<div role="none" className="absolute start-3 inset-y-0 w-px bg-fd-border" />
)}
<p
<div
className={cn(
'min-w-0 truncate',
'flex items-center min-w-0',
item.type !== 'page' && 'ps-4',
item.type === 'page' || item.type === 'heading'
? 'font-medium'
@@ -354,8 +453,8 @@ export function SearchDialogListItem({
{item.type === 'heading' && (
<Hash className="inline me-1 size-4 text-fd-muted-foreground" />
)}
{item.contentWithHighlights ? render(item.contentWithHighlights) : item.content}
</p>
{typeof item.content === 'string' ? renderMarkdown(item.content) : item.content}
</div>
</>
);
}
@@ -377,7 +476,7 @@ export function SearchDialogListItem({
)}
aria-selected={active}
className={cn(
'relative select-none px-2.5 py-2 text-start text-sm rounded-lg',
'relative select-none shrink-0 px-2.5 py-2 text-start text-sm overflow-hidden rounded-lg',
active && 'bg-fd-accent text-fd-accent-foreground',
className,
)}
@@ -466,20 +565,6 @@ export function TagsListItem({
);
}
function renderHighlights(highlights: HighlightedText<ReactNode>[]): ReactNode {
return highlights.map((node, i) => {
if (node.styles?.highlight) {
return (
<span key={i} className="text-fd-primary underline">
{node.content}
</span>
);
}
return <Fragment key={i}>{node.content}</Fragment>;
});
}
export function useSearch() {
const ctx = use(Context);
if (!ctx) throw new Error('Missing <SearchDialog />');
+3 -3
View File
@@ -24,8 +24,8 @@
"import": "./dist/toc.js"
},
"./content": {
"types": "./dist/content/index.d.ts",
"import": "./dist/content/index.js"
"types": "./dist/content/md.d.ts",
"import": "./dist/content/md.js"
},
"./content/*": {
"types": "./dist/content/*.d.ts",
@@ -118,7 +118,7 @@
"scripts": {
"build": "tsdown",
"clean": "rimraf dist",
"dev": "tsdown --watch",
"dev": "tsdown --watch --clean false",
"lint": "eslint .",
"types:check": "tsc --noEmit"
},
-44
View File
@@ -1,44 +0,0 @@
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import type { Compatible } from 'vfile';
import { type Components, toJsxRuntime } from 'hast-util-to-jsx-runtime';
import type { Root } from 'hast';
import * as JsxRuntime from 'react/jsx-runtime';
import type { PluggableList, Processor } from 'unified';
import type { ReactNode } from 'react';
function rehypeReact(this: Processor, options: MarkdownProps = {}) {
this.compiler = (tree, file) => {
return toJsxRuntime(tree as Root, {
development: false,
filePath: file.path,
...JsxRuntime,
...options,
});
};
}
export interface MarkdownProps {
components?: Components;
}
export async function Markdown({
children: content,
remarkPlugins = [],
rehypePlugins = [],
...options
}: MarkdownProps & {
remarkPlugins?: PluggableList;
rehypePlugins?: PluggableList;
children: Compatible;
}) {
const processor = remark()
.use(remarkGfm)
.use(remarkPlugins)
.use(remarkRehype)
.use(rehypePlugins)
.use(rehypeReact, options);
return (await processor.process(content)).result as ReactNode;
}
+78
View File
@@ -0,0 +1,78 @@
import { remark } from 'remark';
import remarkRehype, { type Options as RemarkRehypeOptions } from 'remark-rehype';
import { type Compatible, VFile } from 'vfile';
import { type Components, toJsxRuntime } from 'hast-util-to-jsx-runtime';
import type { Root } from 'hast';
import type * as Mdast from 'mdast';
import * as JsxRuntime from 'react/jsx-runtime';
import type { PluggableList } from 'unified';
import { type FC, use } from 'react';
export interface MarkdownRendererOptions {
remarkPlugins?: PluggableList;
rehypePlugins?: PluggableList;
remarkRehypeOptions?: RemarkRehypeOptions;
}
export interface MarkdownRenderer {
Markdown: FC<MarkdownProps>;
MarkdownServer: FC<Omit<MarkdownProps, 'async'>>;
}
export interface MarkdownProps {
async?: boolean;
components?: Components;
children: Compatible;
}
export function createMarkdownRenderer({
rehypePlugins = [],
remarkPlugins = [],
remarkRehypeOptions,
}: MarkdownRendererOptions = {}): MarkdownRenderer {
const processor = remark()
.use(remarkPlugins)
.use(remarkRehype, remarkRehypeOptions)
.use(rehypePlugins);
const cache: Record<string, Root> = {};
const promises: Record<string, Promise<Root>> = {};
function render(tree: Root, file: VFile, props: MarkdownProps) {
return toJsxRuntime(tree, {
development: false,
filePath: file.path,
components: props.components,
...JsxRuntime,
});
}
function parse(file: VFile, _props: MarkdownProps): Mdast.Root {
return processor.parse(file) as Mdast.Root;
}
return {
Markdown(props) {
const { async = false, children } = props;
const file = new VFile(children);
const key = String(file.value);
if (async) {
promises[key] ??= processor.run(parse(file, props), file);
const out = use(promises[key]);
return render(out, file, props);
}
cache[key] ??= processor.runSync(parse(file, props), file);
return render(cache[key], file, props);
},
async MarkdownServer(props) {
const file = new VFile(props.children);
return render(await processor.run(parse(file, props), file), file, props);
},
};
}
export function Markdown(props: MarkdownProps & MarkdownRendererOptions) {
return createMarkdownRenderer(props).MarkdownServer(props);
}
+128 -64
View File
@@ -1,4 +1,4 @@
import type { Nodes, Root } from 'mdast';
import type { Link, Nodes, Root } from 'mdast';
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import type { PluggableList, Processor, Transformer } from 'unified';
@@ -13,39 +13,50 @@ import type {
import { type Handle, type Options, toMarkdown } from 'mdast-util-to-markdown';
import { remarkHeading } from './remark-heading';
interface Heading {
interface StructuredDataHeading {
id: string;
content: string;
}
interface Content {
interface StructuredDataContent {
heading: string | undefined;
content: string;
}
export interface StructuredData {
headings: Heading[];
headings: StructuredDataHeading[];
/**
* Refer to paragraphs, a heading may contain multiple contents as well
*/
contents: Content[];
contents: StructuredDataContent[];
}
type Stringifier = (this: Processor, node: Nodes, ctx: StringifierContext) => string;
interface StringifierContext {
addContent: (...content: StructuredDataContent[]) => void;
}
interface StringifyOptions {
/**
* Determine whether the element itself should be stringified in content block, only stringify its attributes & children if `false`.
* Filter the elements to be included in the output:
*
* Always return `false` by default.
* - `true`: include element & its children.
* - `children-only`: exclude element but keep its children.
* - `false`: exclude element & its children.
*/
filterMdxElements?: (node: MdxJsxFlowElement | MdxJsxTextElement) => boolean;
filterElement?: (node: Nodes) => boolean | 'children-only';
/**
* Filter the attributes to stringify.
*/
filterMdxAttributes?: (
node: MdxJsxFlowElement | MdxJsxTextElement,
attribute: MdxJsxAttribute | MdxJsxExpressionAttribute,
) => boolean;
}
export interface StructureOptions extends Omit<StringifyOptions, 'filterMdxAttributes'> {
export interface StructureOptions {
/**
* MDAST node types to be scanned as a content block.
*
@@ -58,14 +69,16 @@ export interface StructureOptions extends Omit<StringifyOptions, 'filterMdxAttri
/**
* stringify a given node & its children, you can use something like `mdast-util-to-markdown`.
*/
stringify?: (this: Processor, node: Nodes) => string;
stringify?: Stringifier | StringifyOptions;
/**
* By default, it will not index MDX attributes. You can define a list of MDX attributes to index, either:
* Whether the MDX element should be treated as a single content block, only effective if `types` has `mdxJsxFlowElement`.
*
* - an array of attribute names.
* - a function that determines if attribute should be indexed.
* Default: return `true` if the element is a leaf node, otherwise `false`.
*/
mdxTypes?: (node: MdxJsxFlowElement | MdxJsxTextElement) => boolean;
/** @deprecated use `stringify.filterMdxAttributes` instead */
allowedMdxAttributes?:
| string[]
| ((
@@ -82,16 +95,23 @@ export interface StructureOptions extends Omit<StringifyOptions, 'filterMdxAttri
declare module 'mdast' {
interface Data {
/**
* [Fumadocs] Stringified form of node, `remarkStructure` uses it to generate search index.
* [Fumadocs: remark-structure] The stringified form of node for generating search index.
*/
_string?: string[];
_string?: string | (() => string);
/**
* [Fumadocs: remark-structure] Items to add to the structured data.
*/
structuredData?: {
contents: StructuredDataContent[];
};
}
}
declare module 'vfile' {
interface DataMap {
/**
* [Fumadocs] injected by `remarkStructure`
* [Fumadocs: remark-structure] output data.
*/
structuredData: StructuredData;
}
@@ -99,6 +119,9 @@ declare module 'vfile' {
export const remarkStructureDefaultOptions = {
types: ['heading', 'paragraph', 'blockquote', 'tableCell', 'mdxJsxFlowElement'],
mdxTypes(node) {
return node.children.length === 0;
},
exportAs: false,
} satisfies StructureOptions;
@@ -111,27 +134,28 @@ export function remarkStructure(
this: Processor,
{
types = remarkStructureDefaultOptions.types,
stringify,
mdxTypes = remarkStructureDefaultOptions.mdxTypes,
stringify: stringifyOptions,
allowedMdxAttributes,
exportAs = remarkStructureDefaultOptions.exportAs,
...stringifyOptions
}: StructureOptions = {},
): Transformer<Root, Root> {
if (Array.isArray(allowedMdxAttributes)) {
const arr = allowedMdxAttributes;
allowedMdxAttributes = (_node, attribute) =>
attribute.type === 'mdxJsxAttribute' && arr.includes(attribute.name);
}
if (Array.isArray(types)) {
const arr = types;
types = (node) => arr.includes(node.type);
}
stringify ??= defaultStringify({
filterMdxAttributes: allowedMdxAttributes,
...stringifyOptions,
});
const stringify =
typeof stringifyOptions === 'function'
? stringifyOptions
: defaultStringify({
filterMdxAttributes: Array.isArray(allowedMdxAttributes)
? (_node, attribute) =>
attribute.type === 'mdxJsxAttribute' &&
allowedMdxAttributes.includes(attribute.name)
: allowedMdxAttributes,
...stringifyOptions,
});
return (tree, file) => {
const data: StructuredData = { contents: [], headings: [] };
let lastHeading: string | undefined;
@@ -150,8 +174,24 @@ export function remarkStructure(
}
}
const stringifierCtx: StringifierContext = {
addContent(...content) {
for (const item of content) {
data.contents.push({ ...item, heading: item.heading ?? lastHeading });
}
},
};
visit(tree, (element) => {
if (element.type === 'root' || !types(element)) return;
if (!types(element)) return;
switch (element.type) {
case 'root':
return;
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement':
if (!mdxTypes(element)) return;
break;
}
if (element.type === 'heading') {
element.data ||= {};
@@ -173,7 +213,7 @@ export function remarkStructure(
return 'skip';
}
const content = stringify.call(this, element).trim();
const content = stringify.call(this, element, stringifierCtx).trim();
if (content.length > 0) {
data.contents.push({
heading: lastHeading,
@@ -214,77 +254,101 @@ export function structure(
interface CustomRootNode {
type: '_custom';
root: Nodes;
ctx: StringifierContext;
}
export function defaultStringify(
config: Options & StringifyOptions = {},
): (this: Processor, node: Nodes) => string {
export function defaultStringify(config: Options & StringifyOptions = {}): Stringifier {
const {
filterMdxAttributes = (node) => {
switch (node.name) {
case 'TypeTable':
case 'Callout':
return true;
default:
return false;
filterMdxAttributes,
filterElement = (node) => {
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement':
switch (node.name) {
case 'File':
case 'TypeTable':
case 'Callout':
case 'Card':
return true;
}
return 'children-only';
}
return true;
},
filterMdxElements = () => false,
} = config;
function modHandler(handler: Handle): Handle {
return function (node: Nodes, ...rest) {
if (node.data?._string) return node.data._string.join('\n');
function modHandler(handler: Handle, ctx: StringifierContext): Handle {
return function (node: Nodes, parent, state, info) {
const { structuredData, _string } = node.data ?? {};
if (structuredData) ctx.addContent(...structuredData.contents);
if (_string) return typeof _string === 'function' ? _string() : _string;
const visibility = filterElement ? filterElement(node) : true;
if (visibility === false) return '';
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement': {
const filteredAttributes = node.attributes.filter((attr) =>
filterMdxAttributes(node, attr),
);
if (visibility === 'children-only') {
return node.type === 'mdxJsxTextElement'
? state.containerPhrasing(node, info)
: state.containerFlow(node, info);
}
if (!filterMdxElements(node)) {
let attrStr = '';
const stringifiedAttributes: MdxJsxAttribute[] = [];
for (const attr of node.attributes) {
if (attr.type === 'mdxJsxExpressionAttribute') continue;
if (filterMdxAttributes && !filterMdxAttributes(node, attr)) continue;
const str = typeof attr.value === 'string' ? attr.value : attr.value?.value;
if (!str) continue;
for (const attr of filteredAttributes) {
const str = typeof attr.value === 'string' ? attr.value : attr.value?.value;
if (!str) continue;
attrStr += attr.type === 'mdxJsxAttribute' ? `(${attr.name}=${str}) ` : `(${str}) `;
}
if (node.children.length === 0) return attrStr.trimEnd();
return attrStr + rest[1].handle({ type: 'root', children: node.children }, ...rest);
stringifiedAttributes.push({
...attr,
value: str,
});
}
const temp = node.attributes;
node.attributes = filteredAttributes;
const s = handler(node, ...rest);
node.attributes = stringifiedAttributes;
const s = handler(node, parent, state, info);
node.attributes = temp;
return s;
}
default:
return handler(node, ...rest);
if (visibility === 'children-only')
return 'children' in node
? state.containerFlow({ type: 'root', children: node.children }, info)
: '';
return handler(node, parent, state, info);
}
};
}
const handlers: Record<string, Handle> = {
...config.handlers,
link(node: Link, _, state, info) {
return state.containerPhrasing(node, info);
},
image() {
return '';
},
_custom(node: CustomRootNode, _, state, info) {
const handlers: Record<string, Handle> = state.handlers;
for (const k in handlers) {
handlers[k] = modHandler(handlers[k]);
handlers[k] = modHandler(handlers[k], node.ctx);
}
return state.handle(node.root, undefined, state, info);
},
...config.handlers,
};
return function (root) {
return function (root, ctx) {
// from https://github.com/remarkjs/remark/blob/main/packages/remark-stringify/lib/index.js
const defaultExtensions = this.data('toMarkdownExtensions') ?? [];
return toMarkdown({ type: '_custom', root } as never, {
return toMarkdown({ type: '_custom', root, ctx } as never, {
...this.data('settings'),
...config,
extensions: config.extensions
+1 -1
View File
@@ -76,7 +76,7 @@ export async function searchDocs(
if (hit.type === 'page') {
return {
...hit,
contentWithHighlights: hit.contentWithHighlights ?? highlighter.highlight(hit.content),
content: highlighter.highlightMarkdown(hit.content),
};
}
@@ -63,15 +63,13 @@ export async function searchDocs(
{
id: hit.id,
type: 'page',
content: doc.title,
contentWithHighlights: highlighter.highlight(doc.title),
content: highlighter.highlightMarkdown(doc.title),
url: doc.path,
},
{
id: 'page' + hit.id,
type: 'text',
content: doc.content,
contentWithHighlights: highlighter.highlight(doc.content),
content: highlighter.highlightMarkdown(doc.content),
url: doc.path,
},
);
@@ -107,9 +105,8 @@ export async function searchDocs(
list.push({
id: doc.page_id,
type: 'page',
content: doc.title,
content: highlighter.highlightMarkdown(doc.title),
breadcrumbs: doc.breadcrumbs,
contentWithHighlights: highlighter.highlight(doc.title),
url: doc.url,
});
addedHead = true;
@@ -117,8 +114,7 @@ export async function searchDocs(
list.push({
id: doc.id,
content: doc.content,
contentWithHighlights: highlighter.highlight(doc.content),
content: highlighter.highlightMarkdown(doc.content),
type: doc.content === doc.section ? 'heading' : 'text',
url: doc.section_id ? `${doc.url}#${doc.section_id}` : doc.url,
});
@@ -64,15 +64,13 @@ export async function searchDocs(
{
id: hit.id,
type: 'page',
content: doc.title,
contentWithHighlights: highlighter.highlight(doc.title),
content: highlighter.highlightMarkdown(doc.title),
url: doc.path,
},
{
id: 'page' + hit.id,
type: 'text',
content: doc.content,
contentWithHighlights: highlighter.highlight(doc.content),
content: highlighter.highlightMarkdown(doc.content),
url: doc.path,
},
);
@@ -109,9 +107,8 @@ export async function searchDocs(
list.push({
id: doc.page_id,
type: 'page',
content: doc.title,
content: highlighter.highlightMarkdown(doc.title),
breadcrumbs: doc.breadcrumbs,
contentWithHighlights: highlighter.highlight(doc.title),
url: doc.url,
});
addedHead = true;
@@ -119,8 +116,7 @@ export async function searchDocs(
list.push({
id: doc.id,
content: doc.content,
contentWithHighlights: highlighter.highlight(doc.content),
content: highlighter.highlightMarkdown(doc.content),
type: doc.content === doc.section ? 'heading' : 'text',
url: doc.section_id ? `${doc.url}#${doc.section_id}` : doc.url,
});
+45
View File
@@ -1,4 +1,8 @@
import type { Root } from 'mdast';
import type { ReactNode } from 'react';
import { remark } from 'remark';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
export interface SortedResult<Content = string> {
id: string;
@@ -10,6 +14,9 @@ export interface SortedResult<Content = string> {
* breadcrumbs to be displayed on UI
*/
breadcrumbs?: Content[];
/**
* @deprecated it is now included in `content` as Markdown using `<mark />`.
*/
contentWithHighlights?: HighlightedText<Content>[];
}
@@ -44,7 +51,11 @@ function buildRegexFromQuery(q: string): RegExp | null {
}
export function createContentHighlighter(query: string | RegExp) {
let processor = remark();
const regex = typeof query === 'string' ? buildRegexFromQuery(query) : query;
if (regex) {
processor = processor.use(remarkHighlight, regex) as never;
}
return {
highlight(content: string): HighlightedText[] {
@@ -80,5 +91,39 @@ export function createContentHighlighter(query: string | RegExp) {
return out;
},
/**
* @param content - Markdown, it assumes the content is already sanitized & safe, no escape is performed.
*/
highlightMarkdown(content: string): string {
if (!regex) return content;
return String(processor.processSync(content).value);
},
};
}
function remarkHighlight(regex: RegExp): Transformer<Root, Root> {
return (tree) => {
visit(tree, 'text', (node) => {
let out = '';
const content = node.value;
let i = 0;
for (const match of content.matchAll(regex)) {
if (i < match.index) {
out += content.substring(i, match.index);
}
out += `<mark>${match[0]}</mark>`;
i = match.index + match[0].length;
}
if (i < content.length) {
out += content.substring(i);
}
node.type = 'html' as never;
node.value = out;
});
};
}
@@ -14,7 +14,7 @@ export async function searchAdvanced(
): Promise<SortedResult[]> {
if (typeof tag === 'string') tag = [tag];
let params = {
const params = {
...override,
mode,
where: removeUndefined({
@@ -26,6 +26,7 @@ export async function searchAdvanced(
: undefined,
...override.where,
}),
limit: 10,
groupBy: {
properties: ['page_id'],
maxResult: 8,
@@ -34,11 +35,10 @@ export async function searchAdvanced(
} as SearchParams<typeof db, AdvancedDocument>;
if (query.length > 0) {
params = {
...params,
Object.assign(params, {
term: query,
properties: mode === 'fulltext' ? ['content'] : ['content', 'embeddings'],
} as SearchParams<typeof db, AdvancedDocument>;
} as SearchParams<typeof db, AdvancedDocument>);
}
const highlighter = createContentHighlighter(query);
@@ -53,9 +53,8 @@ export async function searchAdvanced(
list.push({
id: pageId,
type: 'page',
content: page.content,
content: highlighter.highlightMarkdown(page.content),
breadcrumbs: page.breadcrumbs,
contentWithHighlights: highlighter.highlight(page.content),
url: page.url,
});
@@ -64,13 +63,12 @@ export async function searchAdvanced(
list.push({
id: hit.document.id.toString(),
content: hit.document.content,
content: highlighter.highlightMarkdown(hit.document.content),
breadcrumbs: hit.document.breadcrumbs,
contentWithHighlights: highlighter.highlight(hit.document.content),
type: hit.document.type as SortedResult['type'],
url: hit.document.url,
});
}
}
return list;
return list.length > 80 ? list.slice(0, 80) : list;
}
@@ -20,9 +20,8 @@ export async function searchSimple(
return result.hits.map<SortedResult>((hit) => ({
type: 'page',
content: hit.document.title,
content: highlighter.highlightMarkdown(hit.document.title),
breadcrumbs: hit.document.breadcrumbs,
contentWithHighlights: highlighter.highlight(hit.document.title),
id: hit.document.url,
url: hit.document.url,
}));
+27
View File
@@ -18,6 +18,8 @@ function main() {
}
```
<Cards>
<Card title="Test">wow</Card>
<Card title="Test" className="test-sm">
@@ -26,4 +28,29 @@ wow
</Card>
</Cards>
<TypeTable type={{ haha: test }} />
<FeedbackBlock>
- hello world.
- hello world.
- hello world.
```ts
console.log('Hello World');
```
- hello **world**.
- hello world.
# Heading
| key | **value** |
| ----- | --------------- |
| apple | banana **test** |
[Test](/docs)
![Test](/docs)
</FeedbackBlock>
+46 -2
View File
@@ -26,7 +26,7 @@
},
{
"heading": "heading-3",
"content": "wow"
"content": "<Card title=\"Test\">wow</Card>"
},
{
"heading": "heading-3",
@@ -34,7 +34,47 @@
},
{
"heading": "heading-3",
"content": "(type={ haha: test })"
"content": "<TypeTable type=\"{ haha: test }\" />"
},
{
"heading": "heading-3",
"content": "hello world."
},
{
"heading": "heading-3",
"content": "hello world."
},
{
"heading": "heading-3",
"content": "hello world."
},
{
"heading": "heading-3",
"content": "hello **world**."
},
{
"heading": "heading-3",
"content": "hello world."
},
{
"heading": "heading",
"content": "key"
},
{
"heading": "heading",
"content": "**value**"
},
{
"heading": "heading",
"content": "apple"
},
{
"heading": "heading",
"content": "banana **test**"
},
{
"heading": "heading",
"content": "Test"
}
],
"headings": [
@@ -49,6 +89,10 @@
{
"id": "heading-3",
"content": "Heading 3"
},
{
"id": "heading",
"content": "Heading"
}
]
}
+4 -21
View File
@@ -58,33 +58,16 @@ something`,
[
{
"breadcrumbs": undefined,
"content": "Index",
"contentWithHighlights": [
{
"content": "Index",
"type": "text",
},
],
"content": "Index
",
"id": "1",
"type": "page",
"url": "/",
},
{
"breadcrumbs": undefined,
"content": "Hello World",
"contentWithHighlights": [
{
"content": "Hello",
"styles": {
"highlight": true,
},
"type": "text",
},
{
"content": " World",
"type": "text",
},
],
"content": "<mark>Hello</mark> World
",
"id": "1-0",
"type": "heading",
"url": "/#hello-world",
+6 -2
View File
@@ -128,12 +128,15 @@
"next-themes": "^0.4.6",
"react-medium-image-zoom": "^5.4.0",
"react-remove-scroll": "^2.7.2",
"rehype-raw": "^7.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@fumadocs/cli": "workspace:*",
"@tailwindcss/cli": "^4.1.18",
"@types/hast": "^3.0.4",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
@@ -141,7 +144,8 @@
"fumadocs-core": "workspace:*",
"tailwindcss": "^4.1.18",
"tsconfig": "workspace:*",
"tsdown": "^0.20.3"
"tsdown": "^0.20.3",
"unified": "^11.0.5"
},
"peerDependencies": {
"@types/react": "*",
@@ -24,6 +24,11 @@ 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 { createMarkdownRenderer } from 'fumadocs-core/content/md';
import rehypeRaw from 'rehype-raw';
import { visit } from 'unist-util-visit';
import type { Transformer } from 'unified';
import type { Root } from 'hast';
export type SearchItemType =
| (BaseResultType & {
@@ -68,6 +73,97 @@ const TagsListContext = createContext<{
allowClear: boolean;
} | null>(null);
const mdRenderer = createMarkdownRenderer({
remarkRehypeOptions: {
allowDangerousHtml: true,
},
rehypePlugins: [rehypeRaw, rehypeCustomElements],
});
const badgeVariant = cva('rounded-md border px-0.5 bg-fd-muted text-fd-muted-foreground', {
variants: {
variant: {
primary: 'bg-fd-primary text-fd-primary-foreground',
},
},
});
const mdComponents = {
mark(props: ComponentProps<'mark'>) {
return <span {...props} className={cn('text-fd-primary underline', props.className)} />;
},
a({ children }: ComponentProps<'a'>) {
return <span>{children}</span>;
},
p(props: ComponentProps<'p'>) {
return <p {...props} className="min-w-0" />;
},
code({ children, className, ...props }: ComponentProps<'pre'>) {
return (
<code
className={cn(
'border rounded-md -mx-0.5 px-0.5 bg-fd-secondary text-fd-secondary-foreground',
className,
)}
{...props}
>
{children}
</code>
);
},
custom({
_tagName,
children,
...rest
}: Record<string, unknown> & { _tagName: string; children: ReactNode }) {
const propNodes = Object.entries(rest).map(([k, v]) => {
if (typeof v !== 'string') return;
return (
<code key={k} className={cn(badgeVariant(), 'truncate')}>
{k}: {v}
</code>
);
});
return (
<span {...rest}>
<span className="flex min-w-0 text-xs items-center gap-2">
<code className={cn(badgeVariant({ variant: 'primary' }))}>{_tagName}</code>
{propNodes}
</span>
{children}
</span>
);
},
pre(props: ComponentProps<'pre'>) {
return (
<pre
{...props}
className={cn(
'border rounded-md p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden',
props.className,
)}
>
{props.children}
</pre>
);
},
};
function rehypeCustomElements(): Transformer<Root, Root> {
return (tree) => {
visit(tree, (node) => {
if (
node.type === 'element' &&
document.createElement(node.tagName) instanceof HTMLUnknownElement
) {
node.properties._tagName = node.tagName;
node.tagName = 'custom';
}
});
};
}
export function SearchDialog({
open,
onOpenChange,
@@ -303,10 +399,13 @@ export function SearchDialogListItem({
item,
className,
children,
renderHighlights: render = renderHighlights,
renderMarkdown = (s) => <mdRenderer.Markdown components={mdComponents}>{s}</mdRenderer.Markdown>,
renderHighlights: _,
...props
}: ComponentProps<'button'> & {
renderHighlights?: typeof renderHighlights;
renderMarkdown?: (v: string) => ReactNode;
/** @deprecated highlight blocks is now wrapped in `<mark />`, use `renderMarkdown` to handle instead. */
renderHighlights?: (blocks: HighlightedText<ReactNode>[]) => ReactNode;
item: SearchItemType;
}) {
const { active: activeId, setActive } = useSearchList();
@@ -329,9 +428,9 @@ export function SearchDialogListItem({
{item.type !== 'page' && (
<div role="none" className="absolute start-3 inset-y-0 w-px bg-fd-border" />
)}
<p
<div
className={cn(
'min-w-0 truncate',
'flex items-center min-w-0',
item.type !== 'page' && 'ps-4',
item.type === 'page' || item.type === 'heading'
? 'font-medium'
@@ -341,8 +440,8 @@ export function SearchDialogListItem({
{item.type === 'heading' && (
<Hash className="inline me-1 size-4 text-fd-muted-foreground" />
)}
{item.contentWithHighlights ? render(item.contentWithHighlights) : item.content}
</p>
{typeof item.content === 'string' ? renderMarkdown(item.content) : item.content}
</div>
</>
);
}
@@ -364,7 +463,7 @@ export function SearchDialogListItem({
)}
aria-selected={active}
className={cn(
'relative select-none px-2.5 py-2 text-start text-sm rounded-lg',
'relative select-none shrink-0 px-2.5 py-2 text-start text-sm overflow-hidden rounded-lg',
active && 'bg-fd-accent text-fd-accent-foreground',
className,
)}
@@ -453,20 +552,6 @@ export function TagsListItem({
);
}
function renderHighlights(highlights: HighlightedText<ReactNode>[]): ReactNode {
return highlights.map((node, i) => {
if (node.styles?.highlight) {
return (
<span key={i} className="text-fd-primary underline">
{node.content}
</span>
);
}
return <Fragment key={i}>{node.content}</Fragment>;
});
}
export function useSearch() {
const ctx = use(Context);
if (!ctx) throw new Error('Missing <SearchDialog />');
+30 -5
View File
@@ -1006,7 +1006,7 @@ importers:
version: 19.2.3(@types/react@19.2.13)
'@vitejs/plugin-rsc':
specifier: ^0.5.19
version: 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.0))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
react-router-devtools:
specifier: ^6.2.0
version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -1552,12 +1552,18 @@ importers:
react-remove-scroll:
specifier: ^2.7.2
version: 2.7.2(@types/react@19.2.13)(react@19.2.4)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
scroll-into-view-if-needed:
specifier: ^3.1.0
version: 3.1.0
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
unist-util-visit:
specifier: ^5.1.0
version: 5.1.0
devDependencies:
'@fumadocs/cli':
specifier: workspace:*
@@ -1565,6 +1571,9 @@ importers:
'@tailwindcss/cli':
specifier: ^4.1.18
version: 4.1.18
'@types/hast':
specifier: ^3.0.4
version: 3.0.4
'@types/node':
specifier: ^25.2.1
version: 25.2.1
@@ -1589,6 +1598,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(oxc-resolver@11.17.0)(typescript@5.9.3)
unified:
specifier: ^11.0.5
version: 11.0.5
packages/cli:
dependencies:
@@ -2331,7 +2343,7 @@ importers:
version: 5.1.3(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc':
specifier: ^0.5.19
version: 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.0))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
commander:
specifier: ^14.0.3
version: 14.0.3
@@ -2496,12 +2508,18 @@ importers:
react-remove-scroll:
specifier: ^2.7.2
version: 2.7.2(@types/react@19.2.13)(react@19.2.4)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
scroll-into-view-if-needed:
specifier: ^3.1.0
version: 3.1.0
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
unist-util-visit:
specifier: ^5.1.0
version: 5.1.0
devDependencies:
'@fumadocs/cli':
specifier: workspace:*
@@ -2509,6 +2527,9 @@ importers:
'@tailwindcss/cli':
specifier: ^4.1.18
version: 4.1.18
'@types/hast':
specifier: ^3.0.4
version: 3.0.4
'@types/node':
specifier: ^25.2.1
version: 25.2.1
@@ -2533,6 +2554,9 @@ importers:
tsdown:
specifier: ^0.20.3
version: 0.20.3(oxc-resolver@11.17.0)(typescript@5.9.3)
unified:
specifier: ^11.0.5
version: 11.0.5
packages/shared:
devDependencies:
@@ -7160,6 +7184,7 @@ packages:
bun@1.3.8:
resolution: {integrity: sha512-TmmtMGQSXY3o0enSMMkvR5g7Dw/2n2oylAUprLVdqA8pDPBoKbzgOmmgHOtvnfRznFppl+Gu2cid3vgAjcNoSg==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -14659,7 +14684,7 @@ snapshots:
vite-node: 3.2.4(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@react-router/serve': 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
'@vitejs/plugin-rsc': 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.0))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc': 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.0)
typescript: 5.9.3
transitivePeerDependencies:
@@ -16189,7 +16214,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-rsc@0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.0))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vitejs/plugin-rsc@0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
es-module-lexer: 2.0.0
@@ -22355,7 +22380,7 @@ snapshots:
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.8)
'@vitejs/plugin-react': 5.1.3(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc': 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.0))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc': 0.5.19(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
dotenv: 17.2.4
hono: 4.11.8
magic-string: 0.30.21