Core: Expose Markdown stringifier

This commit is contained in:
Fuma Nama
2026-03-13 16:23:04 +08:00
parent f06540672f
commit 417f07aaae
4 changed files with 202 additions and 160 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Expose Markdown stringifier
+17 -160
View File
@@ -1,18 +1,21 @@
import type { Heading, Link, Nodes, Root } from 'mdast';
import type { 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 { toMdxExport } from './mdast-utils';
import {
mdxToMarkdown,
type MdxJsxAttribute,
type MdxJsxExpressionAttribute,
type MdxJsxFlowElement,
type MdxJsxTextElement,
} from 'mdast-util-mdx';
import { type Handle, type Options, toMarkdown } from 'mdast-util-to-markdown';
import { remarkHeading } from './remark-heading';
import {
type Stringifier as BaseStringifier,
type StringifyOptions as BaseStringifyOptions,
defaultStringifier as defaultBaseStringifier,
} from './stringifier';
interface StructuredDataHeading {
id: string;
@@ -32,52 +35,6 @@ export interface StructuredData {
contents: StructuredDataContent[];
}
type Stringifier = (this: Processor, node: Nodes, ctx: StringifierContext) => string;
interface StringifierContext {
addContent: (...content: StructuredDataContent[]) => void;
}
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';
/**
* Filter the attributes to stringify.
*/
filterMdxAttributes?: (
node: MdxJsxFlowElement | MdxJsxTextElement,
attribute: MdxJsxAttribute | MdxJsxExpressionAttribute,
) => boolean;
}
export interface StructureOptions {
/**
* MDAST node types to be scanned as a content block.
@@ -116,11 +73,6 @@ export interface StructureOptions {
declare module 'mdast' {
interface Data {
/**
* [Fumadocs: remark-structure] The stringified form of node for generating search index.
*/
_string?: string | (() => string);
/**
* [Fumadocs: remark-structure] Items to add to the structured data.
*/
@@ -276,113 +228,18 @@ export function structure(
return result.data.structuredData!;
}
interface CustomRootNode {
type: '_custom';
root: Nodes;
ctx: StringifierContext;
interface StringifierContext {
addContent: (...content: StructuredDataContent[]) => void;
}
export type Stringifier = BaseStringifier<StringifierContext>;
export type StringifyOptions = BaseStringifyOptions<StringifierContext>;
export function defaultStringifier(config: Options & StringifyOptions = {}): Stringifier {
const {
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;
},
...customExtension
} = config;
function modHandler(handler: Handle, ctx: StringifierContext): Handle {
return function (node: Nodes, parent, state, info) {
export function defaultStringifier(config: StringifyOptions): Stringifier {
return defaultBaseStringifier<StringifierContext>({
...config,
onStringify(node, ctx) {
if (node.data?.structuredData) ctx.addContent(...node.data.structuredData.contents);
if (node.data?._string)
return typeof node.data._string === 'function' ? node.data._string() : node.data._string;
const visibility = filterElement(node);
if (visibility === false) return '';
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement': {
if (visibility === 'children-only') {
return node.type === 'mdxJsxTextElement'
? state.containerPhrasing(node, info)
: state.containerFlow(node, info);
}
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;
stringifiedAttributes.push({
...attr,
value: str,
});
}
const temp = node.attributes;
node.attributes = stringifiedAttributes;
const s = handler(node, parent, state, info);
node.attributes = temp;
return s;
}
default:
if (visibility === 'children-only')
return 'children' in node
? state.containerFlow({ type: 'root', children: node.children }, info)
: '';
return handler(node, parent, state, info);
}
};
}
const customToMarkdown: Options = {
handlers: {
link(node: Link, _, state, info) {
return state.containerPhrasing(node, info);
},
heading(node: Heading, _, 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], node.ctx);
}
return state.handle(node.root, undefined, state, info);
},
} as Record<string, Handle>,
};
return function (root, ctx) {
return toMarkdown({ type: '_custom', root, ctx } satisfies CustomRootNode as never, {
...this.data('settings'),
extensions: [
mdxToMarkdown(),
...(this.data('toMarkdownExtensions') ?? []),
customToMarkdown,
customExtension,
],
});
};
config.onStringify?.(node, ctx);
},
});
}
@@ -0,0 +1,179 @@
import type { Nodes, Link, Heading } from 'mdast';
import {
type MdxJsxFlowElement,
type MdxJsxTextElement,
type MdxJsxAttribute,
type MdxJsxExpressionAttribute,
mdxToMarkdown,
} from 'mdast-util-mdx';
import { type Handle, type Options, toMarkdown } from 'mdast-util-to-markdown';
import type { Processor } from 'unified';
declare module 'mdast' {
interface Data {
/**
* [Fumadocs: stringify] The stringified form of node, used for generating search index, `llms.txt`, etc.
*/
_string?: string | (() => string);
}
}
export type Stringifier<Context> =
| ((this: Processor, node: Nodes, ctx: Context) => string)
| ((node: Nodes, ctx: Context) => string);
export interface StringifyOptions<Context = undefined> extends Options {
/**
* 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';
/**
* Filter the attributes to stringify.
*/
filterMdxAttributes?: (
node: MdxJsxFlowElement | MdxJsxTextElement,
attribute: MdxJsxAttribute | MdxJsxExpressionAttribute,
) => boolean;
onStringify?: (node: Nodes, ctx: Context) => void;
}
interface CustomRootNode<Context> {
type: '_custom';
root: Nodes;
ctx: Context;
}
export function defaultStringifier<Context>(
config: StringifyOptions<Context> = {},
): Stringifier<Context> {
const {
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;
},
onStringify,
...customExtension
} = config;
function modHandler(handler: Handle, ctx: Context): Handle {
return function (node: Nodes, parent, state, info) {
if (onStringify) onStringify(node, ctx);
if (node.data?._string)
return typeof node.data._string === 'function' ? node.data._string() : node.data._string;
const visibility = filterElement(node);
if (visibility === false) return '';
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement': {
if (visibility === 'children-only') {
return node.type === 'mdxJsxTextElement'
? state.containerPhrasing(node, info)
: state.containerFlow(node, info);
}
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;
stringifiedAttributes.push({
...attr,
value: str,
});
}
const temp = node.attributes;
node.attributes = stringifiedAttributes;
const s = handler(node, parent, state, info);
node.attributes = temp;
return s;
}
default:
if (visibility === 'children-only')
return 'children' in node
? state.containerFlow({ type: 'root', children: node.children }, info)
: '';
return handler(node, parent, state, info);
}
};
}
const customToMarkdown: Options = {
handlers: {
link(node: Link, _, state, info) {
return state.containerPhrasing(node, info);
},
heading(node: Heading, _, state, info) {
return state.containerPhrasing(node, info);
},
image() {
return '';
},
_custom(node: CustomRootNode<Context>, _, state, info) {
const handlers: Record<string, Handle> = state.handlers;
for (const k in handlers) {
handlers[k] = modHandler(handlers[k], node.ctx);
}
return state.handle(node.root, undefined, state, info);
},
} as Record<string, Handle>,
};
return function (this: Processor | undefined, root, ctx) {
return toMarkdown({ type: '_custom', root, ctx } satisfies CustomRootNode<Context> as never, {
...this?.data('settings'),
extensions: [
mdxToMarkdown(),
...(this?.data('toMarkdownExtensions') ?? []),
customToMarkdown,
customExtension,
],
});
};
}
+1
View File
@@ -25,6 +25,7 @@ export default defineConfig({
'src/mdx-plugins/{index,codeblock-utils}.ts',
'src/mdx-plugins/remark-*.ts',
'src/mdx-plugins/rehype-*.ts',
'src/mdx-plugins/stringifier.ts',
'src/framework/*',
],
deps: {