Skip to content

Commit 1c6193a

Browse files
committed
docs: rewrite the README API reference and add recipes
Describe parse per entry, add an options table and the order in which the options run, and list the other exports. Replace placeholder examples with real ones, fix the attrsMap example that DOMPurify made a no-op, and move longer recipes to EXAMPLES.md.
1 parent 1cad459 commit 1c6193a

2 files changed

Lines changed: 561 additions & 79 deletions

File tree

‎EXAMPLES.md‎

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
# Examples
2+
3+
Recipes for [react-html5-parser](README.md). Each one is self-contained; the [API section of the README](README.md#api) explains every option.
4+
5+
- [Render CMS content in a component](#render-cms-content-in-a-component)
6+
- [Next.js links and images](#nextjs-links-and-images)
7+
- [Heading anchors](#heading-anchors)
8+
- [Syntax-highlighted code blocks](#syntax-highlighted-code-blocks)
9+
- [Lazy images and scrollable tables](#lazy-images-and-scrollable-tables)
10+
- [Custom elements as components](#custom-elements-as-components)
11+
- [Allow YouTube embeds](#allow-youtube-embeds)
12+
- [Bring your own sanitizer](#bring-your-own-sanitizer)
13+
- [Server rendering](#server-rendering)
14+
- [React Native article](#react-native-article)
15+
16+
## Render CMS content in a component
17+
18+
Parse once per HTML string, keep `components` at module scope, and show a fallback when parsing fails.
19+
20+
```tsx
21+
import { useMemo } from "react";
22+
import { parse, type Components } from "react-html5-parser";
23+
24+
const components: Components = {
25+
table: ({ children, ...props }) => (
26+
<div className="table-scroll">
27+
<table {...props}>{children}</table>
28+
</div>
29+
),
30+
};
31+
32+
export function Article({ html }: { html: string }) {
33+
const content = useMemo(
34+
() =>
35+
parse(html, {
36+
components,
37+
onError: (error, html) => reportError({ error, html }),
38+
}),
39+
[html]
40+
);
41+
42+
return (
43+
<article className="prose">
44+
{content ?? <p>This article could not be displayed.</p>}
45+
</article>
46+
);
47+
}
48+
```
49+
50+
`components` defined inside `Article` would be a new object on every render, so React would see a new component type per tag and remount the whole tree.
51+
52+
## Next.js links and images
53+
54+
Internal links go through the router; external links open in a new tab. Images with known dimensions go through `next/image`.
55+
56+
```tsx
57+
import Image from "next/image";
58+
import Link from "next/link";
59+
import type { Components } from "react-html5-parser";
60+
61+
export const components: Components = {
62+
a: ({ href, children }) => {
63+
const url = String(href ?? "");
64+
65+
return url.startsWith("/") ? (
66+
<Link href={url}>{children}</Link>
67+
) : (
68+
<a href={url} target="_blank" rel="noopener noreferrer">
69+
{children}
70+
</a>
71+
);
72+
},
73+
img: ({ src, alt, width, height }) =>
74+
width && height ? (
75+
<Image
76+
src={String(src)}
77+
alt={String(alt ?? "")}
78+
width={Number(width)}
79+
height={Number(height)}
80+
/>
81+
) : (
82+
<img src={String(src)} alt={String(alt ?? "")} loading="lazy" />
83+
),
84+
};
85+
```
86+
87+
Remote image hosts must be listed in `images.remotePatterns` in `next.config.js`.
88+
89+
## Heading anchors
90+
91+
Give every `h2` and `h3` a linkable id, generated from its text when the HTML has none.
92+
93+
```tsx
94+
import type { ReactNode } from "react";
95+
import type { Components } from "react-html5-parser";
96+
97+
function textOf(node: ReactNode): string {
98+
if (typeof node == "string" || typeof node == "number") {
99+
return String(node);
100+
}
101+
if (Array.isArray(node)) {
102+
return node.map(textOf).join("");
103+
}
104+
if (node && typeof node == "object" && "props" in node) {
105+
return textOf((node.props as { children?: ReactNode }).children);
106+
}
107+
return "";
108+
}
109+
110+
function slugify(text: string) {
111+
return text
112+
.toLowerCase()
113+
.trim()
114+
.replace(/[^\w]+/g, "-");
115+
}
116+
117+
function heading(Tag: "h2" | "h3") {
118+
return ({ id, children }: { id?: unknown; children?: ReactNode }) => {
119+
const anchor = String(id || slugify(textOf(children)));
120+
121+
return (
122+
<Tag id={anchor}>
123+
<a href={`#${anchor}`}>{children}</a>
124+
</Tag>
125+
);
126+
};
127+
}
128+
129+
export const components: Components = {
130+
h2: heading("h2"),
131+
h3: heading("h3"),
132+
};
133+
```
134+
135+
## Syntax-highlighted code blocks
136+
137+
Rich-text editors usually write `<pre><code class="language-ts">`. Replace `pre` and read the language from the inner `code` element.
138+
139+
```tsx
140+
import { isValidElement, type ReactElement } from "react";
141+
import { Highlight } from "prism-react-renderer";
142+
import type { Components } from "react-html5-parser";
143+
144+
type CodeProps = { className?: string; children?: string | string[] };
145+
146+
export const components: Components = {
147+
pre: ({ children }) => {
148+
const [code] = [children].flat();
149+
150+
if (!isValidElement(code) || code.type != "code") {
151+
return <pre>{children}</pre>;
152+
}
153+
154+
const { className = "", children: text } = (code as ReactElement<CodeProps>)
155+
.props;
156+
const language = className.replace(/^language-/, "") || "text";
157+
158+
return (
159+
<Highlight code={[text].flat().join("")} language={language}>
160+
{({ className, style, tokens, getLineProps, getTokenProps }) => (
161+
<pre className={className} style={style}>
162+
{tokens.map((line, i) => (
163+
<div key={i} {...getLineProps({ line })}>
164+
{line.map((token, key) => (
165+
<span key={key} {...getTokenProps({ token })} />
166+
))}
167+
</div>
168+
))}
169+
</pre>
170+
)}
171+
</Highlight>
172+
);
173+
},
174+
};
175+
```
176+
177+
## Lazy images and scrollable tables
178+
179+
`mapElement` adjusts props without replacing a tag; `mapNode` can wrap or drop DOM nodes before they render.
180+
181+
```tsx
182+
import { cloneElement, type ReactElement } from "react";
183+
import { parse, renderNode } from "react-html5-parser";
184+
185+
parse(html, {
186+
mapElement: (element: ReactElement) =>
187+
element.type == "img"
188+
? cloneElement(element, { loading: "lazy", decoding: "async" })
189+
: element,
190+
191+
mapNode: (node, key, options) => {
192+
// Rich-text editors leave empty paragraphs behind.
193+
if (node.nodeName == "P" && !node.textContent?.trim()) {
194+
return null;
195+
}
196+
197+
if (node.nodeName == "TABLE") {
198+
// Without `mapNode: undefined`, renderNode would call this function
199+
// on the same table again, forever.
200+
return (
201+
<div key={key} className="table-scroll">
202+
{renderNode(node, key, { ...options, mapNode: undefined })}
203+
</div>
204+
);
205+
}
206+
207+
return node;
208+
},
209+
});
210+
```
211+
212+
## Custom elements as components
213+
214+
Unknown tags are removed by the sanitizer and their content is kept. A tag you map in `components` is allowed, along with its attributes (minus event handlers and `javascript:` values).
215+
216+
```tsx
217+
import { parse } from "react-html5-parser";
218+
219+
parse('<x-callout type="warning">Back up your data first.</x-callout>', {
220+
components: {
221+
"x-callout": ({ type, children }) => (
222+
<aside className={`callout callout-${String(type ?? "info")}`}>
223+
{children}
224+
</aside>
225+
),
226+
},
227+
});
228+
// <><aside className="callout callout-warning">Back up your data first.</aside></>
229+
```
230+
231+
This is a safe way to let authors place your own widgets in HTML content.
232+
233+
## Allow YouTube embeds
234+
235+
Frames are removed by default. Mapping `iframe` in `components` lets it through the sanitizer, and the component decides which sources render.
236+
237+
```tsx
238+
import { parse } from "react-html5-parser";
239+
240+
const YOUTUBE = /^https:\/\/www\.youtube(-nocookie)?\.com\/embed\/[\w-]+$/;
241+
242+
parse(html, {
243+
components: {
244+
iframe: ({ src, title }) =>
245+
typeof src == "string" && YOUTUBE.test(src) ? (
246+
<iframe
247+
src={src}
248+
title={String(title ?? "YouTube video")}
249+
sandbox="allow-scripts allow-same-origin allow-presentation"
250+
allow="encrypted-media; picture-in-picture"
251+
allowFullScreen
252+
/>
253+
) : null,
254+
},
255+
});
256+
```
257+
258+
Always check `src` in the component: a mapped `iframe` passes the sanitizer with any http(s) source.
259+
260+
## Bring your own sanitizer
261+
262+
The core entry skips DOMPurify (3 kB instead of 15 kB). Use it when the HTML is already trusted, or pass your own sanitizer.
263+
264+
```tsx
265+
import { parse } from "react-html5-parser/core";
266+
import sanitizeHtml from "sanitize-html";
267+
268+
parse(html, {
269+
sanitize: (html) =>
270+
sanitizeHtml(html, {
271+
allowedTags: ["p", "a", "b", "i", "ul", "ol", "li", "br"],
272+
allowedAttributes: { a: ["href"] },
273+
}),
274+
});
275+
```
276+
277+
`sanitize` runs on the string before parsing. On the default entry it runs before DOMPurify, never instead of it.
278+
279+
## Server rendering
280+
281+
The browser entries parse with the DOM, so on the server `parse` returns `null` and reports a `ReferenceError` to `onError`. Render the HTML on the client, and show a placeholder until then:
282+
283+
```tsx
284+
"use client";
285+
286+
import { useEffect, useState, type ReactNode } from "react";
287+
import { parse } from "react-html5-parser";
288+
289+
export function ClientHtml({ html }: { html: string }) {
290+
const [content, setContent] = useState<ReactNode>(null);
291+
292+
useEffect(() => {
293+
setContent(parse(html));
294+
}, [html]);
295+
296+
return <div className="prose">{content}</div>;
297+
}
298+
```
299+
300+
If the content matters for SEO, sanitize the HTML on the server with your own tool and render it there instead.
301+
302+
## React Native article
303+
304+
Style tags, open links in an in-app browser, and replace images.
305+
306+
```tsx
307+
import { Image, ScrollView } from "react-native";
308+
import * as WebBrowser from "expo-web-browser";
309+
import { parse } from "react-html5-parser/native";
310+
311+
const tagStyles = {
312+
h1: { text: { fontSize: 28, fontWeight: "700", marginBottom: 12 } },
313+
p: { view: { marginBottom: 12 } },
314+
a: { text: { color: "#0a66c2" } },
315+
code: { text: { fontFamily: "Menlo", backgroundColor: "#f2f2f2" } },
316+
};
317+
318+
export function Article({ html }: { html: string }) {
319+
return (
320+
<ScrollView contentContainerStyle={{ padding: 16 }}>
321+
{parse(html, {
322+
tagStyles,
323+
textStyle: { fontSize: 16, lineHeight: 24 },
324+
onLinkPress: (href) => {
325+
// onLinkPress receives every link, relative ones included;
326+
// checking the URL is up to you.
327+
if (/^https?:\/\//.test(href)) {
328+
WebBrowser.openBrowserAsync(href);
329+
}
330+
},
331+
})}
332+
</ScrollView>
333+
);
334+
}
335+
```
336+
337+
Without `onLinkPress`, links open through `Linking` for `http:`, `https:`, `mailto:` and `tel:` only.

0 commit comments

Comments
 (0)