Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 119 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,143 @@
# react-render-to-markdown

[![npm version](https://img.shields.io/npm/v/react-render-to-markdown.svg)](https://www.npmjs.com/package/react-render-to-markdown)
[![license](https://img.shields.io/npm/l/react-render-to-markdown.svg)](https://github.com/SoonIter/react-render-to-markdown/blob/main/LICENSE)

Render React components to Markdown strings — like `renderToString` in `react-dom`, but outputs **Markdown** instead of HTML.

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.

## Installation

The major version of `react-render-to-markdown` follows the React version. Install the one that matches your project:

```bash
# React 19
npm install react-render-to-markdown@19

# React 18
npm install react-render-to-markdown@18
```

## Quick Start

```tsx
import { renderToMarkdownString } from 'react-render-to-markdown';

const markdown = renderToMarkdownString(<h1>Hello, World!</h1>);
const markdown = await renderToMarkdownString(<h1>Hello, World!</h1>);
console.log(markdown); // # Hello, World!
```

## Installation
## Usage

```bash
npm install react-render-to-markdown
### Basic HTML Elements

```tsx
import { renderToMarkdownString } from 'react-render-to-markdown';

await renderToMarkdownString(
<div>
<strong>foo</strong>
<span>bar</span>
</div>,
);
// Output: '**foo**bar'
```

### React Components & Hooks

Synchronous hooks (`useState`, `useMemo`, `useRef`, `useContext`, etc.) work as expected. Client-side effects (`useEffect`, `useLayoutEffect`) are automatically suppressed:

```tsx
import { createContext, useContext, useMemo, useState } from 'react';
import { renderToMarkdownString } from 'react-render-to-markdown';

const ThemeContext = createContext('light');

const Article = () => {
const [count] = useState(42);
const theme = useContext(ThemeContext);
const doubled = useMemo(() => count * 2, [count]);

return (
<>
<h1>Hello World</h1>
<p>Count: {count}, Doubled: {doubled}, Theme: {theme}</p>
</>
);
};

await renderToMarkdownString(
<ThemeContext.Provider value="dark">
<Article />
</ThemeContext.Provider>,
);
// Output:
// # Hello World
//
// Count: 42, Doubled: 84, Theme: dark
```

### Code Blocks

Fenced code blocks with language and title support:

```tsx
await renderToMarkdownString(
<pre data-lang="ts" data-title="rspress.config.ts">
<code>{'const a = 1;\n'}</code>
</pre>,
);
// Output:
// ```ts title=rspress.config.ts
// const a = 1;
// ```
```

For languages that may contain triple backticks (like `markdown`, `mdx`, `md`), four backticks (``````) are automatically used as delimiters.

## Supported Elements

| HTML Element | Markdown Output |
| --- | --- |
| `<h1>` – `<h6>` | `#` – `######` headings |
| `<p>` | Paragraph with trailing newlines |
| `<strong>`, `<b>` | `**bold**` |
| `<em>`, `<i>` | `*italic*` |
| `<code>` | `` `inline code` `` |
| `<pre>` + `<code>` | Fenced code block (` ``` `) |
| `<a href="">` | `[text](url)` |
| `<img>` | `![alt](src)` |
| `<ul>`, `<ol>`, `<li>` | Unordered / ordered lists |
| `<blockquote>` | `> blockquote` |
| `<br>` | Line break |
| `<hr>` | `---` horizontal rule |
| `<style>` | Ignored |
| `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>` | GFM table |

Any unrecognized elements (e.g. `<div>`, `<span>`, `<section>`) render their children as-is, acting as transparent wrappers.

## How It Works

1. **Custom React Reconciler** — Uses `react-reconciler` to build a lightweight tree of `MarkdownNode` objects from your React element tree.
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.
3. **Tree-to-Markdown Serialization** — The `MarkdownNode` tree is serialized to a Markdown string via a recursive `toMarkdown` function.

## Requirements

```json
{
"react": "^18.2.0",
"react": ">=18.2.0",
"react-reconciler": "^0.29.0"
}
```

> **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`).

## Used By

- [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.
- [**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.

## License

MIT License.
[MIT](./LICENSE)
22 changes: 22 additions & 0 deletions src/react/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,28 @@ console.log('Hello, world!');
});

describe('renderToMarkdownString - styles', () => {
it('ignores style tag content', async () => {
expect(
await renderToMarkdownString(
<div>
<h1>Title</h1>
<style>{`
.rspress-doc {
color: red;
}
`}</style>
<p>Content</p>
</div>,
),
).toMatchInlineSnapshot(`
"# Title

Content

"
`);
});

it('renders two row correctly', async () => {
const Comp1 = () => {
return (
Expand Down
65 changes: 34 additions & 31 deletions src/react/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,46 +113,47 @@ function toMarkdown(root: MarkdownNode): string {
const { type, props, children } = root;

// Get children's Markdown
const childrenMd = children
.map((child) => {
if (child instanceof TextNode) {
return child.text;
}
return toMarkdown(child);
})
.join('');
const childrenMd = () =>
children
.map((child) => {
if (child instanceof TextNode) {
return child.text;
}
return toMarkdown(child);
})
.join('');
Comment thread
SoonIter marked this conversation as resolved.

// Generate corresponding Markdown based on element type
switch (type) {
case 'root':
return childrenMd;
return childrenMd();
case 'h1':
return `# ${childrenMd}\n\n`;
return `# ${childrenMd()}\n\n`;
case 'h2':
return `## ${childrenMd}\n\n`;
return `## ${childrenMd()}\n\n`;
case 'h3':
return `### ${childrenMd}\n\n`;
return `### ${childrenMd()}\n\n`;
case 'h4':
return `#### ${childrenMd}\n\n`;
return `#### ${childrenMd()}\n\n`;
case 'h5':
return `##### ${childrenMd}\n\n`;
return `##### ${childrenMd()}\n\n`;
case 'h6':
return `###### ${childrenMd}\n\n`;
return `###### ${childrenMd()}\n\n`;
case 'p':
return `${childrenMd}\n\n`;
return `${childrenMd()}\n\n`;
case 'strong':
case 'b':
return `**${childrenMd}**`;
return `**${childrenMd()}**`;
case 'em':
case 'i':
return `*${childrenMd}*`;
return `*${childrenMd()}*`;
case 'code':
// When <code> is nested inside <pre>, it represents the code block body,
// so we must not wrap it with inline backticks (would create nested fences).
if (root.parent?.type === 'pre') {
return childrenMd;
return childrenMd();
}
return `\`${childrenMd}\``;
return `\`${childrenMd()}\``;
case 'pre': {
const _language =
props['data-lang'] || props.language || props.lang || '';
Expand All @@ -163,33 +164,35 @@ function toMarkdown(root: MarkdownNode): string {
? '````'
: '```';

return `\n${block}${language}${title ? ` title=${title}` : ''}\n${childrenMd}\n${block}\n`;
return `\n${block}${language}${title ? ` title=${title}` : ''}\n${childrenMd()}\n${block}\n`;
}
case 'a':
return `[${childrenMd}](${props.href || '#'})`;
return `[${childrenMd()}](${props.href || '#'})`;
case 'img':
return `![${props.alt || ''}](${props.src || ''})`;
case 'ul':
return `${childrenMd}\n`;
return `${childrenMd()}\n`;
case 'ol':
return `${childrenMd}\n`;
return `${childrenMd()}\n`;
case 'li': {
const isOrdered = root.parent && root.parent.type === 'ol';
const prefix = isOrdered ? '1. ' : '- ';
return `${prefix}${childrenMd}\n`;
return `${prefix}${childrenMd()}\n`;
}
case 'blockquote':
return `> ${childrenMd.split('\n').join('\n> ')}\n\n`;
return `> ${childrenMd().split('\n').join('\n> ')}\n\n`;
case 'br':
return '\n';
case 'hr':
return '---\n\n';
case 'style':
return '';
case 'table':
return `${childrenMd}\n`;
return `${childrenMd()}\n`;
case 'thead':
return childrenMd;
return childrenMd();
case 'tbody':
return childrenMd;
return childrenMd();
case 'tr': {
const cells = children
.filter((child): child is MarkdownNode => child instanceof MarkdownNode)
Expand All @@ -205,9 +208,9 @@ function toMarkdown(root: MarkdownNode): string {
}
case 'th':
case 'td':
return childrenMd;
return childrenMd();
default:
return childrenMd;
return childrenMd();
}
}

Expand Down