Core: use mdast-util-to-markdown for accurate stringification

This commit is contained in:
Fuma Nama
2026-02-09 09:46:17 -08:00
committed by zeekay
parent 2ab7ebfb50
commit 8b325330fd
17 changed files with 474 additions and 2410 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
use `mdast-util-to-markdown` for accurate stringification
-3
View File
@@ -59,9 +59,6 @@ export const docs = defineDocs({
shiki: shikiConfig,
};
return applyMdxPreset({
remarkStructureOptions: {
types: [...remarkStructureDefaultOptions.types, 'code'],
},
rehypeCodeOptions: isLint
? false
: {
+2 -6
View File
@@ -132,6 +132,8 @@
"hast-util-to-estree": "^3.1.3",
"hast-util-to-jsx-runtime": "^2.3.6",
"image-size": "^2.0.2",
"mdast-util-mdx": "^3.0.0",
"mdast-util-to-markdown": "^2.1.2",
"negotiator": "^1.0.0",
"npm-to-yarn": "^3.0.1",
"path-to-regexp": "^8.3.0",
@@ -161,8 +163,6 @@
"algoliasearch": "5.48.0",
"eslint-config-custom": "workspace:*",
"lucide-react": "^0.563.0",
"mdast-util-mdx-jsx": "^3.2.0",
"mdast-util-mdxjs-esm": "^2.0.1",
"next": "16.1.6",
"react-router": "^7.13.0",
"remark-directive": "^4.0.0",
@@ -186,7 +186,6 @@
"@types/react": "*",
"algoliasearch": "5.x.x",
"lucide-react": "*",
"mdast-util-mdx-jsx": "*",
"next": "16.x.x",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -195,9 +194,6 @@
"zod": "4.x.x"
},
"peerDependenciesMeta": {
"mdast-util-mdx-jsx": {
"optional": true
},
"@mdx-js/mdx": {
"optional": true
},
@@ -1,4 +1,4 @@
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import type { BlockContent, Text } from 'mdast';
export interface CodeBlockTabsOptions {
@@ -9,7 +9,7 @@ import {
} from '@shikijs/transformers';
import type { Processor, Transformer } from 'unified';
import type { Highlighter, ShikiTransformer } from 'shiki';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import type { CodeBlockIcon, IconOptions } from './transformer-icon';
import { transformerIcon } from './transformer-icon';
import { parseCodeBlockAttributes } from '@/mdx-plugins/codeblock-utils';
@@ -1,7 +1,7 @@
import type { Processor, Transformer } from 'unified';
import type { BlockContent, Code, DefinitionContent, Root } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import { generateCodeBlockTabs, parseCodeBlockAttributes } from '@/mdx-plugins/codeblock-utils';
type TabType = keyof typeof Types;
@@ -1,5 +1,5 @@
import type { BlockContent, DefinitionContent, PhrasingContent, Root } from 'mdast';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
@@ -1,6 +1,6 @@
import type { Transformer } from 'unified';
import type { BlockContent, Root, RootContent } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
import { createHash } from 'node:crypto';
import { flattenNode } from './mdast-utils';
@@ -2,9 +2,9 @@ import * as path from 'node:path';
import type { Image, Root } from 'mdast';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import type { MdxjsEsm } from 'mdast-util-mdxjs-esm';
import type { MdxjsEsm } from 'mdast-util-mdx';
import type { ISizeCalculationResult } from 'image-size/types/interface';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { fileURLToPath } from 'node:url';
const VALID_BLUR_EXT = ['.jpeg', '.png', '.webp', '.avif', '.jpg'];
@@ -1,7 +1,7 @@
import type { Root } from 'mdast';
import { visit } from 'unist-util-visit';
import type { Transformer } from 'unified';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import type { VFile } from 'vfile';
import path from 'node:path';
@@ -1,7 +1,7 @@
import { visit } from 'unist-util-visit';
import type { Transformer } from 'unified';
import type { Root } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
function toMDX(code: string): MdxJsxFlowElement {
return {
@@ -1,7 +1,7 @@
import type { Transformer } from 'unified';
import type { BlockContent, Heading, Root, RootContent } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
export interface RemarkStepsOptions {
/**
@@ -2,14 +2,17 @@ import Slugger from 'github-slugger';
import type { Nodes, Root } from 'mdast';
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import type { PluggableList, Transformer } from 'unified';
import type { PluggableList, Processor, Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { flattenNode, toMdxExport } from './mdast-utils';
import type {
MdxJsxAttribute,
MdxJsxExpressionAttribute,
MdxJsxFlowElement,
} from 'mdast-util-mdx-jsx';
import {
mdxToMarkdown,
type MdxJsxAttribute,
type MdxJsxExpressionAttribute,
type MdxJsxFlowElement,
type MdxJsxTextElement,
} from 'mdast-util-mdx';
import { type Handle, toMarkdown } from 'mdast-util-to-markdown';
interface Heading {
id: string;
@@ -31,14 +34,21 @@ export interface StructuredData {
export interface StructureOptions {
/**
* Types to be scanned as content.
* MDAST **block** types to be scanned as content.
*
* If a node's type is represented in this array, it will be stringified and its children will not be scanned.
*
* @defaultValue ['heading', 'paragraph', 'blockquote', 'tableCell', 'mdxJsxFlowElement']
*/
types?: string[] | ((node: Nodes) => boolean);
/**
* A list of indexable MDX attributes, either:
* stringify a given node & its children, you can use something like `mdast-util-to-markdown`.
*/
stringify?: (this: Processor, node: Nodes) => string;
/**
* By default, it will not index MDX attributes. You can define a list of MDX attributes to index, either:
*
* - an array of attribute names.
* - a function that determines if attribute should be indexed.
@@ -46,12 +56,12 @@ export interface StructureOptions {
allowedMdxAttributes?:
| string[]
| ((
node: MdxJsxFlowElement,
node: MdxJsxFlowElement | MdxJsxTextElement,
attribute: MdxJsxAttribute | MdxJsxExpressionAttribute,
) => boolean);
/**
* export as `structuredData` or specified variable name.
* export as `structuredData` (if true) or specified variable name.
*/
exportAs?: string | boolean;
}
@@ -76,24 +86,32 @@ declare module 'vfile' {
export const remarkStructureDefaultOptions = {
types: ['heading', 'paragraph', 'blockquote', 'tableCell', 'mdxJsxFlowElement'],
allowedMdxAttributes: (node) => {
if (!node.name) return false;
return ['TypeTable', 'Callout'].includes(node.name);
allowedMdxAttributes(node) {
switch (node.name) {
case 'TypeTable':
case 'Callout':
return true;
default:
return false;
}
},
exportAs: false,
} satisfies Required<StructureOptions>;
} satisfies StructureOptions;
/**
* Extract content into structured data.
*
* By default, the output is stored into VFile (`vfile.data.structuredData`), you can specify `exportAs` to export it.
*/
export function remarkStructure({
types = remarkStructureDefaultOptions.types,
allowedMdxAttributes = remarkStructureDefaultOptions.allowedMdxAttributes,
exportAs = remarkStructureDefaultOptions.exportAs,
}: StructureOptions = {}): Transformer<Root, Root> {
export function remarkStructure(
this: Processor,
{
types = remarkStructureDefaultOptions.types,
stringify,
allowedMdxAttributes = remarkStructureDefaultOptions.allowedMdxAttributes,
exportAs = remarkStructureDefaultOptions.exportAs,
}: StructureOptions = {},
): Transformer<Root, Root> {
const slugger = new Slugger();
if (Array.isArray(allowedMdxAttributes)) {
@@ -107,6 +125,29 @@ export function remarkStructure({
types = (node) => arr.includes(node.type);
}
stringify ??= (root) => {
const { mdxJsxFlowElement } = mdxToMarkdown({ tightSelfClosing: true }).extensions![1]
.handlers!;
const wrapper: Handle = (node: MdxJsxFlowElement | MdxJsxTextElement, ...rest) => {
const originalAttributes = node.attributes;
if (allowedMdxAttributes)
node.attributes = node.attributes.filter((attr) => allowedMdxAttributes(node, attr));
const s = mdxJsxFlowElement!(node, ...rest);
node.attributes = originalAttributes;
return s;
};
return toMarkdown(root, {
...this.data('settings'),
// from https://github.com/remarkjs/remark/blob/main/packages/remark-stringify/lib/index.js
extensions: this.data('toMarkdownExtensions') ?? [],
handlers: {
mdxJsxFlowElement: wrapper,
mdxJsxTextElement: wrapper,
},
});
};
return (tree, file) => {
slugger.reset();
const data: StructuredData = { contents: [], headings: [] };
@@ -156,32 +197,14 @@ export function remarkStructure({
return 'skip';
}
if (element.type === 'mdxJsxFlowElement' && element.name) {
data.contents.push(
...element.attributes.flatMap((attribute) => {
const value =
typeof attribute.value === 'string' ? attribute.value : attribute.value?.value;
if (!value || value.length === 0) return [];
if (allowedMdxAttributes && !allowedMdxAttributes(element, attribute)) return [];
return {
heading: lastHeading,
content: attribute.type === 'mdxJsxAttribute' ? `${attribute.name}: ${value}` : value,
};
}),
);
return;
const content = stringify.call(this, element).trim();
if (content.length > 0) {
data.contents.push({
heading: lastHeading,
content,
});
}
const content = flattenNode(element).trim();
if (content.length === 0) return;
data.contents.push({
heading: lastHeading,
content,
});
return 'skip';
});
@@ -17,3 +17,11 @@ function main() {
console.log('indexed');
}
```
<Card title="Test">wow</Card>
<Card title="Test" className="test-sm">
wow
</Card>
+19 -11
View File
@@ -1,42 +1,50 @@
{
"contents": [
{
"content": "Some text here",
"heading": "heading-1",
"content": "Some text here"
},
{
"content": "Some text here",
"heading": "heading-2",
"content": "Some text here"
},
{
"content": "Name",
"heading": "heading-3",
"content": "Name"
},
{
"content": "Description",
"heading": "heading-3",
"content": "Description"
},
{
"content": "Hello World",
"heading": "heading-3",
"content": "Hello World"
},
{
"content": "Goodbye",
"heading": "heading-3",
"content": "Goodbye"
},
{
"heading": "heading-3",
"content": "<Card title=\"Test\">wow</Card>"
},
{
"heading": "heading-3",
"content": "<Card title=\"Test\">\n wow\n</Card>"
}
],
"headings": [
{
"content": "Heading 1",
"id": "heading-1",
"content": "Heading 1"
},
{
"content": "Heading 2",
"id": "heading-2",
"content": "Heading 2"
},
{
"content": "Heading 3",
"id": "heading-3",
},
],
"content": "Heading 3"
}
]
}
+7 -3
View File
@@ -46,10 +46,14 @@ test('Remark Mdx Files', async () => {
});
test('Remark Structure', async () => {
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-structure.md'));
const result = await remark().use(remarkGfm).use(remarkStructure).process(content);
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-structure.mdx'));
const result = await remark()
.use(remarkGfm)
.use(remarkMdx)
.use(remarkStructure, { allowedMdxAttributes: ['title'] })
.process(content);
await expect(result.data.structuredData).toMatchFileSnapshot(
await expect(JSON.stringify(result.data.structuredData, null, 2)).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-structure.output.json'),
);
});
+356 -2333
View File
File diff suppressed because it is too large Load Diff