mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-07-28 00:15:01 +00:00
Core & UI: support Markdown in search results
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
'fumadocs-core': minor
|
||||
'@fumadocs/base-ui': minor
|
||||
---
|
||||
|
||||
Support Markdown in search results
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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 />');
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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)
|
||||
|
||||

|
||||
|
||||
</FeedbackBlock>
|
||||
|
||||
+46
-2
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />');
|
||||
|
||||
Generated
+30
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user