Skip to content

Commit ecf2d0e

Browse files
authored
feat: ignore <style> tag content in markdown (#5)
## Summary - Backport #3 to the v18 branch. - Ignore `<style>` nodes during Markdown serialization so CSS content is omitted from SSG-MD output. - Add a regression test for `<style>` handling on React 18. ## Testing - `pnpm install --frozen-lockfile` - `pnpm test` - `pnpm exec biome check .` - `pnpm build`
1 parent f213a5f commit ecf2d0e

3 files changed

Lines changed: 175 additions & 38 deletions

File tree

README.md

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,143 @@
11
# react-render-to-markdown
22

3+
[![npm version](https://img.shields.io/npm/v/react-render-to-markdown.svg)](https://www.npmjs.com/package/react-render-to-markdown)
4+
[![license](https://img.shields.io/npm/l/react-render-to-markdown.svg)](https://github.com/SoonIter/react-render-to-markdown/blob/main/LICENSE)
5+
6+
Render React components to Markdown strings — like `renderToString` in `react-dom`, but outputs **Markdown** instead of HTML.
7+
8+
Built on top of `react-reconciler`, this library creates a custom React renderer that traverses the React element tree and produces well-formatted Markdown. It follows **SSR-like behavior**: `useEffect`, `useLayoutEffect`, and `useInsertionEffect` are suppressed (as no-ops), while `useState`, `useMemo`, `useRef`, `useContext`, and other synchronous hooks work as expected.
9+
10+
## Installation
11+
12+
The major version of `react-render-to-markdown` follows the React version. Install the one that matches your project:
13+
14+
```bash
15+
# React 19
16+
npm install react-render-to-markdown@19
17+
18+
# React 18
19+
npm install react-render-to-markdown@18
20+
```
21+
22+
## Quick Start
23+
324
```tsx
425
import { renderToMarkdownString } from 'react-render-to-markdown';
526

6-
const markdown = renderToMarkdownString(<h1>Hello, World!</h1>);
27+
const markdown = await renderToMarkdownString(<h1>Hello, World!</h1>);
728
console.log(markdown); // # Hello, World!
829
```
930

10-
## Installation
31+
## Usage
1132

12-
```bash
13-
npm install react-render-to-markdown
33+
### Basic HTML Elements
34+
35+
```tsx
36+
import { renderToMarkdownString } from 'react-render-to-markdown';
37+
38+
await renderToMarkdownString(
39+
<div>
40+
<strong>foo</strong>
41+
<span>bar</span>
42+
</div>,
43+
);
44+
// Output: '**foo**bar'
1445
```
1546

47+
### React Components & Hooks
48+
49+
Synchronous hooks (`useState`, `useMemo`, `useRef`, `useContext`, etc.) work as expected. Client-side effects (`useEffect`, `useLayoutEffect`) are automatically suppressed:
50+
51+
```tsx
52+
import { createContext, useContext, useMemo, useState } from 'react';
53+
import { renderToMarkdownString } from 'react-render-to-markdown';
54+
55+
const ThemeContext = createContext('light');
56+
57+
const Article = () => {
58+
const [count] = useState(42);
59+
const theme = useContext(ThemeContext);
60+
const doubled = useMemo(() => count * 2, [count]);
61+
62+
return (
63+
<>
64+
<h1>Hello World</h1>
65+
<p>Count: {count}, Doubled: {doubled}, Theme: {theme}</p>
66+
</>
67+
);
68+
};
69+
70+
await renderToMarkdownString(
71+
<ThemeContext.Provider value="dark">
72+
<Article />
73+
</ThemeContext.Provider>,
74+
);
75+
// Output:
76+
// # Hello World
77+
//
78+
// Count: 42, Doubled: 84, Theme: dark
79+
```
80+
81+
### Code Blocks
82+
83+
Fenced code blocks with language and title support:
84+
85+
```tsx
86+
await renderToMarkdownString(
87+
<pre data-lang="ts" data-title="rspress.config.ts">
88+
<code>{'const a = 1;\n'}</code>
89+
</pre>,
90+
);
91+
// Output:
92+
// ```ts title=rspress.config.ts
93+
// const a = 1;
94+
// ```
95+
```
96+
97+
For languages that may contain triple backticks (like `markdown`, `mdx`, `md`), four backticks (``````) are automatically used as delimiters.
98+
99+
## Supported Elements
100+
101+
| HTML Element | Markdown Output |
102+
| --- | --- |
103+
| `<h1>``<h6>` | `#``######` headings |
104+
| `<p>` | Paragraph with trailing newlines |
105+
| `<strong>`, `<b>` | `**bold**` |
106+
| `<em>`, `<i>` | `*italic*` |
107+
| `<code>` | `` `inline code` `` |
108+
| `<pre>` + `<code>` | Fenced code block (` ``` `) |
109+
| `<a href="">` | `[text](url)` |
110+
| `<img>` | `![alt](src)` |
111+
| `<ul>`, `<ol>`, `<li>` | Unordered / ordered lists |
112+
| `<blockquote>` | `> blockquote` |
113+
| `<br>` | Line break |
114+
| `<hr>` | `---` horizontal rule |
115+
| `<style>` | Ignored |
116+
| `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>` | GFM table |
117+
118+
Any unrecognized elements (e.g. `<div>`, `<span>`, `<section>`) render their children as-is, acting as transparent wrappers.
119+
120+
## How It Works
121+
122+
1. **Custom React Reconciler** — Uses `react-reconciler` to build a lightweight tree of `MarkdownNode` objects from your React element tree.
123+
2. **SSR-like Hook Behavior** — Client-side effects (`useEffect`, `useLayoutEffect`, `useInsertionEffect`) are intercepted and turned into no-ops, matching React's Fizz server renderer behavior. This ensures browser-only code (e.g. `document`, `window`) in effects never runs.
124+
3. **Tree-to-Markdown Serialization** — The `MarkdownNode` tree is serialized to a Markdown string via a recursive `toMarkdown` function.
125+
16126
## Requirements
17127

18128
```json
19129
{
20-
"react": "^18.2.0",
130+
"react": ">=18.2.0",
21131
"react-reconciler": "^0.29.0"
22132
}
23133
```
24134

135+
> **Note:** React 18.2 or above is required. The effect-interception mechanism relies on React 18's internal hooks dispatcher (`__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current`).
136+
25137
## Used By
26138

27-
- [Rspress SSG-MD](https://rspress.rs/guide/basic/ssg-md) — Rspress uses this library to power its SSG-MD feature, which renders documentation pages as Markdown files instead of HTML. This enables Generative Engine Optimization (GEO) by generating `llms.txt` and `llms-full.txt` for better Agent accessibility.
139+
- [**Rspress SSG-MD**](https://rspress.rs/guide/basic/ssg-md) — Rspress uses this library to power its SSG-MD (Static Site Generation to Markdown) feature. SSG-MD renders documentation pages as Markdown files instead of HTML, generating `llms.txt` and `llms-full.txt` for [Generative Engine Optimization (GEO)](https://en.wikipedia.org/wiki/Generative_engine_optimization), enabling better accessibility for AI agents and large language models.
28140

29141
## License
30142

31-
MIT License.
143+
[MIT](./LICENSE)

src/react/render.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,28 @@ console.log('Hello, world!');
243243
});
244244

245245
describe('renderToMarkdownString - styles', () => {
246+
it('ignores style tag content', async () => {
247+
expect(
248+
await renderToMarkdownString(
249+
<div>
250+
<h1>Title</h1>
251+
<style>{`
252+
.rspress-doc {
253+
color: red;
254+
}
255+
`}</style>
256+
<p>Content</p>
257+
</div>,
258+
),
259+
).toMatchInlineSnapshot(`
260+
"# Title
261+
262+
Content
263+
264+
"
265+
`);
266+
});
267+
246268
it('renders two row correctly', async () => {
247269
const Comp1 = () => {
248270
return (

src/react/render.ts

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -113,46 +113,47 @@ function toMarkdown(root: MarkdownNode): string {
113113
const { type, props, children } = root;
114114

115115
// Get children's Markdown
116-
const childrenMd = children
117-
.map((child) => {
118-
if (child instanceof TextNode) {
119-
return child.text;
120-
}
121-
return toMarkdown(child);
122-
})
123-
.join('');
116+
const childrenMd = () =>
117+
children
118+
.map((child) => {
119+
if (child instanceof TextNode) {
120+
return child.text;
121+
}
122+
return toMarkdown(child);
123+
})
124+
.join('');
124125

125126
// Generate corresponding Markdown based on element type
126127
switch (type) {
127128
case 'root':
128-
return childrenMd;
129+
return childrenMd();
129130
case 'h1':
130-
return `# ${childrenMd}\n\n`;
131+
return `# ${childrenMd()}\n\n`;
131132
case 'h2':
132-
return `## ${childrenMd}\n\n`;
133+
return `## ${childrenMd()}\n\n`;
133134
case 'h3':
134-
return `### ${childrenMd}\n\n`;
135+
return `### ${childrenMd()}\n\n`;
135136
case 'h4':
136-
return `#### ${childrenMd}\n\n`;
137+
return `#### ${childrenMd()}\n\n`;
137138
case 'h5':
138-
return `##### ${childrenMd}\n\n`;
139+
return `##### ${childrenMd()}\n\n`;
139140
case 'h6':
140-
return `###### ${childrenMd}\n\n`;
141+
return `###### ${childrenMd()}\n\n`;
141142
case 'p':
142-
return `${childrenMd}\n\n`;
143+
return `${childrenMd()}\n\n`;
143144
case 'strong':
144145
case 'b':
145-
return `**${childrenMd}**`;
146+
return `**${childrenMd()}**`;
146147
case 'em':
147148
case 'i':
148-
return `*${childrenMd}*`;
149+
return `*${childrenMd()}*`;
149150
case 'code':
150151
// When <code> is nested inside <pre>, it represents the code block body,
151152
// so we must not wrap it with inline backticks (would create nested fences).
152153
if (root.parent?.type === 'pre') {
153-
return childrenMd;
154+
return childrenMd();
154155
}
155-
return `\`${childrenMd}\``;
156+
return `\`${childrenMd()}\``;
156157
case 'pre': {
157158
const _language =
158159
props['data-lang'] || props.language || props.lang || '';
@@ -163,33 +164,35 @@ function toMarkdown(root: MarkdownNode): string {
163164
? '````'
164165
: '```';
165166

166-
return `\n${block}${language}${title ? ` title=${title}` : ''}\n${childrenMd}\n${block}\n`;
167+
return `\n${block}${language}${title ? ` title=${title}` : ''}\n${childrenMd()}\n${block}\n`;
167168
}
168169
case 'a':
169-
return `[${childrenMd}](${props.href || '#'})`;
170+
return `[${childrenMd()}](${props.href || '#'})`;
170171
case 'img':
171172
return `![${props.alt || ''}](${props.src || ''})`;
172173
case 'ul':
173-
return `${childrenMd}\n`;
174+
return `${childrenMd()}\n`;
174175
case 'ol':
175-
return `${childrenMd}\n`;
176+
return `${childrenMd()}\n`;
176177
case 'li': {
177178
const isOrdered = root.parent && root.parent.type === 'ol';
178179
const prefix = isOrdered ? '1. ' : '- ';
179-
return `${prefix}${childrenMd}\n`;
180+
return `${prefix}${childrenMd()}\n`;
180181
}
181182
case 'blockquote':
182-
return `> ${childrenMd.split('\n').join('\n> ')}\n\n`;
183+
return `> ${childrenMd().split('\n').join('\n> ')}\n\n`;
183184
case 'br':
184185
return '\n';
185186
case 'hr':
186187
return '---\n\n';
188+
case 'style':
189+
return '';
187190
case 'table':
188-
return `${childrenMd}\n`;
191+
return `${childrenMd()}\n`;
189192
case 'thead':
190-
return childrenMd;
193+
return childrenMd();
191194
case 'tbody':
192-
return childrenMd;
195+
return childrenMd();
193196
case 'tr': {
194197
const cells = children
195198
.filter((child): child is MarkdownNode => child instanceof MarkdownNode)
@@ -205,9 +208,9 @@ function toMarkdown(root: MarkdownNode): string {
205208
}
206209
case 'th':
207210
case 'td':
208-
return childrenMd;
211+
return childrenMd();
209212
default:
210-
return childrenMd;
213+
return childrenMd();
211214
}
212215
}
213216

0 commit comments

Comments
 (0)