@nanogiants/react-native-render-html 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Snapshot tests for the RenderHTML renderer.
3
+ *
4
+ * Each test renders a specific HTML fragment via React Native Testing Library
5
+ * and records the component tree as a snapshot. When you refactor the renderers
6
+ * the snapshots will tell you exactly what changed, making regressions
7
+ * immediately visible.
8
+ *
9
+ * Rules:
10
+ * - One focused HTML snippet per test (easy to understand diffs).
11
+ * - `renderImage` is a simple stub that renders nothing — we test structure,
12
+ * not image library integration.
13
+ * - `renderAsync` is used (React 19 ready) so all async state updates from
14
+ * Image.getSize are properly flushed inside act().
15
+ */
16
+
17
+ import { renderAsync, screen } from '@testing-library/react-native';
18
+
19
+ import { RenderHTML } from './RenderHTML';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Shared helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const renderImage = () => null;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Snapshots
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe('RenderHTML snapshots', () => {
32
+ // ── empty / trivial ───────────────────────────────────────────────────────
33
+
34
+ it('renders empty string without crashing', async () => {
35
+ await renderAsync(<RenderHTML html="" renderImage={renderImage} />);
36
+ expect(screen.toJSON()).toMatchSnapshot();
37
+ });
38
+
39
+ it('renders a plain text node', async () => {
40
+ await renderAsync(<RenderHTML html="Hello world" renderImage={renderImage} />);
41
+ expect(screen.toJSON()).toMatchSnapshot();
42
+ });
43
+
44
+ // ── headings ─────────────────────────────────────────────────────────────
45
+
46
+ it('renders h1', async () => {
47
+ await renderAsync(<RenderHTML html="<h1>Heading 1</h1>" renderImage={renderImage} />);
48
+ expect(screen.toJSON()).toMatchSnapshot();
49
+ });
50
+
51
+ it('renders h2', async () => {
52
+ await renderAsync(<RenderHTML html="<h2>Heading 2</h2>" renderImage={renderImage} />);
53
+ expect(screen.toJSON()).toMatchSnapshot();
54
+ });
55
+
56
+ it('renders h3', async () => {
57
+ await renderAsync(<RenderHTML html="<h3>Heading 3</h3>" renderImage={renderImage} />);
58
+ expect(screen.toJSON()).toMatchSnapshot();
59
+ });
60
+
61
+ it('renders h4', async () => {
62
+ await renderAsync(<RenderHTML html="<h4>Heading 4</h4>" renderImage={renderImage} />);
63
+ expect(screen.toJSON()).toMatchSnapshot();
64
+ });
65
+
66
+ it('renders h5', async () => {
67
+ await renderAsync(<RenderHTML html="<h5>Heading 5</h5>" renderImage={renderImage} />);
68
+ expect(screen.toJSON()).toMatchSnapshot();
69
+ });
70
+
71
+ it('renders h6', async () => {
72
+ await renderAsync(<RenderHTML html="<h6>Heading 6</h6>" renderImage={renderImage} />);
73
+ expect(screen.toJSON()).toMatchSnapshot();
74
+ });
75
+
76
+ // ── paragraph & inline formatting ────────────────────────────────────────
77
+
78
+ it('renders a paragraph', async () => {
79
+ await renderAsync(<RenderHTML html="<p>A simple paragraph.</p>" renderImage={renderImage} />);
80
+ expect(screen.toJSON()).toMatchSnapshot();
81
+ });
82
+
83
+ it('renders bold text', async () => {
84
+ await renderAsync(
85
+ <RenderHTML
86
+ html="<p><b>Bold</b> and <strong>strong</strong></p>"
87
+ renderImage={renderImage}
88
+ />,
89
+ );
90
+ expect(screen.toJSON()).toMatchSnapshot();
91
+ });
92
+
93
+ it('renders italic text', async () => {
94
+ await renderAsync(
95
+ <RenderHTML html="<p><i>Italic</i> and <em>emphasis</em></p>" renderImage={renderImage} />,
96
+ );
97
+ expect(screen.toJSON()).toMatchSnapshot();
98
+ });
99
+
100
+ it('renders underline text', async () => {
101
+ await renderAsync(<RenderHTML html="<p><u>Underlined</u></p>" renderImage={renderImage} />);
102
+ expect(screen.toJSON()).toMatchSnapshot();
103
+ });
104
+
105
+ it('renders strikethrough text', async () => {
106
+ await renderAsync(
107
+ <RenderHTML html="<p><s>Strike</s> and <del>deleted</del></p>" renderImage={renderImage} />,
108
+ );
109
+ expect(screen.toJSON()).toMatchSnapshot();
110
+ });
111
+
112
+ it('renders mark (highlight)', async () => {
113
+ await renderAsync(
114
+ <RenderHTML html="<p><mark>Highlighted</mark></p>" renderImage={renderImage} />,
115
+ );
116
+ expect(screen.toJSON()).toMatchSnapshot();
117
+ });
118
+
119
+ it('renders small text', async () => {
120
+ await renderAsync(
121
+ <RenderHTML html="<p><small>Tiny text</small></p>" renderImage={renderImage} />,
122
+ );
123
+ expect(screen.toJSON()).toMatchSnapshot();
124
+ });
125
+
126
+ it('renders superscript and subscript', async () => {
127
+ await renderAsync(
128
+ <RenderHTML html="<p>H<sub>2</sub>O and E=mc<sup>2</sup></p>" renderImage={renderImage} />,
129
+ );
130
+ expect(screen.toJSON()).toMatchSnapshot();
131
+ });
132
+
133
+ it('renders span', async () => {
134
+ await renderAsync(
135
+ <RenderHTML html="<p>Normal <span>span</span> text</p>" renderImage={renderImage} />,
136
+ );
137
+ expect(screen.toJSON()).toMatchSnapshot();
138
+ });
139
+
140
+ // ── links ─────────────────────────────────────────────────────────────────
141
+
142
+ it('renders an internal link', async () => {
143
+ await renderAsync(
144
+ <RenderHTML html='<a href="/about">About us</a>' renderImage={renderImage} />,
145
+ );
146
+ expect(screen.toJSON()).toMatchSnapshot();
147
+ });
148
+
149
+ it('renders an external link', async () => {
150
+ await renderAsync(
151
+ <RenderHTML html='<a href="https://example.com">External</a>' renderImage={renderImage} />,
152
+ );
153
+ expect(screen.toJSON()).toMatchSnapshot();
154
+ });
155
+
156
+ it('renders a link with no href', async () => {
157
+ await renderAsync(<RenderHTML html="<a>No href</a>" renderImage={renderImage} />);
158
+ expect(screen.toJSON()).toMatchSnapshot();
159
+ });
160
+
161
+ // ── div ───────────────────────────────────────────────────────────────────
162
+
163
+ it('renders a div with mixed content', async () => {
164
+ await renderAsync(
165
+ <RenderHTML
166
+ html="<div><p>Paragraph</p><span>Inline</span></div>"
167
+ renderImage={renderImage}
168
+ />,
169
+ );
170
+ expect(screen.toJSON()).toMatchSnapshot();
171
+ });
172
+
173
+ // ── semantic sectioning ───────────────────────────────────────────────────
174
+
175
+ it('renders main', async () => {
176
+ await renderAsync(
177
+ <RenderHTML html="<main><p>Main content</p></main>" renderImage={renderImage} />,
178
+ );
179
+ expect(screen.toJSON()).toMatchSnapshot();
180
+ });
181
+
182
+ it('renders section', async () => {
183
+ await renderAsync(
184
+ <RenderHTML html="<section><p>Section content</p></section>" renderImage={renderImage} />,
185
+ );
186
+ expect(screen.toJSON()).toMatchSnapshot();
187
+ });
188
+
189
+ it('renders article', async () => {
190
+ await renderAsync(
191
+ <RenderHTML html="<article><p>Article content</p></article>" renderImage={renderImage} />,
192
+ );
193
+ expect(screen.toJSON()).toMatchSnapshot();
194
+ });
195
+
196
+ it('renders aside', async () => {
197
+ await renderAsync(
198
+ <RenderHTML html="<aside><p>Aside content</p></aside>" renderImage={renderImage} />,
199
+ );
200
+ expect(screen.toJSON()).toMatchSnapshot();
201
+ });
202
+
203
+ it('renders nav', async () => {
204
+ await renderAsync(
205
+ <RenderHTML
206
+ html='<nav><a href="/home">Home</a><a href="/about">About</a></nav>'
207
+ renderImage={renderImage}
208
+ />,
209
+ );
210
+ expect(screen.toJSON()).toMatchSnapshot();
211
+ });
212
+
213
+ it('renders header', async () => {
214
+ await renderAsync(
215
+ <RenderHTML html="<header><h1>Site Title</h1></header>" renderImage={renderImage} />,
216
+ );
217
+ expect(screen.toJSON()).toMatchSnapshot();
218
+ });
219
+
220
+ it('renders footer', async () => {
221
+ await renderAsync(
222
+ <RenderHTML html="<footer><p>Copyright 2026</p></footer>" renderImage={renderImage} />,
223
+ );
224
+ expect(screen.toJSON()).toMatchSnapshot();
225
+ });
226
+
227
+ it('applies tagStyles to section', async () => {
228
+ await renderAsync(
229
+ <RenderHTML
230
+ html="<section><p>Styled section</p></section>"
231
+ renderImage={renderImage}
232
+ tagStyles={{ section: { block: { backgroundColor: 'lightyellow', padding: 12 } } }}
233
+ />,
234
+ );
235
+ expect(screen.toJSON()).toMatchSnapshot();
236
+ });
237
+
238
+ // ── blockquote ────────────────────────────────────────────────────────────
239
+
240
+ it('renders a blockquote', async () => {
241
+ await renderAsync(
242
+ <RenderHTML html="<blockquote><p>Quoted text</p></blockquote>" renderImage={renderImage} />,
243
+ );
244
+ expect(screen.toJSON()).toMatchSnapshot();
245
+ });
246
+
247
+ // ── horizontal rule & line break ──────────────────────────────────────────
248
+
249
+ it('renders hr', async () => {
250
+ await renderAsync(
251
+ <RenderHTML html="<p>Before</p><hr /><p>After</p>" renderImage={renderImage} />,
252
+ );
253
+ expect(screen.toJSON()).toMatchSnapshot();
254
+ });
255
+
256
+ it('renders br between text nodes', async () => {
257
+ await renderAsync(
258
+ <RenderHTML html="<p>Line one<br />Line two</p>" renderImage={renderImage} />,
259
+ );
260
+ expect(screen.toJSON()).toMatchSnapshot();
261
+ });
262
+
263
+ // ── code / pre ────────────────────────────────────────────────────────────
264
+
265
+ it('renders inline code', async () => {
266
+ await renderAsync(
267
+ <RenderHTML html="<p>Run <code>npm install</code> first</p>" renderImage={renderImage} />,
268
+ );
269
+ expect(screen.toJSON()).toMatchSnapshot();
270
+ });
271
+
272
+ it('renders a pre block', async () => {
273
+ await renderAsync(<RenderHTML html="<pre>const x = 1;</pre>" renderImage={renderImage} />);
274
+ expect(screen.toJSON()).toMatchSnapshot();
275
+ });
276
+
277
+ // ── lists ─────────────────────────────────────────────────────────────────
278
+
279
+ it('renders an unordered list', async () => {
280
+ await renderAsync(
281
+ <RenderHTML
282
+ html="<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"
283
+ renderImage={renderImage}
284
+ />,
285
+ );
286
+ expect(screen.toJSON()).toMatchSnapshot();
287
+ });
288
+
289
+ it('renders an ordered list', async () => {
290
+ await renderAsync(
291
+ <RenderHTML
292
+ html="<ol><li>First</li><li>Second</li><li>Third</li></ol>"
293
+ renderImage={renderImage}
294
+ />,
295
+ );
296
+ expect(screen.toJSON()).toMatchSnapshot();
297
+ });
298
+
299
+ it('renders a nested list', async () => {
300
+ await renderAsync(
301
+ <RenderHTML
302
+ html="<ul><li>Item 1<ul><li>Sub A</li><li>Sub B</li></ul></li><li>Item 2</li></ul>"
303
+ renderImage={renderImage}
304
+ />,
305
+ );
306
+ expect(screen.toJSON()).toMatchSnapshot();
307
+ });
308
+
309
+ // ── definition list ───────────────────────────────────────────────────────
310
+
311
+ it('renders a definition list', async () => {
312
+ await renderAsync(
313
+ <RenderHTML
314
+ html="<dl><dt>Term</dt><dd>Definition of the term</dd></dl>"
315
+ renderImage={renderImage}
316
+ />,
317
+ );
318
+ expect(screen.toJSON()).toMatchSnapshot();
319
+ });
320
+
321
+ // ── table ─────────────────────────────────────────────────────────────────
322
+
323
+ it('renders a simple table', async () => {
324
+ await renderAsync(
325
+ <RenderHTML
326
+ html={
327
+ '<table>' +
328
+ '<thead><tr><th>Name</th><th>Age</th></tr></thead>' +
329
+ '<tbody><tr><td>Alice</td><td>30</td></tr><tr><td>Bob</td><td>25</td></tr></tbody>' +
330
+ '<tfoot><tr><td>Total</td><td>2</td></tr></tfoot>' +
331
+ '</table>'
332
+ }
333
+ renderImage={renderImage}
334
+ />,
335
+ );
336
+ expect(screen.toJSON()).toMatchSnapshot();
337
+ });
338
+
339
+ // ── image ─────────────────────────────────────────────────────────────────
340
+
341
+ it('renders img (after Image.getSize resolves)', async () => {
342
+ // renderAsync flushes all async state updates (including Image.getSize
343
+ // callbacks) inside act(), so we get the stable post-load snapshot.
344
+ await renderAsync(
345
+ <RenderHTML
346
+ html='<img src="https://example.com/photo.jpg" alt="Photo" />'
347
+ renderImage={renderImage}
348
+ />,
349
+ );
350
+ expect(screen.toJSON()).toMatchSnapshot();
351
+ });
352
+
353
+ // ── style overrides ───────────────────────────────────────────────────────
354
+
355
+ it('applies baseStyle to text', async () => {
356
+ await renderAsync(
357
+ <RenderHTML
358
+ html="<p>Styled text</p>"
359
+ renderImage={renderImage}
360
+ baseStyle={{ fontSize: 18, color: 'navy' }}
361
+ />,
362
+ );
363
+ expect(screen.toJSON()).toMatchSnapshot();
364
+ });
365
+
366
+ it('applies tagStyles overrides', async () => {
367
+ await renderAsync(
368
+ <RenderHTML
369
+ html="<h1>Custom heading</h1>"
370
+ renderImage={renderImage}
371
+ tagStyles={{ h1: { text: { color: 'red', fontSize: 40 }, block: { marginTop: 0 } } }}
372
+ />,
373
+ );
374
+ expect(screen.toJSON()).toMatchSnapshot();
375
+ });
376
+
377
+ it('applies classesStyles', async () => {
378
+ await renderAsync(
379
+ <RenderHTML
380
+ html='<p class="highlight">Highlighted paragraph</p>'
381
+ renderImage={renderImage}
382
+ classesStyles={{
383
+ highlight: { text: { color: 'green' }, block: { backgroundColor: 'yellow' } },
384
+ }}
385
+ />,
386
+ );
387
+ expect(screen.toJSON()).toMatchSnapshot();
388
+ });
389
+
390
+ it('applies listGap', async () => {
391
+ await renderAsync(
392
+ <RenderHTML html="<ul><li>A</li><li>B</li></ul>" renderImage={renderImage} listGap={8} />,
393
+ );
394
+ expect(screen.toJSON()).toMatchSnapshot();
395
+ });
396
+
397
+ // ── unsupported / ignored tags ────────────────────────────────────────────
398
+
399
+ it('ignores script tags', async () => {
400
+ await renderAsync(
401
+ <RenderHTML
402
+ html="<p>Visible</p><script>alert(1)</script><p>Also visible</p>"
403
+ renderImage={renderImage}
404
+ />,
405
+ );
406
+ expect(screen.toJSON()).toMatchSnapshot();
407
+ });
408
+
409
+ it('ignores style tags', async () => {
410
+ await renderAsync(
411
+ <RenderHTML html="<style>body{color:red}</style><p>Text</p>" renderImage={renderImage} />,
412
+ );
413
+ expect(screen.toJSON()).toMatchSnapshot();
414
+ });
415
+
416
+ // ── complex document ──────────────────────────────────────────────────────
417
+
418
+ it('renders a realistic article fragment', async () => {
419
+ await renderAsync(
420
+ <RenderHTML
421
+ html={
422
+ '<h1>Article Title</h1>' +
423
+ '<p>An introductory paragraph with <b>bold</b>, <i>italic</i> and an ' +
424
+ '<a href="https://example.com">external link</a>.</p>' +
425
+ '<h2>Section</h2>' +
426
+ '<ul><li>Point one</li><li>Point two</li></ul>' +
427
+ '<blockquote><p>A notable quote.</p></blockquote>' +
428
+ '<pre>code block</pre>'
429
+ }
430
+ renderImage={renderImage}
431
+ />,
432
+ );
433
+ expect(screen.toJSON()).toMatchSnapshot();
434
+ });
435
+ });
@@ -0,0 +1,47 @@
1
+ import { parseDocument } from 'htmlparser2';
2
+ import { type ComponentType, type FunctionComponent, type ReactNode, useMemo } from 'react';
3
+ import {
4
+ type ColorValue,
5
+ type ImageProps,
6
+ type TextProps,
7
+ type TextStyle,
8
+ View,
9
+ } from 'react-native';
10
+
11
+ import { HtmlProvider, type OnHTMLLinkPress } from './context/HtmlProvider';
12
+ import { HTMLValidator } from './HTMLValidator';
13
+ import { NodesRenderer } from './renderers/_NodesRenderer';
14
+ import type { CommonProps, HtmlStyle, TagStyles } from './types';
15
+
16
+ export interface RenderHTMLProps extends CommonProps {
17
+ html: string;
18
+ }
19
+
20
+ export const RenderHTML: FunctionComponent<RenderHTMLProps> = (props) => {
21
+ const nodes = useMemo(() => {
22
+ if (!props.html) {
23
+ return [];
24
+ }
25
+ const cleaned = props.html.replace(/\n/g, '');
26
+ const n = parseDocument(cleaned);
27
+ return new HTMLValidator(n.children).cleanup();
28
+ }, [props.html]);
29
+
30
+ return (
31
+ <View>
32
+ <HtmlProvider
33
+ tagStyles={props.tagStyles}
34
+ baseStyle={props.baseStyle}
35
+ classesStyles={props.classesStyles}
36
+ listGap={props.listGap}
37
+ overrideExternalLinkTintColor={props.overrideExternalLinkTintColor}
38
+ markerColor={props.markerColor}
39
+ onLinkPress={props.onLinkPress}
40
+ renderImage={props.renderImage}
41
+ renderTextComponent={props.renderTextComponent}
42
+ >
43
+ <NodesRenderer nodes={nodes} />
44
+ </HtmlProvider>
45
+ </View>
46
+ );
47
+ };