Core & UI: Improve inline elements in search

This commit is contained in:
Fuma Nama
2026-02-12 19:27:47 +08:00
parent e8a69f0620
commit 64a0057fdb
8 changed files with 201 additions and 135 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
[Remark Feedback] skip MDX elements by default to avoid interfering with component logic
@@ -7,7 +7,7 @@ description: Extract information from your documents, useful for implementing do
Add it as a remark plugin.
```ts title="MDX Compiler"
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkStructure } from 'fumadocs-core/mdx-plugins';
@@ -16,7 +16,17 @@ const vfile = await compile('...', {
});
```
> This plugin is included by default on Fumadocs MDX.
```ts tab="Fumadocs MDX" title="source.config.ts"
import { defineConfig } from 'fumadocs-mdx/config';
export default defineConfig({
mdxOptions: {
// This plugin is enabled by default on Fumadocs MDX
// options:
remarkStructureOptions: {},
},
});
```
Extracted information could be found in `vfile.data.structuredData`, you may
write your own plugin to convert it into a MDX export.
@@ -40,14 +50,16 @@ array, each item contains a `heading` prop indicating the heading of paragraph.
| Prop | |
| --------- | ------------------------------------ |
| `id` | unique identifier or slug of heading |
| `content` | Text content |
| `content` | Text content (Markdown) |
#### Content
| Prop | |
| --------- | ------------------------------- |
| `heading` | Heading of paragraph (nullable) |
| `content` | Text content |
| `content` | Text content (Markdown) |
The content is stringified as Markdown text, you can customise it using the [`stringify`](#options) option.
## As a Function
+22 -9
View File
@@ -4,7 +4,6 @@ import type { ElementContent } from 'hast';
import jsonSchema from 'fumadocs-mdx/plugins/json-schema';
import lastModified from 'fumadocs-mdx/plugins/last-modified';
import type { ShikiTransformer } from 'shiki';
import type { RemarkFeedbackBlockOptions } from 'fumadocs-core/mdx-plugins';
import type { RemarkAutoTypeTableOptions } from 'fumadocs-typescript';
import { shikiConfig } from './lib/shiki';
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
@@ -43,13 +42,6 @@ export const docs = defineDocs({
const { remarkAutoTypeTable, createGenerator, createFileSystemGeneratorCache } =
await import('fumadocs-typescript');
const feedbackOptions: RemarkFeedbackBlockOptions = {
resolve(node) {
// defensive approach
if (node.type === 'mdxJsxFlowElement') return 'skip';
return node.type === 'paragraph' || node.type === 'image' || node.type === 'list';
},
};
const typeTableOptions: RemarkAutoTypeTableOptions = {
generator: createGenerator({
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
@@ -77,6 +69,27 @@ export const docs = defineDocs({
remarkCodeTabOptions: {
parseMdx: true,
},
remarkStructureOptions: {
stringify: {
filterElement(node) {
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement':
switch (node.name) {
case 'File':
case 'TypeTable':
case 'Callout':
case 'Card':
case 'Custom':
return true;
}
return 'children-only';
}
return true;
},
},
},
remarkNpmOptions: {
persist: {
id: 'package-manager',
@@ -87,7 +100,7 @@ export const docs = defineDocs({
: [
remarkSteps,
remarkMath,
[remarkFeedbackBlock, feedbackOptions],
remarkFeedbackBlock,
[remarkAutoTypeTable, typeTableOptions],
remarkTypeScriptToJavaScript,
],
@@ -82,60 +82,56 @@ const mdRenderer = createMarkdownRenderer({
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>;
},
strong(props: ComponentProps<'strong'>) {
return <strong {...props} className="font-medium" />;
return <span {...props} className="text-fd-primary underline" />;
},
a: 'span',
p(props: ComponentProps<'p'>) {
return <p {...props} className="min-w-0" />;
},
strong(props: ComponentProps<'strong'>) {
return <strong {...props} className="text-fd-accent-foreground font-medium" />;
},
code(props: ComponentProps<'pre'>) {
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is a component
const inPre = use(PreContext);
if (inPre) return <code {...props} />;
if (inPre)
return (
<code
{...props}
className="mask-[linear-gradient(to_bottom,white,white_30px,transparent_80px)]"
/>
);
return (
<code
{...props}
className="border rounded-md -mx-0.5 px-0.5 bg-fd-secondary text-fd-secondary-foreground"
className="border rounded-md px-px bg-fd-secondary text-fd-secondary-foreground"
/>
);
},
custom({
_tagName,
_tagName = 'fragment',
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 className="inline-flex max-w-full items-center border p-0.5 rounded-md bg-fd-card text-fd-card-foreground divide-x divide-fd-border">
<code className="rounded-sm px-0.5 me-1 bg-fd-primary font-medium text-xs text-fd-primary-foreground border-none">
{_tagName}
</code>
{Object.entries(rest).map(([k, v]) => {
if (typeof v !== 'string') return;
return (
<code key={k} className="truncate text-xs text-fd-muted-foreground px-1">
<span className="text-fd-card-foreground">{k}: </span>
{v}
</code>
);
})}
{children && <span className="ps-1">{children}</span>}
</span>
);
},
@@ -144,7 +140,7 @@ const mdComponents = {
<pre
{...props}
className={cn(
'flex flex-col border rounded-md my-0.5 p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden *:mask-[linear-gradient(to_bottom,white,white_30px,transparent_80px)]',
'flex flex-col border rounded-md my-0.5 p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden',
props.className,
)}
>
@@ -178,7 +174,11 @@ export function SearchDialog({
children,
}: SearchDialogProps) {
const router = useRouter();
const onSelect = useEffectEvent((item: SearchItemType) => {
const onOpenChangeCallback = useRef(onOpenChange);
onOpenChangeCallback.current = onOpenChange;
const onSearchChangeCallback = useRef(onSearchChange);
onSearchChangeCallback.current = onSearchChange;
const onSelect = (item: SearchItemType) => {
if (item.type === 'action') {
item.onSelect();
} else if (item.external) {
@@ -189,7 +189,9 @@ export function SearchDialog({
onOpenChange(false);
onSelectProp?.(item);
});
};
const onSelectCallback = useRef(onSelect);
onSelectCallback.current = onSelect;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
@@ -197,14 +199,13 @@ export function SearchDialog({
value={useMemo(
() => ({
open,
onOpenChange,
search,
onSearchChange,
// eslint-disable-next-line react-hooks/rules-of-hooks -- used in child components
onSelect,
isLoading,
onOpenChange: (v) => onOpenChangeCallback.current(v),
onSearchChange: (v) => onSearchChangeCallback.current(v),
onSelect: (v) => onSelectCallback.current(v),
}),
[isLoading, onOpenChange, onSearchChange, open, search],
[isLoading, open, search],
)}
>
{children}
@@ -492,7 +493,6 @@ export function SearchDialogListItem({
</button>
);
}
export function SearchDialogIcon(props: ComponentProps<'svg'>) {
const { isLoading } = useSearch();
@@ -524,18 +524,19 @@ const itemVariants = cva(
},
},
);
export function TagsList({ tag, onTagChange, allowClear = false, ...props }: TagsListProps) {
const onTagChangeCallback = useRef(onTagChange);
onTagChangeCallback.current = onTagChange;
return (
<div {...props} className={cn('flex items-center gap-1 flex-wrap', props.className)}>
<TagsListContext
value={useMemo(
() => ({
value: tag,
onValueChange: onTagChange,
onValueChange: (v) => onTagChangeCallback.current(v),
allowClear,
}),
[allowClear, onTagChange, tag],
[allowClear, tag],
)}
>
{props.children}
@@ -559,9 +560,7 @@ export function TagsListItem({
type="button"
data-active={selected}
className={cn(itemVariants({ active: selected, className }))}
onClick={() => {
onValueChange(selected && allowClear ? undefined : value);
}}
onClick={() => onValueChange(selected && allowClear ? undefined : value)}
tabIndex={-1}
{...props}
>
-1
View File
@@ -8,7 +8,6 @@ export default [
{
rules: {
'no-console': 'off',
'no-fallthrough': 'off',
// handled by bundler
'import/no-cycle': 'off',
},
@@ -19,7 +19,7 @@ export interface RemarkFeedbackBlockOptions {
/**
* determine how the node should be resolved into a feedback block.
*
* scan paragraph, list, and image nodes by default.
* default: skip MDX elements, convert paragraph, list item, and image nodes.
*
* @returns
* - `true`: convert the node into a feedback block.
@@ -50,20 +50,31 @@ export interface FeedbackBlockProps {
export function remarkFeedbackBlock({
generateHash = ({ body }) => createHash('md5').update(body).digest('hex').substring(0, 16),
tagName = 'FeedbackBlock',
resolve = (node) => node.type === 'paragraph' || node.type === 'image' || node.type === 'list',
resolve = (node) => {
switch (node.type) {
case 'mdxJsxFlowElement':
return 'skip';
case 'paragraph':
case 'image':
case 'listItem':
return true;
default:
return false;
}
},
generateBody = true,
}: RemarkFeedbackBlockOptions = {}): Transformer<Root, Root> {
return (tree) => {
const counts = new Map<string, number>();
visit(tree, (node, index, parent) => {
if (node.type === 'root') return;
if (node.type === 'root' || !parent || typeof index !== 'number') return;
const resolved = resolve(node);
if (resolved === false) return;
if (resolved === 'skip') return 'skip';
const text = flattenNode(node).trim();
if (text.length === 0 || !parent || typeof index !== 'number') return;
if (text.length === 0) return;
let id = generateHash({ body: text });
const count = counts.get(id) ?? 0;
if (count > 0) id = `${id}-${count}`;
@@ -1,9 +1,9 @@
import type { Link, Nodes, Root } from 'mdast';
import type { Heading, Link, Nodes, Root } from 'mdast';
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import type { PluggableList, Processor, Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { flattenNode, toMdxExport } from './mdast-utils';
import { toMdxExport } from './mdast-utils';
import type {
MdxJsxAttribute,
MdxJsxExpressionAttribute,
@@ -37,13 +37,34 @@ interface StringifierContext {
addContent: (...content: StructuredDataContent[]) => void;
}
interface StringifyOptions {
export interface StringifyOptions {
/**
* Filter the elements to be included in the output:
*
* - `true`: include element & its children.
* - `children-only`: exclude element but keep its children.
* - `false`: exclude element & its children.
*
* Default:
*
* ```ts
* 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;
* },
* ```
*/
filterElement?: (node: Nodes) => boolean | 'children-only';
@@ -60,14 +81,14 @@ export interface StructureOptions {
/**
* MDAST node types to be scanned as a content block.
*
* If a node's type represents in this array, it will be converted into a single content block.
* If a node's type is listed in this array, it will be converted into a single content block.
*
* @defaultValue ['heading', 'paragraph', 'blockquote', 'tableCell', 'mdxJsxFlowElement']
*/
types?: string[] | ((node: Nodes) => boolean);
/**
* stringify a given node & its children, you can use something like `mdast-util-to-markdown`.
* stringify text content from a MDAST node.
*/
stringify?: Stringifier | StringifyOptions;
@@ -148,7 +169,7 @@ export function remarkStructure(
const stringify =
typeof stringifyOptions === 'function'
? stringifyOptions
: defaultStringify({
: defaultStringifier({
filterMdxAttributes: Array.isArray(allowedMdxAttributes)
? (_node, attribute) =>
attribute.type === 'mdxJsxAttribute' &&
@@ -191,26 +212,28 @@ export function remarkStructure(
case 'mdxJsxTextElement':
if (!mdxTypes(element)) return;
break;
}
case 'heading': {
element.data ||= {};
element.data.hProperties ||= {};
const id = element.data.hProperties.id;
if (typeof id !== 'string') {
console.warn(
'[remark-structure] hProperties.id is missing in heading node, it is required to generate heading data. You can add remark-heading prior to remark-structure to generate heading IDs.',
);
return 'skip';
}
if (element.type === 'heading') {
element.data ||= {};
element.data.hProperties ||= {};
const id = element.data.hProperties.id;
if (typeof id !== 'string') {
console.warn(
'[remark-structure] hProperties.id is missing in heading node, it is required to generate heading data. You can add remark-heading prior to remark-structure to generate heading IDs.',
);
const content = stringify.call(this, element, stringifierCtx).trim();
if (content.length > 0) {
data.headings.push({
id,
content,
});
}
lastHeading = id;
return 'skip';
}
data.headings.push({
id,
content: flattenNode(element).trim(),
});
lastHeading = id;
return 'skip';
}
const content = stringify.call(this, element, stringifierCtx).trim();
@@ -257,7 +280,7 @@ interface CustomRootNode {
ctx: StringifierContext;
}
export function defaultStringify(config: Options & StringifyOptions = {}): Stringifier {
export function defaultStringifier(config: Options & StringifyOptions = {}): Stringifier {
const {
filterMdxAttributes,
filterElement = (node) => {
@@ -283,7 +306,7 @@ export function defaultStringify(config: Options & StringifyOptions = {}): Strin
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;
const visibility = filterElement(node);
if (visibility === false) return '';
@@ -330,6 +353,9 @@ export function defaultStringify(config: Options & StringifyOptions = {}): Strin
link(node: Link, _, state, info) {
return state.containerPhrasing(node, info);
},
heading(node: Heading, _, state, info) {
return state.containerPhrasing(node, info);
},
image() {
return '';
},
@@ -348,7 +374,7 @@ export function defaultStringify(config: Options & StringifyOptions = {}): Strin
// from https://github.com/remarkjs/remark/blob/main/packages/remark-stringify/lib/index.js
const defaultExtensions = this.data('toMarkdownExtensions') ?? [];
return toMarkdown({ type: '_custom', root, ctx } as never, {
return toMarkdown({ type: '_custom', root, ctx } satisfies CustomRootNode as never, {
...this.data('settings'),
...config,
extensions: config.extensions
@@ -82,21 +82,11 @@ const mdRenderer = createMarkdownRenderer({
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>;
return <span {...props} className="text-fd-primary underline" />;
},
a: 'span',
p(props: ComponentProps<'p'>) {
return <p {...props} className="min-w-0" />;
},
@@ -106,36 +96,42 @@ const mdComponents = {
code(props: ComponentProps<'pre'>) {
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is a component
const inPre = use(PreContext);
if (inPre) return <code {...props} />;
if (inPre)
return (
<code
{...props}
className="mask-[linear-gradient(to_bottom,white,white_30px,transparent_80px)]"
/>
);
return (
<code
{...props}
className="border rounded-md -mx-0.5 px-0.5 bg-fd-secondary text-fd-secondary-foreground"
className="border rounded-md px-px bg-fd-secondary text-fd-secondary-foreground"
/>
);
},
custom({
_tagName,
_tagName = 'fragment',
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 className="inline-flex max-w-full items-center border p-0.5 rounded-md bg-fd-card text-fd-card-foreground divide-x divide-fd-border">
<code className="rounded-sm px-0.5 me-1 bg-fd-primary font-medium text-xs text-fd-primary-foreground border-none">
{_tagName}
</code>
{Object.entries(rest).map(([k, v]) => {
if (typeof v !== 'string') return;
return (
<code key={k} className="truncate text-xs text-fd-muted-foreground px-1">
<span className="text-fd-card-foreground">{k}: </span>
{v}
</code>
);
})}
{children && <span className="ps-1">{children}</span>}
</span>
);
},
@@ -144,7 +140,7 @@ const mdComponents = {
<pre
{...props}
className={cn(
'flex flex-col border rounded-md my-0.5 p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden *:mask-[linear-gradient(to_bottom,white,white_30px,transparent_80px)]',
'flex flex-col border rounded-md my-0.5 p-2 bg-fd-secondary text-fd-secondary-foreground max-h-20 overflow-hidden',
props.className,
)}
>
@@ -178,7 +174,11 @@ export function SearchDialog({
children,
}: SearchDialogProps) {
const router = useRouter();
const onSelect = useEffectEvent((item: SearchItemType) => {
const onOpenChangeCallback = useRef(onOpenChange);
onOpenChangeCallback.current = onOpenChange;
const onSearchChangeCallback = useRef(onSearchChange);
onSearchChangeCallback.current = onSearchChange;
const onSelect = (item: SearchItemType) => {
if (item.type === 'action') {
item.onSelect();
} else if (item.external) {
@@ -189,7 +189,9 @@ export function SearchDialog({
onOpenChange(false);
onSelectProp?.(item);
});
};
const onSelectCallback = useRef(onSelect);
onSelectCallback.current = onSelect;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -197,14 +199,13 @@ export function SearchDialog({
value={useMemo(
() => ({
open,
onOpenChange,
search,
onSearchChange,
// eslint-disable-next-line react-hooks/rules-of-hooks -- used in child components
onSelect,
isLoading,
onOpenChange: (v) => onOpenChangeCallback.current(v),
onSearchChange: (v) => onSearchChangeCallback.current(v),
onSelect: (v) => onSelectCallback.current(v),
}),
[isLoading, onOpenChange, onSearchChange, open, search],
[isLoading, open, search],
)}
>
{children}
@@ -513,16 +514,18 @@ const itemVariants = cva(
);
export function TagsList({ tag, onTagChange, allowClear = false, ...props }: TagsListProps) {
const onTagChangeCallback = useRef(onTagChange);
onTagChangeCallback.current = onTagChange;
return (
<div {...props} className={cn('flex items-center gap-1 flex-wrap', props.className)}>
<TagsListContext
value={useMemo(
() => ({
value: tag,
onValueChange: onTagChange,
onValueChange: (v) => onTagChangeCallback.current(v),
allowClear,
}),
[allowClear, onTagChange, tag],
[allowClear, tag],
)}
>
{props.children}
@@ -546,9 +549,7 @@ export function TagsListItem({
type="button"
data-active={selected}
className={cn(itemVariants({ active: selected, className }))}
onClick={() => {
onValueChange(selected && allowClear ? undefined : value);
}}
onClick={() => onValueChange(selected && allowClear ? undefined : value)}
tabIndex={-1}
{...props}
>