Skip to content

Commit d330716

Browse files
authored
fix(ssg-md): preserve mdxFlowExpression nodes in remarkSplitMdx (#3181)
1 parent 505ced6 commit d330716

File tree

5 files changed

+172
-13
lines changed

5 files changed

+172
-13
lines changed

e2e/fixtures/react-18/index.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,13 @@ test('React 18 build should be successful', async () => {
6262
const docBuildDir = path.join(appDir, 'doc_build');
6363
const indexHtml = readFileSync(path.join(docBuildDir, 'index.md'), 'utf-8');
6464

65-
// const reactVersion = getPackageVersion('react');
66-
// const routerVersion = getPackageVersion('react-router-dom');
65+
const reactVersion = getPackageVersion('react');
66+
const routerVersion = getPackageVersion('react-router-dom');
6767

6868
// Verify SSG-MD rendered correctly with React 18 (versions should be resolved, not raw {version})
69-
// FIXME: should be "react 18"
70-
expect(indexHtml).toContain(`react {reactVersion}`);
71-
expect(indexHtml).toContain(`react-router-dom {version}`);
69+
expect(indexHtml).toContain(`react ${reactVersion}`);
70+
expect(indexHtml).toContain(`react-router-dom ${routerVersion}`);
7271

73-
// expect(indexHtml).not.toContain('{reactVersion}');
74-
// expect(indexHtml).not.toContain('{version}');
72+
expect(indexHtml).not.toContain('{reactVersion}');
73+
expect(indexHtml).not.toContain('{version}');
7574
});

packages/core/src/node/ssg-md/remarkSplitMdx.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,87 @@ End of content.`;
194194
"
195195
`);
196196
});
197+
198+
it('should preserve mdxFlowExpression as-is', async () => {
199+
const input = `# hello
200+
201+
{window.foo}`;
202+
203+
const result = await processMdx(input);
204+
205+
expect(result).toMatchInlineSnapshot(`
206+
"/*@jsxRuntime automatic*/
207+
/*@jsxImportSource react*/
208+
function _createMdxContent(props) {
209+
return <>{"# hello\\n"}{"\\n"}{window.foo}</>;
210+
}
211+
export default function MDXContent(props = {}) {
212+
const {wrapper: MDXLayout} = props.components || ({});
213+
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
214+
}
215+
"
216+
`);
217+
});
218+
219+
it('should preserve inline mdxTextExpression in headings', async () => {
220+
const input = `# hello {window.foo}`;
221+
222+
const result = await processMdx(input);
223+
224+
expect(result).toMatchInlineSnapshot(`
225+
"/*@jsxRuntime automatic*/
226+
/*@jsxImportSource react*/
227+
function _createMdxContent(props) {
228+
return <><>{"# hello "}{window.foo}</></>;
229+
}
230+
export default function MDXContent(props = {}) {
231+
const {wrapper: MDXLayout} = props.components || ({});
232+
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
233+
}
234+
"
235+
`);
236+
});
237+
238+
it('should preserve inline mdxTextExpression in paragraphs', async () => {
239+
const input = `Hello {window.foo}`;
240+
241+
const result = await processMdx(input);
242+
243+
expect(result).toMatchInlineSnapshot(`
244+
"/*@jsxRuntime automatic*/
245+
/*@jsxImportSource react*/
246+
function _createMdxContent(props) {
247+
return <><>{"Hello "}{window.foo}</></>;
248+
}
249+
export default function MDXContent(props = {}) {
250+
const {wrapper: MDXLayout} = props.components || ({});
251+
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
252+
}
253+
"
254+
`);
255+
});
256+
257+
it('should preserve mdxTextExpression inside JSX children', async () => {
258+
const input = `import Foo from '@components'
259+
260+
<Foo>Hello {window.bar}</Foo>`;
261+
262+
const result = await processMdx(input);
263+
264+
expect(result).toMatchInlineSnapshot(`
265+
"/*@jsxRuntime automatic*/
266+
/*@jsxImportSource react*/
267+
import Foo from '@components';
268+
function _createMdxContent(props) {
269+
return <Foo>{"Hello \\n"}{window.bar}</Foo>;
270+
}
271+
export default function MDXContent(props = {}) {
272+
const {wrapper: MDXLayout} = props.components || ({});
273+
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
274+
}
275+
"
276+
`);
277+
});
197278
});
198279

199280
describe('remarkWrapMarkdown with filters', () => {

packages/core/src/node/ssg-md/remarkSplitMdx.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export function remarkSplitMdx(
6666
continue;
6767
}
6868

69+
// Preserve MDX expressions as-is (e.g., {window.foo})
70+
if (
71+
node.type === 'mdxFlowExpression' ||
72+
node.type === 'mdxTextExpression'
73+
) {
74+
newChildren.push(node);
75+
continue;
76+
}
77+
6978
// Process JSX elements
7079
if (
7180
node.type === 'mdxJsxFlowElement' ||
@@ -80,7 +89,9 @@ export function remarkSplitMdx(
8089
const hasJsxChildren = (node as any).children?.some(
8190
(child: any) =>
8291
child.type === 'mdxJsxFlowElement' ||
83-
child.type === 'mdxJsxTextElement',
92+
child.type === 'mdxJsxTextElement' ||
93+
child.type === 'mdxFlowExpression' ||
94+
child.type === 'mdxTextExpression',
8495
);
8596

8697
if (hasJsxChildren) {
@@ -171,14 +182,20 @@ function processJsxChildren(
171182
};
172183

173184
for (const child of node.children) {
174-
// Only process nested JSX elements recursively
175185
if (
176186
child.type === 'mdxJsxFlowElement' ||
177187
child.type === 'mdxJsxTextElement'
178188
) {
179189
// Flush any accumulated text before the JSX element
180190
flushTextBuffer();
181191
processedChildren.push(processJsxElement(child, importMap, options));
192+
} else if (
193+
child.type === 'mdxFlowExpression' ||
194+
child.type === 'mdxTextExpression'
195+
) {
196+
// Preserve MDX expressions as-is
197+
flushTextBuffer();
198+
processedChildren.push(child as any);
182199
} else {
183200
// Accumulate non-JSX content to be serialized as string
184201
textBuffer.push(child);
@@ -278,6 +295,12 @@ function processMixedContent(
278295
) {
279296
flushTextBuffer();
280297
result.push(processJsxElement(child, importMap, options));
298+
} else if (
299+
child.type === 'mdxFlowExpression' ||
300+
child.type === 'mdxTextExpression'
301+
) {
302+
flushTextBuffer();
303+
result.push(child as MdxFlowExpression | MdxTextExpression);
281304
} else if (child.type === 'text') {
282305
textBuffer.push(child.value || '');
283306
} else {

website/docs/en/guide/basic/ssg-md.mdx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,35 @@ renderToMarkdownString(<Article />);
108108

109109
In principle, this API works for any site built with React; see [react-render-to-markdown](https://www.npmjs.com/package/react-render-to-markdown) if you're interested.
110110

111-
2. Provides `import.meta.env.SSG_MD` environment variable, making it easy for users to distinguish between SSG-MD rendering and browser rendering in React components, thus achieving more flexible content customization:
111+
2. Rspress uses a custom remark plugin `remarkSplitMdx` to preprocess MDX files before rendering. This plugin splits the MDX AST, separating pure Markdown content from JSX components: Markdown text is serialized as string literals, while JSX components and MDX expressions (e.g., `{variable}`) are preserved as React elements. This ensures that Markdown content passes through as-is without being processed by React rendering, while dynamic components are rendered by `renderToMarkdownString`.
112+
113+
For example, the following MDX:
114+
115+
```mdx
116+
# Hello
117+
118+
Some **bold** text.
119+
120+
<PackageManagerTabs command="install rspress" />
121+
122+
{window.title}
123+
```
124+
125+
Will be transformed to a component like:
126+
127+
```tsx
128+
function _createMdxContent() {
129+
return (
130+
<>
131+
{'# Hello\n\nSome **bold** text.\n'}
132+
<PackageManagerTabs command="install rspress" />
133+
{window.title}
134+
</>
135+
);
136+
}
137+
```
138+
139+
3. Provides `import.meta.env.SSG_MD` environment variable, making it easy for users to distinguish between SSG-MD rendering and browser rendering in React components, thus achieving more flexible content customization:
112140

113141
```tsx
114142
export function Tab({ label }: { label: string }) {
@@ -119,7 +147,7 @@ export function Tab({ label }: { label: string }) {
119147
}
120148
```
121149

122-
3. Rspress internal component library has been adapted for SSG-MD to ensure reasonable Markdown content is rendered during the SSG-MD phase. For example:
150+
4. Rspress internal component library has been adapted for SSG-MD to ensure reasonable Markdown content is rendered during the SSG-MD phase. For example:
123151

124152
```tsx
125153
<PackageManagerTabs command="create rspress@latest" />

website/docs/zh/guide/basic/ssg-md.mdx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,35 @@ renderToMarkdownString(<Article />);
108108

109109
理论上这一 API 适用于任何使用 React 构建的网站,如果你对它感兴趣,请参考 [react-render-to-markdown](https://www.npmjs.com/package/react-render-to-markdown)
110110

111-
2. 提供 `import.meta.env.SSG_MD` 环境变量,方便用户在 React 组件中区分 SSG-MD 渲染和浏览器渲染,从而实现更灵活的内容定制:
111+
2. Rspress 使用自定义的 remark 插件 `remarkSplitMdx` 对 MDX 文件进行预处理。该插件会拆分 MDX AST,将纯 Markdown 内容与 JSX 组件分离:Markdown 文本被序列化为字符串字面量,而 JSX 组件和 MDX 表达式(如 `{variable}`)则保留为 React 元素。这确保了 Markdown 内容能原样透传,不经过 React 渲染处理,而动态组件则由 `renderToMarkdownString` 渲染。
112+
113+
例如以下 MDX:
114+
115+
```mdx
116+
# Hello
117+
118+
Some **bold** text.
119+
120+
<PackageManagerTabs command="install rspress" />
121+
122+
{window.title}
123+
```
124+
125+
会被转换为如下组件:
126+
127+
```tsx
128+
function _createMdxContent() {
129+
return (
130+
<>
131+
{'# Hello\n\nSome **bold** text.\n'}
132+
<PackageManagerTabs command="install rspress" />
133+
{window.title}
134+
</>
135+
);
136+
}
137+
```
138+
139+
3. 提供 `import.meta.env.SSG_MD` 环境变量,方便用户在 React 组件中区分 SSG-MD 渲染和浏览器渲染,从而实现更灵活的内容定制:
112140

113141
```tsx
114142
export function Tab({ label }: { label: string }) {
@@ -119,7 +147,7 @@ export function Tab({ label }: { label: string }) {
119147
}
120148
```
121149

122-
3. Rspress 内部组件对于 SSG-MD 做了适配,确保在 SSG-MD 阶段渲染出合理的 Markdown 内容。例如:
150+
4. Rspress 内部组件对于 SSG-MD 做了适配,确保在 SSG-MD 阶段渲染出合理的 Markdown 内容。例如:
123151

124152
```tsx
125153
<PackageManagerTabs command="create rspress@latest" />

0 commit comments

Comments
 (0)